欢迎光临
我们一直在努力

goframe框架签到系统项目开发(前端代码介绍及联调、签到模块相关 API、积分模块相关 API、用户积分汇总表、交易明细表、月度奖励记录表、签到记录表、基于Redis实现用户签到)

文章目录

  • api.json文件下载
  • 前端项目配置
    • 安装node和npm
    • 本地启动
    • 解决找不到模块"node:url"或其相应的类型声明报错
  • 要想前后端协同,后端也要启动
  • 测试refresh token
    • 修改impl下的userinfo.go
  • 签到模块相关 API
  • 积分模块相关 API
  • 用户积分汇总表设计
  • 用户积分交易明细表设计
  • 月度奖励记录表设计
  • 签到记录表设计
  • 基于Redis实现用户签到
    • 修改hack下的配置文件config.yaml添加新增数据表名
    • 执行下面的命令生成 dao/do/entity 代码
    • 增加用户签到的api
    • 生成 controller 层代码
    • 配置redis对应的配置文件 manifest/config/config.yaml
    • 注入依赖redis.go
    • 把redis依赖注入添加到默认依赖注入injection.go
    • 配置service服务下的checkin抽象接口
    • 配置service服务下的impl实现
    • 配置controller层的new注入依赖
    • 配置controller层的Daily 每日签到接口实现
    • 配置路由cmd.go
  • 前后端启动服务测试
    • 先登录
    • 然后才能签到
    • 根据DEBU的输出信息,可以在redis客户端进行数据验证

本项目前端代码

本项目完整前后端代码

api.json文件下载

打开 http://127.0.0.1:8000/swagger/ 即可查看完整 API 文档

在这里插入图片描述

{"openapi":"3.0.0","components":{"schemas":{"backend.api.userinfo.v1.LoginReq":{"properties":{"username":{"description":"用户名","format":"string","maxLength":20,"minLength":3,"type":"string"},"password":{"description":"密码","format":"string","maxLength":20,"minLength":6,"type":"string"}},"required":["username","password"],"type":"object"},"backend.api.userinfo.v1.LoginRes":{"properties":{"accessToken":{"description":"访问令牌","format":"string","type":"string"},"refreshToken":{"description":"刷新令牌","format":"string","type":"string"}},"type":"object"},"backend.api.userinfo.v1.RefreshTokenReq":{"properties":{"refreshToken":{"description":"刷新令牌","format":"string","type":"string"}},"required":["refreshToken"],"type":"object"},"backend.api.userinfo.v1.RefreshTokenRes":{"properties":{"accessToken":{"description":"访问令牌","format":"string","type":"string"},"refreshToken":{"description":"刷新令牌","format":"string","type":"string"}},"type":"object"},"backend.api.hello.v1.HelloReq":{"properties":{},"type":"object"},"backend.api.hello.v1.HelloRes":{"example":"string","properties":{},"type":"object"},"backend.api.userinfo.v1.CreateReq":{"properties":{"username":{"description":"用户名","format":"string","maxLength":20,"minLength":3,"type":"string"},"email":{"description":"邮箱","format":"string","type":"string"},"password":{"description":"密码","format":"string","maxLength":20,"minLength":6,"type":"string"},"confirmPassword":{"description":"确认密码","format":"string","type":"string"}},"required":["username","email","password","confirmPassword"],"type":"object"},"backend.api.userinfo.v1.CreateRes":{"properties":{"userID":{"description":"用户ID","format":"uint64","type":"integer"},"username":{"description":"用户名","format":"string","type":"string"}},"type":"object"},"backend.api.userinfo.v1.MeReq":{"properties":{},"type":"object"},"backend.api.userinfo.v1.MeRes":{"properties":{"username":{"format":"string","type":"string"},"email":{"format":"string","type":"string"},"avatar":{"format":"string","type":"string"}},"type":"object"}}},"info":{"title":"","version":""},"paths":{"/api/v1/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.LoginReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.LoginRes","description":""}}},"description":""}},"summary":"登录","tags":["用户模块"]}},"/api/v1/auth/refresh":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.RefreshTokenReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.RefreshTokenRes","description":""}}},"description":""}},"summary":"刷新令牌","tags":["用户模块"]}},"/api/v1/hello":{"get":{"responses":{"200":{"content":{"text/html":{"schema":{"$ref":"#/components/schemas/backend.api.hello.v1.HelloRes","description":""}}},"description":""}},"summary":"You first hello api","tags":["Hello"]}},"/api/v1/users":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.CreateReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.CreateRes","description":""}}},"description":""}},"summary":"创建用户","tags":["用户模块"]}},"/api/v1/users/me":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/backend.api.userinfo.v1.MeRes","description":""}}},"description":""}},"summary":"获取当前登录的用户信息","tags":["用户模块"]}}}}

