goframe框架签到系统项目开发(补签逻辑实现、编写Lua脚本实现断签提醒功能、简历示例)

文章目录

补签逻辑实现

配置service服务下的impl实现

go 复制代码
package impl

import (
	"backend/internal/dao"
	"backend/internal/model"
	"backend/internal/model/entity"
	"backend/utility/injection"
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/gogf/gf/v2/database/gdb"
	"github.com/gogf/gf/v2/errors/gcode"
	"github.com/gogf/gf/v2/errors/gerror"
	"github.com/gogf/gf/v2/frame/g"
	"github.com/gogf/gf/v2/os/gtime"
	"github.com/redis/go-redis/v9"
)

// 签到相关业务逻辑的具体实现

const (
	yearSignKeyFormat            = "user:checkins:daily:%d:%d"     // user:checkins:daily:12131321421312:2025
	monthRetroKeyFormat          = "user:checkins:retro:%d:%d%02d" // user:checkins:retro:12131321421231202501
	defaultDailyPoints     int64 = 1                               // 每日签到积分(注意:用 int64,和积分字段统一)
	defaultRetroCostPoints       = 100                             // 补签消耗积分
	maxRetroTimesPerMonth        = 3                               // 单月最多补签次数
)

type PointsTransactionType int

const (
	PointsTransactionTypeDaily       PointsTransactionType = iota + 1 // 每日签到 1
	PointsTransactionTypeConsecutive                                  // 连续签到 2
	PointsTransactionTypeRetro                                        // 补签 3
)

type ConsecutiveBonusType int32

const (
	// 连续签到奖励规则
	consecutiveBonus3  ConsecutiveBonusType = 1 // "连续签到3天奖励"
	consecutiveBonus7  ConsecutiveBonusType = 2 // "连续签到7天奖励"
	consecutiveBonus15 ConsecutiveBonusType = 3 // "连续签到15天奖励"
	consecutiveBonus30 ConsecutiveBonusType = 4 // "月度满签奖励"
)

var consecutiveBonusNames = map[ConsecutiveBonusType]string{
	consecutiveBonus3:  "连续签到3天奖励",
	consecutiveBonus7:  "连续签到7天奖励",
	consecutiveBonus15: "连续签到15天奖励",
	consecutiveBonus30: "月度满签奖励",
}

var PointsTransactionTypeMsgMap = map[PointsTransactionType]string{
	PointsTransactionTypeDaily:       "每日签到奖励",
	PointsTransactionTypeConsecutive: "连续签到奖励",
	PointsTransactionTypeRetro:       "补签%s消耗积分",
}

// consecutiveBonusRule 连续签到奖励规则
type ConsecutiveBonusRule struct {
	TriggerDays int                  // 触发连续签到奖励的天数
	Points      int64                // 连续签到奖励的积分
	BonusType   ConsecutiveBonusType // 连续签到奖励类型
}

var consecutiveBonusRules = []ConsecutiveBonusRule{
	{TriggerDays: 3, Points: 5, BonusType: consecutiveBonus3},
	{TriggerDays: 7, Points: 10, BonusType: consecutiveBonus7},
	{TriggerDays: 15, Points: 20, BonusType: consecutiveBonus15},
	{TriggerDays: 30, Points: 100, BonusType: consecutiveBonus30},
}

var (
	ErrInvalidRetroDate = errors.New("补签日期无效")
	ErrChecked          = errors.New("日期已签到")
	ErrRetroNotimes     = errors.New("本月补签次数已用完")
	ErrNoEnoughPoints   = gerror.New("积分不足")
)

type Service struct {
	rc *redis.Client
}

func NewService() *Service {
	return &Service{
		rc: injection.MustInvoke[*redis.Client](), // 从注入器中获取 Redis 客户端实例
	}
}

// // Daily 每日签到
// func (s *Service) Daily(ctx context.Context, userId uint64) error {
// 	// 采用服务器时间进行每日签到,不依赖客户端传递的时间
// 	// 1. Redis 中使用 bitmap setbit 执行签到逻辑
// 	// 拿到当天是一年中的第几天,然后使用 setbit 记录这一天是否签到
// 	now := time.Now()
// 	year := now.Year()
// 	dayOfYearOffset := now.YearDay() - 1 // 因为 Redis bitmap 从 0 开始,所以要减一
// 	key := fmt.Sprintf(yearSignKeyFormat, userId, year)
// 	g.Log().Debugf(ctx, "key: %s dayOfYearOffset:%d", key, dayOfYearOffset)

// 	ret := s.rc.SetBit(ctx, key, int64(dayOfYearOffset), 1).Val()
// 	if ret == 1 {
// 		return errors.New("今日已签到")
// 	}

// 	// 2. 发放每日签到的积分
// 	// 用户积分汇总表 user_points 增加积分
// 	// 2.1 先查询(新用户可能没有记录)
// 	var userPoint entity.UserPoints
// 	if err := dao.UserPoints.Ctx(ctx).
// 		Where(dao.UserPoints.Columns().UserId, userId).
// 		Scan(&userPoint); err != nil && !errors.Is(err, sql.ErrNoRows) {
// 		g.Log().Errorf(ctx, "查询用户积分汇总表失败: %v", err)
// 		return err
// 	}

// 	// 如果查不到,则插入一条记录
// 	if userPoint.Id == 0 {
// 		userPoint = entity.UserPoints{UserId: userId} // 创建新对象
// 	}

// 	userPoint.Points = userPoint.Points + defaultDailyPoints           // 增加每日签到积分
// 	userPoint.PointsTotal = userPoint.PointsTotal + defaultDailyPoints // 累计积分

// 	// 2.2 事务更新 用户积分汇总表 和 用户积分明细表
// 	// 为什么要事务?
// 	// 因为要保证:流水插入成功, 汇总更新成功
// 	// 这两件事要么都成功,要么都失败,不然会出现"余额变了但没流水"或"有流水但余额没变"。
// 	err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 		// 用户积分明细表 user_points_transactions 增加记录
// 		newRecord := entity.UserPointsTransactions{
// 			UserId:          userId,
// 			PointsChange:    defaultDailyPoints,
// 			CurrentBalance:  userPoint.Points,
// 			TransactionType: int(PointsTransactionTypeDaily),
// 			Description:     PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
// 			CreatedAt:       gtime.NewFromTime(time.Now()),
// 			UpdatedAt:       gtime.NewFromTime(time.Now()),
// 		}

// 		// return nil => 提交 commit
// 		// return err => 回滚 rollback
// 		// tx.Model(...):明确使用事务 tx 执行 SQL
// 		if _, err := tx.Model(&entity.UserPointsTransactions{}).Insert(&newRecord); err != nil {
// 			g.Log().Errorf(ctx, "插入用户积分明细表失败: %v", err)
// 			return err
// 		}
// 		if _, err := tx.Model(&entity.UserPoints{}).
// 			Where(dao.UserPoints.Columns().UserId, userId).
// 			Save(&userPoint); err != nil {
// 			g.Log().Errorf(ctx, "更新用户积分汇总表失败: %v", err)
// 			return err
// 		}
// 		return nil
// 	})

