代码改变世界

如何实现每日签到送积分的逻辑

2025-08-04 23:56  SeektonYe  阅读(157)  评论(0)    收藏  举报

如何实现每日签到送积分的逻辑:从数据库设计到业务实现

在现代 Web 应用中,用户留存是产品成功的关键指标之一。每日签到系统作为一种经典的用户激励机制,能够有效提升用户的日活跃度和粘性。本文将详细介绍如何在 Next.js + TypeScript + Drizzle ORM 的技术栈下,实现一个完整的每日签到送积分系统。

系统概述

我们要实现的每日签到系统具备以下核心功能:

  • 用户每日只能签到一次
  • 连续签到有额外奖励机制
  • 积分自动发放到用户账户
  • 完整的签到状态查询
  • 防重复签到的安全机制

这套系统已经在 Monet Vision 平台上成功运行,为用户提供了稳定可靠的每日积分奖励服务。

数据库设计

核心表结构

首先,我们需要设计两个核心数据表:

1. 每日签到记录表 (daily_check_in)

CREATE TABLE `daily_check_in` (
    `id` text PRIMARY KEY NOT NULL,
    `userId` text NOT NULL,
    `checkInDate` text NOT NULL,
    `creditsEarned` integer DEFAULT 5 NOT NULL,
    `consecutiveDays` integer DEFAULT 1 NOT NULL,
    `createdAt` integer NOT NULL,
    FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);

2. 使用记录表 (usage_log)

ALTER TABLE `usage_log` ADD `source` text;

索引优化

为了提升查询性能,我们创建了以下索引:

CREATE INDEX `daily_check_in_user_id_idx` ON `daily_check_in` (`userId`);
CREATE INDEX `daily_check_in_date_idx` ON `daily_check_in` (`checkInDate`);
CREATE INDEX `daily_check_in_user_date_idx` ON `daily_check_in` (`userId`,`checkInDate`);
CREATE INDEX `usage_log_source_idx` ON `usage_log` (`source`);

这些索引确保了:

  • 按用户查询签到记录的高效性
  • 按日期查询的快速响应
  • 用户+日期的复合查询优化
  • 积分来源的快速筛选

Drizzle ORM Schema 定义

使用 Drizzle ORM 定义数据表结构:

// 每日签到记录表
export const dailyCheckInTable = sqliteTable(
  "daily_check_in",
  {
    id: text("id")
      .primaryKey()
      .$defaultFn(() => createId()),
    userId: text("userId")
      .notNull()
      .references(() => userTable.id),
    checkInDate: text("checkInDate").notNull(), // 格式: "2025-01-15"
    creditsEarned: integer("creditsEarned").notNull().default(5),
    consecutiveDays: integer("consecutiveDays").notNull().default(1),
    createdAt: integer("createdAt", { mode: "timestamp" })
      .notNull()
      .$defaultFn(() => new Date()),
  },
  (table) => [
    index("daily_check_in_user_id_idx").on(table.userId),
    index("daily_check_in_date_idx").on(table.checkInDate),
    index("daily_check_in_user_date_idx").on(table.userId, table.checkInDate),
  ],
);

核心业务逻辑实现

1. 签到状态检查

首先实现检查用户今日是否已签到的功能:

export async function getTodayCheckInStatus(userId: string): Promise<{
  hasCheckedIn: boolean;
  checkIn?: {
    creditsEarned: number;
    consecutiveDays: number;
  };
}> {
  const db = getDB();
  const today = new Date().toISOString().split("T")[0]; // 格式: "2025-01-15"

  const todayCheckIn = await db.query.dailyCheckInTable.findFirst({
    where: and(
      eq(dailyCheckInTable.userId, userId),
      eq(dailyCheckInTable.checkInDate, today),
    ),
  });

  if (todayCheckIn) {
    return {
      hasCheckedIn: true,
      checkIn: {
        creditsEarned: todayCheckIn.creditsEarned,
        consecutiveDays: todayCheckIn.consecutiveDays,
      },
    };
  }

  return { hasCheckedIn: false };
}

2. 连续签到天数计算

连续签到天数的计算是系统的核心逻辑之一:

async function calculateConsecutiveDays(userId: string): Promise<number> {
  const db = getDB();

  // 获取用户最近的签到记录,按日期倒序
  const recentCheckIns = await db.query.dailyCheckInTable.findMany({
    where: eq(dailyCheckInTable.userId, userId),
    orderBy: [desc(dailyCheckInTable.checkInDate)],
    limit: 100, // 最多查询100天
  });

  if (recentCheckIns.length === 0) {
    return 1; // 首次签到
  }

  let consecutiveDays = 1; // 包括今天
  const today = new Date();

  // 从昨天开始检查连续性
  for (let i = 1; i < 100; i++) {
    const checkDate = new Date(today);
    checkDate.setDate(checkDate.getDate() - i);
    const dateString = checkDate.toISOString().split("T")[0];

    const hasCheckIn = recentCheckIns.some(
      (checkIn) => checkIn.checkInDate === dateString,
    );

    if (hasCheckIn) {
      consecutiveDays++;
    } else {
      break; // 中断连续签到
    }
  }

  return consecutiveDays;
}

3. 积分奖励计算

基于连续签到天数计算奖励积分:

function calculateCheckInCredits(consecutiveDays: number): number {
  // 基础积分: 5
  // 连续签到奖励:连续7天额外+5积分,最大总积分限制为10
  const baseCredits = 5;
  const bonusCredits = Math.min(Math.floor((consecutiveDays - 1) / 7) * 5, 5);
  const totalCredits = baseCredits + bonusCredits;

  return Math.min(totalCredits, 10); // 确保总积分不超过10
}

这个算法的设计思路:

  • 基础奖励:每次签到获得 5 积分
  • 连续奖励:每连续 7 天额外获得 5 积分
  • 上限控制:单次签到最多获得 10 积分

4. 执行签到操作

完整的签到流程实现:

export async function performDailyCheckIn(): Promise<DailyCheckInResult> {
  try {
    const t = await getTranslations("user");
    const session = await getSession();
    const userId = session?.user?.id;

    if (!userId) {
      return {
        success: false,
        message: t("checkin_not_logged_in"),
      };
    }

    // 检查今日是否已签到
    const todayStatus = await getTodayCheckInStatus(userId);
    if (todayStatus.hasCheckedIn) {
      return {
        success: false,
        message: t("checkin_already_done"),
        data: {
          creditsEarned: todayStatus.checkIn!.creditsEarned,
          consecutiveDays: todayStatus.checkIn!.consecutiveDays,
          alreadyCheckedIn: true,
        },
      };
    }

    const db = getDB();
    const today = new Date().toISOString().split("T")[0];

    // 计算连续签到天数
    const consecutiveDays = await calculateConsecutiveDays(userId);

    // 计算奖励积分
    const creditsEarned = calculateCheckInCredits(consecutiveDays);

    // 记录签到
    await db.insert(dailyCheckInTable).values({
      userId,
      checkInDate: today,
      creditsEarned,
      consecutiveDays,
      createdAt: new Date(),
    });

    // 增加用户积分
    const addCreditsSuccess = await addCreditsUsage(
      userId,
      creditsEarned,
      "daily_checkin",
      today,
    );

    if (!addCreditsSuccess) {
      // 如果积分添加失败,删除签到记录
      await db
        .delete(dailyCheckInTable)
        .where(
          and(
            eq(dailyCheckInTable.userId, userId),
            eq(dailyCheckInTable.checkInDate, today),
          ),
        );

      return {
        success: false,
        message: t("checkin_error"),
      };
    }

    return {
      success: true,
      message: t("checkin_success", {
        credits: creditsEarned,
        days: consecutiveDays,
      }),
      data: {
        creditsEarned,
        consecutiveDays,
        alreadyCheckedIn: false,
      },
    };
  } catch (error) {
    console.error("Daily check-in error:", error);
    const t = await getTranslations("user");
    return {
      success: false,
      message: t("checkin_system_error"),
    };
  }
}

关键技术要点

1. 事务处理

在签到过程中,我们需要确保数据的一致性。如果积分添加失败,必须回滚签到记录:

// 增加用户积分
const addCreditsSuccess = await addCreditsUsage(
  userId,
  creditsEarned,
  "daily_checkin",
  today,
);

