Skip to content

Commit 3402d09

Browse files
authored
Merge pull request #2565 from QuantumNous/feat/check-in
feat(checkin): add check-in functionality
2 parents a195e88 + 8abfbe3 commit 3402d09

File tree

16 files changed

+970
-48
lines changed

16 files changed

+970
-48
lines changed

controller/checkin.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package controller
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"time"
7+
8+
"github.com/QuantumNous/new-api/common"
9+
"github.com/QuantumNous/new-api/logger"
10+
"github.com/QuantumNous/new-api/model"
11+
"github.com/QuantumNous/new-api/setting/operation_setting"
12+
"github.com/gin-gonic/gin"
13+
)
14+
15+
// GetCheckinStatus 获取用户签到状态和历史记录
16+
func GetCheckinStatus(c *gin.Context) {
17+
setting := operation_setting.GetCheckinSetting()
18+
if !setting.Enabled {
19+
common.ApiErrorMsg(c, "签到功能未启用")
20+
return
21+
}
22+
userId := c.GetInt("id")
23+
// 获取月份参数,默认为当前月份
24+
month := c.DefaultQuery("month", time.Now().Format("2006-01"))
25+
26+
stats, err := model.GetUserCheckinStats(userId, month)
27+
if err != nil {
28+
c.JSON(http.StatusOK, gin.H{
29+
"success": false,
30+
"message": err.Error(),
31+
})
32+
return
33+
}
34+
35+
c.JSON(http.StatusOK, gin.H{
36+
"success": true,
37+
"data": gin.H{
38+
"enabled": setting.Enabled,
39+
"min_quota": setting.MinQuota,
40+
"max_quota": setting.MaxQuota,
41+
"stats": stats,
42+
},
43+
})
44+
}
45+
46+
// DoCheckin 执行用户签到
47+
func DoCheckin(c *gin.Context) {
48+
setting := operation_setting.GetCheckinSetting()
49+
if !setting.Enabled {
50+
common.ApiErrorMsg(c, "签到功能未启用")
51+
return
52+
}
53+
54+
userId := c.GetInt("id")
55+
56+
checkin, err := model.UserCheckin(userId)
57+
if err != nil {
58+
c.JSON(http.StatusOK, gin.H{
59+
"success": false,
60+
"message": err.Error(),
61+
})
62+
return
63+
}
64+
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("用户签到,获得额度 %s", logger.LogQuota(checkin.QuotaAwarded)))
65+
c.JSON(http.StatusOK, gin.H{
66+
"success": true,
67+
"message": "签到成功",
68+
"data": gin.H{
69+
"quota_awarded": checkin.QuotaAwarded,
70+
"checkin_date": checkin.CheckinDate},
71+
})
72+
}

controller/misc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ func GetStatus(c *gin.Context) {
114114
"setup": constant.Setup,
115115
"user_agreement_enabled": legalSetting.UserAgreement != "",
116116
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
117+
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
117118
}
118119

119120
// 根据启用状态注入可选内容

