在各类现代应用开发场景中,签到打卡功能是提升用户粘性、记录用户行为的核心模块,广泛应用于企业考勤、在线教育、社区运营等领域。
本文将从技术实现角度,详细拆解5种适用于不同场景的签到打卡方案,涵盖传统数据库方案、高性能Redis方案、省空间的Bitmap方案、基于地理位置的精准签到方案等,每个方案均附完整代码实现与场景适配分析。
一、基于关系型数据库的传统签到系统
1.1 核心原理
关系型数据库(如MySQL、PostgreSQL) 是实现签到系统最基础的方案,核心逻辑是通过数据表记录用户每次签到行为,再通过统计逻辑计算总签到天数、连续签到天数等指标。
该方案的核心优势是逻辑直观、易于落地,适合中小型应用的基础签到场景。
1.2 数据模型设计
首先需设计三张核心表:用户表(存储用户基础信息)、签到记录表(存储每次签到的明细)、签到统计表(存储用户签到汇总数据),表结构如下:
-- 用户表:存储用户基础信息
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 主键ID
username VARCHAR(50) NOT NULL UNIQUE, -- 用户名(唯一)
password VARCHAR(100) NOT NULL, -- 密码(建议加密存储)
email VARCHAR(100) UNIQUE, -- 邮箱(唯一)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 用户创建时间
);
-- 签到记录表:存储每次签到的明细数据
CREATE TABLE check_ins (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 签到记录ID
user_id BIGINT NOT NULL, -- 关联用户ID
check_in_time TIMESTAMP NOT NULL, -- 具体签到时间(精确到时分秒)
check_in_date DATE NOT NULL, -- 签到日期(便于按天统计)
check_in_type VARCHAR(20) NOT NULL, -- 签到类型:DAILY(日常)/COURSE(课程)/MEETING(会议)
location VARCHAR(255), -- 签到地点(可选)
device_info VARCHAR(255), -- 签到设备信息(可选)
remark VARCHAR(255), -- 签到备注(可选)
-- 联合唯一索引:确保同一用户同一天同一类型仅能签到一次
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE KEY unique_user_date (user_id, check_in_date, check_in_type)
);
-- 签到统计表:存储用户签到汇总数据,避免频繁统计明细
CREATE TABLE check_in_stats (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 统计记录ID
user_id BIGINT NOT NULL, -- 关联用户ID
total_days INT DEFAULT 0, -- 总签到天数
continuous_days INT DEFAULT 0, -- 连续签到天数
last_check_in_date DATE, -- 最后一次签到日期
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE KEY unique_user (user_id) -- 确保每个用户仅一条统计记录
);
1.3 核心代码实现
1.3.1 实体类设计
对应数据库表结构,创建三个实体类,使用MyBatis-Plus注解关联数据表:
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 签到记录表实体类
* 关联数据库表:check_ins
*/
@Data
@TableName("check_ins")
public class CheckIn {
// 主键ID,自增策略
@TableId(value = "id", type = IdType.AUTO)
private Long id;
// 关联用户ID
@TableField("user_id")
private Long userId;
// 签到具体时间(时分秒)
@TableField("check_in_time")
private LocalDateTime checkInTime;
// 签到日期(仅日期)
@TableField("check_in_date")
private LocalDate checkInDate;
// 签到类型:DAILY/COURSE/MEETING
@TableField("check_in_type")
private String checkInType;
// 签到地点
private String location;
// 签到设备信息(如手机型号、IP等)
@TableField("device_info")
private String deviceInfo;
// 签到备注
private String remark;
}
/**
* 签到统计表实体类
* 关联数据库表:check_in_stats
*/
@Data
@TableName("check_in_stats")
public class CheckInStats {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("user_id")
private Long userId;
// 总签到天数,默认0
@TableField("total_days")
private Integer totalDays = 0;
// 连续签到天数,默认0
@TableField("continuous_days")
private Integer continuousDays = 0;
// 最后一次签到日期
@TableField("last_check_in_date")
private LocalDate lastCheckInDate;
}
/**
* 用户表实体类
* 关联数据库表:users
*/
@Data
@TableName("users")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String email;
// 用户创建时间
@TableField("created_at")
private LocalDateTime createdAt;
}
1.3.2 Mapper层(数据访问层)
定义数据操作接口,基于MyBatis-Plus实现基础CRUD,扩展自定义统计查询方法:
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.List;
/**
* 签到记录Mapper接口
* 继承BaseMapper实现基础CRUD,自定义签到相关查询方法
*/
@Mapper
public interface CheckInMapper extends BaseMapper<CheckIn> {
/**
* 根据用户ID和签到类型统计签到次数
* @param userId 用户ID
* @param type 签到类型
* @return 签到次数
*/
@Select("SELECT COUNT(*) FROM check_ins WHERE user_id = #{userId} AND check_in_type = #{type}")
int countByUserIdAndType(@Param("userId") Long userId, @Param("type") String type);
/**
* 查询用户指定日期范围内的签到记录
* @param userId 用户ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 签到记录列表
*/
@Select("SELECT * FROM check_ins WHERE user_id = #{userId} AND check_in_date BETWEEN #{startDate} AND #{endDate} ORDER BY check_in_date ASC")
List<CheckIn> findByUserIdAndDateBetween(
@Param("userId") Long userId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
/**
* 校验用户指定日期+类型是否已签到
* @param userId 用户ID
* @param date 签到日期
* @param type 签到类型
* @return 0=未签到,1=已签到
*/
@Select("SELECT COUNT(*) FROM check_ins WHERE user_id = #{userId} AND check_in_date = #{date} AND check_in_type = #{type}")
int existsByUserIdAndDateAndType(
@Param("userId") Long userId,
@Param("date") LocalDate date,
@Param("type") String type);
}
/**
* 签到统计Mapper接口
*/
@Mapper
public interface CheckInStatsMapper extends BaseMapper<CheckInStats> {
/**
* 根据用户ID查询签到统计信息
* @param userId 用户ID
* @return 签到统计对象
*/
@Select("SELECT * FROM check_in_stats WHERE user_id = #{userId}")
CheckInStats findByUserId(@Param("userId") Long userId);
}
/**
* 用户Mapper接口
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名查询用户信息
* @param username 用户名
* @return 用户对象
*/
@Select("SELECT * FROM users WHERE username = #{username}")
User findByUsername(@Param("username") String username);
}
1.3.3 Service层(业务逻辑层)
封装签到核心业务逻辑,包括签到校验、记录创建、统计更新:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 签到业务逻辑层
* 注解 @Transactional:保证签到+统计更新的事务一致性
*/
@Service
@Transactional
public class CheckInService {
@Autowired
private CheckInMapper checkInMapper;
@Autowired
private CheckInStatsMapper checkInStatsMapper;
@Autowired
private UserMapper userMapper;
/**
* 执行用户签到操作
* @param userId 用户ID
* @param type 签到类型
* @param location 签到地点
* @param deviceInfo 设备信息
* @param remark 备注
* @return 签到记录对象
* @throws RuntimeException 用户不存在/已签到时抛出异常
*/
public CheckIn checkIn(Long userId, String type, String location, String deviceInfo, String remark) {
// 1. 校验用户是否存在
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 2. 获取今日日期,校验是否已签到
LocalDate today = LocalDate.now();
if (checkInMapper.existsByUserIdAndDateAndType(userId, today, type) > 0) {
throw new RuntimeException("今日已签到,无需重复签到");
}
// 3. 创建签到记录
CheckIn checkIn = new CheckIn();
checkIn.setUserId(userId);
checkIn.setCheckInTime(LocalDateTime.now()); // 签到具体时间
checkIn.setCheckInDate(today); // 签到日期
checkIn.setCheckInType(type);
checkIn.setLocation(location);
checkIn.setDeviceInfo(deviceInfo);
checkIn.setRemark(remark);
checkInMapper.insert(checkIn); // 插入签到记录
// 4. 更新用户签到统计数据
updateCheckInStats(userId, today);
return checkIn;
}
/**
* 私有方法:更新用户签到统计信息
* @param userId 用户ID
* @param today 今日日期
*/
private void updateCheckInStats(Long userId, LocalDate today) {
// 查询用户现有统计记录
CheckInStats stats = checkInStatsMapper.findByUserId(userId);
if (stats == null) {
// 首次签到:初始化统计记录
stats = new CheckInStats();
stats.setUserId(userId);
stats.setTotalDays(1); // 总天数=1
stats.setContinuousDays(1); // 连续天数=1
stats.setLastCheckInDate(today);
checkInStatsMapper.insert(stats);
} else {
// 非首次签到:更新统计
stats.setTotalDays(stats.getTotalDays() + 1); // 总天数+1
// 计算连续签到天数
if (stats.getLastCheckInDate() != null) {
if (today.minusDays(1).equals(stats.getLastCheckInDate())) {
// 昨日已签到:连续天数+1
stats.setContinuousDays(stats.getContinuousDays() + 1);
} else if (!today.equals(stats.getLastCheckInDate())) {
// 中断签到:重置连续天数为1
stats.setContinuousDays(1);
}
// 今日重复签到:不修改连续天数
}
// 更新最后签到日期
stats.setLastCheckInDate(today);
checkInStatsMapper.updateById(stats);
}
}
/**
* 查询用户签到统计信息
* @param userId 用户ID
* @return 签到统计对象
* @throws RuntimeException 统计记录不存在时抛出异常
*/
public CheckInStats getCheckInStats(Long userId) {
CheckInStats stats = checkInStatsMapper.findByUserId(userId);
if (stats == null) {
throw new RuntimeException("暂无签到统计数据");
}
return stats;
}
/**
* 查询用户指定日期范围内的签到记录
* @param userId 用户ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 签到记录列表
*/
public List<CheckIn> getCheckInHistory(Long userId, LocalDate startDate, LocalDate endDate) {
return checkInMapper.findByUserIdAndDateBetween(userId, startDate, endDate);
}
}
1.3.4 Controller层(接口层)
暴露HTTP接口,供前端调用实现签到、查询统计、查询历史等功能:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* 签到接口控制器
* 接口前缀:/api/check-ins
*/
@RestController
@RequestMapping("/api/check-ins")
public class CheckInController {
@Autowired
private CheckInService checkInService;
/**
* 签到接口(POST)
* @param request 签到请求参数
* @return 签到记录
*/
@PostMapping
public ResponseEntity<CheckIn> checkIn(@RequestBody CheckInRequest request) {
CheckIn checkIn = checkInService.checkIn(
request.getUserId(),
request.getType(),
request.getLocation(),
request.getDeviceInfo(),
request.getRemark()
);
return ResponseEntity.ok(checkIn);
}
/**
* 查询用户签到统计(GET)
* @param userId 用户ID
* @return 签到统计数据
*/
@GetMapping("/stats/{userId}")
public ResponseEntity<CheckInStats> getStats(@PathVariable Long userId) {
CheckInStats stats = checkInService.getCheckInStats(userId);
return ResponseEntity.ok(stats);
}
/**
* 查询用户指定日期范围的签到历史(GET)
* @param userId 用户ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 签到记录列表
*/
@GetMapping("/history/{userId}")
public ResponseEntity<List<CheckIn>> getHistory(
@PathVariable Long userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
List<CheckIn> history = checkInService.getCheckInHistory(userId, startDate, endDate);
return ResponseEntity.ok(history);
}
}
/**
* 签到请求参数封装类
*/
@Data
public class CheckInRequest {
private Long userId; // 用户ID
private String type; // 签到类型
private String location; // 签到地点
private String deviceInfo; // 设备信息
private String remark; // 备注
}
1.4 优缺点分析
优点:
- 逻辑简单直观,开发、调试、维护成本低;
- 支持复杂的SQL查询,可灵活实现多维度统计;
- 事务机制保证数据一致性,适配有数据完整性要求的场景;
- 易于与现有业务系统(如用户系统、权限系统)集成。
缺点:
- 数据量达到百万/千万级时,查询统计的性能会明显下降;
- 连续签到、跨月统计等逻辑需手动编写,开发工作量大;
- 高并发场景下(如秒杀式签到),数据库易成为性能瓶颈;
- 频繁的写入/查询操作会增加数据库负载。
1.5 适用场景
- 中小型企业的员工考勤系统(日活≤1万);
- 在线教育的课程签到、会议签到场景;
- 用户量少、签到频率低的社区/小程序签到功能;
- 对数据一致性要求高、统计维度复杂的场景。
二、基于Redis的高性能签到系统
2.1 核心原理
Redis作为高性能内存数据库,凭借其丰富的数据结构(String/Hash/Set/Sorted Set)和毫秒级响应速度,可完美适配高并发、高实时性的签到场景。
核心逻辑是将签到数据存储在Redis中,替代传统数据库的频繁读写,大幅提升系统吞吐量。
2.2 系统设计
基于Redis的签到系统主要用到以下数据结构:
- Set:存储每日签到的用户ID集合,快速判断用户是否已签到;
- Hash:存储用户签到的详细信息(如时间、地点);
- Sorted Set:实现签到排行榜功能;
- String/Hash:存储用户签到统计(总天数、连续天数)。
2.3 核心代码实现
2.3.1 Redis配置
配置Redis序列化方式,保证数据读写的兼容性:
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
* 自定义RedisTemplate序列化方式,解决默认JDK序列化的乱码问题
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 配置JSON序列化器(值序列化)
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
// 开启所有字段的可见性
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 开启类型信息,保证反序列化时能识别对象类型
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
serializer.setObjectMapper(mapper);
// 设置序列化规则
template.setValueSerializer(serializer); // 值序列化
template.setKeySerializer(new StringRedisSerializer()); // 键序列化
template.setHashKeySerializer(new StringRedisSerializer()); // Hash键序列化
template.setHashValueSerializer(serializer); // Hash值序列化
template.afterPropertiesSet();
return template;
}
}
2.3.2 签到服务实现
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
/**
* Redis高性能签到服务
* 适配高并发、高实时性的签到场景
*/
@Service
public class RedisCheckInService {
// 定义Redis键前缀(规范命名,避免键冲突)
private static final String USER_CHECKIN_KEY = "checkin:user:"; // 用户签到详情前缀
private static final String DAILY_CHECKIN_KEY = "checkin:daily:"; // 每日签到用户集合前缀
private static final String CHECKIN_RANK_KEY = "checkin:rank:"; // 签到排行榜前缀
private static final String USER_STATS_KEY = "checkin:stats:"; // 用户签到统计前缀
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 执行用户签到
* @param userId 用户ID
* @param location 签到地点
* @return true=签到成功,false=已签到
*/
public boolean checkIn(Long userId, String location) {
// 1. 构建今日日期字符串(格式:yyyy-MM-dd)
String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);
String userKey = USER_CHECKIN_KEY + userId;
String dailyKey = DAILY_CHECKIN_KEY + today;
// 2. 校验用户今日是否已签到(Set集合判断)
Boolean isMember = redisTemplate.opsForSet().isMember(dailyKey, userId);
if (isMember != null && isMember) {
return false; // 已签到,返回失败
}
// 3. 记录用户签到(添加到今日签到Set)
redisTemplate.opsForSet().add(dailyKey, userId);
// 设置Set过期时间(35天),保留足够数据用于连续签到统计
redisTemplate.expire(dailyKey, 35, java.util.concurrent.TimeUnit.DAYS);
// 4. 存储用户签到详情(Hash结构)
Map<String, String> checkInInfo = new HashMap<>();
checkInInfo.put("time", LocalDateTime.now().toString()); // 签到时间
checkInInfo.put("location", location); // 签到地点
redisTemplate.opsForHash().putAll(userKey + ":" + today, checkInInfo);
// 5. 更新签到排行榜(Sorted Set,分数+1)
redisTemplate.opsForZSet().incrementScore(CHECKIN_RANK_KEY + today, userId, 1);
// 6. 更新用户签到统计
updateUserCheckInStats(userId);
return true; // 签到成功
}
/**
* 私有方法:更新用户签到统计(总天数、连续天数)
* @param userId 用户ID
*/
private void updateUserCheckInStats(Long userId) {
String userStatsKey = USER_STATS_KEY + userId;
LocalDate today = LocalDate.now();
String todayStr = today.format(DateTimeFormatter.ISO_DATE);
// 1. 获取用户最后签到日期
String lastCheckInDate = (String) redisTemplate.opsForHash().get(userStatsKey, "lastCheckInDate");
// 2. 总签到天数+1
redisTemplate.opsForHash().increment(userStatsKey, "totalDays", 1);
// 3. 更新连续签到天数
if (lastCheckInDate != null) {
LocalDate lastDate = LocalDate.parse(lastCheckInDate, DateTimeFormatter.ISO_DATE);
if (today.minusDays(1).equals(lastDate)) {
// 昨日已签到:连续天数+1
redisTemplate.opsForHash().increment(userStatsKey, "continuousDays", 1);
} else if (!today.equals(lastDate)) {
// 签到中断:重置连续天数为1
redisTemplate.opsForHash().put(userStatsKey, "continuousDays", "1");
}
// 今日重复签到:不处理
} else {
// 首次签到:初始化连续天数为1
redisTemplate.opsForHash().put(userStatsKey, "continuousDays", "1");
}
// 4. 更新最后签到日期
redisTemplate.opsForHash().put(userStatsKey, "lastCheckInDate", todayStr);
}
/**
* 查询用户签到统计信息
* @param userId 用户ID
* @return 统计数据(Hash结构)
*/
public Map<Object, Object> getUserCheckInStats(Long userId) {
String userStatsKey = USER_STATS_KEY + userId;
return redisTemplate.opsForHash().entries(userStatsKey);
}
/**
* 判断用户今日是否已签到
* @param userId 用户ID
* @return true=已签到,false=未签到
*/
public boolean isUserCheckedInToday(Long userId) {
String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);
String dailyKey = DAILY_CHECKIN_KEY + today;
Boolean isMember = redisTemplate.opsForSet().isMember(dailyKey, userId);
return isMember != null && isMember;
}
/**
* 查询今日签到用户总数
* @return 签到人数
*/
public long getTodayCheckInCount() {
String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);
String dailyKey = DAILY_CHECKIN_KEY + today;
Long size = redisTemplate.opsForSet().size(dailyKey);
return size != null ? size : 0;
}
/**
* 查询签到排行榜(按签到次数降序)
* @param limit 返回前N名
* @return 排行榜数据(包含用户ID和分数)
*/
public ZSetOperations.TypedTuple<Object> getCheckInRank(int limit) {
String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);
String rankKey = CHECKIN_RANK_KEY + today;
// 反向范围查询(降序),返回前limit条,包含分数
return (ZSetOperations.TypedTuple<Object>) redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, 0, limit - 1);
}
/**
* 校验用户指定日期是否签到
* @param userId 用户ID
* @param date 待校验日期
* @return true=已签到,false=未签到
*/
public boolean checkUserSignedInDate(Long userId, LocalDate date) {
String dateStr = date.format(DateTimeFormatter.ISO_DATE);
String dailyKey = DAILY_CHECKIN_KEY + dateStr;
Boolean isMember = redisTemplate.opsForSet().isMember(dailyKey, userId);
return isMember != null && isMember;
}
/**
* 查询用户指定月份的签到日期列表
* @param userId 用户ID
* @param year 年
* @param month 月
* @return 签到日期字符串列表
*/
public List<String> getMonthlyCheckInStatus(Long userId, int year, int month) {
List<String> result = new java.util.ArrayList<>();
java.time.YearMonth yearMonth = java.time.YearMonth.of(year, month);
LocalDate firstDay = yearMonth.atDay(1); // 当月第一天
LocalDate lastDay = yearMonth.atEndOfMonth(); // 当月最后一天
// 遍历当月每一天,校验是否签到
LocalDate currentDate = firstDay;
while (!currentDate.isAfter(lastDay)) {
if (checkUserSignedInDate(userId, currentDate)) {
result.add(currentDate.format(DateTimeFormatter.ISO_DATE));
}
currentDate = currentDate.plusDays(1);
}
return result;
}
}
2.3.3 控制器实现
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Redis签到接口控制器
* 接口前缀:/api/redis-check-in
*/
@RestController
@RequestMapping("/api/redis-check-in")
public class RedisCheckInController {
@Autowired
private RedisCheckInService checkInService;
/**
* 签到接口(POST)
* @param request 签到请求参数
* @return 签到结果
*/
@PostMapping
public ResponseEntity<?> checkIn(@RequestBody RedisCheckInRequest request) {
boolean success = checkInService.checkIn(request.getUserId(), request.getLocation());
if (success) {
return ResponseEntity.ok(Map.of("message", "签到成功"));
} else {
return ResponseEntity.badRequest().body(Map.of("message", "今日已签到"));
}
}
/**
* 查询用户签到统计(GET)
* @param userId 用户ID
* @return 统计数据
*/
@GetMapping("/stats/{userId}")
public ResponseEntity<Map<Object, Object>> getUserStats(@PathVariable Long userId) {
Map<Object, Object> stats = checkInService.getUserCheckInStats(userId);
return ResponseEntity.ok(stats);
}
/**
* 查询用户今日签到状态+今日总签到数(GET)
* @param userId 用户ID
* @return 状态数据
*/
@GetMapping("/status/{userId}")
public ResponseEntity<Map<String, Object>> getCheckInStatus(@PathVariable Long userId) {
boolean checkedIn = checkInService.isUserCheckedInToday(userId);
long todayCount = checkInService.getTodayCheckInCount();
Map<String, Object> response = new HashMap<>();
response.put("checkedIn", checkedIn); // 是否已签到
response.put("todayCount", todayCount); // 今日签到总数
return ResponseEntity.ok(response);
}
/**
* 查询签到排行榜(GET)
* @param limit 排行榜条数(默认10)
* @return 排行榜数据
*/
@GetMapping("/rank")
public ResponseEntity<Set<ZSetOperations.TypedTuple<Object>>> getCheckInRank(
@RequestParam(defaultValue = "10") int limit) {
Set<ZSetOperations.TypedTuple<Object>> rank = checkInService.getCheckInRank(limit);
return ResponseEntity.ok(rank);
}
/**
* 查询用户指定月份的签到日期(GET)
* @param userId 用户ID
* @param year 年
* @param month 月
* @return 签到日期列表
*/
@GetMapping("/monthly/{userId}")
public ResponseEntity<List<String>> getMonthlyStatus(
@PathVariable Long userId,
@RequestParam int year,
@RequestParam int month) {
List<String> checkInDays = checkInService.getMonthlyCheckInStatus(userId, year, month);
return ResponseEntity.ok(checkInDays);
}
}
/**
* Redis签到请求参数封装类
*/
@Data
public class RedisCheckInRequest {
private Long userId; // 用户ID
private String location; // 签到地点
}
2.4 优缺点分析
优点:
- 性能极致,单Redis实例可支撑10万+QPS,适配高并发场景;
- 数据结构丰富,可快速实现排行榜、签到日历等扩展功能;
- 内存读写,响应延迟低(毫秒级),用户体验好;
- 支持过期策略,自动清理历史数据,减少存储成本。
缺点:
- 数据存储在内存,服务器宕机易丢失(需开启持久化缓解);
- 复杂统计(如按周/按季度)需手动编写逻辑,不如SQL灵活;
- 内存成本高,大规模用户场景需集群部署,增加运维成本;
- 不支持事务回滚,极端场景可能出现数据不一致。
2.5 适用场景
- 千万级用户的大型社区/电商APP签到功能;
- 秒杀、活动等短时间高并发的签到场景;
- 需要实时展示签到排行榜、签到日历的应用;
- 对响应速度要求高(≤100ms)的移动应用。
三、基于Bitmap的连续签到统计系统
3.1 核心原理
Redis的Bitmap(位图) 是一种极致省空间的数据结构,核心逻辑是用1个bit位表示1天的签到状态(1=签到,0=未签到)。
1个用户1个月的签到状态仅需4字节(31bit),1000万用户1个月仅需约48MB内存,是大规模用户签到场景的最优解。
3.2 系统设计
- 每个用户每个月对应1个Bitmap键;
- Bitmap的第N位(从0开始)对应当月第N+1天的签到状态;
- 通过位操作(BITCOUNT/BITOP)快速统计签到天数、连续签到等指标。
3.3 核心代码实现
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
/**
* Bitmap签到服务
* 极致省空间,适配大规模用户的连续签到统计
*/
@Service
public class BitmapCheckInService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 执行用户签到
* @param userId 用户ID
* @return true=签到成功,false=已签到
*/
public boolean checkIn(Long userId) {
LocalDate today = LocalDate.now();
int dayOfMonth = today.getDayOfMonth(); // 当月第几天(1-31)
String bitmapKey = buildBitmapKey(userId, today); // 构建当月Bitmap键
// 1. 校验今日是否已签到(获取对应bit位的值)
Boolean isSigned = redisTemplate.opsForValue().getBit(bitmapKey, dayOfMonth - 1);
if (isSigned != null && isSigned) {
return false; // 已签到
}
// 2. 设置今日签到bit位为1
redisTemplate.opsForValue().setBit(bitmapKey, dayOfMonth - 1, true);
// 3. 设置Bitmap过期时间(100天),避免内存泄漏
redisTemplate.expire(bitmapKey, 100, java.util.concurrent.TimeUnit.DAYS);
// 4. 更新连续签到天数
updateContinuousSignDays(userId);
return true; // 签到成功
}
/**
* 私有方法:更新用户连续签到天数
* @param userId 用户ID
*/
private void updateContinuousSignDays(Long userId) {
LocalDate today = LocalDate.now();
String continuousKey = "user:sign:continuous:" + userId; // 连续天数存储键
// 1. 校验昨日是否签到
boolean yesterdaySigned = isSignedIn(userId, today.minusDays(1));
if (yesterdaySigned) {
// 昨日已签到:连续天数+1
redisTemplate.opsForValue().increment(continuousKey);
} else {
// 昨日未签到:重置连续天数为1
redisTemplate.opsForValue().set(continuousKey, "1");
}
}
/**
* 校验用户指定日期是否签到
* @param userId 用户ID
* @param date 待校验日期
* @return true=已签到,false=未签到
*/
public boolean isSignedIn(Long userId, LocalDate date) {
int dayOfMonth = date.getDayOfMonth();
String bitmapKey = buildBitmapKey(userId, date);
// 获取对应bit位的值
Boolean isSigned = redisTemplate.opsForValue().getBit(bitmapKey, dayOfMonth - 1);
return isSigned != null && isSigned;
}
/**
* 查询用户当前连续签到天数
* @param userId 用户ID
* @return 连续天数(默认0)
*/
public int getContinuousSignDays(Long userId) {
String continuousKey = "user:sign:continuous:" + userId;
String value = redisTemplate.opsForValue().get(continuousKey);
return value != null ? Integer.parseInt(value) : 0;
}
/**
* 查询用户指定月份的签到总次数
* @param userId 用户ID
* @param date 月份日期(任意一天即可)
* @return 签到次数
*/
public long getMonthSignCount(Long userId, LocalDate date) {
String bitmapKey = buildBitmapKey(userId, date);
// 使用Redis原生方法统计bit位为1的数量(高效)
return redisTemplate.execute((RedisCallback<Long>) connection -> {
return connection.bitCount(bitmapKey.getBytes());
});
}
/**
* 查询用户指定月份的签到详情(每天是否签到)
* @param userId 用户ID
* @param date 月份日期
* @return 列表:1=签到,0=未签到(索引对应日期-1)
*/
public List<Integer> getMonthSignData(Long userId, LocalDate date) {
List<Integer> result = new ArrayList<>();
String bitmapKey = buildBitmapKey(userId, date);
YearMonth yearMonth = YearMonth.from(date);
int totalDays = yearMonth.lengthOfMonth(); // 当月总天数
// 遍历当月每一天,获取签到状态
for (int i = 0; i < totalDays; i++) {
Boolean isSigned = redisTemplate.opsForValue().getBit(bitmapKey, i);
result.add(isSigned != null && isSigned ? 1 : 0);
}
return result;
}
/**
* 查询用户指定月份首次签到日期
* @param userId 用户ID
* @param date 月份日期
* @return 首次签到日期(1-31),-1=未签到
*/
public int getFirstSignDay(Long userId, LocalDate date) {
String bitmapKey = buildBitmapKey(userId, date);
YearMonth yearMonth = YearMonth.from(date);
int totalDays = yearMonth.lengthOfMonth();
// 遍历bit位,找到第一个为1的位置
for (int i = 0; i < totalDays; i++) {
Boolean isSigned = redisTemplate.opsForValue().getBit(bitmapKey, i);
if (isSigned != null && isSigned) {
return i + 1; // 日期=索引+1
}
}
return -1; // 未签到
}
/**
* 私有方法:构建Bitmap键
* 格式:user:sign:用户ID:年月份(如user:sign:1001:202506)
* @param userId 用户ID
* @param date 日期
* @return Bitmap键
*/
private String buildBitmapKey(Long userId, LocalDate date) {
return String.format("user:sign:%d:%d%02d", userId, date.getYear(), date.getMonthValue());
}
}
控制器实现
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Bitmap签到接口控制器
* 接口前缀:/api/bitmap-check-in
*/
@RestController
@RequestMapping("/api/bitmap-check-in")
public class BitmapCheckInController {
@Autowired
private BitmapCheckInService checkInService;
/**
* 签到接口(POST)
* @param userId 用户ID
* @return 签到结果+连续天数
*/
@PostMapping("/{userId}")
public ResponseEntity<?> checkIn(@PathVariable Long userId) {
boolean success = checkInService.checkIn(userId);
if (success) {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("continuousDays", checkInService.getContinuousSignDays(userId));
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(Map.of("message", "今日已签到"));
}
}
/**
* 查询用户签到状态(GET)
* @param userId 用户ID
* @param date 待查询日期(默认今日)
* @return 签到状态+统计数据
*/
@GetMapping("/{userId}/status")
public ResponseEntity<?> checkInStatus(
@PathVariable Long userId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (date == null) {
date = LocalDate.now(); // 默认查询今日
}
Map<String, Object> response = new HashMap<>();
response.put("signedToday", checkInService.isSignedIn(userId, date)); // 今日是否签到
response.put("continuousDays", checkInService.getContinuousSignDays(userId)); // 连续天数
response.put("monthSignCount", checkInService.getMonthSignCount(userId, date)); // 当月签到次数
response.put("monthSignData", checkInService.getMonthSignData(userId, date)); // 当月签到详情
return ResponseEntity.ok(response);
}
/**
* 查询用户指定月份首次签到日期(GET)
* @param userId 用户ID
* @param date 月份日期
* @return 首次签到日期
*/
@GetMapping("/{userId}/first-sign-day")
public ResponseEntity<Integer> getFirstSignDay(
@PathVariable Long userId,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (date == null) {
date = LocalDate.now();
}
int firstDay = checkInService.getFirstSignDay(userId, date);
return ResponseEntity.ok(firstDay);
}
}
3.4 优缺点分析
优点:
- 极致省空间,1000万用户/月仅需约48MB内存,存储成本极低;
- 位操作性能高,统计签到天数仅需O(1)时间复杂度;
- 适配大规模用户场景,Redis集群可轻松支撑亿级用户;
- 逻辑简洁,易于集成到现有系统。
缺点:
- 仅能存储“签到/未签到”二元状态,无法记录签到地点、设备等详情;
- 历史数据查询需按月份遍历,跨月统计逻辑复杂;
- 不支持复杂的签到类型区分(如需区分需单独建Bitmap);
- 依赖Redis,需保证Redis集群的稳定性。
3.5 适用场景
- 亿级用户的APP每日签到领奖励功能;
- 社区/电商平台的签到日历展示场景;
- 对存储成本敏感、仅需统计签到状态的场景;
- 移动应用的轻量化签到功能(流量/内存有限)。
四、基于地理位置的签到打卡系统
4.1 核心原理
基于地理位置的签到系统,核心是通过GPS定位+Redis GEO功能 验证用户是否在指定区域内签到,常用于企业考勤、校园签到、线下活动签到等场景。
该方案结合关系型数据库(存储签到记录)和Redis GEO(位置校验),兼顾数据完整性和校验性能。
4.2 数据模型设计
新增两张核心表:签到位置表(存储可签到的地理位置)、地理位置签到记录表(存储带位置的签到明细):
-- 签到位置表:存储可签到的地理位置信息
CREATE TABLE check_in_locations (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 位置ID
name VARCHAR(100) NOT NULL, -- 位置名称(如"XX公司总部")
latitude DOUBLE NOT NULL, -- 纬度
longitude DOUBLE NOT NULL, -- 经度
radius DOUBLE NOT NULL, -- 有效签到半径(单位:米)
address VARCHAR(255), -- 详细地址
location_type VARCHAR(50), -- 位置类型(如"OFFICE"/"CLASSROOM"/"EVENT")
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 创建时间
);
-- 地理位置签到记录表:存储带位置的签到明细
CREATE TABLE geo_check_ins (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 签到ID
user_id BIGINT NOT NULL, -- 用户ID
location_id BIGINT NOT NULL, -- 关联签到位置ID
check_in_time TIMESTAMP NOT NULL, -- 签到时间
latitude DOUBLE NOT NULL, -- 用户签到纬度
longitude DOUBLE NOT NULL, -- 用户签到经度
accuracy DOUBLE, -- 定位精度(米)
is_valid BOOLEAN DEFAULT TRUE, -- 是否有效(是否在指定区域内)
device_info VARCHAR(255), -- 签到设备信息
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (location_id) REFERENCES check_in_locations(id)
);
4.3 核心代码实现
4.3.1 实体类设计
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 签到位置实体类
* 关联表:check_in_locations
*/
@Data
@TableName("check_in_locations")
public class CheckInLocation {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String name; // 位置名称
private Double latitude; // 纬度
private Double longitude; // 经度
private Double radius; // 有效半径(米)
private String address; // 详细地址
@TableField("location_type")
private String locationType; // 位置类型
@TableField("created_at")
private LocalDateTime createdAt; // 创建时间
}
/**
* 地理位置签到实体类
* 关联表:geo_check_ins
*/
@Data
@TableName("geo_check_ins")
public class GeoCheckIn {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("user_id")
private Long userId; // 用户ID
@TableField("location_id")
private Long locationId; // 签到位置ID
@TableField("check_in_time")
private LocalDateTime checkInTime; // 签到时间
private Double latitude; // 用户签到纬度
private Double longitude; // 用户签到经度
private Double accuracy; // 定位精度
@TableField("is_valid")
private Boolean isValid = true; // 是否有效签到
@TableField("device_info")
private String deviceInfo; // 设备信息
}
4.3.2 Mapper层
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
/**
* 签到位置Mapper接口
*/
@Mapper
public interface CheckInLocationMapper extends BaseMapper<CheckInLocation> {
/**
* 根据位置类型查询签到位置
* @param locationType 位置类型
* @return 位置列表
*/
@Select("SELECT * FROM check_in_locations WHERE location_type = #{locationType}")
List<CheckInLocation> findByLocationType(@Param("locationType") String locationType);
}
/**
* 地理位置签到Mapper接口
*/
@Mapper
public interface GeoCheckInMapper extends BaseMapper<GeoCheckIn> {
/**
* 查询用户指定时间范围内的地理位置签到记录
* @param userId 用户ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 签到记录列表
*/
@Select("SELECT * FROM geo_check_ins WHERE user_id = #{userId} AND check_in_time BETWEEN #{startTime} AND #{endTime}")
List<GeoCheckIn> findByUserIdAndCheckInTimeBetween(
@Param("userId") Long userId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 查询用户今日是否已在指定位置签到
* @param userId 用户ID
* @param locationId 位置ID
* @param date 日期
* @return 签到记录(null=未签到)
*/
@Select("SELECT * FROM geo_check_ins WHERE user_id = #{userId} AND location_id = #{locationId} " +
"AND DATE(check_in_time) = DATE(#{date})")
GeoCheckIn findByUserIdAndLocationIdAndDate(
@Param("userId") Long userId,
@Param("locationId") Long locationId,
@Param("date") LocalDateTime date);
}
4.3.3 Service层
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.GeoOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Collections;
/**
* 地理位置签到服务
* 结合MySQL+Redis GEO实现精准位置签到
*/
@Service
@Transactional
public class GeoCheckInService {
@Autowired
private CheckInLocationMapper locationMapper;
@Autowired
private GeoCheckInMapper geoCheckInMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
// Redis GEO键:存储所有签到位置
private static final String GEO_KEY = "geo:locations";
/**
* 初始化方法:项目启动时将签到位置加载到Redis GEO
*/
@PostConstruct
public void init() {
// 查询所有签到位置
List<CheckInLocation> locations = locationMapper.selectList(null);
if (locations.isEmpty()) {
return;
}
// 批量添加到Redis GEO
Map<String, RedisGeoCommands.Point> locationPoints = new HashMap<>();
for (CheckInLocation location : locations) {
// 构建Point(经度,纬度)
RedisGeoCommands.Point point = new RedisGeoCommands.Point(location.getLongitude(), location.getLatitude());
locationPoints.put(location.getId().toString(), point);
}
redisTemplate.opsForGeo().add(GEO_KEY, locationPoints);
}
/**
* 添加新的签到位置
* @param location 位置信息
* @return 新增的位置对象
*/
public CheckInLocation addCheckInLocation(CheckInLocation location) {
location.setCreatedAt(LocalDateTime.now());
locationMapper.insert(location);
// 添加到Redis GEO
RedisGeoCommands.Point point = new RedisGeoCommands.Point(location.getLongitude(), location.getLatitude());
redisTemplate.opsForGeo().add(GEO_KEY, point, location.getId().toString());
return location;
}
/**
* 执行地理位置签到
* @param userId 用户ID
* @param locationId 签到位置ID
* @param latitude 用户签到纬度
* @param longitude 用户签到经度
* @param accuracy 定位精度
* @param deviceInfo 设备信息
* @return 签到记录
* @throws RuntimeException 用户/位置不存在/已签到时抛出异常
*/
public GeoCheckIn checkIn(Long userId, Long locationId, Double latitude,
Double longitude, Double accuracy, String deviceInfo) {
// 1. 校验用户是否存在
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 2. 校验签到位置是否存在
CheckInLocation location = locationMapper.selectById(locationId);
if (location == null) {
throw new RuntimeException("签到位置不存在");
}
// 3. 校验今日是否已在该位置签到
LocalDateTime now = LocalDateTime.now();
GeoCheckIn existingCheckIn = geoCheckInMapper
.findByUserIdAndLocationIdAndDate(userId, locationId, now);
if (existingCheckIn != null) {
throw new RuntimeException("今日已在该位置签到");
}
// 4. 校验用户是否在签到范围内
boolean isWithinRange = isWithinCheckInRange(
latitude, longitude, location.getLatitude(), location.getLongitude(), location.getRadius());
// 5. 创建签到记录
GeoCheckIn checkIn = new GeoCheckIn();
checkIn.setUserId(userId);
checkIn.setLocationId(locationId);
checkIn.setCheckInTime(now);
checkIn.setLatitude(latitude);
checkIn.setLongitude(longitude);
checkIn.setAccuracy(accuracy);
checkIn.setIsValid(isWithinRange); // 是否有效签到
checkIn.setDeviceInfo(deviceInfo);
geoCheckInMapper.insert(checkIn);
return checkIn;
}
/**
* 私有方法:校验用户是否在签到范围内
* @param userLat 用户纬度
* @param userLng 用户经度
* @param locLat 位置纬度
* @param locLng 位置经度
* @param radius 有效半径(米)
* @return true=在范围内,false=不在
*/
private boolean isWithinCheckInRange(Double userLat, Double userLng,
Double locLat, Double locLng, Double radius) {
// 使用Redis GEO计算用户与签到位置的距离(单位:米)
RedisGeoCommands.Distance distance = redisTemplate.opsForGeo().distance(
GEO_KEY,
new RedisGeoCommands.Point(locLng, locLat), // 签到位置经纬度
new RedisGeoCommands.Point(userLng, userLat), // 用户经纬度
RedisGeoCommands.DistanceUnit.METERS
);
// 距离≤有效半径则为有效签到
return distance != null && distance.getValue() <= radius;
}
/**
* 查询用户附近的签到位置
* @param latitude 用户纬度
* @param longitude 用户经度
* @param radius 搜索半径(米)
* @return 附近位置列表(包含距离)
*/
public List<RedisGeoCommands.GeoResult<RedisGeoCommands.GeoLocation<String>>> findNearbyLocations(
Double latitude, Double longitude, Double radius) {
// 构建搜索范围(圆形区域)
RedisGeoCommands.Circle circle = new RedisGeoCommands.Circle(
new RedisGeoCommands.Point(longitude, latitude),
new RedisGeoCommands.Distance(radius, RedisGeoCommands.DistanceUnit.METERS)
);
// 配置查询参数:包含距离、按距离升序
RedisGeoCommands.GeoRadiusCommandArgs args =
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance().sortAscending();
// 执行GEO半径查询
RedisGeoCommands.GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
.radius(GEO_KEY, circle, args);
return results != null ? results.getContent() : Collections.emptyList();
}
/**
* 查询用户指定日期范围内的地理位置签到记录
* @param userId 用户ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 签到记录列表
*/
public List<GeoCheckIn> getUserCheckInHistory(Long userId, LocalDate startDate, LocalDate endDate) {
LocalDateTime startTime = startDate.atStartOfDay(); // 开始时间:日期0点
LocalDateTime endTime = endDate.atTime(23, 59, 59); // 结束时间:日期23:59:59
return geoCheckInMapper.findByUserIdAndCheckInTimeBetween(userId, startTime, endTime);
}
}
4.3.4 Controller层
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* 地理位置签到接口控制器
* 接口前缀:/api/geo-check-in
*/
@RestController
@RequestMapping("/api/geo-check-in")
public class GeoCheckInController {
@Autowired
private GeoCheckInService geoCheckInService;
/**
* 添加签到位置(POST)
* @param location 位置信息
* @return 新增位置
*/
@PostMapping("/locations")
public ResponseEntity<CheckInLocation> addLocation(@RequestBody CheckInLocation location) {
CheckInLocation newLocation = geoCheckInService.addCheckInLocation(location);
return ResponseEntity.ok(newLocation);
}
/**
* 地理位置签到(POST)
* @param request 签到请求参数
* @return 签到记录
*/
@PostMapping
public ResponseEntity<GeoCheckIn> checkIn(@RequestBody GeoCheckInRequest request) {
GeoCheckIn checkIn = geoCheckInService.checkIn(
request.getUserId(),
request.getLocationId(),
request.getLatitude(),
request.getLongitude(),
request.getAccuracy(),
request.getDeviceInfo()
);
return ResponseEntity.ok(checkIn);
}
/**
* 查询附近的签到位置(GET)
* @param latitude 用户纬度
* @param longitude 用户经度
* @param radius 搜索半径(米,默认1000)
* @return 附近位置列表
*/
@GetMapping("/nearby")
public ResponseEntity<List<RedisGeoCommands.GeoResult<RedisGeoCommands.GeoLocation<String>>>> findNearby(
@RequestParam Double latitude,
@RequestParam Double longitude,
@RequestParam(defaultValue = "1000") Double radius) {
List<RedisGeoCommands.GeoResult<RedisGeoCommands.GeoLocation<String>>> locations = geoCheckInService
.findNearbyLocations(latitude, longitude, radius);
return ResponseEntity.ok(locations);
}
/**
* 查询用户地理位置签到历史(GET)
* @param userId 用户ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 签到记录列表
*/
@GetMapping("/history/{userId}")
public ResponseEntity<List<GeoCheckIn>> getHistory(
@PathVariable Long userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
List<GeoCheckIn> history = geoCheckInService.getUserCheckInHistory(userId, startDate, endDate);
return ResponseEntity.ok(history);
}
}
/**
* 地理位置签到请求参数封装类
*/
@Data
public class GeoCheckInRequest {
private Long userId; // 用户ID
private Long locationId; // 签到位置ID
private Double latitude; // 用户纬度
private Double longitude; // 用户经度
private Double accuracy; // 定位精度
private String deviceInfo; // 设备信息
}
4.4 优缺点分析
优点:
- 支持精准的位置校验,满足线下场景的签到需求;
- 结合MySQL+Redis,兼顾数据完整性和校验性能;
- 可扩展附近位置查询、签到范围动态调整等功能;
- 签到记录包含位置信息,便于后续数据分析。
缺点:
- 依赖用户GPS授权,部分场景下用户可能拒绝授权;
- 定位精度受设备/网络影响,可能出现误判;
- 实现复杂度高,需维护位置数据和GEO索引;
- 高并发场景下,GEO查询可能成为性能瓶颈。
4.5 适用场景
- 企业员工的线下考勤(需在公司范围内签到);
- 学校的课堂点名、校园活动签到;
- 线下展会、会议的签到核销场景;
- 需验证用户实际位置的打卡任务(如外卖员打卡)。
五、方案选型总结
| 方案类型 |
核心优势 |
核心劣势 |
适用场景 |
| 关系型数据库 |
逻辑简单、数据一致性高 |
高并发性能差 |
中小型应用、低并发、复杂统计场景 |
| Redis高性能方案 |
高并发、低延迟、功能丰富 |
数据易丢失、内存成本高 |
大型应用、高并发、实时性要求高 |
| Bitmap连续签到方案 |
极致省空间、大规模用户适配 |
仅支持二元状态 |
亿级用户、轻量化签到、成本敏感 |
| 地理位置签到方案 |
精准位置校验、线下场景适配 |
依赖GPS、实现复杂 |
企业考勤、线下活动、位置验证场景 |
选择方案时,需优先考虑用户规模、并发量、功能需求、成本预算四大因素:
- 小体量应用直接选关系型数据库方案,快速落地;
- 高并发/大规模用户优先选Redis/Bitmap方案;
- 线下场景必须选地理位置签到方案;
- 复杂统计+高并发可采用“MySQL+Redis”混合方案(Redis存实时数据,MySQL存持久化数据)。
如需深入学习签到系统背后的架构设计与工程实践,可前往 后端 & 架构 板块系统研读;若正参与团队学习打卡计划,欢迎加入 学习打卡 社区,与数千开发者共同坚持成长。