当前位置: 首页 > news >正文

SpringBoot的5种签到打卡实现方案(完整版)

在现代应用开发中,签到打卡功能广泛应用于企业考勤管理、在线教育、社区运营、电商等多个领域。

它不仅是一种记录用户行为的方式,也是提升用户粘性和活跃度的重要手段。

本文将介绍5种签到打卡的实现方案。

一、基于关系型数据库的传统签到系统

1.1 基本原理

        最直接的签到系统实现方式是利用关系型数据库(如MySQL、PostgreSQL)记录每次签到行为。

这种方案设计简单,易于理解和实现,适合大多数中小型应用场景。

1.2 数据模型设计

-- 用户表
CREATE TABLE users (id BIGINT PRIMARY KEY AUTO_INCREMENT,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,user_id BIGINT NOT NULL,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,user_id BIGINT NOT NULL,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 核心代码实现

实体类设计

@Data
@TableName("check_ins")
public class CheckIn {@TableId(value = "id", type = IdType.AUTO)private Long id;@TableField("user_id")private Long userId;@TableField("check_in_time")private LocalDateTime checkInTime;@TableField("check_in_date")private LocalDate checkInDate;@TableField("check_in_type")private String checkInType;private String location;@TableField("device_info")private String deviceInfo;private String remark;
}@Data
@TableName("check_in_stats")
public class CheckInStats {@TableId(value = "id", type = IdType.AUTO)private Long id;@TableField("user_id")private Long userId;@TableField("total_days")private Integer totalDays = 0;@TableField("continuous_days")private Integer continuousDays = 0;@TableField("last_check_in_date")private LocalDate lastCheckInDate;
}@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;
}

Mapper层

@Mapper
public interface CheckInMapper extends BaseMapper<CheckIn> {@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);@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);@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
public interface CheckInStatsMapper extends BaseMapper<CheckInStats> {@Select("SELECT * FROM check_in_stats WHERE user_id = #{userId}")CheckInStats findByUserId(@Param("userId") Long userId);
}@Mapper
public interface UserMapper extends BaseMapper<User> {@Select("SELECT * FROM users WHERE username = #{username}")User findByUsername(@Param("username") String username);
}

Service层

@Service
@Transactional
public class CheckInService {@Autowiredprivate CheckInMapper checkInMapper;@Autowiredprivate CheckInStatsMapper checkInStatsMapper;@Autowiredprivate UserMapper userMapper;/*** 用户签到*/public CheckIn checkIn(Long userId, String type, String location, String deviceInfo, String remark) {// 检查用户是否存在User user = userMapper.selectById(userId);if (user == null) {throw new RuntimeException("User not found");}LocalDate today = LocalDate.now();// 检查今天是否已经签到if (checkInMapper.existsByUserIdAndDateAndType(userId, today, type) > 0) {throw new RuntimeException("Already checked in today");}// 创建签到记录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);// 更新签到统计updateCheckInStats(userId, today);return checkIn;}/*** 更新签到统计信息*/private void updateCheckInStats(Long userId, LocalDate today) {CheckInStats stats = checkInStatsMapper.findByUserId(userId);if (stats == null) {stats = new CheckInStats();stats.setUserId(userId);stats.setTotalDays(1);stats.setContinuousDays(1);stats.setLastCheckInDate(today);checkInStatsMapper.insert(stats);} else {// 更新总签到天数stats.setTotalDays(stats.getTotalDays() + 1);// 更新连续签到天数if (stats.getLastCheckInDate() != null) {if (today.minusDays(1).equals(stats.getLastCheckInDate())) {// 连续签到stats.setContinuousDays(stats.getContinuousDays() + 1);} else if (today.equals(stats.getLastCheckInDate())) {// 当天重复签到,不计算连续天数} else {// 中断连续签到stats.setContinuousDays(1);}}stats.setLastCheckInDate(today);checkInStatsMapper.updateById(stats);}}/*** 获取用户签到统计*/public CheckInStats getCheckInStats(Long userId) {CheckInStats stats = checkInStatsMapper.findByUserId(userId);if (stats == null) {throw new RuntimeException("Check-in stats not found");}return stats;}/*** 获取用户指定日期范围内的签到记录*/public List<CheckIn> getCheckInHistory(Long userId, LocalDate startDate, LocalDate endDate) {return checkInMapper.findByUserIdAndDateBetween(userId, startDate, endDate);}
}

Controller层

@RestController
@RequestMapping("/api/check-ins")
public class CheckInController {@Autowiredprivate CheckInService checkInService;@PostMappingpublic ResponseEntity<CheckIn> checkIn(@RequestBody CheckInRequest request) {CheckIn checkIn = checkInService.checkIn(request.getUserId(),request.getType(),request.getLocation(),request.getDeviceInfo(),request.getRemark());return ResponseEntity.ok(checkIn);}@GetMapping("/stats/{userId}")public ResponseEntity<CheckInStats> getStats(@PathVariable Long userId) {CheckInStats stats = checkInService.getCheckInStats(userId);return ResponseEntity.ok(stats);}@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;private String type;private String location;private String deviceInfo;private String remark;
}

1.4 优缺点分析

优点:

