找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1879

积分

0

好友

300

主题
发表于 昨天 15:53 | 查看: 2| 回复: 0

在各类现代应用开发场景中,签到打卡功能是提升用户粘性、记录用户行为的核心模块,广泛应用于企业考勤、在线教育、社区运营等领域。

本文将从技术实现角度,详细拆解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的签到系统主要用到以下数据结构:

  1. Set:存储每日签到的用户ID集合,快速判断用户是否已签到;
  2. Hash:存储用户签到的详细信息(如时间、地点);
  3. Sorted Set:实现签到排行榜功能;
  4. 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存持久化数据)。

如需深入学习签到系统背后的架构设计与工程实践,可前往 后端 & 架构 板块系统研读;若正参与团队学习打卡计划,欢迎加入 学习打卡 社区,与数千开发者共同坚持成长。




上一篇:iPhone Air官方价格破新低,活动价竟低于二手市场
下一篇:AI应用爆发:2026年CPU销量与价格上涨的需求侧逻辑
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-27 04:27 , Processed in 0.325484 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表