详解Java项目中用户连续签到阶梯式送积分场景

280 阅读3分钟

场景描述:Java项目,用户签到得积分,积分有过期时间(一年),消费积分(从积分商城购买物品会花积分)的时候优先使用快过期的积分,使用Bitmap记录签到,连续签到可以获得更多积分,规则如下,第1天1分,第2天2分。。。直到第10天达到最大10分,后面连续签到都是保持10积分,如果有一天中断则重新从1分开始叠加。

要实现该需求,需结合Redis和MySQL,核心设计如下:


1. 签到功能(Redis Bitmap)

  • 键设计sign:{userId}:{yearMonth}(例:sign:1001:202506

  • 操作

    // 用户签到(返回连续签到天数)
    public int sign(int userId) {
        LocalDate today = LocalDate.now();
        String key = "sign:" + userId + ":" + today.format(DateTimeFormatter.ofPattern("yyyyMM"));
        int dayOfMonth = today.getDayOfMonth() - 1; // Bitmap偏移量(0-indexed)
    
        // 1. 检查今日是否已签到
        if (Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, dayOfMonth))) {
            throw new BusinessException("今日已签到");
        }
    
        // 2. 标记今日签到
        redisTemplate.opsForValue().setBit(key, dayOfMonth, true);
    
        // 3. 计算连续签到天数(含今日)
        int continuousDays = calculateContinuousDays(userId, today);
        
        // 4. 发放积分(规则:第1天1分,第2天2分...第10天起10分)
        int points = Math.min(continuousDays, 10);
        addPoints(userId, points, today); // 积分入库(见下文)
        return continuousDays;
    }
    
  • 连续签到计算

    private int calculateContinuousDays(int userId, LocalDate today) {
        int days = 1; // 今日已签,从1开始
        LocalDate date = today.minusDays(1); // 昨日开始检查
      
        while (date != null) {
            String key = "sign:" + userId + ":" + date.format(DateTimeFormatter.ofPattern("yyyyMM"));
            int offset = date.getDayOfMonth() - 1;
            
            // 检查该日是否签到(跨月需切换key)
            if (offset >= 0 && Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, offset))) {
                days++;
                date = date.minusDays(1);
            } else {
                break;
            }
        }
        return days;
    }
    

2. 积分管理(MySQL + Redis)

积分记录表 user_points

字段类型说明
idBIGINT主键
user_idBIGINT用户ID
pointsINT积分数量
expire_timeDATETIME过期时间(签到时间+1年)
typeTINYINT类型(1:获得 2:使用)
create_timeDATETIME创建时间

积分过期策略

  • 存储:每次签到生成一条记录,expire_time = 当前时间 + 1年
  • 消费规则:优先使用最早过期的积分(FIFO)。

3. 积分消费(优先快过期)

public void consumePoints(int userId, int pointsToUse) {
    // 1. 查询可用积分(按过期时间升序)
    List<UserPoints> pointsList = userPointsMapper.selectAvailablePoints(userId);
    
    // 2. 校验总积分
    int totalPoints = pointsList.stream().mapToInt(UserPoints::getPoints).sum();
    if (totalPoints < pointsToUse) throw new BusinessException("积分不足");

    // 3. 优先扣除快过期的积分
    int remaining = pointsToUse;
    for (UserPoints up : pointsList) {
        if (remaining <= 0) break;
        
        if (up.getPoints() > remaining) {
            // 部分扣除:当前记录拆分
            userPointsMapper.deductPoints(up.getId(), remaining);
            userPointsMapper.insert(up.copyWithPoints(up.getPoints() - remaining)); // 剩余部分新记录
            remaining = 0;
        } else {
            // 整条扣除
            remaining -= up.getPoints();
            userPointsMapper.deleteById(up.getId());
        }
    }
    
    // 4. 记录消费流水(略)
}

SQL查询(按过期时间排序):

SELECT * FROM user_points 
WHERE user_id = #{userId} 
  AND expire_time > NOW() 
  AND type = 1 
ORDER BY expire_time ASC;

4. 关键细节

  1. 跨月签到计算

    • calculateContinuousDays()中处理跨月逻辑,自动切换Redis Key。
    • 使用LocalDate.minusDays(1)回溯日期并检查Bitmap。
  2. 积分过期清理

    • 定时任务每日删除expire_time < NOW()的记录。
    • 消费积分时自动跳过已过期记录。
  3. 并发控制

    • 签到:用Redis分布式锁(LOCK:sign:{userId})防止重复提交。
    • 消费:数据库乐观锁(版本号)或SELECT FOR UPDATE。
  4. 性能优化

    • Bitmap压缩:每月一个Key,仅占31位(4字节)。
    • 积分查询缓存:用Redis缓存用户总积分(更新时删除缓存)。

架构图

+--------+    签到    +------------+    记录积分    +------------+
| 用户端 | ---------> | Redis服务  | -----------> |   MySQL    |
+--------+ (Bitmap)  +------------+  (积分明细)  +------------+
                         |  ^
                         |  | 查询连续签到
                         |  |
                         v  |
                     +------------+
                     | 应用服务器 |
                     +------------+
                         |  ^
                         |  | 消费请求
                         v  |
                     +-----------+    扣减积分    +------------+
                     | 积分商城  | -----------> |   MySQL    |
                     +-----------+ (优先过期)    +------------+

此设计确保:

  • 签到高效(Bitmap O(1)操作)
  • 积分管理符合业务规则(优先过期)
  • 连续签到计算准确(跨月兼容)