goframe框架签到系统项目开发(每日签到添加积分和积分记录、获取当月最大连续签到天数、发放连续签到奖励积分、实现签到日历详情接口)

文章目录

每日签到添加积分和积分记录

修改impl下的checkin.go

go 复制代码
package impl

import (
	"backend/internal/dao"
	"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/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
	defaultDailyPoints = 1                           // 每日签到积分
)

type PointsTransactionType int

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

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

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
	})

	if err != nil {
		g.Log().Errorf(ctx, "事务处理失败: %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("今日已签到")
	}

	return nil
}

启动前后端服务进行测试


获取当月最大连续签到天数

增加impl下checkin.go的功能

go 复制代码
package impl

import (
	"backend/internal/dao"
	"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/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  = 1                               // 每日签到积分
)

type PointsTransactionType int

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

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

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
	})

	if err != nil {
		g.Log().Errorf(ctx, "事务处理失败: %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("今日已签到")
	}

	return nil
}

// updateConsecutiveBonus 更新连续签到奖励积分
func (s *Service) updateConsecutiveBonus(ctx context.Context, userId uint64) error {
	// 1. 获取当前连续签到天数
	// 2. 计算连续签到奖励积分
	// 3. 更新用户积分汇总表和用户积分明细表
	return nil
}

// CalcMonthConsecutiveDays 计算本月连续签到天数
func (s *Service) CalcMonthConsecutiveDays(ctx context.Context, userId uint64, year, month int) (int, error) {
	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, 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, err
	}

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

	retroBitmap := uint64(retroValues[0])

	// 逻辑或
	bitmap := checkinBitmap | retroBitmap // 合并本月签到和补签数据

	return calcMaxConsecutiveDays(bitmap, monthDays), nil
}

// calcMaxConsecutiveDays 计算最大连续签到天数
func calcMaxConsecutiveDays(bitmap uint64, monthDays int) int {
	// 逐位判断,计算出连续签到天数
	maxCount := 0
	currCount := 0
	for i := range monthDays {
		// 从右向左逐位判断
		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
func getFirstOfMonthOffset(year, month int) int {
	// 1. 获取当月第一天
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	// 2. 计算偏移量
	return firstOfMonth.YearDay() - 1 // offset 从 0 开始
}

// getMonhDays 获取当月天数
func getMonthDays(year, month int) int {
	// 1. 获取当月第一天
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	// 2. 获取当月最后一天
	lastOfMonth := firstOfMonth.AddDate(0, 1, -1)
	return lastOfMonth.Day() // 返回当月天数
}

关于s.rc.BitField的通俗解释

发放连续签到奖励积分

定义积分模型

go 复制代码
package model

// 定义 积分 相关模型
type PointsTransactionInput struct {
	UserId uint64 // 用户ID
	Points int64  // 积分数量
	Desc   string // 描述
	Type   int    // 积分类型
}

增加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/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  = 1                               // 每日签到积分
)

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:       "补签消耗积分",
}

// 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},
}

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 加积分
	err = s.AddPoints(ctx, &model.PointsTransactionInput{
		UserId: userId,
		Points: defaultDailyPoints, // int64
		Desc:   PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
		Type:   int(PointsTransactionTypeDaily),
	})

	if err != nil {
		g.Log().Errorf(ctx, "事务处理失败: %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
	}

	// 把领取的奖励塞到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 表
			newLog := &entity.UserMonthlyBonusLog{
				UserId:      userId,
				YearMonth:   fmt.Sprintf("%d%02d", year, month),
				Description: consecutiveBonusNames[rule.BonusType],
				BonusType:   int(rule.BonusType),
				CreatedAt:   gtime.NewFromTime(time.Now()),
				UpdatedAt:   gtime.NewFromTime(time.Now()),
			}

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

	return nil
}