前端项目配置

安装node和npm

root@GoLang:~/proj/proj2/goframProj/frontend# curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
root@GoLang:~/proj/proj2/goframProj/frontend# source ~/.bashrc
root@GoLang:~/proj/proj2/goframProj/frontend# nvm install –lts
root@GoLang:~/proj/proj2/goframProj/frontend# nvm use –lts
root@GoLang:~/proj/proj2/goframProj/frontend# npm i

本地启动

root@GoLang:~/proj/proj2/goframProj/frontend# npm run dev

在这里插入图片描述

解决找不到模块"node:url"或其相应的类型声明报错

在这里插入图片描述 这个报错本质是:TypeScript/类型声明无法识别 node: 前缀的内置模块(node:url)。常见原因是 TS 版本太旧 或 没装/没启用 Node 类型,或者 tsconfig 里解析方式不对

root@GoLang:~/proj/proj2/goframProj/frontend# npm i -D typescript@latest @types/node@latest

added 2 packages, and audited 143 packages in 3s

33 packages are looking for funding
run `npm fund` for details

found 0 vulnerabilities

要想前后端协同,后端也要启动

root@GoLang:~/proj/proj2/goframProj/backend# go run main.go

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

{
"code": 0,
"message": "OK",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc4NDg2NDM5Mzk4MjE4MTksInVzZXJuYW1lIjoiSmFja3NvbiIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY2OTM0MjMwfQ.WwuT0VSsgZhRpsuLirYKSrMFf0nwtzQoHzb_sDuPMxo",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc4NDg2NDM5Mzk4MjE4MTksInVzZXJuYW1lIjoiSmFja3NvbiIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY3NDUyNjMwfQ.z1-zeFte_ij748MNggKYcHJyWAFUziT3Jj3JwNDx3yQ"
}
}

在这里插入图片描述

Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc4NDg2NDM5Mzk4MjE4MTksInVzZXJuYW1lIjoiSmFja3NvbiIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY2OTM0MjMwfQ.WwuT0VSsgZhRpsuLirYKSrMFf0nwtzQoHzb_sDuPMxo

测试refresh token

修改impl下的userinfo.go

package impl

import (
"backend/internal/consts"
"backend/internal/dao"
"backend/internal/model"
"backend/internal/model/entity"
"backend/internal/service/userinfo"
"backend/utility/injection"
"context"
"strconv"
"time"

"github.com/gogf/gf/crypto/gmd5"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/golang-jwt/jwt/v5"
"github.com/sony/sonyflake/v2"
)

// 用户相关业务逻辑
// 定义一个结构体, 实现UserInfoService接口
type UserInfo struct {
snowflack *sonyflake.Sonyflake
}

// encryptPassword 加密密码
func (u *UserInfo) encryptPassword(password string) string {
return gmd5.MustEncryptString(password)
}

// func New() *UserInfo {
// return &UserInfo{}
// }

func New() userinfo.UserInfoService {
return &UserInfo{
snowflack: injection.MustInvoke[*sonyflake.Sonyflake](),
}
}

// 建议:在包级别初始化一次(比如 init() 或 main 启动时)
// var sf *sonyflake.Sonyflake

const (
defaultAvatar = "https://avatars.githubusercontent.com/u/51045272?v=4"
)

// func init() {
// st, err := time.Parse(time.DateOnly, "2025-11-01") // 用过去时间
// if err != nil {
// panic(err) // 这里只在启动阶段 panic 可以接受
// }
// sf, err = sonyflake.New(sonyflake.Settings{StartTime: st})
// if err != nil {
// panic(err)
// }
// }