  • 设计简单直观,易于理解和实现

  • 支持丰富的数据查询和统计功能

  • 事务支持,确保数据一致性

  • 易于与现有系统集成

缺点:

  • 数据量大时查询性能可能下降

  • 连续签到统计等复杂查询逻辑实现相对繁琐

  • 不适合高并发场景

  • 数据库负载较高

1.5 适用场景

  • 中小型企业的员工考勤系统

  • 课程签到系统

  • 会议签到管理

  • 用户量不大的社区签到功能

二、基于Redis的高性能签到系统

2.1 基本原理

        利用Redis的高性能和丰富的数据结构,可以构建一个响应迅速的签到系统。尤其是对于高并发场景和需要实时统计的应用,Redis提供了显著的性能优势。

2.2 系统设计

Redis中我们可以使用以下几种数据结构来实现签到系统:

String: 记录用户最后签到时间和连续签到天数
Hash: 存储用户当天的签到详情
Sorted Set: 按签到时间排序的用户列表,便于排行榜等功能
Set: 记录每天签到的用户集合

2.3 核心代码实现

Redis配置

@Configuration
public class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(factory);// 使用Jackson2JsonRedisSerializer序列化和反序列化redis的value值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());template.setHashValueSerializer(serializer);template.afterPropertiesSet();return template;}
}

签到服务实现

@Service
public class RedisCheckInService {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:";@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 用户签到*/public boolean checkIn(Long userId, String location) {String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);String userKey = USER_CHECKIN_KEY + userId;String dailyKey = DAILY_CHECKIN_KEY + today;// 判断用户今天是否已经签到Boolean isMember = redisTemplate.opsForSet().isMember(dailyKey, userId);if (isMember != null && isMember) {return false; // 已经签到过了}// 记录用户签到redisTemplate.opsForSet().add(dailyKey, userId);// 设置过期时间(35天后过期,以便统计连续签到)redisTemplate.expire(dailyKey, 35, TimeUnit.DAYS);// 记录用户签到详情Map<String, String> checkInInfo = new HashMap<>();checkInInfo.put("time", LocalDateTime.now().toString());checkInInfo.put("location", location);redisTemplate.opsForHash().putAll(userKey + ":" + today, checkInInfo);// 更新签到排行榜redisTemplate.opsForZSet().incrementScore(CHECKIN_RANK_KEY + today, userId, 1);// 更新用户签到统计updateUserCheckInStats(userId);return true;}/*** 更新用户签到统计*/private void updateUserCheckInStats(Long userId) {String userStatsKey = USER_STATS_KEY + userId;// 获取当前日期LocalDate today = LocalDate.now();String todayStr = today.format(DateTimeFormatter.ISO_DATE);// 获取用户最后签到日期String lastCheckInDate = (String) redisTemplate.opsForHash().get(userStatsKey, "lastCheckInDate");// 更新总签到天数redisTemplate.opsForHash().increment(userStatsKey, "totalDays", 1);// 更新连续签到天数if (lastCheckInDate != null) {LocalDate lastDate = LocalDate.parse(lastCheckInDate, DateTimeFormatter.ISO_DATE);if (today.minusDays(1).equals(lastDate)) {// 连续签到redisTemplate.opsForHash().increment(userStatsKey, "continuousDays", 1);} else if (today.equals(lastDate)) {// 当天重复签到,不增加连续天数} else {// 中断连续签到redisTemplate.opsForHash().put(userStatsKey, "continuousDays", 1);}} else {// 第一次签到redisTemplate.opsForHash().put(userStatsKey, "continuousDays", 1);}// 更新最后签到日期redisTemplate.opsForHash().put(userStatsKey, "lastCheckInDate", todayStr);}/*** 获取用户签到统计信息*/public Map<Object, Object> getUserCheckInStats(Long userId) {String userStatsKey = USER_STATS_KEY + userId;return redisTemplate.opsForHash().entries(userStatsKey);}/*** 获取用户是否已签到*/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;}/*** 获取今日签到用户数*/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;}/*** 获取签到排行榜*/public Set<ZSetOperations.TypedTuple<Object>> getCheckInRank(int limit) {String today = LocalDate.now().format(DateTimeFormatter.ISO_DATE);String rankKey = CHECKIN_RANK_KEY + today;return redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, 0, limit - 1);}/*** 检查用户在指定日期是否签到*/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;}/*** 获取用户指定月份的签到情况*/public List<String> getMonthlyCheckInStatus(Long userId, int year, int month) {List<String> result = new ArrayList<>();YearMonth yearMonth = 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;}
}

