goframe框架签到系统项目开发(实现总积分和积分明细接口、补签日期校验)

文章目录

实现总积分接口

增加总积分和积分明细接口api

go 复制代码
package v1

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

// SummaryReq 总积分请求结构体
type SummaryReq struct {
	g.Meta `path:"/points/summary" method:"get" sm:"总积分" tags:"积分"`
}

type SummaryRes struct {
	Total int `json:"total"`
}

// RecordsReq 积分明细请求结构体
type RecordsReq struct {
	g.Meta `path:"/points/records" method:"get" sm:"积分明细" tags:"积分"`
	Limit  int `p:"limit" d:"10" dc:"分页大小,默认为10"`
	Offset int `p:"offset" d:"0" dc:"分页偏移"`
}

type RecordsRes struct {
	Total   int       `json:"total"`
	HasMore bool      `json:"hasMore"`
	List    []*Record `json:"list"`
}

type Record struct {
	PointsChange    int64  `json:"pointsChange"`    // 积分变化量
	TransactionType int    `json:"transactionType"` // 交易类型
	Description     string `json:"description"`     // 描述
	TransactionTime string `json:"transactionTime"` // 交易时间
}

生成 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/api/hello/hello.go
generated: /root/proj/proj2/goframProj/backend/api/points/points.go
generated: /root/proj/proj2/goframProj/backend/internal/controller/points/points.go
generated: /root/proj/proj2/goframProj/backend/internal/controller/points/points_new.go
generated: /root/proj/proj2/goframProj/backend/internal/controller/points/points_v1_summary.go
generated: /root/proj/proj2/goframProj/backend/internal/controller/points/points_v1_records.go
generated: /root/proj/proj2/goframProj/backend/api/userinfo/userinfo.go
done!

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

go 复制代码
package points

import (
	"context"
)

type Service interface {
	Summary(ctx context.Context, userId uint64) (int, error)
}

配置service服务下的impl实现

go 复制代码
package impl

import (
	"backend/internal/dao"
	"backend/internal/model/entity"
	"context"
	"database/sql"
	"errors"

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

// 积分服务的具体实现
type Service struct{}

func New() *Service {
	return &Service{}
}

// Summary 总积分查询
func (s *Service) Summary(ctx context.Context, userId uint64) (int, error) {
	var userPoint entity.UserPoints
	if err := dao.UserPoints.Ctx(ctx).
		Where(dao.UserPoints.Columns().UserId, userId).
		Scan(&userPoint); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, nil // 没有积分记录,返回0
		}
		g.Log().Errorf(ctx, "查询积分失败: %v", err)
		return 0, err
	}
	return int(userPoint.Points), nil
}

配置points构造函数

go 复制代码
// =================================================================================
// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish.
// =================================================================================

package points

import (
	"backend/api/points"
	srvPoints "backend/internal/service/points"
	"backend/internal/service/points/impl"
)

type ControllerV1 struct {
	svc srvPoints.Service
}

func NewV1() points.IPointsV1 {
	return &ControllerV1{
		svc: impl.New(),
	}
}

配置总积分controller层代码

go 复制代码
package points

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/points/v1"
)

func (c *ControllerV1) Summary(ctx context.Context, req *v1.SummaryReq) (res *v1.SummaryRes, err error) {
	// 1. 获取登录的用户id
	// 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. 调用积分服务查询总积分
	total, err := c.svc.Summary(ctx, userId)
	if err != nil {
		return nil, err
	}

	return &v1.SummaryRes{
		Total: total,
	}, nil
}

配置路由

go 复制代码
package cmd

import (
	"context"

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

	"backend/internal/controller/checkin"
	"backend/internal/controller/hello"
	"backend/internal/controller/points"
	"backend/internal/controller/userinfo"
	"backend/internal/logic/middleware"
	"backend/utility/injection"
)

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(),
				)
			})
			s.Run()
			return nil
		},
	}
)

启动前后端测试

实现积分明细接口

增加service服务下的points抽象接口

go 复制代码
package points

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

type Service interface {
	Summary(ctx context.Context, userId uint64) (int, error)
	Records(ctx context.Context, input *model.PointsRecordsInput) (*model.PointsRecordsOutput, error)
}

配置service服务下的impl实现

go 复制代码
package impl

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

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

// 积分服务的具体实现
type Service struct{}

func New() *Service {
	return &Service{}
}

// Summary 总积分查询
func (s *Service) Summary(ctx context.Context, userId uint64) (int, error) {
	var userPoint entity.UserPoints
	if err := dao.UserPoints.Ctx(ctx).
		Where(dao.UserPoints.Columns().UserId, userId).
		Scan(&userPoint); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, nil // 没有积分记录,返回0
		}
		g.Log().Errorf(ctx, "查询积分失败: %v", err)
		return 0, err
	}
	return int(userPoint.Points), nil
}

// Records 积分记录查询
func (s *Service) Records(ctx context.Context, input *model.PointsRecordsInput) (*model.PointsRecordsOutput, error) {
	// 1. 分页查询积分记录
	var (
		total   int
		records []entity.UserPointsTransactions
	)
	if err := dao.UserPointsTransactions.Ctx(ctx).
		Where(dao.UserPointsTransactions.Columns().UserId, input.UserId).
		OrderDesc(dao.UserPointsTransactions.Columns().CreatedAt). // 创建时间倒序
		Offset(input.Offset).
		Limit(input.Limit).
		ScanAndCount(&records, &total, false); err != nil {
		g.Log().Errorf(ctx, "查询积分记录失败: %v", err)
		return nil, err
	}

	// 2. 格式化输出, 把数据库中记录格式化为需要的数据
	list := make([]*model.PointsRecordItem, 0, len(records))
	for _, v := range records {
		list = append(list, &model.PointsRecordItem{
			Points:          v.PointsChange,
			TransactionType: v.TransactionType,
			Description:     v.Description,
			// 把数据库里的创建时间转换成接口返回用的字符串时间
			Date: v.CreatedAt.Time.Format(time.DateTime),
		})
	}

	return &model.PointsRecordsOutput{
		List: list,
		// 判断下一页是否存在
		// len(records) == input.Limit:本页拿满了(说明可能还有后续)
		HasMore: len(records) == input.Limit && total > (input.Offset+input.Limit),
		Total:   total,
	}, nil
}