// 	if err != nil {
// 		g.Log().Errorf(ctx, "事务处理失败: %v", err)
// 		return err
// 	}

// 	// 3. 发送连续签到的奖励积分

// 	return nil
// }

// Daily 每日签到(先 DB 成功,再 setbit)
// ✅ 先 DB 加分成功
// ✅ 再 Redis 标记已签到
// 这样就不会出现"签了但没加分"
// 关键点:
// 1) 先 GetBit 判断是否已签到(只读)
// 2) 事务里:写明细 + 更新/插入汇总(先 DB)
// 3) DB 成功后:SetBit 标记已签到
// 4) 用 Redis SetNX 做锁,防止并发重复加分
func (s *Service) Daily(ctx context.Context, userId uint64) error {
	// 采用服务器时间进行每日签到,不依赖客户端传递的时间
	// 1. Redis 中使用 bitmap setbit 执行签到逻辑
	// 拿到当天是一年中的第几天,然后使用 setbit 记录这一天是否签到
	now := time.Now()
	year := now.Year()
	dayOfYearOffset := now.YearDay() - 1 // 因为 Redis bitmap 从 0 开始,所以要减1
	signKey := fmt.Sprintf(yearSignKeyFormat, userId, year)

	// 0) 防并发:同一用户同一天只允许一个请求进来(10s 超时防死锁)
	lockKey := fmt.Sprintf("lock:checkins:daily:%d:%d:%d", userId, year, dayOfYearOffset)
	locked, err := s.rc.SetNX(ctx, lockKey, 1, 10*time.Second).Result()
	if err != nil {
		return err
	}
	if !locked {
		return errors.New("签到处理中,请稍后重试")
	}
	defer s.rc.Del(ctx, lockKey)

	// 1) 只读判断:是否已签到
	bit, err := s.rc.GetBit(ctx, signKey, int64(dayOfYearOffset)).Result()
	if err != nil {
		return err
	}
	if bit == 1 {
		return errors.New("今日已签到")
	}

	// 2) 先 DB:事务里写明细 + 更新/插入汇总
	// err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
	// 	// 2.1 查询积分汇总(新用户可能没有记录)
	// 	var userPoint entity.UserPoints
	// 	if err := tx.Model(&entity.UserPoints{}).
	// 		Where(dao.UserPoints.Columns().UserId, userId).
	// 		Scan(&userPoint); err != nil && !errors.Is(err, sql.ErrNoRows) {
	// 		g.Log().Errorf(ctx, "查询用户积分汇总表失败: %v", err)
	// 		return err
	// 	}

	// 	isNew := userPoint.Id == 0
	// 	if isNew {
	// 		userPoint = entity.UserPoints{
	// 			UserId: userId,
	// 		}
	// 	}

	// 	// 2.2 计算加分后的值
	// 	userPoint.Points += defaultDailyPoints
	// 	userPoint.PointsTotal += defaultDailyPoints

	// 	// 2.3 插入积分明细
	// 	newRecord := entity.UserPointsTransactions{
	// 		UserId:          userId,
	// 		PointsChange:    defaultDailyPoints,
	// 		CurrentBalance:  userPoint.Points,
	// 		TransactionType: int(PointsTransactionTypeDaily), // ✅ entity 字段是 int,显式转换
	// 		Description:     PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
	// 		CreatedAt:       gtime.NewFromTime(now),
	// 		UpdatedAt:       gtime.NewFromTime(now),
	// 	}

	// 	if _, err := tx.Model(&entity.UserPointsTransactions{}).Insert(&newRecord); err != nil {
	// 		g.Log().Errorf(ctx, "插入用户积分明细表失败: %v", err)
	// 		return err
	// 	}

	// 	// 2.4 更新/插入积分汇总
	// 	if isNew {
	// 		userPoint.CreatedAt = gtime.NewFromTime(now)
	// 		userPoint.UpdatedAt = gtime.NewFromTime(now)

	// 		if _, err := tx.Model(&entity.UserPoints{}).Insert(&userPoint); err != nil {
	// 			g.Log().Errorf(ctx, "插入用户积分汇总表失败: %v", err)
	// 			return err
	// 		}
	// 	} else {
	// 		if _, err := tx.Model(&entity.UserPoints{}).
	// 			Where(dao.UserPoints.Columns().UserId, userId).
	// 			Data(g.Map{
	// 				dao.UserPoints.Columns().Points:      userPoint.Points,
	// 				dao.UserPoints.Columns().PointsTotal: userPoint.PointsTotal,
	// 				dao.UserPoints.Columns().UpdatedAt:   gtime.NewFromTime(now),
	// 			}).
	// 			Update(); err != nil {
	// 			g.Log().Errorf(ctx, "更新用户积分汇总表失败: %v", err)
	// 			return err
	// 		}
	// 	}

	// 	return nil
	// })

	// 2. 发放每日签到的积分
	// 先写 DB 加积分
	if err := s.AddPoints(ctx, &model.PointsTransactionInput{
		UserId: userId,
		Points: defaultDailyPoints,
		Desc:   PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
		Type:   int(PointsTransactionTypeDaily),
	}); err != nil {
		g.Log().Errorf(ctx, "AddPoints 事务处理失败: %v", err)
		return err
	}

	// 3) 再 Redis:DB 成功后再 setbit(避免"签到了但没加分")
	old, err := s.rc.SetBit(ctx, signKey, int64(dayOfYearOffset), 1).Result()
	if err != nil {
		return err
	}
	// 理论上有锁不会发生,兜底
	if old == 1 {
		return errors.New("今日已签到")
	}

	// 3. 发送连续签到的奖励积分
	return s.updateConsecutiveBonus(ctx, userId, year, int(now.Month()))
}