// CalcMonthConsecutiveDays 计算本月连续签到天数
func (s *Service) CalcMonthConsecutiveDays(ctx context.Context, userId uint64, year, month int) (int, 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, 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, err
	}

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

	retroBitmap := uint64(retroValues[0])

	// 逻辑或
	bitmap := checkinBitmap | retroBitmap // 合并本月签到和补签数据

	return calcMaxConsecutiveDays(bitmap, monthDays), nil
}

// calcMaxConsecutiveDays 计算最大连续签到天数
func calcMaxConsecutiveDays(bitmap uint64, monthDays int) int {
	// 逐位判断,计算出连续签到天数
	maxCount := 0
	currCount := 0
	for i := range monthDays {
		// 从右向左逐位判断
		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
func getFirstOfMonthOffset(year, month int) int {
	// 1. 获取当月第一天
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	// 2. 计算偏移量
	return firstOfMonth.YearDay() - 1 // offset 从 0 开始
}

// getMonhDays 获取当月天数
func getMonthDays(year, month int) int {
	// 1. 获取当月第一天
	firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	// 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
	})
}

实现controller层的daily代码

go 复制代码
package checkin

import (
	"backend/internal/consts"
	"context"

	// "github.com/gogf/gf/v2/errors/gcode"
	"github.com/gogf/gf/v2/errors/gerror"
	"github.com/gogf/gf/v2/frame/g"

	v1 "backend/api/checkin/v1"
)

// Daily 每日签到接口实现
func (c *ControllerV1) Daily(ctx context.Context, req *v1.DailyReq) (res *v1.DailyRes, err error) {
	// 1. 从请求上下文获取 userId
	userId, ok := ctx.Value(consts.CtxKeyUserID).(uint64)

	g.Log().Debugf(ctx, "从请求上下文中获取 userId: %d", userId)
	if !ok || userId == 0 {
		return nil, gerror.New("用户信息获取失败")
	}

	// 2. 调用服务层每日签到逻辑
	err = c.svc.Daily(ctx, userId)
	if err != nil {
		return nil, err
	}

	return &v1.DailyRes{}, nil
}

实现签到日历详情接口

增加签到日历详情接口api

go 复制代码
package v1

import (
	"github.com/gogf/gf/v2/frame/g"
)

// 定义签到相关的 API
type DailyReq struct {
	g.Meta `path:"/checkins" method:"POST" tags:"签到" summary:"每日签到"`
}

type DailyRes struct{}

type CalendarReq struct {
	g.Meta    `path:"/checkins/calendar" method:"GET" tags:"签到" summary:"获取签到日历"`
	YearMonth string `p:"yearMonth" v:"required#请选择年月,例如2025-02"`
}

type CalendarRes struct {
	Year   int        `json:"year"`
	Month  int        `json:"month"`
	Detail DetailInfo `json:"detail"`
}

type DetailInfo struct {
	CheckedInDays      []int `json:"checkedInDays"`      // 签到的日期
	RetroCheckedInDays []int `json:"retroCheckedInDays"` // 补签的日期
	IsCheckedInToday   bool  `json:"isCheckedInToday"`        // 当天是否签到
	RemainRetroTimes   int   `json:"remainRetroTimes"`   // 剩余补签次数
	ConsecutiveDays    int   `json:"consecutiveDays"`    // 连续签到天数
}

生成 controller 层代码

bash 复制代码
root@GoLang:~/proj/proj2/goframProj/backend# gf gen ctrl
generated: /root/proj/proj2/goframProj/backend/api/checkin/checkin.go
generated: /root/proj/proj2/goframProj/backend/internal/controller/checkin/checkin_v1_calendar.go
generated: /root/proj/proj2/goframProj/backend/api/hello/hello.go
generated: /root/proj/proj2/goframProj/backend/api/userinfo/userinfo.go
done!

配置service服务下的checkin抽象接口

go 复制代码
package checkin

import (
	"backend/internal/model"
	"context"
)

type Service interface {
	Daily(ctx context.Context, userID uint64) error                                                   // 每日签到
	MonthDetail(ctx context.Context, input *model.MonthDetailInput) (*model.MonthDetailOutput, error) // 签到详情
}

