Files
jiang13-bbs/backend/service/checkin.go
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

188 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"log"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// DailyCheckinPoints 每日签到默认基础分(可被设置 points_checkin_base 覆盖)
const DailyCheckinPoints = 5
// maxStreakScan 连续天数最多向前回溯的天数
const maxStreakScan = 400
// ErrAlreadyCheckedIn 今日已签到
var ErrAlreadyCheckedIn = errors.New("今日已签到")
// CheckinStatus 签到状态(首页右栏与签到接口共用)
type CheckinStatus struct {
CheckedToday bool `json:"checked_today"`
Streak int `json:"streak"` // 连续签到天数
TotalPoints int `json:"total_points"` // 累计积分(可用余额)
TodayPoints int `json:"today_points"` // 今日签到可得基础积分(连续加成另行入账,流水可见)
}
// CheckinService 每日签到
type CheckinService struct {
db *gorm.DB
setting *SettingService
}
func NewCheckinService(db *gorm.DB) *CheckinService {
return &CheckinService{db: db}
}
// WithSetting 注入站点设置(积分增长规则;未注入时用默认值)
func (s *CheckinService) WithSetting(setting *SettingService) *CheckinService {
s.setting = setting
return s
}
// rules 读取积分规则;未注入或读取失败回落默认值(签到可用性优先于规则精度)
func (s *CheckinService) rules() PointsRules {
if s.setting == nil {
return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays}
}
rules, err := s.setting.PointsRules()
if err != nil {
log.Printf("[checkin] 读取积分规则失败,使用默认值: %v", err)
return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays}
}
return rules
}
// localDay 取本地时区零点日期
func localDay(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
// streakFrom 从 anchor(含)起按日倒推连续签到天数
func streakFrom(dates map[string]bool, anchor time.Time) int {
streak := 0
for i := 0; i < maxStreakScan; i++ {
day := anchor.AddDate(0, 0, -i)
if !dates[day.Format("2006-01-02")] {
break
}
streak++
}
return streak
}
// streakDatesInTx 从事务连接查询 anchor 起回溯窗口内的签到日期集合
func streakDatesInTx(tx *gorm.DB, userID uint, anchor time.Time) (map[string]bool, error) {
var dates []time.Time
if err := tx.Model(&model.Checkin{}).
Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)).
Pluck("checkin_date", &dates).Error; err != nil {
return nil, err
}
dateSet := make(map[string]bool, len(dates))
for _, d := range dates {
dateSet[localDay(d).Format("2006-01-02")] = true
}
return dateSet, nil
}
// Status 查询用户签到状态
func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
today := localDay(time.Now())
base := s.rules().CheckinBase
var todayRow model.Checkin
err := s.db.Where("user_id = ? AND checkin_date::date = ?::date", userID, today).
First(&todayRow).Error
checkedToday := true
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
checkedToday = false
}
// 连续天数:今日已签从今天起算,未签从昨天起算(今日尚未签不打断连续)
anchor := today
if !checkedToday {
anchor = today.AddDate(0, 0, -1)
}
dateSet, err := streakDatesInTx(s.db, userID, anchor)
if err != nil {
return nil, err
}
var total int64
if err := s.db.Model(&model.User{}).
Where("id = ?", userID).
Select("points").Scan(&total).Error; err != nil {
return nil, err
}
return &CheckinStatus{
CheckedToday: checkedToday,
Streak: streakFrom(dateSet, anchor),
TotalPoints: int(total),
TodayPoints: base,
}, nil
}
// CheckIn 执行今日签到(每日一条,唯一索引兜底并发)。
// 基础分与连续签到里程碑加成在同一事务内入账(加成规则可配置:每满 N 天额外 +X)。
func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
today := localDay(time.Now())
rules := s.rules()
var status *CheckinStatus
err := s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.Checkin{}).
Where("user_id = ? AND checkin_date::date = ?::date", userID, today).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrAlreadyCheckedIn
}
row := model.Checkin{
UserID: userID,
CheckinDate: today,
Points: rules.CheckinBase,
}
if err := tx.Create(&row).Error; err != nil {
return err
}
// 同步入账到用户积分余额
if _, err := CreditTx(tx, userID, rules.CheckinBase, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil {
return err
}
// 连续签到里程碑加成(每满 N 天额外 +X;N=0 或加成=0 时关闭)
if rules.StreakEveryDays > 0 && rules.StreakBonus > 0 {
dateSet, err := streakDatesInTx(tx, userID, today)
if err != nil {
return err
}
if streak := streakFrom(dateSet, today); streak > 0 && streak%rules.StreakEveryDays == 0 {
if _, err := CreditTx(tx, userID, rules.StreakBonus, model.PointReasonStreakBonus, "checkin", row.ID,
"连续签到 "+strconv.Itoa(streak)+" 天奖励"); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return nil, err
}
status, err = s.Status(userID)
if err != nil {
return nil, err
}
return status, nil
}