// updateConsecutiveBonus 更新连续签到奖励积分
func (s *Service) updateConsecutiveBonus(ctx context.Context, userId uint64, year, month int) error {
	// 1. 获取当前本月连续签到天数
	maxConsecutive, err := s.CalcMonthConsecutiveDays(ctx, userId, year, month)
	if err != nil {
		g.Log().Errorf(ctx, "计算连续签到天数失败: %v", err)
		return err
	}

	// 2. 计算连续签到奖励积分
	// 3. 更新用户积分汇总表和用户积分明细表
	// 如何避免重复发放连续签到奖励? --> 使用 user_monthly_bonus_log 表记录用户指定月份已领取的奖励
	// 2.1 查询用户本月已领取的奖励(避免重复发放)
	var bonusLogs []*entity.UserMonthlyBonusLog
	if err := dao.UserMonthlyBonusLog.Ctx(ctx).
		Where(dao.UserMonthlyBonusLog.Columns().UserId, userId).
		Where(dao.UserMonthlyBonusLog.Columns().YearMonth, fmt.Sprintf("%d%02d", year, month)). // 202505
		Scan(&bonusLogs); err != nil && !errors.Is(err, sql.ErrNoRows) {
		g.Log().Errorf(ctx, "查询用户已领取的奖励失败: %v", err)
		return err
	}

	// 用 BonusType 做去重 key(比用 Description 更稳)
	// 把领取的奖励塞到map中,方便后续判断是否已经发放过奖励
	bonusLogsMap := make(map[ConsecutiveBonusType]bool)
	for _, v := range bonusLogs {
		bonusLogsMap[ConsecutiveBonusType(v.BonusType)] = true
	}

	// 遍历连续签到奖励配置,如果符合条件就发奖励
	for _, rule := range consecutiveBonusRules {
		if maxConsecutive >= rule.TriggerDays && !bonusLogsMap[rule.BonusType] {
			// 发放连续签到奖励积分
			// 更新 user_points 表和 user_points_transactions 表
			if err := s.AddPoints(ctx, &model.PointsTransactionInput{
				UserId: userId,
				Points: rule.Points,
				Desc:   consecutiveBonusNames[rule.BonusType],
				Type:   int(PointsTransactionTypeConsecutive),
			}); err != nil {
				g.Log().Errorf(ctx, "发放连续签到奖励失败: %v", err)
				continue
			}

			// 记录到 user_monthly_bonus_log 表(用于幂等)
			now := time.Now()
			newLog := &entity.UserMonthlyBonusLog{
				UserId:      userId,
				YearMonth:   fmt.Sprintf("%d%02d", year, month),
				Description: consecutiveBonusNames[rule.BonusType],
				BonusType:   int(rule.BonusType),
				CreatedAt:   gtime.NewFromTime(now),
				UpdatedAt:   gtime.NewFromTime(now),
			}

			if _, err := dao.UserMonthlyBonusLog.Ctx(ctx).Insert(newLog); err != nil {
				// 积分已加,连续签到奖励已发,但月度奖励记录插入失败,需要手动处理
				g.Log().Errorf(ctx, "[NEED_HANDLE]插入用户月度奖励记录失败: %v", err)
				continue
			}

			// 更新本地 map,避免本次循环中重复发
			bonusLogsMap[rule.BonusType] = true
		}
	}

	return nil
}

// CalcMonthConsecutiveDays 计算本月最大连续签到天数(含补签)
func (s *Service) CalcMonthConsecutiveDays(ctx context.Context, userId uint64, year, month int) (int, error) {
	monthDays := getMonthDays(year, month)
	checkinBitmap, retroBitmap, err := s.getMonthBitmap(ctx, userId, year, month)
	if err != nil {
		g.Log().Errorf(ctx, "获取用户签到记录失败: %v", err)
		return 0, err
	}
	// 逻辑或
	bitmap := checkinBitmap | retroBitmap // 合并本月签到和补签数据

	return calcMaxConsecutiveDays(bitmap, monthDays), nil
}

// calcMaxConsecutiveDays 计算最大连续签到天数
func calcMaxConsecutiveDays(bitmap uint64, monthDays int) int {
	// 逐位判断,计算出连续签到天数
	maxCount := 0
	currCount := 0
	for i := 0; i < monthDays; i++ {
		// 从右向左逐位判断
		checked := (bitmap>>i)&1 == 1
		if checked {
			currCount++
		} else {
			if currCount > maxCount {
				maxCount = currCount
			}
			currCount = 0
		}
	}
	// 循环结束再最后比较一次
	if currCount > maxCount {
		maxCount = currCount
	}
	return maxCount
}

// getFirstOfMonthOffset 获取当月第一天在一年中的偏移量
// 假设 2025-12-01:
// time.Date(2025, 12, 1, ...) 得到 12 月 1 日
// firstOfMonth.YearDay() 得到它是一年中的第几天(2025 不是闰年,12/1 是第 335 天)
// 那么 offset = 335 - 1 = 334
// 注意:这里使用 time.Local,保证和 Daily() 的 time.Now() 同一时区口径
func getFirstOfMonthOffset(year, month int) int {
	// 1. 获取当月第一天
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local)
	// 2. 计算偏移量
	return firstOfMonth.YearDay() - 1 // offset 从 0 开始
}

// getMonhDays 获取当月天数
// 注意:使用 time.Local,避免时区差异导致月界限异常
func getMonthDays(year, month int) int {
	// 1. 获取当月第一天
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local)
	// 2. 获取当月最后一天
	lastOfMonth := firstOfMonth.AddDate(0, 1, -1)
	return lastOfMonth.Day() // 返回当月天数
}

// AddPoints 封装"积分变更"的事务逻辑:写明细 + 更新/插入汇总
// input.Points 可正可负:正数=加分,负数=扣分(如需禁止扣分可在下面校验)
func (s *Service) AddPoints(ctx context.Context, input *model.PointsTransactionInput) error {
	if input == nil {
		return errors.New("input is nil")
	}
	if input.UserId == 0 {
		return errors.New("invalid userId")
	}
	if input.Points == 0 {
		return nil // 没有变更直接返回
	}

	now := time.Now()

	return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
		// 1) 查询积分汇总(新用户可能没有记录)
		var userPoint entity.UserPoints
		if err := tx.Model(&entity.UserPoints{}).
			Where(dao.UserPoints.Columns().UserId, input.UserId).
			Scan(&userPoint); err != nil && !errors.Is(err, sql.ErrNoRows) {
			g.Log().Errorf(ctx, "查询用户积分汇总表失败: %v", err)
			return err
		}

		isNew := userPoint.Id == 0
		if isNew {
			userPoint = entity.UserPoints{
				UserId: input.UserId,
			}
		}

		// 2) 计算新余额(可选:不允许余额为负)
		newBalance := userPoint.Points + input.Points
		if newBalance < 0 {
			return errors.New("积分不足")
		}

		// 3) 更新汇总
		userPoint.Points = newBalance
		// PointsTotal 通常表示"累计获得",一般只在加分时累加
		if input.Points > 0 {
			userPoint.PointsTotal += input.Points
		}

		// 4) 插入积分明细
		newRecord := entity.UserPointsTransactions{
			UserId:          input.UserId,
			PointsChange:    input.Points,
			CurrentBalance:  newBalance,
			TransactionType: input.Type,
			Description:     input.Desc,
			CreatedAt:       gtime.NewFromTime(now),
			UpdatedAt:       gtime.NewFromTime(now),
		}
		if _, err := tx.Model(&entity.UserPointsTransactions{}).Insert(&newRecord); err != nil {
			g.Log().Errorf(ctx, "插入用户积分明细表失败: %v", err)
			return err
		}

		// 5) 更新/插入积分汇总
		if isNew {
			userPoint.CreatedAt = gtime.NewFromTime(now)
			userPoint.UpdatedAt = gtime.NewFromTime(now)
			if _, err := tx.Model(&entity.UserPoints{}).Insert(&userPoint); err != nil {
				g.Log().Errorf(ctx, "插入用户积分汇总表失败: %v", err)
				return err
			}
		} else {
			if _, err := tx.Model(&entity.UserPoints{}).
				Where(dao.UserPoints.Columns().UserId, input.UserId).
				Data(g.Map{
					dao.UserPoints.Columns().Points:      userPoint.Points,
					dao.UserPoints.Columns().PointsTotal: userPoint.PointsTotal,
					dao.UserPoints.Columns().UpdatedAt:   gtime.NewFromTime(now),
				}).
				Update(); err != nil {
				g.Log().Errorf(ctx, "更新用户积分汇总表失败: %v", err)
				return err
			}
		}

		return nil
	})
}

