文章目录
- 实现总积分接口
-
- 增加总积分和积分明细接口api
- [生成 controller 层代码](#生成 controller 层代码)
- 配置service服务下的points抽象接口
- 配置service服务下的impl实现
- 配置points构造函数
- 配置总积分controller层代码
- 配置路由
- 启动前后端测试
- 实现积分明细接口
- 补签日期校验
-
- 增加补签结构体接口api
- [生成 controller 层代码](#生成 controller 层代码)
- 增加service服务下的补签抽象接口
- 配置service服务下的impl实现
- 配置补签controller层代码
- 启动后端进行测试
实现总积分接口
增加总积分和积分明细接口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"
}






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