if (!addCreditsSuccess) {
  // 如果积分添加失败,删除签到记录
  await db
    .delete(dailyCheckInTable)
    .where(
      and(
        eq(dailyCheckInTable.userId, userId),
        eq(dailyCheckInTable.checkInDate, today),
      ),
    );

  return { success: false, message: t("checkin_error") };
}

2. 日期处理

使用 ISO 日期格式确保跨时区的一致性:

const today = new Date().toISOString().split("T")[0]; // "2025-01-15"

3. 防重复机制

通过数据库索引和业务逻辑双重保障防止重复签到:

  • 数据库层面:daily_check_in_user_date_idx 复合索引
  • 业务层面:签到前检查当日状态

4. 国际化支持

使用 next-intl 提供多语言支持:

const t = await getTranslations("user");
return {
  success: true,
  message: t("checkin_success", {
    credits: creditsEarned,
    days: consecutiveDays,
  }),
};

性能优化策略

1. 查询优化

  • 使用复合索引优化用户+日期查询
  • 限制历史记录查询数量(limit: 100)
  • 按需查询,避免全表扫描

2. 缓存策略

可以考虑在 Redis 或 Cloudflare KV 中缓存用户的签到状态:

// 伪代码示例
const cacheKey = `checkin:${userId}:${today}`;
const cached = await kv.get(cacheKey);
if (cached) {
  return JSON.parse(cached);
}

3. 异步处理

对于积分发放等非核心流程,可以考虑异步处理以提升响应速度。

安全考虑

1. 身份验证

确保只有登录用户才能执行签到操作:

const session = await getSession();
const userId = session?.user?.id;

if (!userId) {
  return { success: false, message: t("checkin_not_logged_in") };
}

2. 防刷机制

  • 数据库约束防止重复签到
  • 业务逻辑二次验证
  • 可考虑添加 IP 限制或验证码

3. 数据完整性

使用外键约束确保数据关联的完整性:

FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action

扩展功能

1. 签到统计

可以添加用户签到统计功能:

export async function getUserCheckInStats(userId: string) {
  const db = getDB();

  const stats = await db
    .select({
      totalDays: count(),
      totalCredits: sum(dailyCheckInTable.creditsEarned),
      maxConsecutive: max(dailyCheckInTable.consecutiveDays),
    })
    .from(dailyCheckInTable)
    .where(eq(dailyCheckInTable.userId, userId));

  return stats[0];
}

2. 签到排行榜

实现用户签到排行榜功能:

export async function getCheckInLeaderboard(limit: number = 10) {
  const db = getDB();

  return await db
    .select({
      userId: dailyCheckInTable.userId,
      maxConsecutive: max(dailyCheckInTable.consecutiveDays),
      totalCredits: sum(dailyCheckInTable.creditsEarned),
    })
    .from(dailyCheckInTable)
    .groupBy(dailyCheckInTable.userId)
    .orderBy(desc(max(dailyCheckInTable.consecutiveDays)))
    .limit(limit);
}

3. 特殊活动

可以根据特殊节日或活动调整奖励规则:

function calculateSpecialEventCredits(
  consecutiveDays: number,
  eventType?: string,
): number {
  const baseCredits = calculateCheckInCredits(consecutiveDays);

  switch (eventType) {
    case "double_credits":
      return baseCredits * 2;
    case "new_year":
      return baseCredits + 10;
    default:
      return baseCredits;
  }
}

总结

通过以上的设计和实现,我们构建了一个完整、可靠的每日签到送积分系统。该系统具备以下特点:

  1. 数据一致性:通过事务处理确保数据的完整性
  2. 高性能:合理的索引设计和查询优化
  3. 可扩展性:模块化的设计便于功能扩展
  4. 安全性:完善的身份验证和防刷机制
  5. 用户体验:国际化支持和友好的错误提示

这套系统已经在 Monet Vision 等多个生产环境中稳定运行,为用户提供了良好的签到体验。通过合理的奖励机制设计,有效提升了用户的日活跃度和平台粘性。

在实际应用中,你可以根据具体的业务需求调整奖励规则、添加更多的统计功能,或者集成到现有的用户成长体系中。希望这篇文章能够帮助你快速实现自己的每日签到系统!