// MonthDetail 签到详情
func (s *Service) MonthDetail(ctx context.Context, input *model.MonthDetailInput) (*model.MonthDetailOutput, error) {
	// 1. 从redis中分别取出签到bitmap和补签bitmap,分别得到签到日期和补签日期
	checkinBitmap, retroBitmap, err := s.getMonthBitmap(ctx, input.UserId, input.Year, input.Month)
	if err != nil {
		g.Log().Errorf(ctx, "获取年月bitmap失败: %v", err)
		return nil, err
	}

	g.Log().Debugf(ctx, "--> checkinBitmap: %031b retroBitmap:%031b", checkinBitmap, retroBitmap)
	monthDays := getMonthDays(input.Year, input.Month) // 当月天数
	checkinDays := parseBitmap2Days(checkinBitmap, monthDays)
	retroDays := parseBitmap2Days(retroBitmap, monthDays)

	// 2. 计算连续签到天数
	bitmap := checkinBitmap | retroBitmap
	maxConsecutive := calcMaxConsecutiveDays(bitmap, monthDays)

	// 3. 计算剩余补签次数
	remainRetroTimes := maxRetroTimesPerMonth - len(retroDays) // 用月度补签次数减去已补签天数

	// 4. 计算当天是否签到
	isCheckedToday, err := s.IsCheckedToday(ctx, input.UserId)
	if err != nil {
		g.Log().Errorf(ctx, "查询当天是否签到失败: %v", err)
		return nil, err
	}

	return &model.MonthDetailOutput{
		CheckedInDays:      checkinDays,
		RetroCheckedInDays: retroDays,
		ConsecutiveDays:    maxConsecutive,
		RemainRetroTimes:   remainRetroTimes,
		IsCheckedInToday:   isCheckedToday,
	}, nil
}

func (s *Service) IsCheckedToday(ctx context.Context, userId uint64) (bool, error) {
	// 计算今天的年度索引,然后使用 getbit 判断这一天是否签到
	now := time.Now()
	year := now.Year()
	key := fmt.Sprintf(yearSignKeyFormat, userId, year)

	// 算出"今天是今年第几天"对应的 bit 下标
	dayOffset := now.YearDay() - 1
	value, err := s.rc.GetBit(ctx, key, int64(dayOffset)).Result()
	if err != nil {
		g.Log().Errorf(ctx, "GetBit 获取当天签到状态失败: %v", err)
		return false, err
	}
	return value == 1, nil
}

// parseBitmap2Days 根据当月的天数和bitmap, 输出对应的签到/补签日期
func parseBitmap2Days(bitmap uint64, monthDays int) []int {
	days := make([]int, 0)
	for i := 0; i < monthDays; i++ {
		// 0000000000000000000000000000110
		if (bitmap & (1 << (monthDays - 1 - i))) != 0 {
			days = append(days, i+1)
		}
	}
	return days
}

// getMonthBitmap 获取当月 签到bitmap 和 补签的bitmap
func (s *Service) getMonthBitmap(ctx context.Context, userId uint64, year, month int) (uint64, uint64, error) {
	// 从用户年度签到记录中取出当月签到 bitmap
	key := fmt.Sprintf(yearSignKeyFormat, userId, year)
	firstOfMonthOffset := getFirstOfMonthOffset(year, month)
	monthDays := getMonthDays(year, month)
	bitWidthType := fmt.Sprintf("u%d", monthDays) // u30/u31

	values, err := s.rc.BitField(ctx, key, "GET", bitWidthType, firstOfMonthOffset).Result()
	if err != nil {
		g.Log().Errorf(ctx, "获取用户签到记录到失败: %v", err)
		return 0, 0, err
	}

	if len(values) == 0 {
		values = []int64{0} // 如果没有查询到,则默认为0
	}

	checkinBitmap := uint64(values[0])
	g.Log().Debugf(ctx, "checkinBitmap: %0b", checkinBitmap)

	// 获取当月补签 bitmap
	retroKey := fmt.Sprintf(monthRetroKeyFormat, userId, year, month)

	// 去 Redis 里 retroKey 这个补签位图,从第 0 位开始,读出 monthDays(比如 30/31)个 bit,打包成一个整数返回
	retroValues, err := s.rc.BitField(ctx, retroKey, "GET", bitWidthType, "#0").Result()
	if err != nil {
		g.Log().Errorf(ctx, "获取用户补签记录失败: %v", err)
		return 0, 0, err
	}

	if len(retroValues) == 0 {
		retroValues = []int64{0} // 没有查询到,则默认为0
	}

	retroBitmap := uint64(retroValues[0])
	return checkinBitmap, retroBitmap, nil
}

// Retro 根据输入的日期进行补签
func (s *Service) Retro(ctx context.Context, userId uint64, date time.Time) error {
	// 1. 判断补签日期是否有效
	if err := s.checkRetroDate(ctx, userId, date); err != nil {
		return err
	}

	// 2. 执行补签逻辑
	// 2.1 Redis里 补签的月度bitmap 中设置补签的标识
	retroKey := fmt.Sprintf(monthRetroKeyFormat, userId, date.Year(), date.Month())
	retroOffset := date.Day() - 1 // 索引是从0开始的,所以要减1

	// 因为 IntCmd 嵌入了 baseCmd,所以 baseCmd 的方法会被"提升",表现得像是 IntCmd 自己的方法
	// 写 cmd.Err()等价于 cmd.baseCmd.Err()
	err := s.rc.SetBit(ctx, retroKey, int64(retroOffset), 1).Err()
	if err != nil {
		g.Log().Errorf(ctx, "SetBit 设置补签状态失败: %v", err)
		return gerror.NewCode(gcode.CodeInternalError)
	}

	// 2.2 补签消耗积分、增加积分、增加积分记录
	// 正常应该把签到服务和积分服务分开,通过消息队列的方式实现事件驱动。
	// 签到服务负责签到/补签,发出消息;积分服务监听消息,处理积分的增加和扣减逻辑。
	if err := s.retroWithTransaction(ctx, userId, date); err != nil {
		// 如果数据库更新失败,则回滚 Redis 中的补签标识
		err := s.rc.SetBit(ctx, retroKey, int64(retroOffset), 0).Err()
		if err != nil {
			g.Log().Errorf(ctx, "SetBit 回滚补签状态失败: %v", err)
			return gerror.NewCode(gcode.CodeInternalError)
		}
	}

	// 3. 计算连续签到日期发放连续签到奖励
	return s.updateConsecutiveBonus(ctx, userId, date.Year(), int(date.Month()))
}