// Create 创建用户
// func (u *UserInfo) Create(ctx context.Context, username, password, email string) error {
func (u *UserInfo) Create(ctx context.Context, input *model.CreateUserInput) (*model.CreateUserOutput, error) {
// 1. 判断用户名是否已经存在(根据用户名查重)
exist, err := dao.Userinfo.Ctx(ctx).
Where(dao.Userinfo.Columns().Username, input.Username).
Exist()
if err != nil {
g.Log().Errorf(ctx, "查询用户是否存在失败: %v", err)
return nil, err
}

if exist {
return nil, gerror.New("用户已存在")
}

// 2. 生成唯一 id
userId, err := u.snowflack.NextID()
if err != nil {
g.Log().Errorf(ctx, "生成用户ID失败: %v", err)
return nil, gerror.Wrap(err, "生成用户ID失败")
}

// 3. 创建用户
// 创建用户,入库
newUserInfo := entity.Userinfo{
UserId: uint64(userId), // 使用雪花算法生成唯一ID
Username: input.Username,
// Password: req.Password, // 是不是需要对用户输入的密码进行加密
Password: u.encryptPassword(input.Password), // 对字符串 s 做 MD5, 返回 MD5 的十六进制字符串
// 练习可用;生产换 bcrypt

Email: input.Email,
Avatar: defaultAvatar, // 简化注册流程,一般使用默认头像,后续支持用户在个人中心上传头像
}

// id, err := dao.Userinfo.Ctx(ctx).InsertAndGetId(newUserInfo)

_, err = dao.Userinfo.Ctx(ctx).Insert(newUserInfo)
if err != nil {
g.Log().Errorf(ctx, "创建用户失败:%v", err)
return nil, gerror.Wrap(err, "创建用户失败")
}
// 4. 返回结果
return &model.CreateUserOutput{
UserId: uint64(userId),
Username: input.Username,
}, nil
}

// Login 登录
func (u *UserInfo) Login(ctx context.Context, input *model.LoginInput) (*model.LoginOutput, error) {
// 拿用户输入的用户名和密码,去数据库查询
var user entity.Userinfo
err := dao.Userinfo.Ctx(ctx).
Where(dao.Userinfo.Columns().Username, input.Username).
Where(dao.Userinfo.Columns().Password, u.encryptPassword(input.Password)).
Scan(&user)

if err != nil {
g.Log().Errorf(ctx, "查询用户失败: %v", err)
return nil, gerror.Wrapf(err, "查询用户失败")
}

// 生成 JWT Token
tokenObj, err := genJwtByUserInfo(ctx, user.UserId, user.Username)
if err != nil {
g.Log().Errorf(ctx, "生成 JWT Token 失败: %v", err)
return nil, gerror.Wrap(err, "生成 JWT Token 失败")
}

// 返回结果
return &model.LoginOutput{
AccessToken: tokenObj.AccessToken,
RefreshToken: tokenObj.RefreshToken,
}, nil
}

// type JWTClaims struct {
// UserId uint64 `json:"userId"`
// Username string `json:"username"`
// jwt.RegisteredClaims
// }

// genJwtByUserInfo 根据用户信息生成 JWT
func genJwtByUserInfo(ctx context.Context, userID uint64, username string) (*model.TokenOutput, error) {
// 生成 Access Token
claims := &model.JWTClaims{
UserId: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "Simon",
Subject: "check-in-system",
// ExpiresAt: jwt.NewNumericDate(time.Now().Add(consts.JWTTokenExpireDay * time.Hour)), // 设置过期时间为1天
ExpiresAt: jwt.NewNumericDate(time.Now().Add(consts.JWTTokenExpireSeconds * time.Second)), // 设置过期时间为20秒,【for the test sake】
},
}

// 创建一个新的 JWT 对象
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

// SignedString:把 Header + Payload 按 JWT 规则 base64url 编码后,使用 secret 生成签名,最后拼成标准 JWT 字符串
// []byte(…):HMAC 需要字节数组形式的 key
signedAccessToken, err := accessToken.SignedString([]byte(consts.JWTAccessTokenSecret))
if err != nil {
g.Log().Errorf(ctx, "生成 JWT Token 失败: %v", err)
return nil, err
}