控制器实现

@RestController
@RequestMapping("/api/redis-check-in")
public class RedisCheckInController {@Autowiredprivate RedisCheckInService checkInService;@PostMappingpublic ResponseEntity<?> checkIn(@RequestBody RedisCheckInRequest request) {boolean success = checkInService.checkIn(request.getUserId(), request.getLocation());if (success) {return ResponseEntity.ok(Map.of("message", "Check-in successful"));} else {return ResponseEntity.badRequest().body(Map.of("message", "Already checked in today"));}}@GetMapping("/stats/{userId}")public ResponseEntity<Map<Object, Object>> getUserStats(@PathVariable Long userId) {Map<Object, Object> stats = checkInService.getUserCheckInStats(userId);return ResponseEntity.ok(stats);}@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);}@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);}@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);}
}@Data
public class RedisCheckInRequest {private Long userId;private String location;
}

2.4 优缺点分析

优点:

  • 极高的性能,支持高并发场景

  • 丰富的数据结构支持多种签到功能(排行榜、签到日历等)

  • 内存数据库,响应速度快

  • 适合实时统计和分析

缺点:

  • 数据持久性不如关系型数据库

  • 复杂查询能力有限

  • 内存成本较高

2.5 适用场景

  • 大型社区或应用的签到功能

  • 实时性要求高的签到系统

  • 高并发场景下的打卡功能

  • 需要签到排行榜、签到日历等交互功能的应用

三、基于位图(Bitmap)的连续签到统计系统

3.1 基本原理

Redis的Bitmap是一种非常节省空间的数据结构,它可以用来记录签到状态,每个bit位代表一天的签到状态(0表示未签到,1表示已签到)。利用Bitmap可以高效地实现连续签到统计、月度签到日历等功能,同时极大地节省内存使用。

3.2 系统设计

主要使用Redis的Bitmap操作来记录和统计用户签到情况:

  1. 每个用户每个月的签到记录使用一个Bitmap

  2. Bitmap的每一位代表当月的一天(1-31)

  3. 通过位操作可以高效地统计签到天数、连续签到等信息

3.3 核心代码实现

@Service
public class BitmapCheckInService {@Autowiredprivate StringRedisTemplate redisTemplate;/*** 用户签到*/public boolean checkIn(Long userId) {LocalDate today = LocalDate.now();int day = today.getDayOfMonth(); // 获取当月的第几天String key = buildSignKey(userId, today);// 检查今天是否已经签到Boolean isSigned = redisTemplate.opsForValue().getBit(key, day - 1);if (isSigned != null && isSigned) {return false; // 已经签到}// 设置签到标记redisTemplate.opsForValue().setBit(key, day - 1, true);// 设置过期时间(确保数据不会永久保存)redisTemplate.expire(key, 100, TimeUnit.DAYS);// 更新连续签到记录updateContinuousSignDays(userId);return true;}/*** 更新连续签到天数*/private void updateContinuousSignDays(Long userId) {LocalDate today = LocalDate.now();String continuousKey = "user:sign:continuous:" + userId;// 判断昨天是否签到boolean yesterdayChecked = isSignedIn(userId, today.minusDays(1));if (yesterdayChecked) {// 昨天签到了,连续签到天数+1redisTemplate.opsForValue().increment(continuousKey);} else {// 昨天没签到,重置连续签到天数为1redisTemplate.opsForValue().set(continuousKey, "1");}}/*** 判断用户指定日期是否签到*/public boolean isSignedIn(Long userId, LocalDate date) {int day = date.getDayOfMonth();String key = buildSignKey(userId, date);Boolean isSigned = redisTemplate.opsForValue().getBit(key, day - 1);return isSigned != null && isSigned;}/*** 获取用户连续签到天数*/public int getContinuousSignDays(Long userId) {String continuousKey = "user:sign:continuous:" + userId;String value = redisTemplate.opsForValue().get(continuousKey);return value != null ? Integer.parseInt(value) : 0;}/*** 获取用户当月签到次数*/public long getMonthSignCount(Long userId, LocalDate date) {String key = buildSignKey(userId, date);int dayOfMonth = date.lengthOfMonth(); // 当月总天数return redisTemplate.execute((RedisCallback<Long>) con -> {return con.bitCount(key.getBytes());});}/*** 获取用户当月签到情况*/public List<Integer> getMonthSignData(Long userId, LocalDate date) {List<Integer> result = new ArrayList<>();String key = buildSignKey(userId, date);int dayOfMonth = date.lengthOfMonth(); // 当月总天数for (int i = 0; i < dayOfMonth; i++) {Boolean isSigned = redisTemplate.opsForValue().getBit(key, i);result.add(isSigned != null && isSigned ? 1 : 0);}return result;}/*** 获取用户当月首次签到时间*/public int getFirstSignDay(Long userId, LocalDate date) {String key = buildSignKey(userId, date);int dayOfMonth = date.lengthOfMonth(); // 当月总天数for (int i = 0; i < dayOfMonth; i++) {Boolean isSigned = redisTemplate.opsForValue().getBit(key, i);if (isSigned != null && isSigned) {return i + 1; // 返回第一次签到的日期}}return -1; // 本月没有签到记录}/*** 构建签到Key*/private String buildSignKey(Long userId, LocalDate date) {return String.format("user:sign:%d:%d%02d", userId, date.getYear(), date.getMonthValue());}
}

控制器实现

@RestController
@RequestMapping("/api/bitmap-check-in")
public class BitmapCheckInController {@Autowiredprivate BitmapCheckInService checkInService;@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", "Already checked in today"));}}@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);}@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 优缺点分析

优点:

  • 极其节省存储空间,一个月的签到记录仅需要4字节

  • 位操作性能高,适合大规模用户

  • 统计操作(如计算签到天数)非常高效

  • 适合实现签到日历和连续签到统计

缺点:

  • 不能存储签到的详细信息(如签到时间、地点等)

  • 仅适合简单的签到/未签到二元状态记录

  • 复杂的签到业务逻辑实现较困难

  • 历史数据查询相对复杂

3.5 适用场景

  • 需要节省存储空间的大规模用户签到系统

  • 社区/电商平台的每日签到奖励功能

  • 需要高效计算连续签到天数的应用

  • 移动应用的签到日历功能

四、基于地理位置的签到打卡系统

4.1 基本原理

地理位置签到系统利用用户的GPS定位信息,验证用户是否在指定区域内进行签到,常用于企业考勤、学校上课点名、实地活动签到等场景。

该方案结合了关系型数据库存储签到记录和Redis的GEO功能进行位置验证。

4.2 数据模型设计

-- 签到位置表
CREATE TABLE check_in_locations (id BIGINT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(100) NOT NULL,latitude DOUBLE NOT NULL,longitude DOUBLE NOT NULL,radius DOUBLE NOT NULL, -- 有效半径(米)address VARCHAR(255),location_type VARCHAR(50),created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);-- 地理位置签到记录表
CREATE TABLE geo_check_ins (id BIGINT PRIMARY KEY AUTO_INCREMENT,user_id BIGINT NOT NULL,location_id BIGINT NOT NULL,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 核心代码实现

实体类设计

@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;
}@Data
@TableName("geo_check_ins")
public class GeoCheckIn {@TableId(value = "id", type = IdType.AUTO)private Long id;@TableField("user_id")private Long userId;@TableField("location_id")private Long locationId;@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;
}

Mapper层

@Mapper
public interface CheckInLocationMapper extends BaseMapper<CheckInLocation> {@Select("SELECT * FROM check_in_locations WHERE location_type = #{locationType}")List<CheckInLocation> findByLocationType(@Param("locationType") String locationType);
}@Mapper
public interface GeoCheckInMapper extends BaseMapper<GeoCheckIn> {@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);@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);
}

Service层