model/checkin.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package model
2+
3+
import (
4+
"errors"
5+
"math/rand"
6+
"time"
7+
8+
"github.com/QuantumNous/new-api/common"
9+
"github.com/QuantumNous/new-api/setting/operation_setting"
10+
"gorm.io/gorm"
11+
)
12+
13+
// Checkin 签到记录
14+
type Checkin struct {
15+
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
16+
UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_checkin_date"`
17+
CheckinDate string `json:"checkin_date" gorm:"type:varchar(10);not null;uniqueIndex:idx_user_checkin_date"` // 格式: YYYY-MM-DD
18+
QuotaAwarded int `json:"quota_awarded" gorm:"not null"`
19+
CreatedAt int64 `json:"created_at" gorm:"bigint"`
20+
}
21+
22+
// CheckinRecord 用于API返回的签到记录(不包含敏感字段)
23+
type CheckinRecord struct {
24+
CheckinDate string `json:"checkin_date"`
25+
QuotaAwarded int `json:"quota_awarded"`
26+
}
27+
28+
func (Checkin) TableName() string {
29+
return "checkins"
30+
}
31+
32+
// GetUserCheckinRecords 获取用户在指定日期范围内的签到记录
33+
func GetUserCheckinRecords(userId int, startDate, endDate string) ([]Checkin, error) {
34+
var records []Checkin
35+
err := DB.Where("user_id = ? AND checkin_date >= ? AND checkin_date <= ?",
36+
userId, startDate, endDate).
37+
Order("checkin_date DESC").
38+
Find(&records).Error
39+
return records, err
40+
}
41+
42+
// HasCheckedInToday 检查用户今天是否已签到
43+
func HasCheckedInToday(userId int) (bool, error) {
44+
today := time.Now().Format("2006-01-02")
45+
var count int64
46+
err := DB.Model(&Checkin{}).
47+
Where("user_id = ? AND checkin_date = ?", userId, today).
48+
Count(&count).Error
49+
return count > 0, err
50+
}
51+
52+
// UserCheckin 执行用户签到
53+
// MySQL 和 PostgreSQL 使用事务保证原子性
54+
// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚
55+
func UserCheckin(userId int) (*Checkin, error) {
56+
setting := operation_setting.GetCheckinSetting()
57+
if !setting.Enabled {
58+
return nil, errors.New("签到功能未启用")
59+
}
60+
61+
// 检查今天是否已签到
62+
hasChecked, err := HasCheckedInToday(userId)
63+
if err != nil {
64+
return nil, err
65+
}
66+
if hasChecked {
67+
return nil, errors.New("今日已签到")
68+
}
69+
70+
// 计算随机额度奖励
71+
quotaAwarded := setting.MinQuota
72+
if setting.MaxQuota > setting.MinQuota {
73+
quotaAwarded = setting.MinQuota + rand.Intn(setting.MaxQuota-setting.MinQuota+1)
74+
}
75+
76+
today := time.Now().Format("2006-01-02")
77+
checkin := &Checkin{
78+
UserId: userId,
79+
CheckinDate: today,
80+
QuotaAwarded: quotaAwarded,
81+
CreatedAt: time.Now().Unix(),
82+
}
83+
84+
// 根据数据库类型选择不同的策略
85+
if common.UsingSQLite {
86+
// SQLite 不支持嵌套事务,使用顺序操作 + 手动回滚
87+
return userCheckinWithoutTransaction(checkin, userId, quotaAwarded)
88+
}
89+
90+
// MySQL 和 PostgreSQL 支持事务,使用事务保证原子性
91+
return userCheckinWithTransaction(checkin, userId, quotaAwarded)
92+
}
93+
94+
// userCheckinWithTransaction 使用事务执行签到(适用于 MySQL 和 PostgreSQL)
95+
func userCheckinWithTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) {
96+
err := DB.Transaction(func(tx *gorm.DB) error {
97+
// 步骤1: 创建签到记录
98+
// 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到
99+
if err := tx.Create(checkin).Error; err != nil {
100+
return errors.New("签到失败,请稍后重试")
101+
}
102+
103+
// 步骤2: 在事务中增加用户额度
104+
if err := tx.Model(&User{}).Where("id = ?", userId).
105+
Update("quota", gorm.Expr("quota + ?", quotaAwarded)).Error; err != nil {
106+
return errors.New("签到失败:更新额度出错")
107+
}
108+
109+
return nil
110+
})
111+
112+
if err != nil {
113+
return nil, err
114+
}
115+
116+
// 事务成功后,异步更新缓存
117+
go func() {
118+
_ = cacheIncrUserQuota(userId, int64(quotaAwarded))
119+
}()
120+
121+
return checkin, nil
122+
}
123+
124+
// userCheckinWithoutTransaction 不使用事务执行签到(适用于 SQLite)
125+
func userCheckinWithoutTransaction(checkin *Checkin, userId int, quotaAwarded int) (*Checkin, error) {
126+
// 步骤1: 创建签到记录
127+
// 数据库有唯一约束 (user_id, checkin_date),可以防止并发重复签到
128+
if err := DB.Create(checkin).Error; err != nil {
129+
return nil, errors.New("签到失败,请稍后重试")
130+
}
131+
132+
// 步骤2: 增加用户额度
133+
// 使用 db=true 强制直接写入数据库,不使用批量更新
134+
if err := IncreaseUserQuota(userId, quotaAwarded, true); err != nil {
135+
// 如果增加额度失败,需要回滚签到记录
136+
DB.Delete(checkin)
137+
return nil, errors.New("签到失败:更新额度出错")
138+
}
139+
140+
return checkin, nil
141+
}
142+
143+
// GetUserCheckinStats 获取用户签到统计信息
144+
func GetUserCheckinStats(userId int, month string) (map[string]interface{}, error) {
145+
// 获取指定月份的所有签到记录
146+
startDate := month + "-01"
147+
endDate := month + "-31"
148+
149+
records, err := GetUserCheckinRecords(userId, startDate, endDate)
150+
if err != nil {
151+
return nil, err
152+
}
153+
154+
// 转换为不包含敏感字段的记录
155+
checkinRecords := make([]CheckinRecord, len(records))
156+
for i, r := range records {
157+
checkinRecords[i] = CheckinRecord{
158+
CheckinDate: r.CheckinDate,
159+
QuotaAwarded: r.QuotaAwarded,
160+
}
161+
}
162+
163+
// 检查今天是否已签到
164+
hasCheckedToday, _ := HasCheckedInToday(userId)
165+
166+
// 获取用户所有时间的签到统计
167+
var totalCheckins int64
168+
var totalQuota int64
169+
DB.Model(&Checkin{}).Where("user_id = ?", userId).Count(&totalCheckins)
170+
DB.Model(&Checkin{}).Where("user_id = ?", userId).Select("COALESCE(SUM(quota_awarded), 0)").Scan(&totalQuota)
171+
172+
return map[string]interface{}{
173+
"total_quota": totalQuota, // 所有时间累计获得的额度
174+
"total_checkins": totalCheckins, // 所有时间累计签到次数
175+
"checkin_count": len(records), // 本月签到次数
176+
"checked_in_today": hasCheckedToday, // 今天是否已签到
177+
"records": checkinRecords, // 本月签到记录详情(不含id和user_id)
178+
}, nil
179+
}