// 生成 refresh Token
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, &model.JWTClaims{
UserId: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "Simon",
Subject: "check-in-system",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(consts.JWTRefreshExpireWeek * time.Hour)), // 设置过期时间为1周
},
})

signedRefreshToken, err := refreshToken.SignedString([]byte(consts.JWTRefreshTokenSecret))
if err != nil {
g.Log().Errorf(ctx, "生成 JWT Token 失败: %v", err)
return nil, err
}

return &model.TokenOutput{
AccessToken: signedAccessToken,
RefreshToken: signedRefreshToken,
}, nil
}

func (u *UserInfo) GetInfo(ctx context.Context, userId string) (*entity.Userinfo, error) {
var user entity.Userinfo
err := dao.Userinfo.Ctx(ctx).
Where(dao.Userinfo.Columns().UserId, userId).
Scan(&user)

if err != nil {
g.Log().Errorf(ctx, "查询用户失败: %v", err)
return nil, gerror.Wrapf(err, "查询用户失败")
}
return &user, nil
}

// RefreshToken 刷新 Token
func (u *UserInfo) RefreshToken(ctx context.Context, refreshToken string) (res *model.TokenOutput, err error) {
// 1. 解析 refreshToken,拿到 userid
// 解析token获取用户id
var claim model.JWTClaims

// 解析 JWT:将 JWT 字符串(即 accessToken)分解为 Header、Payload、Signature
// 验证签名:通过密钥 consts.JWTAccessTokenSecret 来验证 Signature 是否正确,确保 Token 没有被篡改。
// 填充 claim:将解析出来的 Payload 内容(例如用户的 UserId、Username)填充到 claim 变量中
// keyFunc:这个是一个回调函数,用来返回签名验证所需的密钥。jwt.ParseWithClaims 会使用它来验证 JWT 的 Signature 是否有效
token, err := jwt.ParseWithClaims(refreshToken, &claim, func(token *jwt.Token) (any, error) {
return []byte(consts.JWTRefreshTokenSecret), nil
})

if err != nil || !token.Valid {
g.Log().Errorf(ctx, "refresh token: %v, err: %+v", token, err)
return nil, gerror.New("refresh token 无效")
}

// 2. 根据 userid 获取用户信息
userInfo, err := u.GetInfo(ctx, strconv.FormatUint(claim.UserId, 10))
if err != nil {
g.Log().Errorf(ctx, "根据 userid 获取用户信息失败: %v", err)
return nil, gerror.Wrap(err, "根据 userid 获取用户信息失败")
}

// 3. 生成新的 accessToken 和 refreshToken
// 4. 返回新的 token
return genJwtByUserInfo(ctx, userInfo.UserId, userInfo.Username)
}

在这里插入图片描述 在这里插入图片描述 登录后到20秒之后,accesstoken过期 在这里插入图片描述 在这里插入图片描述

前端自动refresh

在这里插入图片描述 在这里插入图片描述

{
"code": 0,
"message": "OK",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc4NDg2NDM5Mzk4MjE4MTksInVzZXJuYW1lIjoiSmFja3NvbiIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY2ODg4MzI1fQ.Sh9G-MrRPbSrVa_6LT9bhi1-PRmFRVmNbgGWH94wusU",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc4NDg2NDM5Mzk4MjE4MTksInVzZXJuYW1lIjoiSmFja3NvbiIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY3NDkzMTA1fQ.p3OoppgYkSWpWp2NK2W8aEBLuIlf4EY-4Dy36RWT_rA"
}
}

在这里插入图片描述