// retroWithTransaction 补签逻辑,使用事务保证原子性
func (s *Service) retroWithTransaction(ctx context.Context, userId uint64, date time.Time) error {
	return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
		// 1. 查询用户的当前积分,积分不够的不能补签
		var userPoint entity.UserPoints
		if err := tx.Model(dao.UserPoints.Table()).
			Where(dao.UserPoints.Columns().UserId, userId).
			Scan(&userPoint); err != nil {
			if !errors.Is(err, sql.ErrNoRows) {
				g.Log().Errorf(ctx, "查询用户积分失败: %v", err)
				return err
			}
			// 如果是 ErrNoRows(没记录),就手动给一个默认值
			userPoint = entity.UserPoints{
				UserId: userId,
			}
		}

		if userPoint.Points < defaultRetroCostPoints {
			return ErrNoEnoughPoints
		}

		// 2. 计算积分变化(补签消耗的、每日签到奖励)
		pointsChange := -defaultRetroCostPoints + defaultDailyPoints
		nowPoints := userPoint.Points + int64(pointsChange)
		nowTotalPoints := userPoint.PointsTotal + defaultDailyPoints // 只算得到的积分,不算消费的

		// 3. 积分记录中新增一条补签消耗100积分的记录
		retroCostRecord := entity.UserPointsTransactions{
			UserId:          userId,
			PointsChange:    -defaultRetroCostPoints,
			TransactionType: int(PointsTransactionTypeRetro),
			// Description:     PointsTransactionTypeMsgMap[PointsTransactionTypeRetro],
			Description:    fmt.Sprintf(PointsTransactionTypeMsgMap[PointsTransactionTypeRetro], date.Format(time.DateOnly)),
			CurrentBalance: userPoint.Points - defaultRetroCostPoints,
			CreatedAt:      gtime.NewFromTime(time.Now()),
			UpdatedAt:      gtime.NewFromTime(time.Now()),
		}

		if _, err := tx.Model(dao.UserPointsTransactions.Table()).Insert(&retroCostRecord); err != nil {
			g.Log().Errorf(ctx, "插入补签消耗的积分记录失败: %v", err)
			return err
		}

		// 4. 积分记录中新增每日签到固定得的奖励积分记录
		checkinBonusRecord := entity.UserPointsTransactions{
			UserId:          userId,
			PointsChange:    defaultDailyPoints,
			TransactionType: int(PointsTransactionTypeDaily),
			Description:     PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
			CurrentBalance:  nowPoints,
			CreatedAt:       gtime.NewFromTime(time.Now()),
			UpdatedAt:       gtime.NewFromTime(time.Now()),
		}

		if _, err := tx.Model(dao.UserPointsTransactions.Table()).Insert(&checkinBonusRecord); err != nil {
			g.Log().Errorf(ctx, "插入补签奖励积分记录失败: %v", err)
			return err
		}

		// 5. 更新用户积分
		userPoint.Points = nowPoints
		userPoint.PointsTotal = nowTotalPoints
		if _, err := tx.Model(dao.UserPoints.Table()).
			Where(dao.UserPoints.Columns().UserId, userId).
			Update(&userPoint); err != nil {
			g.Log().Errorf(ctx, "更新用户积分失败: %v", err)
			return err
		}

		return nil
	})
}

func (s *Service) checkRetroDate(ctx context.Context, userId uint64, date time.Time) error {
	// 补签日期不能是今天或者未来的日期
	now := time.Now()
	if date.Year() > now.Year() ||
		date.Month() != now.Month() ||
		(date.Year() == now.Year() && date.YearDay() >= now.YearDay()) {
		// return errors.New("补签日期无效")
		return ErrInvalidRetroDate
	}

	// 补签的日期不能是本月之前的日期
	// 补签的日期不能是已经签到的日期(签到或者补签过都算)
	checkinKey := fmt.Sprintf(yearSignKeyFormat, userId, date.Year())
	yearOffset := date.YearDay() - 1
	checked, err := s.rc.GetBit(ctx, checkinKey, int64(yearOffset)).Result()
	if err != nil {
		g.Log().Errorf(ctx, "GetBit 获取当天签到状态失败: %v", err)
		return err
	}

	if checked == 1 {
		// return errors.New("该日期已签到")
		return ErrInvalidRetroDate
	}

	retroKey := fmt.Sprintf(monthRetroKeyFormat, userId, date.Year(), date.Month())
	retroOffset := date.Day() - 1
	retroRet, err := s.rc.GetBit(ctx, retroKey, int64(retroOffset)).Result()
	if err != nil {
		g.Log().Errorf(ctx, "GetBit 获取当天补签状态失败: %v", err)
		return err
	}

	if retroRet == 1 {
		return ErrInvalidRetroDate
	}

	// 每个月补签不能超过三次
	// nil:表示不指定范围(统计整个 key 的所有 bit)
	retroCount, err := s.rc.BitCount(ctx, retroKey, nil).Result()
	if err != nil {
		g.Log().Errorf(ctx, "BitCount 获取补签次数失败: %v", err)
		return err
	}

	if retroCount >= maxRetroTimesPerMonth {
		return ErrRetroNotimes
	}
	return nil
}

mock 一下数据库记录,便于演示补签功能

bash 复制代码
mysql> select * from user_points;
+----+------------------+--------+--------------+---------------------+---------------------+------------+
| id | user_id          | points | points_total | created_at          | updated_at          | deleted_at |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
|  1 | 7848412598790395 |      1 |            1 | 2025-12-30 10:48:18 | 2025-12-30 10:48:18 | NULL       |
|  2 | 7848643939821819 |      3 |            3 | 2025-12-30 10:50:50 | 2026-01-05 10:57:54 | NULL       |
|  3 | 7740084346448123 |      3 |            3 | 2025-12-30 22:31:58 | 2026-01-05 10:58:31 | NULL       |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
3 rows in set (0.00 sec)

mysql> update user_points set points=300 where user_id=7740084346448123;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> select * from user_points;
+----+------------------+--------+--------------+---------------------+---------------------+------------+
| id | user_id          | points | points_total | created_at          | updated_at          | deleted_at |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
|  1 | 7848412598790395 |      1 |            1 | 2025-12-30 10:48:18 | 2025-12-30 10:48:18 | NULL       |
|  2 | 7848643939821819 |      3 |            3 | 2025-12-30 10:50:50 | 2026-01-05 10:57:54 | NULL       |
|  3 | 7740084346448123 |    300 |            3 | 2025-12-30 22:31:58 | 2026-01-05 10:59:10 | NULL       |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
3 rows in set (0.00 sec)

启动前后端进行测试





签到提醒

每日签到提醒