model/main.go

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -248,26 +248,27 @@ func InitLogDB() (err error) {
248248
}
249249

250250
func migrateDB() error {
251-
err := DB.AutoMigrate(
252-
&Channel{},
253-
&Token{},
254-
&User{},
255-
&PasskeyCredential{},
251+
err := DB.AutoMigrate(
252+
&Channel{},
253+
&Token{},
254+
&User{},
255+
&PasskeyCredential{},
256256
&Option{},
257-
&Redemption{},
258-
&Ability{},
259-
&Log{},
260-
&Midjourney{},
261-
&TopUp{},
262-
&QuotaData{},
263-
&Task{},
264-
&Model{},
265-
&Vendor{},
266-
&PrefillGroup{},
267-
&Setup{},
268-
&TwoFA{},
269-
&TwoFABackupCode{},
270-
)
257+
&Redemption{},
258+
&Ability{},
259+
&Log{},
260+
&Midjourney{},
261+
&TopUp{},
262+
&QuotaData{},
263+
&Task{},
264+
&Model{},
265+
&Vendor{},
266+
&PrefillGroup{},
267+
&Setup{},
268+
&TwoFA{},
269+
&TwoFABackupCode{},
270+
&Checkin{},
271+
)
271272
if err != nil {
272273
return err
273274
}
@@ -278,29 +279,30 @@ func migrateDBFast() error {
278279

279280
var wg sync.WaitGroup
280281

281-
migrations := []struct {
282-
model interface{}
283-
name string
284-
}{
285-
{&Channel{}, "Channel"},
286-
{&Token{}, "Token"},
287-
{&User{}, "User"},
288-
{&PasskeyCredential{}, "PasskeyCredential"},
282+
migrations := []struct {
283+
model interface{}
284+
name string
285+
}{
286+
{&Channel{}, "Channel"},
287+
{&Token{}, "Token"},
288+
{&User{}, "User"},
289+
{&PasskeyCredential{}, "PasskeyCredential"},
289290
{&Option{}, "Option"},
290-
{&Redemption{}, "Redemption"},
291-
{&Ability{}, "Ability"},
292-
{&Log{}, "Log"},
293-
{&Midjourney{}, "Midjourney"},
294-
{&TopUp{}, "TopUp"},
295-
{&QuotaData{}, "QuotaData"},
296-
{&Task{}, "Task"},
297-
{&Model{}, "Model"},
298-
{&Vendor{}, "Vendor"},
299-
{&PrefillGroup{}, "PrefillGroup"},
300-
{&Setup{}, "Setup"},
301-
{&TwoFA{}, "TwoFA"},
302-
{&TwoFABackupCode{}, "TwoFABackupCode"},
303-
}
291+
{&Redemption{}, "Redemption"},
292+
{&Ability{}, "Ability"},
293+
{&Log{}, "Log"},
294+
{&Midjourney{}, "Midjourney"},
295+
{&TopUp{}, "TopUp"},
296+
{&QuotaData{}, "QuotaData"},
297+
{&Task{}, "Task"},
298+
{&Model{}, "Model"},
299+
{&Vendor{}, "Vendor"},
300+
{&PrefillGroup{}, "PrefillGroup"},
301+
{&Setup{}, "Setup"},
302+
{&TwoFA{}, "TwoFA"},
303+
{&TwoFABackupCode{}, "TwoFABackupCode"},
304+
{&Checkin{}, "Checkin"},
305+
}
304306
// 动态计算migration数量,确保errChan缓冲区足够大
305307
errChan := make(chan error, len(migrations))
306308

router/api-router.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ func SetApiRouter(router *gin.Engine) {
9393
selfRoute.POST("/2fa/enable", controller.Enable2FA)
9494
selfRoute.POST("/2fa/disable", controller.Disable2FA)
9595
selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes)
96+
97+
// Check-in routes
98+
selfRoute.GET("/checkin", controller.GetCheckinStatus)
99+
selfRoute.POST("/checkin", controller.DoCheckin)
96100
}
97101

98102
adminRoute := userRoute.Group("/")

0 commit comments

Comments
 (0)