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
}

View File

@@ -1,6 +1,7 @@
package service
import (
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
@@ -39,12 +40,51 @@ type BoardCount struct {
PostCount int64 `json:"post_count"`
}
// LatestCommentItem 首页"最新评论"条目(纯文本摘要 + 所属帖子)
type LatestCommentItem struct {
ID uint `json:"id"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
User struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
} `json:"user"`
Post struct {
ID uint `json:"id"`
Title string `json:"title"`
} `json:"post"`
}
// NewUserItem 最新注册成员
type NewUserItem struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
CreatedAt time.Time `json:"created_at"`
}
// OverviewData 首页聚合数据
type OverviewData struct {
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
LatestComments []LatestCommentItem `json:"latest_comments"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
}
// truncateText 折叠空白并按 rune 截断(评论为纯文本,无 HTML 注入面)
func truncateText(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) > n {
return string(r[:n]) + "…"
}
return s
}
const activeUserWindow = 14 * 24 * time.Hour
@@ -52,9 +92,11 @@ const activeUserWindow = 14 * 24 * time.Hour
// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。
func (s *OverviewService) Get() (*OverviewData, error) {
data := &OverviewData{
Hot: []PostListItem{},
ActiveUsers: []ActiveUser{},
Boards: []BoardCount{},
Hot: []PostListItem{},
ActiveUsers: []ActiveUser{},
Boards: []BoardCount{},
LatestComments: []LatestCommentItem{},
NewUsers: []NewUserItem{},
}
now := time.Now()
@@ -163,5 +205,65 @@ LIMIT 5`
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
}
// 最新评论:JOIN 未封禁用户与已发布帖,一条 SQL 取齐
type latestCommentRow struct {
ID uint
Content string
CreatedAt time.Time
UID uint
Username string
Nickname string
Avatar string
PID uint
Title string
}
var commentRows []latestCommentRow
commentSQL := `
SELECT c.id, c.content, c.created_at,
u.id AS uid, u.username AS username, u.nickname AS nickname, u.avatar AS avatar,
p.id AS pid, p.title AS title
FROM comments c
JOIN users u ON u.id = c.user_id AND u.banned = ? AND u.deleted_at IS NULL
JOIN posts p ON p.id = c.post_id AND p.status = ? AND p.deleted_at IS NULL
WHERE c.status = ? AND c.deleted_at IS NULL
ORDER BY c.created_at DESC
LIMIT 6`
if err := s.db.Raw(commentSQL,
false, model.ContentStatusPublished, model.ContentStatusPublished).
Scan(&commentRows).Error; err != nil {
return nil, err
}
for _, r := range commentRows {
item := LatestCommentItem{
ID: r.ID,
Content: truncateText(r.Content, 60),
CreatedAt: r.CreatedAt,
}
item.User.ID = r.UID
item.User.Username = r.Username
item.User.Nickname = r.Nickname
item.User.Avatar = r.Avatar
item.Post.ID = r.PID
item.Post.Title = r.Title
data.LatestComments = append(data.LatestComments, item)
}
// 最新注册成员
var newUsers []model.User
if err := s.db.Select("id, username, nickname, avatar, created_at").
Where("banned = ?", false).
Order("created_at DESC").Limit(8).Find(&newUsers).Error; err != nil {
return nil, err
}
for _, u := range newUsers {
data.NewUsers = append(data.NewUsers, NewUserItem{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt,
})
}
return data, nil
}

View File

@@ -176,6 +176,10 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
if q.Sort == "hot" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
}
// new:纯按发帖时间(新帖子)
if q.Sort == "new" {
order = "pinned DESC, created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err