可以在页面设置一个签到提醒开关,用户打开后,支持每天发 Push 或短信提醒用户签到。需要考虑短信的成本。

运营策略提醒

可以根据特定的运营策略实现签到提醒功能。

比如在每天 18:00 点对那些前两天签到过,但是今天还没有签到的用户发送即将断签提醒,引导用户及时完成签到,获得连续签到奖励。

当用户量级比较大的时候,需要编写 Lua 脚本,并使用 script load 功能,提高执行效率。

lua 复制代码
-- KEYS[1]: 用户签到key
-- ARGV[1]: 当前日偏移量(从年初开始的天数)
-- ARGV[2]: 提醒阈值

local offset = tonumber(ARGV[1])
local threshold = tonumber(ARGV[2])

-- 检查今天是否已签到(最新位)
local today = redis.call('GETBIT', KEYS[1], offset)
if today == 1 then return 0 end  -- 已签到无需提醒

-- 检查最近threshold天的签到情况
local continuous = true
for i = 1, threshold do
    local bit = redis.call('GETBIT', KEYS[1], offset - i)
    if bit ~= 1 then
        continuous = false
        break
    end
end

-- 返回结果:1需要提醒 0不需要
return continuous and 1 or 0

在 Go 语言中使用 go:embed 将 Lua 脚本编译到可执行文件中。

Goframe 版本

go 复制代码
//go:embed remind.lua
var remindScript string

// CheckAndNotify 检查签到并发送通知
func CheckAndNotify(ctx context.Context, remindThreshold int) error {
    // 1. 获取所有符合条件的用户
    // 可以通过 MYSQL 用户表获取近几天有签到的(如果规模大的用户表时间复杂度会较高)
    // 或者可以使用签到key做记录一个 Set,例如 userID(签到过的用户更新该 Key)
    userIDs := []uint64{25016147980058993}

    // 2. 加载 lua 脚本
    sha, err := redisClient.ScriptLoad(ctx, remindScript).Result()
    if err != nil {
        fmt.Printf("ScriptLoad err: %v\n", err)
        return err
    }

    // 3. 遍历用户调用
    for _, userID := range userIDs {
        key := fmt.Sprintf(yearSignKeyFormat, userID, time.Now().Year())

        // 计算当前日偏移量(当年天)
        dayOfYearOffset := time.Now().YearDay() - 1
        fmt.Printf("key: %s, dayOfYearOffset: %d\n", key, dayOfYearOffset)

        result, err := redisClient.EvalSha(
            ctx,
            sha,
            []string{key},
            dayOfYearOffset,
            2,
        ).Int()

        if err != nil {
            fmt.Printf("result: %d, err: %v\n", result, err)
            return err
        }

        if result == 1 {
            fmt.Printf("用户需要发送签到提醒\n", userID)
            // 发送对应通知,如对应的推送渠道(APP Push 或短信)
        }
    }

    return nil
}

使用 goframe 框架的 gcron 实现定时任务。

go 复制代码
// 开启定时任务
_, err := gcron.Add(ctx, "# 0 18 * * *", func(ctx context.Context) {
    g.Log().Print(ctx, "每天18点跑定时任务")
    err := impl.CheckAndNotify(ctx, 2)
    fmt.Printf("CheckAndNotify err: %v\n", err)
})

if err != nil {
    panic(err)
}

Gin 版本

go 复制代码
package task

import (
    "context"
    "fmt"
    "signflower-gin/internal/dao"
    "time"

    _ "embed"
)

const (
    yearSignKeyFormat = "user:checkin:daily:%d:%d"
    userCheckinsdaily1213132142131212025
)

//go:embed remind.lua
var remindScript string

// CheckAndNotify 检查签到并发送通知
func CheckAndNotify(ctx context.Context, remindThreshold int) error {
    fmt.Println("start check and notify...")

    // 1. 获取所有符合条件的用户
	// 可以通过扫 MySQL 用户表找到最近几天有登录过的(如果有现成的用户登录时间记录可以直接拿来用)
	// 或者可以在用户签到的时候记录一个 ZSet, userID:签到时间(如果用户量需要拆分 Key)
    userIDs := []uint64{25016147980058993}

    sha, err := dao.RedisClient.ScriptLoad(ctx, remindScript).Result()
    if err != nil {
        fmt.Printf("ScriptLoad err: %v\n", err)
        return err
    }

    now := time.Now()
    for _, userID := range userIDs {
        key := fmt.Sprintf(yearSignKeyFormat, userID, time.Now().Year())

        // 计算当前日偏移量(当年天)
        dayOfYearOffset := now.YearDay() - 1
        fmt.Printf("key: %s, dayOfYearOffset: %d\n", key, dayOfYearOffset)

        result, err := dao.RedisClient.EvalSha(
            ctx,
            sha,
            []string{key},
            dayOfYearOffset,
            remindThreshold,
        ).Int()

        fmt.Printf("result: %d, err: %v\n", result, err)
        if err != nil {
            return err
        }

        if result == 1 {
            fmt.Printf("用户需要发送签到提醒\n", userID)
            // 发送对应通知,如对应的推送渠道(APP Push 或短信)
        }
    }

    return nil
}

使用 cron 定时任务(github.com/robfig/cron/v3)

go 复制代码
// task.go
package task

import (
    "context"
    "time"

    "github.com/robfig/cron/v3"
)

// 定时任务
/*
github.com/robfig/cron/v3 CRON 表达式格式

Field        | Mandatory? | Allowed values  | Allowed special characters
Minutes      | Yes        | 0-59            | * / , -
Hours        | Yes        | 0-23            | * / , -
Day of month | Yes        | 1-31            | * / , - ?
Month        | Yes        | 1-12 or JAN-DEC | * / , -
Day of week  | Yes        | 0-6 or SUN-SAT  | * / , - ?
*/

func MustInit(ctx context.Context) *cron.Cron {
    tz, err := time.LoadLocation("Local")
    if err != nil {
        panic(err)
    }

    c := cron.New(cron.WithLocation(tz))
    c.AddFunc("25 20 * * *", func() {
        CheckAndNotify(ctx, 2)
    })
    c.Start()
    return c
}

在impl下添加lua脚本(Lua:在 Redis 内部做"计算/判断/原子操作"(快、稳、少网络往返))

lua 复制代码
-- KEYS[1]: 用户签到key
-- ARGV[1]: 当前日偏移量(从年初开始的天数), 即从年初开始的今天是第几天(例如:1 月 1 日为 1,1 月 2 日为 2,以此类推)。
-- ARGV[2]: 提醒阈值, 即最多检查多少天之前的签到状态来决定是否给用户提醒。

-- offset 是当前日期的偏移量(从年初开始的第几天)
local offset = tonumber(ARGV[1])

-- threshold 是提醒阈值(多少天内连续签到才不提醒)
local threshold = tonumber(ARGV[2])

-- 检查今天是否已签到(最新位)
local today = redis.call('GETBIT', KEYS[1], offset)
if today == 1 then return 0 end  -- 已签到无需提醒

