如果相遇的目的是为了失去,那相遇的目的又是什么呢?

设计一个用户签到系统,并且这个签到系统支持7天,14天等不同天数的连续签到功能?

方案1 2张表

很暴力的做法,2张表去实现这个业务。

为了支持连续签到功能和不同天数的签到周期,我们需要设计合理的数据结构。假设使用关系型数据库(如 MySQL),以下是一些关键表的设计。

表1:用户签到记录表(user_signin_log

记录用户每天的签到情况。

字段名 类型 说明
user_id BIGINT 用户ID
signin_date DATE 签到日期
created_at DATETIME 记录创建时间

说明

  • signin_date 记录用户签到的日期。
  • user_idsignin_date 可以设置成复合主键,保证一天只能签到一次。

表2:用户连续签到表(user_signin_streak

记录用户的连续签到情况,方便快速查询。

说明 字段名 类型
用户ID user_id BIGINT
当前连续签到天数 current_streak INT
上一次签到的日期 last_signin_date DATE
用户历史最长连续签到天数 longest_streak INT
是否已经领取奖励 reward_claimed BOOLEAN
连续签到重置日期(可选) next_reset_date DATE

说明

  • current_streak 用于记录当前的连续签到天数。
  • last_signin_date 是用户上次签到的日期,用于判断用户是否连续签到。
  • longest_streak 记录用户历史上最长的连续签到天数。
  • next_reset_date(可选):如果用户的签到周期是 7 天或 14 天,则可以用这个字段来标记下一个周期的重置日期。

主要流程如下:

  1. 签到请求:用户发起签到请求。
  2. 检查是否已经签到:首先查询 user_signin_log 表,检查当天是否已经签到过。如果签到过,返回提示信息。
  3. 计算是否连续签到:
    • 查询user_signin_streak表,获取用户上次签到的日期:
      • 如果 last_signin_date昨天,则认为用户连续签到,更新 current_streak
      • 如果 last_signin_date今天,直接返回(避免重复签到)。
      • 如果 last_signin_date 不是昨天,也不是今天,说明用户中断签到,重置 current_streak 为 1。
    • 更新 user_signin_log 表和 user_signin_streak 表,记录最新的签到情况。
  4. 奖励检查:如果用户的 current_streak 达到设定的天数(如7天、14天等),触发奖励逻辑。

方案2 Redis-BitMap

Redis 的 Bitmap 是一种基于二进制位操作的数据结构,特别适合类似签到这样按天记录的场景。每个用户可以对应一个 Bitmap,Bitmap 中的每个位表示用户在某一天是否签到,1 表示已签到,0 表示未签到。

关键实现:

  • 签到操作:将当天对应的位设置为 1
  • 查看某天是否签到:检查某天对应的位是 1 还是 0
  • 连续签到判断:将最近的几天(如7天)的位进行统计,判断是否连续签到。

实现步骤:

  1. 存储结构

    • 使用 Redis 的 Bitmap,每个用户对应一个 Bitmap 键,例如:user_signin_1001,其中 1001 是用户 ID。
    • 每天的签到对应 Bitmap 的一个位,位的索引可以是当天距离某个基准日的天数。例如,2023年1月1日是基准日,那么 2023年1月2日对应的位索引为 1,2023年1月3日对应的位索引为 2,如此类推。
  2. 签到操作

    • 使用 SETBIT 命令来设置某天的签到状态。
    1
    2
    # 用户 1001 在第 N 天签到
    SETBIT user_signin_1001 N 1
  3. 查询签到状态

    • 使用 GETBIT 查询用户某天是否签到。
    1
    2
    # 查询用户 1001 在第 N 天是否签到
    GETBIT user_signin_1001 N
  4. 判断连续签到

    • 使用 BITFIELDGETBIT 查询最近 N 天的签到状态,然后遍历这些结果进行连续签到的判断。
    1
    2
    # 查询用户 1001 最近 7 天的签到情况
    BITFIELD user_signin_1001 GET u7 0

    这里 u7 表示查询最近 7 位,即最近 7 天的签到记录。

  5. 统计连续签到天数

    • 遍历最近 N 天的位,检查是否每天都为 1。如果存在 0,则认为连续签到中断。

缺点:

  • 依赖 Redis:如果 Redis 发生数据丢失,签到数据也会丢失,可能需要额外的持久化或备份机制。

java实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import redis.clients.jedis.Jedis;

public class RedisSigninService {

private Jedis redisClient;

public RedisSigninService(Jedis redisClient) {
this.redisClient = redisClient;
}

// 用户签到
public void signIn(long userId, int dayOfMonth) {
String redisKey = "user_signin_" + userId;
// 第 dayOfMonth 天签到
redisClient.setbit(redisKey, dayOfMonth - 1, true);
}

// 查询用户某天是否签到
public boolean isSignedIn(long userId, int dayOfMonth) {
String redisKey = "user_signin_" + userId;
return redisClient.getbit(redisKey, dayOfMonth - 1);
}

// 判断用户最近7天是否连续签到
public boolean isContinualSignedIn(long userId, int days) {
String redisKey = "user_signin_" + userId;
long result = 0;
// 获取最近 `days` 天的签到状态
for (int i = 0; i < days; i++) {
result = result << 1 | (redisClient.getbit(redisKey, i) ? 1 : 0);
}
// 如果最近 `days` 天的所有签到位都是 1,则表示连续签到
return result == (1 << days) - 1;
}
}

我们用C++来实现下位图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>
#include <vector>

class BitMap {
private:
std::vector<uint32_t> bits; // 存储位图
size_t size; // 位图的大小(以位为单位)

public:
BitMap(size_t n) : size(n) {
bits.resize((n + 31) / 32, 0); // 初始化位图
}

void set(size_t index) {
bits[index / 32] |= (1 << (index % 32));
}

void clear(size_t index) {
bits[index / 32] &= ~(1 << (index % 32));
}

bool get(size_t index) const {
return (bits[index / 32] & (1 << (index % 32))) != 0;
}

void toggle(size_t index) {
bits[index / 32] ^= (1 << (index % 32));
}

size_t count() const {
size_t total = 0;
for (const auto& b : bits) {
total += __builtin_popcount(b); // 计算1的数量
}
return total;
}
};

int main() {
BitMap bitmap(100);
bitmap.set(5);
bitmap.set(10);

std::cout << "Bit 5: " << bitmap.get(5) << std::endl; // 输出 1
std::cout << "Bit 10: " << bitmap.get(10) << std::endl; // 输出 1
std::cout << "Bit 15: " << bitmap.get(15) << std::endl; // 输出 0

bitmap.clear(5);
std::cout << "Bit 5 after clear: " << bitmap.get(5) << std::endl; // 输出 0

std::cout << "Total bits set: " << bitmap.count() << std::endl; // 输出 1

return 0;
}

方案3 MySQL-VARCHAR

在 MySQL 中,可以使用一个 VARCHAR 字段来表示用户每个月的签到记录。每个字符代表一天的签到状态,例如 0 表示未签到,1 表示已签到。

关键实现:

  • 签到操作:更新 VARCHAR 中对应位置的字符为 1
  • 查看某天是否签到:查询 VARCHAR 中对应位置的字符。
  • 连续签到判断:截取 VARCHAR 中连续的几天进行判断。

实现步骤:

  1. 存储结构

    • 每个用户有一个 VARCHAR(30) 字段,表示当前月份的签到情况,例如 101010110100010111000101010101
    • 每个字符表示一天的签到状态,1 表示已签到,0 表示未签到。
    1
    2
    3
    4
    5
    CREATE TABLE user_signin (
    user_id BIGINT PRIMARY KEY,
    signin_record VARCHAR(30), -- 每个字符代表一天
    last_signin_date DATE
    );
  2. 签到操作

    • 获取用户的签到记录 signin_record,并将当天对应的字符更新为 1,然后更新回数据库。
    1
    2
    3
    UPDATE user_signin 
    SET signin_record = SUBSTRING(signin_record, 1, N-1) + '1' + SUBSTRING(signin_record, N+1)
    WHERE user_id = ?;
  3. 查询签到状态

    • 查询 signin_record 中第 N 位的字符,判断是否为 1
    1
    2
    3
    SELECT SUBSTRING(signin_record, N, 1) 
    FROM user_signin
    WHERE user_id = ?;
  4. 连续签到判断

    • 查询 signin_record 中最后 N 天的字符,检查是否都是 1
    1
    2
    3
    SELECT SUBSTRING(signin_record, LENGTH(signin_record) - N + 1, N) 
    FROM user_signin
    WHERE user_id = ?;
  5. 重置签到记录

    • 每个月可以重置 signin_record 字段为 30 个 0,用于新一轮的签到。

java实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.sql.*;

public class MySQLSigninService {

private Connection connection;

public MySQLSigninService(Connection connection) {
this.connection = connection;
}

// 用户签到
public void signIn(long userId, int dayOfMonth) throws SQLException {
String query = "UPDATE user_signin SET signin_record = CONCAT(SUBSTRING(signin_record, 1, ? - 1), '1', SUBSTRING(signin_record, ? + 1)), last_signin_date = NOW() WHERE user_id = ?";
try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setInt(1, dayOfMonth);
stmt.setInt(2, dayOfMonth);
stmt.setLong(3, userId);
stmt.executeUpdate();
}
}

// 查询用户某天是否签到
public boolean isSignedIn(long userId, int dayOfMonth) throws SQLException {
String query = "SELECT SUBSTRING(signin_record, ?, 1) AS sign_status FROM user_signin WHERE user_id = ?";
try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setInt(1, dayOfMonth);
stmt.setLong(2, userId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return "1".equals(rs.getString("sign_status"));
}
}
}
return false;
}