@Service
@Transactional
public class GeoCheckInService {@Autowiredprivate CheckInLocationMapper locationMapper;@Autowiredprivate GeoCheckInMapper geoCheckInMapper;@Autowiredprivate UserMapper userMapper;@Autowiredprivate StringRedisTemplate redisTemplate;private static final String GEO_KEY = "geo:locations";@PostConstructpublic void init() {// 将所有签到位置加载到Redis GEO中List<CheckInLocation> locations = locationMapper.selectList(null);if (!locations.isEmpty()) {Map<String, Point> locationPoints = new HashMap<>();for (CheckInLocation location : locations) {locationPoints.put(location.getId().toString(), new Point(location.getLongitude(), location.getLatitude()));}redisTemplate.opsForGeo().add(GEO_KEY, locationPoints);}}/*** 添加新的签到地点*/public CheckInLocation addCheckInLocation(CheckInLocation location) {location.setCreatedAt(LocalDateTime.now());locationMapper.insert(location);// 添加到Redis GEOredisTemplate.opsForGeo().add(GEO_KEY, new Point(location.getLongitude(), location.getLatitude()), location.getId().toString());return location;}/*** 用户地理位置签到*/public GeoCheckIn checkIn(Long userId, Long locationId, Double latitude, Double longitude, Double accuracy, String deviceInfo) {// 验证用户和位置是否存在User user = userMapper.selectById(userId);if (user == null) {throw new RuntimeException("User not found");}CheckInLocation location = locationMapper.selectById(locationId);if (location == null) {throw new RuntimeException("Check-in location not found");}// 检查今天是否已经在该位置签到LocalDateTime now = LocalDateTime.now();GeoCheckIn existingCheckIn = geoCheckInMapper.findByUserIdAndLocationIdAndDate(userId, locationId, now);if (existingCheckIn != null) {throw new RuntimeException("Already checked in at this location today");}// 验证用户是否在签到范围内boolean isWithinRange = isWithinCheckInRange(latitude, longitude, location.getLatitude(), location.getLongitude(), location.getRadius());// 创建签到记录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;}/*** 检查用户是否在签到范围内*/private boolean isWithinCheckInRange(Double userLat, Double userLng, Double locationLat, Double locationLng, Double radius) {// 使用Redis GEO计算距离Distance distance = redisTemplate.opsForGeo().distance(GEO_KEY,locationLat + "," + locationLng,userLat + "," + userLng,Metrics.METERS);return distance != null && distance.getValue() <= radius;}/*** 查找附近的签到地点*/public List<GeoResult<RedisGeoCommands.GeoLocation<String>>> findNearbyLocations(Double latitude, Double longitude, Double radius) {Circle circle = new Circle(new Point(longitude, latitude), new Distance(radius, Metrics.METERS));RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().sortAscending();GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo().radius(GEO_KEY, circle, args);return results != null ? results.getContent() : Collections.emptyList();}/*** 获取用户签到历史*/public List<GeoCheckIn> getUserCheckInHistory(Long userId, LocalDate startDate, LocalDate endDate) {LocalDateTime startDateTime = startDate.atStartOfDay();LocalDateTime endDateTime = endDate.atTime(23, 59, 59);return geoCheckInMapper.findByUserIdAndCheckInTimeBetween(userId, startDateTime, endDateTime);}
}

Controller层

@RestController
@RequestMapping("/api/geo-check-in")
public class GeoCheckInController {@Autowiredprivate GeoCheckInService geoCheckInService;@PostMapping("/locations")public ResponseEntity<CheckInLocation> addLocation(@RequestBody CheckInLocation location) {CheckInLocation savedLocation = geoCheckInService.addCheckInLocation(location);return ResponseEntity.ok(savedLocation);}@PostMappingpublic ResponseEntity<?> checkIn(@RequestBody GeoCheckInRequest request) {try {GeoCheckIn checkIn = geoCheckInService.checkIn(request.getUserId(),request.getLocationId(),request.getLatitude(),request.getLongitude(),request.getAccuracy(),request.getDeviceInfo());Map<String, Object> response = new HashMap<>();response.put("id", checkIn.getId());response.put("checkInTime", checkIn.getCheckInTime());response.put("isValid", checkIn.getIsValid());if (!checkIn.getIsValid()) {response.put("message", "You are not within the valid check-in range");}return ResponseEntity.ok(response);} catch (RuntimeException e) {return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));}}@GetMapping("/nearby")public ResponseEntity<?> findNearbyLocations(@RequestParam Double latitude,@RequestParam Double longitude,@RequestParam(defaultValue = "500") Double radius) {List<GeoResult<RedisGeoCommands.GeoLocation<String>>> locations = geoCheckInService.findNearbyLocations(latitude, longitude, radius);return ResponseEntity.ok(locations);}@GetMapping("/history/{userId}")public ResponseEntity<List<GeoCheckIn>> getUserHistory(@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;private Long locationId;private Double latitude;private Double longitude;private Double accuracy;private String deviceInfo;
}

4.4 优缺点分析

优点:

  • 利用地理位置验证提高签到真实性

  • 支持多地点签到和附近地点查找

  • 结合了关系型数据库和Redis的优势

  • 适合需要物理位置验证的场景

缺点:

  • 依赖用户设备的GPS定位精度

  • 可能受到GPS欺骗工具的影响

  • 室内定位精度可能不足

  • 系统复杂度较高

4.5 适用场景

  • 企业员工考勤系统

  • 外勤人员签到打卡

  • 学校课堂点名

  • 实地活动签到验证

  • 外卖/快递配送签收系统

五、基于二维码的签到打卡系统

5.1 基本原理

二维码签到系统通过动态生成带有时间戳和签名的二维码,用户通过扫描二维码完成签到。

这种方式适合会议、课程、活动等场景,可有效防止代签,同时简化签到流程。

5.2 数据模型设计

