feat: 添加每日签到功能,新增帖子排序与社区面板优化

本次提交完成了多个核心功能迭代:
1.  新增用户每日签到系统,包含签到状态查询、连续天数统计、积分累计逻辑
2.  新增"新帖子"排序选项,替换原有的latest排序为新评论排序
3.  重构首页信息流布局,优化移动端与桌面端展示样式
4.  新增最新评论、最新注册用户模块,完善社区面板数据展示
5.  新增静态友情链接配置模块
6.  提取通用时间格式化工具函数,统一全站相对时间展示
7.  补充数据库签到模型与后端API接口,完善前后端交互链路
This commit is contained in:
2026-09-13 06:03:40 +08:00
parent 95921d24fa
commit 9bae6f2513
19 changed files with 790 additions and 280 deletions

136
backend/service/checkin.go Normal file
View File

@@ -0,0 +1,136 @@
package service
import (
"errors"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// DailyCheckinPoints 每日签到固定积分
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
}
func NewCheckinService(db *gorm.DB) *CheckinService {
return &CheckinService{db: db}
}
// 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
}
// Status 查询用户签到状态
func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
today := localDay(time.Now())
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)
}
var dates []time.Time
if err := s.db.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
}
var total int64
if err := s.db.Model(&model.Checkin{}).
Where("user_id = ?", userID).
Select("COALESCE(SUM(points), 0)").Scan(&total).Error; err != nil {
return nil, err
}
return &CheckinStatus{
CheckedToday: checkedToday,
Streak: streakFrom(dateSet, anchor),
TotalPoints: int(total),
TodayPoints: DailyCheckinPoints,
}, nil
}
// CheckIn 执行今日签到(每日一条,唯一索引兜底并发)
func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
today := localDay(time.Now())
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: DailyCheckinPoints,
}
if err := tx.Create(&row).Error; 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
}