// 判断用户最近N天是否连续签到
public boolean isContinualSignedIn(long userId, int days) throws SQLException {
String query = "SELECT SUBSTRING(signin_record, LENGTH(signin_record) - ? + 1, ?) AS recent_sign_status FROM user_signin WHERE user_id = ?";
try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setInt(1, days);
stmt.setInt(2, days);
stmt.setLong(3, userId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
String recentStatus = rs.getString("recent_sign_status");
return recentStatus.matches("1{" + days + "}");
}
}
}
return false;
}
}

方案4 MySQL-状态位

MySQL 基于状态位的实现

这种方法使用 last_signed_datestate 两个字段来实现连续签到的逻辑。last_signed_date 记录用户上次签到的日期,state 记录用户是否在连续签到。

关键实现:

  • 签到操作:更新 last_signed_datestate
  • 判断连续签到:根据 last_signed_datestate 来判断用户是否连续签到。

实现步骤:

  1. 存储结构

    1
    2
    3
    4
    5
    CREATE TABLE user_signin (
    user_id BIGINT PRIMARY KEY,
    last_signed_date DATE, -- 上次签到日期
    state TINYINT -- 1 表示在连续签到,0 表示断签
    );
  2. 签到操作

    • 查询 last_signed_date,如果上次签到日期是昨天,说明用户在连续签到,更新 state1
    • 如果上次签到日期不是昨天,说明用户断签了,更新 state0
    1
    2
    3
    4
    UPDATE user_signin 
    SET last_signed_date = CURDATE(),
    state = IF(last_signed_date = CURDATE() - INTERVAL 1 DAY, 1, 0)
    WHERE user_id = ?;
  3. 连续签到判断

    • 如果 state = 1,说明用户在连续签到。
    • 如果 state = 0,说明用户已经断签。
  4. 断签处理

    • 如果用户某天没签到,state 保持为 0,直到用户重新开始连续签到。

优点:

  • 逻辑简单:只需要两个字段即可判断用户是否连续签到,容易实现。
  • 数据库负担轻:只更新签到日期和状态位,操作简单。

缺点:

  • 不支持查看历史记录:无法查看用户具体哪天签到,哪天没签到。
  • 功能有限:只适合做简单的连续签到判断,无法展示详细的签到历史。

java实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.sql.*;

public class MySQLStateSigninService {

private Connection connection;

public MySQLStateSigninService(Connection connection) {
this.connection = connection;
}

// 用户签到
public void signIn(long userId) throws SQLException {
String query = "UPDATE user_signin SET last_signed_date = NOW(), state = IF(DATE(last_signed_date) = CURDATE() - INTERVAL 1 DAY, 1, 0) WHERE user_id = ?";
try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setLong(1, userId);
stmt.executeUpdate();
}
}

// 查询用户是否处于连续签到状态
public boolean isContinualSignedIn(long userId) throws SQLException {
String query = "SELECT state FROM user_signin WHERE user_id = ?";
try (PreparedStatement stmt = connection.prepareStatement(query)) {
stmt.setLong(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return rs.getInt("state") == 1;
}
}
}
return false;
}
}