-- 签到活动表
CREATE TABLE check_in_events (id BIGINT PRIMARY KEY AUTO_INCREMENT,title VARCHAR(100) NOT NULL,description VARCHAR(500),start_time TIMESTAMP NOT NULL,end_time TIMESTAMP NOT NULL,location VARCHAR(255),organizer_id BIGINT,qr_code_refresh_interval INT DEFAULT 60, -- 二维码刷新间隔(秒)created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY (organizer_id) REFERENCES users(id)
);-- 二维码签到记录表
CREATE TABLE qr_check_ins (id BIGINT PRIMARY KEY AUTO_INCREMENT,event_id BIGINT NOT NULL,user_id BIGINT NOT NULL,check_in_time TIMESTAMP NOT NULL,qr_code_token VARCHAR(100) NOT NULL,ip_address VARCHAR(50),device_info VARCHAR(255),FOREIGN KEY (event_id) REFERENCES check_in_events(id),FOREIGN KEY (user_id) REFERENCES users(id),UNIQUE KEY unique_event_user (event_id, user_id)
);

5.3 核心代码实现

实体类设计

@Data
@TableName("check_in_events")
public class CheckInEvent {@TableId(value = "id", type = IdType.AUTO)private Long id;private String title;private String description;@TableField("start_time")private LocalDateTime startTime;@TableField("end_time")private LocalDateTime endTime;private String location;@TableField("organizer_id")private Long organizerId;@TableField("qr_code_refresh_interval")private Integer qrCodeRefreshInterval = 60; // 默认60秒@TableField("created_at")private LocalDateTime createdAt;@TableField(exist = false)private String currentQrCode;
}@Data
@TableName("qr_check_ins")
public class QrCheckIn {@TableId(value = "id", type = IdType.AUTO)private Long id;@TableField("event_id")private Long eventId;@TableField("user_id")private Long userId;@TableField("check_in_time")private LocalDateTime checkInTime;@TableField("qr_code_token")private String qrCodeToken;@TableField("ip_address")private String ipAddress;@TableField("device_info")private String deviceInfo;
}

Mapper层

@Mapper
public interface CheckInEventMapper extends BaseMapper<CheckInEvent> {
}@Mapper
public interface QrCheckInMapper extends BaseMapper<QrCheckIn> {@Select("SELECT * FROM qr_check_ins WHERE event_id = #{eventId} ORDER BY check_in_time DESC")List<QrCheckIn> findByEventIdOrderByCheckInTimeDesc(@Param("eventId") Long eventId);@Select("SELECT * FROM qr_check_ins WHERE user_id = #{userId} ORDER BY check_in_time DESC")List<QrCheckIn> findByUserIdOrderByCheckInTimeDesc(@Param("userId") Long userId);@Select("SELECT COUNT(*) FROM qr_check_ins WHERE event_id = #{eventId}")long countByEventId(@Param("eventId") Long eventId);@Select("SELECT COUNT(*) FROM qr_check_ins WHERE event_id = #{eventId} AND user_id = #{userId}")int existsByEventIdAndUserId(@Param("eventId") Long eventId, @Param("userId") Long userId);
}

QR码服务和校验