签到模块相关 API

  • 今日签到
  • POST /api/v1/checkins

  • 补签
  • POST /api/v1/checkins/retroactive

  • 获取签到详情接口
  • GET /api/v1/checkins/calendar

    积分模块相关 API

  • 获取总积分
  • GET /api/v1/points/summary

  • 获取积分明细
  • GET /api/v1/points/records

    用户积分汇总表设计

    DROP TABLE IF EXISTS `user_points`;
    CREATE TABLE `user_points` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
    `points` BIGINT DEFAULT 0 COMMENT '当前可用积分',
    `points_total` BIGINT DEFAULT 0 COMMENT '累计获得积分',
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted_at` TIMESTAMP NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_id` (`user_id`)
    ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户积分汇总表';

    用户积分交易明细表设计

    — 用户积分交易明细表

    DROP TABLE IF EXISTS `user_points_transactions`;
    CREATE TABLE `user_points_transactions` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
    `points_change` BIGINT NOT NULL COMMENT '积分变动值(正数为增加,负数为扣除)',
    `current_balance` BIGINT NOT NULL COMMENT '当前余额',
    `transaction_type` TINYINT NOT NULL COMMENT '交易类型(1:签到 2:连续签到 3:补签 4:每日任务 5:福利任务)',
    `description` VARCHAR(100) COMMENT '积分变动说明',
    `ext_json` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '扩展字段',
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted_at` TIMESTAMP NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    KEY `idx_user_id` (`user_id`)
    ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户积分明细表';

    月度奖励记录表设计

    — 月度奖励记录表
    DROP TABLE IF EXISTS `user_monthly_bonus_log`;

    CREATE TABLE `user_monthly_bonus_log` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
    `year_month` CHAR(6) NOT NULL COMMENT '年月 (YYYYMM)',
    `bonus_type` TINYINT NOT NULL COMMENT '奖励类型 (1:连续签到3天 2:连续签到7天 3:连续签到15天 4:月满签)',
    `description` VARCHAR(100) COMMENT '积分变动说明',
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted_at` TIMESTAMP NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_yearmonth_type` (`user_id`, `year_month`, `bonus_type`)
    ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户月度连续签到奖励记录表';

    签到记录表设计

    — 签到记录表
    DROP TABLE IF EXISTS `user_checkin_records`;

    CREATE TABLE `user_checkin_records` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '记录ID',
    `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
    `checkin_date` DATE NOT NULL COMMENT '签到日期',
    `checkin_type` TINYINT NOT NULL DEFAULT 1 COMMENT '签到类型: 1=正常签到, 2=补签',
    `points_awarded_base` INT NOT NULL DEFAULT 1 COMMENT '获得积分',
    `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted_at` TIMESTAMP NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_date` (`user_id`, `checkin_date`),
    KEY `idx_user_id` (`user_id`),
    KEY `idx_checkin_date` (`checkin_date`),
    KEY `idx_checkin_type` (`checkin_type`)
    ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '签到记录表';

    基于Redis实现用户签到

    修改hack下的配置文件config.yaml添加新增数据表名

    # CLI tool, only in development environment.
    # https://goframe.org/docs/cli
    gfcli:
    gen:
    dao:
    link: "mysql:gf:GfPass!123@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=true&loc=Local"
    descriptionTag: true
    tables: "userinfo, user_checkin_records, user_monthly_bonus_log, user_points, user_points_transactions"

    docker:
    build: "-a amd64 -s linux -p temp -ew"
    tagPrefixes:
    my.image.pub/myapp

    在这里插入图片描述

    执行下面的命令生成 dao/do/entity 代码

    root@GoLang:~/proj/proj2/goframProj/backend# gf gen dao

    在这里插入图片描述

    增加用户签到的api

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

    在这里插入图片描述

    生成 controller 层代码

    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.go
    generated: /root/proj/proj2/goframProj/backend/internal/controller/checkin/checkin_new.go
    generated: /root/proj/proj2/goframProj/backend/internal/controller/checkin/checkin_v1_daily.go
    generated: /root/proj/proj2/goframProj/backend/api/hello/hello.go
    generated: /root/proj/proj2/goframProj/backend/api/userinfo/userinfo.go
    done!

    在这里插入图片描述

    配置redis对应的配置文件 manifest/config/config.yaml

    # https://goframe.org/docs/web/server-config-file-template
    server:
    address: ":8000"
    openapiPath: "/api.json"
    swaggerPath: "/swagger"

    # https://goframe.org/docs/core/glog-config
    logger:
    level : "all"
    stdout: true

    # https://goframe.org/docs/core/gdb-config-file
    database:
    default:
    link: "mysql:gf:GfPass!123@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=true&loc=Local"

    redis:
    checkin:
    address: "127.0.0.1:6379"
    password: ""

    snowflake:
    startTime: "2025-11-01"

    在这里插入图片描述

    注入依赖redis.go

    package injection

    import (
    "context"

    "github.com/gogf/gf/v2/errors/gerror"
    "github.com/gogf/gf/v2/frame/g"
    "github.com/redis/go-redis/v9"
    "github.com/samber/do"
    )

    // injectRedis 注入 Redis client
    func injectRedis(ctx context.Context, injector *do.Injector) {
    do.Provide(injector, func(i *do.Injector) (*redis.Client, error) {
    // 结构体字段必须大写:反射才能赋值
    // 配置键可以小写:GF Scan 会自动映射到大写字段名
    type RedisConfig struct {
    Address string
    Password string
    }
    var (
    err error
    config *RedisConfig
    )
    err = g.Cfg().MustGet(ctx, "redis.checkin").Scan(&config)
    if err != nil {
    return nil, err
    }
    if config == nil {
    return nil, gerror.New("redis config not found")
    }
    // g.Log().Debugf(ctx, "Redis Config: %+v", config)
    g.Log().Debugf(ctx, "Redis Config: addr=%s", config.Address)
    svc := redis.NewClient(&redis.Options{
    Addr: config.Address,
    Password: config.Password,
    })

    // 注册“优雅退出”关闭 Redis
    SetupShutdownHelper(injector, svc, func(svc *redis.Client) error {
    return svc.Close()
    })
    return svc, nil
    })
    }

    在这里插入图片描述

    把redis依赖注入添加到默认依赖注入injection.go

    package injection

    import (
    "context"
    "fmt"
    "reflect"
    "sync"

    "github.com/gogf/gf/v2/frame/g"
    "github.com/samber/do" // 使用第三方的依赖注入工具
    )

    var (
    defaultInjector *do.Injector

    // sync.Once 的作用:即使多个 goroutine 同时调用 SetupDefaultInjector,初始化代码也只会跑一次。
    defaultInjectorOnce sync.Once
    )

    // MustInvoke invokes the function with the default injector and panics if any error occurs.
    func MustInvoke[T any]() T {
    return do.MustInvoke[T](defaultInjector)
    }

    // Invoke invokes the function with the default injector.
    func Invoke[T any]() (T, error) {
    return do.Invoke[T](defaultInjector)
    }

    // SetupDefaultInjector initializes the default injector with the given context.
    func SetupDefaultInjector(ctx context.Context) *do.Injector {
    // 确保初始化只执行一次
    defaultInjectorOnce.Do(func() { // make sure this is only called once
    injector := do.NewWithOpts(&do.InjectorOpts{})

    // 注入服务对象
    // TODO: 注入服务对象
    injectSnowflake(ctx, injector)
    injectRedis(ctx, injector)// 注入redis client

    // injectMongo(ctx, injector)
    // injectRedis(ctx, injector)
    // injectGrpcClients(ctx, injector)

    defaultInjector = injector
    })
    return defaultInjector
    }

    // ShutdownDefaultInjector shuts down the default injector.
    func ShutdownDefaultInjector() {
    if defaultInjector != nil {
    if err := defaultInjector.Shutdown(); err != nil {
    g.Log().Debugf(context.Background(), "ShutdownDefaultInjector: %+v", err)
    }
    defaultInjector = nil
    }
    }

    // ShutdownHelper is a helper struct for shutdown.
    type ShutdownHelper[T any] struct {
    name string // 注入的服务对象的名称
    service T // 注入的服务对象
    onShutdown func(service T) error // 关闭服务时的回调函数
    }

    // NewShutdownHelper creates a new ShutdownHelper.
    func NewShutdownHelper[T any](service T, onShutdown func(service T) error) ShutdownHelper[T] {
    return ShutdownHelper[T]{
    service: service,
    onShutdown: onShutdown,
    }
    }

    // NewShutdownHelperNamed creates a new ShutdownHelper with a name.
    func NewShutdownHelperNamed[T any](service T, name string, onShutdown func(service T) error) ShutdownHelper[T] {
    return ShutdownHelper[T]{
    name: name,
    service: service,
    onShutdown: onShutdown,
    }
    }

    // SetupShutdownHelper sets up a shutdown helper.
    func SetupShutdownHelper[T any](injector *do.Injector, service T, onShutdown func(service T) error) {
    do.Provide(injector, func(i *do.Injector) (ShutdownHelper[T], error) {
    g.Log().Debugf(context.Background(), "NewShutdownHelper: %s", reflect.TypeOf(service))
    return NewShutdownHelper(service, onShutdown), nil
    })
    do.MustInvoke[ShutdownHelper[T]](injector)
    }

    // SetupShutdownHelperNamed sets up a shutdown helper with a name.
    func SetupShutdownHelperNamed[T any](injector *do.Injector, service T, name string, onShutdown func(service T) error) {
    name = fmt.Sprintf("ShutdownHelper:%s", name)
    do.ProvideNamed(injector, name, func(i *do.Injector) (ShutdownHelper[T], error) {
    g.Log().Debugf(
    context.Background(),
    "NewShutdownHelper: %s, %s",
    reflect.TypeOf(service), name,
    )
    return NewShutdownHelperNamed(service, name, onShutdown), nil
    })
    do.MustInvokeNamed[ShutdownHelper[T]](injector, name)
    }

    // Shutdown shuts down the service.
    func (h ShutdownHelper[T]) Shutdown() error {
    g.Log().Debugf(
    context.Background(),
    "ShutdownHelper Shutdown: %s, %s",
    reflect.TypeOf(h.service), h.name,
    )
    return h.onShutdown(h.service)
    }

    在这里插入图片描述

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

    package checkin

    import (
    "context"
    )

    type Service interface {
    Daily(ctx context.Context, userID uint64) error // 每日签到
    }

    在这里插入图片描述

    配置service服务下的impl实现

    package impl

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

    "github.com/gogf/gf/v2/frame/g"
    "github.com/redis/go-redis/v9"
    )

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

    const (
    yearSignKeyFormat = "user:checkins:daily:%d:%d" // user:checkins:daily:12131321421312:2025
    )

    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. 发放每日签到的积分
    // 3. 发送连续签到的奖励积分
    return nil
    }

    在这里插入图片描述

    配置controller层的new注入依赖

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

    package checkin

    import (
    "backend/api/checkin"
    srvCheckin "backend/internal/service/checkin"
    "backend/internal/service/checkin/impl"
    )

    type ControllerV1 struct {
    svc srvCheckin.Service
    }

    func NewV1() checkin.ICheckinV1 {
    return &ControllerV1{
    svc: impl.NewService(),
    }
    }

    在这里插入图片描述

    配置controller层的Daily 每日签到接口实现

    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
    }

    在这里插入图片描述

    配置路由cmd.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/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(),
    )
    })
    s.Run()
    return nil
    },
    }
    )

    在这里插入图片描述

    前后端启动服务测试

    先登录

    {
    "username": "Richard",
    "password": "1234567"
    }

    在这里插入图片描述

    {
    "code": 0,
    "message": "OK",
    "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc3NDAwODQzNDY0NDgxMjMsInVzZXJuYW1lIjoiUmljaGFyZCIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY3MDU1Mzk4fQ.3px5E0_jxutgGQ_L2hCbK0T1dSBsNQrVBNj1KXW9Oso",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjc3NDAwODQzNDY0NDgxMjMsInVzZXJuYW1lIjoiUmljaGFyZCIsImlzcyI6IlNpbW9uIiwic3ViIjoiY2hlY2staW4tc3lzdGVtIiwiZXhwIjoxNzY3NTczNzk4fQ.DxkLESxmtzapf-iS00_gpb_yuzmQBlFF2MRoQ6_zDaQ"
    }
    }

    然后才能签到

    在这里插入图片描述

    key: user:checkins:daily:7740084346448123:2025 dayOfYearOffset:362

    根据DEBU的输出信息,可以在redis客户端进行数据验证

    root@GoLang:~/proj# redis-cli
    127.0.0.1:6379> getbit user:checkins:daily:7740084346448123:2025 362
    (integer) 1
    127.0.0.1:6379> bitcount user:checkins:daily:7740084346448123:2025
    (integer) 1
    127.0.0.1:6379>

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

    赞(0)
    未经允许不得转载:171主机测评 » goframe框架签到系统项目开发(前端代码介绍及联调、签到模块相关 API、积分模块相关 API、用户积分汇总表、交易明细表、月度奖励记录表、签到记录表、基于Redis实现用户签到)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址