配置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/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,和积分字段统一)
	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:       "补签消耗积分",
}

// 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},
}

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
}

定义月积分模型

go 复制代码
package model

// 签到相关参数
type MonthDetailInput struct {
	UserId uint64
	Year   int // 年份
	Month  int // 月份
}

type MonthDetailOutput struct {
	CheckedInDays      []int `json:"checkedInDays"`      // 签到的日期
	RetroCheckedInDays []int `json:"retroCheckedInDays"` // 补签的日期
	IsCheckedInToday   bool  `json:"isCheckedIn"`        // 当天是否签到
	RemainRetroTimes   int   `json:"remainRetroTimes"`   // 剩余补签次数
	ConsecutiveDays    int   `json:"consecutiveDays"`    // 连续签到天数
}

配置controller层的calendar 每月签到接口实现

go 复制代码
package checkin

import (
	v1 "backend/api/checkin/v1"
	"backend/internal/consts"
	"backend/internal/model"
	"context"
	"time"

	"github.com/gogf/gf/v2/errors/gcode"
	"github.com/gogf/gf/v2/errors/gerror"
	"github.com/gogf/gf/v2/frame/g"
)

func (c *ControllerV1) Calendar(ctx context.Context, req *v1.CalendarReq) (res *v1.CalendarRes, err error) {
	// 解析输入的参数
	t, err := time.Parse("2006-01", req.YearMonth)
	if err != nil {
		return nil, gerror.NewCode(gcode.CodeInvalidParameter) // 参数错误
	}

	// 1. 从请求上下文中获取 userid
	userId, ok := ctx.Value(consts.CtxKeyUserID).(uint64)
	g.Log().Debugf(ctx, "从请求上下文中获取 userId: %d", userId)
	if !ok || userId == 0 {
		return nil, gerror.New("用户信息获取失败")
	}

	// 调用 service 层获取签到日历数据
	output, err := c.svc.MonthDetail(ctx, &model.MonthDetailInput{
		Year:   t.Year(),
		Month:  int(t.Month()),
		UserId: userId,
	})
	if err != nil {
		return nil, err
	}

	return &v1.CalendarRes{
		Year:  t.Year(),
		Month: int(t.Month()),
		Detail: v1.DetailInfo{
			CheckedInDays:      output.CheckedInDays,
			RetroCheckedInDays: output.RetroCheckedInDays,
			IsCheckedInToday:   output.IsCheckedInToday,
			RemainRetroTimes:   output.RemainRetroTimes,
			ConsecutiveDays:    output.ConsecutiveDays,
		},
	}, nil
}

启动后端测试

json 复制代码
{
    "username": "Richard",
    "password": "1234567"
}




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

相关推荐
倔强的石头_14 小时前
kingbase备份与恢复实战(二)—— sys_dump库级逻辑备份与恢复(Windows详细步骤)
数据库
jiayou642 天前
KingbaseES 实战:深度解析数据库对象访问权限管理
数据库
李广坤3 天前
MySQL 大表字段变更实践(改名 + 改类型 + 改长度)
数据库
爱可生开源社区4 天前
2026 年,优秀的 DBA 需要具备哪些素质?
数据库·人工智能·dba
随逸1774 天前
《从零搭建NestJS项目》
数据库·typescript
花酒锄作田4 天前
Gin 框架中的规范响应格式设计与实现
golang·gin
加号35 天前
windows系统下mysql多源数据库同步部署
数据库·windows·mysql
シ風箏5 天前
MySQL【部署 04】Docker部署 MySQL8.0.32 版本(网盘镜像及启动命令分享)
数据库·mysql·docker
李慕婉学姐5 天前
Springboot智慧社区系统设计与开发6n99s526(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·后端
百锦再5 天前
Django实现接口token检测的实现方案
数据库·python·django·sqlite·flask·fastapi·pip