@Service
public class QrCodeService {@Value("${qrcode.secret:defaultSecretKey}")private String secretKey;@Autowiredprivate StringRedisTemplate redisTemplate;/*** 生成带签名的二维码内容*/public String generateQrCodeContent(Long eventId) {long timestamp = System.currentTimeMillis();String content = eventId + ":" + timestamp;String signature = generateSignature(content);// 创建完整的二维码内容String qrCodeContent = content + ":" + signature;// 保存到Redis,设置过期时间String redisKey = "qrcode:event:" + eventId + ":" + timestamp;redisTemplate.opsForValue().set(redisKey, qrCodeContent, 5, TimeUnit.MINUTES);return qrCodeContent;}/*** 验证二维码内容*/public boolean validateQrCode(String qrCodeContent) {String[] parts = qrCodeContent.split(":");if (parts.length != 3) {return false;}String eventId = parts[0];String timestamp = parts[1];String providedSignature = parts[2];// 验证签名String content = eventId + ":" + timestamp;String expectedSignature = generateSignature(content);if (!expectedSignature.equals(providedSignature)) {return false;}// 验证二维码是否在Redis中存在(防止重复使用)String redisKey = "qrcode:event:" + eventId + ":" + timestamp;Boolean exists = redisTemplate.hasKey(redisKey);return exists != null && exists;}/*** 生成二维码图片*/public byte[] generateQrCodeImage(String content, int width, int height) throws Exception {QRCodeWriter qrCodeWriter = new QRCodeWriter();BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, width, height);ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream();MatrixToImageWriter.writeToStream(bitMatrix, "PNG", pngOutputStream);return pngOutputStream.toByteArray();}/*** 生成内容签名*/private String generateSignature(String content) {try {Mac sha256_HMAC = Mac.getInstance("HmacSHA256");SecretKeySpec secret_key = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256");sha256_HMAC.init(secret_key);byte[] hash = sha256_HMAC.doFinal(content.getBytes());return Base64.getEncoder().encodeToString(hash);} catch (Exception e) {throw new RuntimeException("Failed to generate signature", e);}}
}

服务层实现

@Service
@Transactional
public class QrCheckInService {@Autowiredprivate CheckInEventMapper eventMapper;@Autowiredprivate QrCheckInMapper qrCheckInMapper;@Autowiredprivate UserMapper userMapper;@Autowiredprivate QrCodeService qrCodeService;/*** 创建签到活动*/public CheckInEvent createEvent(CheckInEvent event) {event.setCreatedAt(LocalDateTime.now());eventMapper.insert(event);return event;}/*** 获取活动信息,包括当前二维码*/public CheckInEvent getEventWithQrCode(Long eventId) {CheckInEvent event = eventMapper.selectById(eventId);if (event == null) {throw new RuntimeException("Event not found");}// 检查活动是否在有效期内LocalDateTime now = LocalDateTime.now();if (now.isBefore(event.getStartTime()) || now.isAfter(event.getEndTime())) {throw new RuntimeException("Event is not active");}// 生成当前二维码String qrCodeContent = qrCodeService.generateQrCodeContent(eventId);event.setCurrentQrCode(qrCodeContent);return event;}/*** 用户通过二维码签到*/public QrCheckIn checkIn(String qrCodeContent, Long userId, String ipAddress, String deviceInfo) {// 验证二维码if (!qrCodeService.validateQrCode(qrCodeContent)) {throw new RuntimeException("Invalid QR code");}// 解析二维码内容String[] parts = qrCodeContent.split(":");Long eventId = Long.parseLong(parts[0]);// 验证活动和用户CheckInEvent event = eventMapper.selectById(eventId);if (event == null) {throw new RuntimeException("Event not found");}User user = userMapper.selectById(userId);if (user == null) {throw new RuntimeException("User not found");}// 检查活动是否在有效期内LocalDateTime now = LocalDateTime.now();if (now.isBefore(event.getStartTime()) || now.isAfter(event.getEndTime())) {throw new RuntimeException("Event is not active");}// 检查用户是否已经签到if (qrCheckInMapper.existsByEventIdAndUserId(eventId, userId) > 0) {throw new RuntimeException("User already checked in for this event");}// 创建签到记录QrCheckIn checkIn = new QrCheckIn();checkIn.setEventId(eventId);checkIn.setUserId(userId);checkIn.setCheckInTime(now);checkIn.setQrCodeToken(qrCodeContent);checkIn.setIpAddress(ipAddress);checkIn.setDeviceInfo(deviceInfo);qrCheckInMapper.insert(checkIn);return checkIn;}/*** 获取活动签到列表*/public List<QrCheckIn> getEventCheckIns(Long eventId) {return qrCheckInMapper.findByEventIdOrderByCheckInTimeDesc(eventId);}/*** 获取用户签到历史*/public List<QrCheckIn> getUserCheckIns(Long userId) {return qrCheckInMapper.findByUserIdOrderByCheckInTimeDesc(userId);}/*** 获取活动签到统计*/public Map<String, Object> getEventStatistics(Long eventId) {CheckInEvent event = eventMapper.selectById(eventId);if (event == null) {throw new RuntimeException("Event not found");}long totalCheckIns = qrCheckInMapper.countByEventId(eventId);Map<String, Object> statistics = new HashMap<>();statistics.put("eventId", eventId);statistics.put("title", event.getTitle());statistics.put("startTime", event.getStartTime());statistics.put("endTime", event.getEndTime());statistics.put("totalCheckIns", totalCheckIns);return statistics;}
}

控制器实现

@RestController
@RequestMapping("/api/qr-check-in")
public class QrCheckInController {@Autowiredprivate QrCheckInService checkInService;@Autowiredprivate QrCodeService qrCodeService;@PostMapping("/events")public ResponseEntity<CheckInEvent> createEvent(@RequestBody CheckInEvent event) {CheckInEvent createdEvent = checkInService.createEvent(event);return ResponseEntity.ok(createdEvent);}@GetMapping("/events/{eventId}")public ResponseEntity<CheckInEvent> getEvent(@PathVariable Long eventId) {CheckInEvent event = checkInService.getEventWithQrCode(eventId);return ResponseEntity.ok(event);}@GetMapping("/events/{eventId}/qrcode")public ResponseEntity<?> getEventQrCode(@PathVariable Long eventId,@RequestParam(defaultValue = "300") int width,@RequestParam(defaultValue = "300") int height) {try {CheckInEvent event = checkInService.getEventWithQrCode(eventId);byte[] qrCodeImage = qrCodeService.generateQrCodeImage(event.getCurrentQrCode(), width, height);return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).body(qrCodeImage);} catch (Exception e) {return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));}}@PostMapping("/check-in")public ResponseEntity<?> checkIn(@RequestBody QrCheckInRequest request, HttpServletRequest httpRequest) {try {String ipAddress = httpRequest.getRemoteAddr();QrCheckIn checkIn = checkInService.checkIn(request.getQrCodeContent(),request.getUserId(),ipAddress,request.getDeviceInfo());return ResponseEntity.ok(checkIn);} catch (RuntimeException e) {return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));}}@GetMapping("/events/{eventId}/check-ins")public ResponseEntity<List<QrCheckIn>> getEventCheckIns(@PathVariable Long eventId) {List<QrCheckIn> checkIns = checkInService.getEventCheckIns(eventId);return ResponseEntity.ok(checkIns);}@GetMapping("/users/{userId}/check-ins")public ResponseEntity<List<QrCheckIn>> getUserCheckIns(@PathVariable Long userId) {List<QrCheckIn> checkIns = checkInService.getUserCheckIns(userId);return ResponseEntity.ok(checkIns);}@GetMapping("/events/{eventId}/statistics")public ResponseEntity<Map<String, Object>> getEventStatistics(@PathVariable Long eventId) {Map<String, Object> statistics = checkInService.getEventStatistics(eventId);return ResponseEntity.ok(statistics);}
}@Data
public class QrCheckInRequest {private String qrCodeContent;private Long userId;private String deviceInfo;
}

