feat: 添加每日签到功能,新增帖子排序与社区面板优化
本次提交完成了多个核心功能迭代: 1. 新增用户每日签到系统,包含签到状态查询、连续天数统计、积分累计逻辑 2. 新增"新帖子"排序选项,替换原有的latest排序为新评论排序 3. 重构首页信息流布局,优化移动端与桌面端展示样式 4. 新增最新评论、最新注册用户模块,完善社区面板数据展示 5. 新增静态友情链接配置模块 6. 提取通用时间格式化工具函数,统一全站相对时间展示 7. 补充数据库签到模型与后端API接口,完善前后端交互链路
This commit is contained in:
136
backend/service/checkin.go
Normal file
136
backend/service/checkin.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user