定义积分记录结构体模型

go 复制代码
package model

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

// PointsRecordsInput 积分记录查询模型
type PointsRecordsInput struct {
	UserId uint64 // 用户ID
	Limit  int    // 分页
	Offset int    // 分页
}

type PointsRecordsOutput struct {
	List    []*PointsRecordItem
	HasMore bool // 后面还有没有下一页
	Total   int  // 该用户总共有多少条记录
}

type PointsRecordItem struct {
	Points          int64  // 积分数量
	TransactionType int    // 积分类型
	Description     string // 描述
	Date            string // 时间
}

配置总积分controller层代码

go 复制代码
package points

import (
	v1 "backend/api/points/v1"
	"backend/internal/consts"
	"backend/internal/model"
	"context"

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

const defaultLimit = 10

func (c *ControllerV1) Records(ctx context.Context, req *v1.RecordsReq) (res *v1.RecordsRes, err error) {
	// 1. 获取登录的用户id
	// 1. 从请求上下文中获取 userid
	userId, ok := ctx.Value(consts.CtxKeyUserID).(uint64)
	g.Log().Debugf(ctx, "从请求上下文中获取 userId: %d", userId)
	if !ok || userId == 0 {
		return nil, gerror.New("用户信息获取失败")
	}

	if req.Limit > 50 {
		req.Limit = defaultLimit
	}

	// 2. 调用积分服务查询积分记录
	output, err := c.svc.Records(ctx, &model.PointsRecordsInput{
		UserId: userId,
		Limit:  req.Limit,
		Offset: req.Offset,
	})

	if err != nil {
		return nil, err
	}

	// 格式化, output.List 转为 []*v1.Record
	res = &v1.RecordsRes{
		HasMore: output.HasMore,
		Total:   output.Total,
	}

	list := make([]*v1.Record, 0, len(output.List))
	for _, item := range output.List {
		list = append(list, &v1.Record{
			PointsChange:    item.Points,
			TransactionType: item.TransactionType,
			Description:     item.Description,
			TransactionTime: item.Date,
		})
	}

	res.List = list
	return res, 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"`    // 连续签到天数
}

// RetroReq 补签请求结构体
type RetroReq struct {
	g.Meta `path:"/checkins/retroactive" method:"POST" tags:"签到" summary:"补签"`
	Date   string `p:"date" v:"required#请选择补签日期"`
}

type RetroRes struct{}

生成 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_retro.go
generated: /root/proj/proj2/goframProj/backend/api/hello/hello.go
generated: /root/proj/proj2/goframProj/backend/api/points/points.go
generated: /root/proj/proj2/goframProj/backend/api/userinfo/userinfo.go
done!

增加service服务下的补签抽象接口

go 复制代码
package checkin

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

type Service interface {
	Daily(ctx context.Context, userID uint64) error                                                   // 每日签到
	MonthDetail(ctx context.Context, input *model.MonthDetailInput) (*model.MonthDetailOutput, error) // 签到详情
	Retro(ctx context.Context, userId uint64, date time.Time) 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},
}

var (
	ErrInvalidRetroDate = errors.New("补签日期无效")
	ErrChecked          = errors.New("日期已签到")
	ErrRetroNotimes     = errors.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. 执行补签逻辑
	// 3. 计算连续签到日期发放连续签到奖励
	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
}

配置补签controller层代码

go 复制代码
package checkin

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

	"github.com/gogf/gf/v2/frame/g"

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

	v1 "backend/api/checkin/v1"
)

func (c *ControllerV1) Retro(ctx context.Context, req *v1.RetroReq) (*v1.RetroRes, error) {
	// 1. 校验日期格式, 2025-07-01
	t, err := time.Parse(time.DateOnly, req.Date)
	if err != nil {
		return nil, gerror.New("日期格式不正确")
	}

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

	// 2. 调用 service 层补签逻辑
	if err = c.svc.Retro(ctx, userId, t); err != nil {
		return nil, err
	}
	return &v1.RetroRes{}, nil
}

启动后端进行测试

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






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

相关推荐
无限进步_16 小时前
【C语言】堆(Heap)的数据结构与实现:从构建到应用
c语言·数据结构·c++·后端·其他·算法·visual studio
初次攀爬者16 小时前
基于知识库的知策智能体
后端·ai编程
喵叔哟16 小时前
16.项目架构设计
后端·docker·容器·.net
强强强79517 小时前
python代码实现es文章内容向量化并搜索
后端
A黑桃17 小时前
Paimon 表定时 Compact 数据流程与逻辑详解
后端
掘金者阿豪17 小时前
JVM由简入深学习提升分(生产项目内存飙升分析)
后端
天天摸鱼的java工程师17 小时前
RocketMQ 与 Kafka 对比:消息队列选型的核心考量因素
java·后端
星浩AI17 小时前
10 行代码带你上手 LangChain 智能 Agent
人工智能·后端
洛卡卡了17 小时前
从活动编排到积分系统:事件驱动在业务系统中的一次延伸
前端·后端·面试