5.4 优缺点分析

优点:

  • 签到过程简单快捷,用户体验好

  • 适合集中式签到场景(会议、课程等)

缺点:

  • 需要组织者提前设置签到活动

  • 需要现场展示二维码(投影、打印等)

  • 可能出现二维码被拍照传播的风险

5.5 适用场景

  • 会议、研讨会签到

  • 课堂点名

  • 活动入场签到

  • 培训签到

  • 需要现场确认的签到场景

六、各方案对比与选择指南

6.1 功能对比

功能特性

关系型数据库

Redis

Bitmap

地理位置

二维码

实现复杂度

系统性能

极高

存储效率

极高

用户体验

开发成本

维护成本

6.2 适用场景对比

方案

最佳适用场景

不适合场景

关系型数据库

中小型企业考勤、简单签到系统

高并发、大规模用户场景

Redis

高并发社区签到、连续签到奖励

需要复杂查询和报表统计

Bitmap

大规模用户的每日签到、连续签到统计

需要详细签到信息记录

地理位置

外勤人员打卡、实地活动签到

室内或GPS信号弱的环境

二维码

会议、课程、活动签到

远程办公、分散式签到

七、总结

在实际应用中,可以根据具体需求、用户规模、安全要求和预算等因素选择最合适的方案,也可以将多种方案结合使用,构建更加完善的签到打卡系统。

http://www.xdnf.cn/news/1015633.html

相关文章:

  • 红帽认证工程师(RHCE):掌握Linux自动化的关键
  • 浅谈为windows7平台打包基于pyside6的UI程序
  • AD工程面板拖动以及固定位置
  • 通过XML方式在Word段落前添加空白段落
  • “交错推理”降低首token耗时,并且显著提升推理准确性!!
  • DMC-E 系列总线控制卡----雷赛板卡介绍(五)
  • 组合模式深度解析:Java设计模式实战指南与树形结构处理架构设计
  • 在ros中动态调整雷达,线激光雷达等设备的静态坐标关系
  • NaluCFD 介绍和使用指南
  • 复习embedding编码范式及理解代理Agentic RAG及传统RAG的区别
  • 【leetcode】101. 对称二叉树
  • 编译,多面体库
  • Java SE(13)——工具类
  • 基于深度学习的智能语音合成系统:技术与实践
  • Android中的DX、D8、R8
  • HTML5实现好看的邀请函网页源码
  • 1.13使用 Node.js 操作 SQLite
  • 7. TypeScript接口
  • gazebo仿真中对无人机集成的相机进行标定(VINS-Fusion)
  • 西电新增信息力学与感知学院,26考研正式招生
  • qt配合海康工业相机取图开发
  • UE 新版渲染器输出视频
  • HOT 100 | 73.矩阵置零、54.螺旋矩阵、48.旋转图像
  • 贫血模型与充血模型
  • 从0到1:Dify AI智能体部署与使用全攻略
  • 如何存储和和使用比特币---第1关:比特币的存储
  • 机器学习--分类
  • Kafka入门4.0.0版本(基于Java、SpringBoot操作)
  • [Godot] C#读取CSV表格创建双层字典实现本地化
  • Yarn与NPM缓存存储目录迁移