-- 检查最近threshold天的签到情况
local continuous = true
for i = 1, threshold do
    local bit = redis.call('GETBIT', KEYS[1], offset - i)
    -- 如果某一天的签到状态不是 1(即 bit ~= 1),表示用户某天没有签到,将 continuous 设为 false,并退出循环(break)
    if bit ~= 1 then
        continuous = false
        break
    end
end

-- 返回结果:1需要提醒 0不需要
return continuous and 1 or 0
-- 如果 continuous 为 true:continuous and 1 会返回 1,然后 or 0 不会影响,最终返回 1。
-- 如果 continuous 为 false:continuous and 1 会返回 false,然后 or 0 会使整个表达式的结果变成 0

添加reminder.go(Go:负责"什么时候跑、对谁跑、跑完做什么、失败怎么处理"(业务编排))

go 复制代码
package impl

import (
	"backend/utility/injection"
	"context"
	"fmt"
	"time"

	_ "embed"

	"github.com/redis/go-redis/v9"
)

//go:embed remind.lua
var remindScript string

// CheckAndNotify 检查签到并发送通知
func CheckAndNotify(ctx context.Context, remindThreshold int) error {
	// 1. 获取所有符合条件的用户(避免遍历全量用户)
	// 可以通过扫 MySQL 用户表找到最近一天有登录过的
	// 或者可以在用户签到的时候记录一个 ZSet, userID:签到时间,如果用户量多需要拆分 Key
	// TODO: 不要把用户Id写死,要获取所有符合条件的用户,即连续签到了今天之前的两天只是今天没签的用户
	userIDs := []uint64{7740084346448123}

	// 拿 Redis 客户端
	rc := injection.MustInvoke[*redis.Client]()
	// 2. 加载 lua script
	sha, err := rc.ScriptLoad(ctx, remindScript).Result()
	if err != nil {
		fmt.Printf("ScriptLoad err: %v\n", err)
		return err
	}
	// 3. 遍历判断每个用户
	for _, userID := range userIDs {
		key := fmt.Sprintf(yearSignKeyFormat, userID, time.Now().Year())

		// 计算当前日偏移量(当年第几天)
		dayOfYearOffset := time.Now().YearDay() - 1
		fmt.Printf("key: %s, dayOfYearOffset: %d\n", key, dayOfYearOffset)
		// 执行LUA脚本
		result, err := rc.EvalSha(ctx, sha, []string{key}, dayOfYearOffset, remindThreshold).Int()
		fmt.Printf("result: %d, err: %v\n", result, err)
		if err != nil {
			return err
		}

		if result == 1 {
			fmt.Printf("用户%d需要发送断签提醒\n", userID)
			// 发送到消息队列,执行后续的推送逻辑(APP Push 或 短信等)
		}
	}
	return nil
}

在cmd中添加配置

go 复制代码
package cmd

import (
	"backend/internal/controller/checkin"
	"backend/internal/controller/hello"
	"backend/internal/controller/points"
	"backend/internal/controller/userinfo"
	"backend/internal/logic/middleware"
	"backend/internal/service/checkin/impl"
	"backend/utility/injection"
	"context"
	"fmt"

	"github.com/gogf/gf/v2/frame/g"
	"github.com/gogf/gf/v2/net/ghttp"
	"github.com/gogf/gf/v2/os/gcmd"
	"github.com/gogf/gf/v2/os/gcron"
)

var (
	// 运行 main 命令时,程序会进入 gcmd.Command 的 Func 函数,这是你定义的 main 命令的核心部分。
	Main = gcmd.Command{
		Name:  "main",
		Usage: "main",
		Brief: "start http server",
		Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
			// 在 Func 函数内部,首先初始化了一个 GoFrame 服务器
			s := g.Server()

			// 服务注入
			injection.SetupDefaultInjector(ctx)
			defer injection.ShutdownDefaultInjector()

			// 定义了一个路由组,所有的路由都会以 /api/v1 为前缀。
			s.Group("/api/v1", func(group *ghttp.RouterGroup) {
				// 注册通用响应中间件和CORS跨域中间件
				// ghttp.MiddlewareHandlerResponse 是 GoFrame 的默认响应中间件,负责处理 HTTP 响应的通用逻辑。
				group.Middleware(ghttp.MiddlewareHandlerResponse, middleware.CORS)

				// 不需要登录也能访问的接口
				group.POST("/auth/login", userinfo.NewV1(), "Login")          // 登录
				group.POST("/users", userinfo.NewV1(), "Create")              // 创建用户
				group.POST("/auth/refresh", userinfo.NewV1(), "RefreshToken") // 刷新token

				// 需要登录才能访问的接口
				group.Middleware(middleware.Auth)
				group.GET("/users/me", userinfo.NewV1(), "Me") // 我的信息

				group.Bind(
					hello.NewV1(),
					// userinfo.NewV1(), // 用户模块相关接口
					checkin.NewV1(),
					points.NewV1(),
				)
			})
			// 开启定时任务
			_, err = gcron.Add(ctx, "0 30 8 * * *", func(ctx context.Context) {
				g.Log().Print(ctx, "每天8点30跑定时任务")
				err := impl.CheckAndNotify(ctx, 2)
				fmt.Printf("CheckAndNotify err: %v\n", err)
			})
			if err != nil {
				panic(err)
			}
			s.Run()
			return nil
		},
	}
)

启动前后端进行测试

简历辅导

黄金法则:清晰!亮点!匹配度!让别人看了你的简历就想约你面试!

项目介绍:说清楚你做了什么项目,能干啥,有啥用,有数据支持最好!

个人职务:写清楚你在项目里做什么角色,做了哪些事,抓住重点写!写到别人心坎里。项目收获:这个项目给公司带来了啥收益,你从这个项目学到了啥。

你写到简历上的项目要有一个项目背景(无论是你编的还是你实际工作过过的都要事先准备好一个项目背景);项目背景就是你在一个什么样的公司写了一个什么样的项目,你在其中做了什么。(要尽量真实一点,没有真实的也得真实起来。)

这个项目的优势是可以在任意公司的任意系统中存在(只要有用户登录,就可以说有签到这么个功能,不违和),缺点是如果只写个签到功能会比较单薄,重点是突出【签到功能】的底层设计和【积分兑换】的事务操作,积分兑换可以支持道具、优惠券、实物等,当然你也可以把这个签到中心和账号中心、会员服务、任务中心等关联起来。

比如: 我之前实习/工作的公司是一个xxx的公司,有一个网站/APP/小程序,我进公司之后在用户中心或者会员中心,部门主要是做一些用户运营和用户增长相关的业务,提供基本的用户注册、登录等信息管理,通过一些运营活动来提升用户留存。

组里大概有多少个人(小公司一个组三五个人差不多),当时我作为/跟着组长/leader从零做了一个签到系统,通过引入积分系统吸引用户每天来签到,后续会出兑换礼品的功能。

把签到系统项目写到简历上的示例

项目介绍: 作为公司 xx 小程序用户增长的核心抓手,签到中心通过每日签到、积分体系构建用户活跃闭环,目标提升用户留存率与会员转化效率。项目上线后,带动小程序 MAU 从 500 万增至 630 万(提升 26%),为会员服务日均引流 1.2 万新用户,占会员新增量的 18%,成为用户活跃与会员体系的关键连接节点。

核心架构: 打通账号系统、会员系统及任务系统,构建"签到行为 - 积分积累 - 权益兑换"全链路服务,支撑日均 10 万 + 签到请求与 2 万 + 积分兑换操作。

个人职责:

  1. 基于 goframe 框架搭建高可用服务,利用其模块化设计与Go并发性能优势,支撑峰值 QPS 800 + 的签到请求,服务可用性达 99.95%。
  2. 针对海量签到数据,设计 Redis Bitmap+MySQL 分层存储方案 ------ 用 Bitmap 高效存储当日 / 近期签到状态(内存占用降低 60%),支持毫秒级连续签到天数查询;历史数据按周批量持久化至 MySQL兼顾查询性能与存储成本。
  3. 积分兑换支持虚拟道具(如补签卡、优惠券)与实物礼品的差异化兑换流程;
  4. 基于 MySQL 事务 + 分布式锁实现积分扣减与礼品发放的强一致性,保障兑换过程零资损,兑换成功率稳定在 99.9% 以上。
  5. 根据运营策略编写 lua 脚本,对即将断签的用户发送签到提醒,使连续签到用户占比提升 11%;

项目收获

  1. 主导签到打卡服务向服务化、平台化发展,抽象出独立的签到中心能力。
  2. 熟练掌握了 goframe 框架的主要特性,能够熟练使用 goframe 框架进行业务开发。
  3. 能够灵活使用 MySQL、Redis 解决业务中的存储痛点问题。
  4. 锻炼了自己的自主学习能力,积累了大型项目设计和开发经验。

面试可能会问的问题

0、MySQL 事务是一定会被问到的点

直接点链接查看👉 牛客网MySQL事务相关的面试真题

1、如何保证MySQL和Redis的数据一致性

在典型的互联网应用架构中,MySQL 作为关系型数据库,提供 ACID 事务保证;而 Redis 通常用作缓存或高性能键值存储,其事务(MULTI/EXEC)是原子性的,但不支持跨多个 Redis 命令的回滚,更不支持与外部系统(如 MySQL)的分布式事务(XA/2PC)

因此,我们无法实现一个能同时原子性地覆盖 MySQL 和 Redis 操作的"真"分布式事务。我们需要采用最终一致性的策略,并优先保证核心数据存储(MySQL)的一致性

使用消息队列实现最终一致性(更复杂,适用于高并发或解耦场景)

  1. 启动 MySQL 事务。
  2. 执行核心数据库操作(如上述)。
  3. 在同一个 MySQL 事务中,向一个"本地消息表"或"事务性发件箱表"(Transactional Outbox Pattern)插入一条消息。这条消息包含了需要对 Redis 进行的操作信息(例如,用户ID,新的总积分等)。
  4. 提交 MySQL 事务。
  • 如果事务成功,数据库操作和"待发送消息"都已持久化。
  • 如果事务失败,所有更改(包括消息)都会回滚。
  1. 一个独立的后台轮询进程或消息中继服务:
  • 定期扫描"本地消息表"。
  • 将消息发送到真正的消息队列(如 Kafka, RabbitMQ)。
  • 发送成功后,标记或删除本地消息表中的消息。
  1. 另一个独立的消费者服务:
  • 从消息队列消费消息。
  • 根据消息内容更新 Redis 缓存。
  • 处理消费失败的情况(重试、死信队列等)。

优点:

  • 主业务流程与缓存更新完全解耦。
  • 对 Redis 的更新具有更高的容错性(通过消息队列的重试机制)。
  • MySQL 事务非常快,因为它不直接等待 Redis 操作。

缺点:

  • 架构更复杂,引入了消息队列和额外的服务。
  • 数据在 Redis 中的一致性是最终的,延迟取决于消息处理速度。

2、如果是海量用户该如何分表?

数据库按 user_id %100 分表。

比如:user_id = 1926199058553114624,则 user_id%100 = 24

该用户的数据该存入 userinfo_24、user_points_24。

3、如何拓展项目(也可以直接写到你的项目描述里)

1、增加签到提醒功能 ,用户开启后,每日系统定时给用户发送消息提醒。

2、增加运营玩法,连续签到奖励不是积分,而是可以灵活配置的其他奖励。

将奖励内容抽象为道具,运营可灵活配置。

3、增加与任务中心的联动,用户可以做任务得积分。

  • 任务中心简单版本:可以定义唯一任务ID,接入的服务方通知哪个用户完成了哪个任务。
  • 任务中心高级版本:基于消息队列 + FACT 行为事实实现。

参考资料

产品设计相关参考:

https://www.woshipm.com/pd/4421789.html

https://www.woshipm.com/pd/771177.html

https://www.woshipm.com/pd/5699161.html

https://www.woshipm.com/operate/5889290.html

https://www.woshipm.com/pd/2268594.html

https://www.woshipm.com/it/5610304.html

https://www.woshipm.com/pd/4298167.html

https://www.woshipm.com/user-research/4268902.html

https://www.woshipm.com/operate/5285842.html

技术实现相关参考:

https://juejin.cn/post/6881928046031568903

https://juejin.cn/post/7274847103728844840

https://planeswalker23.github.io/2022/03/10/我所理解的Redis系列列-第篇-如何设计签到系列统(基础级篇)/

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!

相关推荐
悟空码字几秒前
SpringBoot + Redis分布式锁深度剖析,性能暴涨的秘密全在这里
java·spring boot·后端
奋进的芋圆2 分钟前
Spring Boot中实现定时任务
java·spring boot·后端
Moresweet猫甜3 分钟前
Ubuntu LVM引导丢失紧急救援:完整恢复指南
linux·运维·数据库·ubuntu
yumgpkpm7 分钟前
Cloudera CDH5、CDH6、CDP7现状及替代方案
数据库·人工智能·hive·hadoop·elasticsearch·数据挖掘·kafka
BD_Marathon8 分钟前
Spring——容器
java·后端·spring
松涛和鸣10 分钟前
48、MQTT 3.1.1
linux·前端·网络·数据库·tcp/ip·html
晓时谷雨12 分钟前
达梦数据库适配方案及总结
数据库·达梦·数据迁移
武子康14 分钟前
大数据-206 用 NumPy 矩阵乘法手写多元线性回归:正规方程、SSE/MSE/RMSE 与 R²
大数据·后端·机器学习
LaLaLa_OvO15 分钟前
spring boot2.0 里的 javax.validation.Constraint 加入 service
java·数据库·spring boot
小王和八蛋15 分钟前
负载均衡之DNS轮询
后端·算法·程序员