首次提交:姜十三论坛
后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
284
backend/service/auth.go
Normal file
284
backend/service/auth.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleUser = "user"
|
||||
RoleAdmin = "admin"
|
||||
|
||||
// CookieName JWT access token 存储的 HttpOnly cookie 名
|
||||
CookieName = "j13_token"
|
||||
// RefreshCookieName refresh token 存储的 HttpOnly cookie 名
|
||||
RefreshCookieName = "j13_refresh"
|
||||
// CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读)
|
||||
CSRFCookieName = "j13_csrf"
|
||||
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
|
||||
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
|
||||
AccessTokenTTL = 15 * time.Minute
|
||||
// RefreshTokenTTL refresh token 有效期(长期)
|
||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// UserClaims JWT 中携带的用户信息
|
||||
type UserClaims struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Banned bool `json:"banned"`
|
||||
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
|
||||
}
|
||||
|
||||
// AuthService 认证服务
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||||
return &AuthService{db: db, jwtSecret: []byte(jwtSecret)}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("用户名已被使用")
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: string(hashed),
|
||||
Nickname: username,
|
||||
Role: model.RoleUser,
|
||||
}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 access token + refresh token + user
|
||||
func (s *AuthService) Login(username, password string) (string, string, *model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return "", "", nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
if user.Banned {
|
||||
return "", "", nil, errors.New("账号已被封禁")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return "", "", nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
accessToken, err := s.generateToken(&user)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
refreshToken, err := s.CreateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return accessToken, refreshToken, &user, nil
|
||||
}
|
||||
|
||||
// generateToken 签发 access token(短期)
|
||||
func (s *AuthService) generateToken(user *model.User) (string, error) {
|
||||
claims := &tokenClaims{
|
||||
UserClaims: UserClaims{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: string(user.Role),
|
||||
Banned: user.Banned,
|
||||
TokenVersion: user.TokenVersion,
|
||||
},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.Username,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
type tokenClaims struct {
|
||||
UserClaims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT(仅校验签名和过期,不查 DB)
|
||||
func (s *AuthService) ParseToken(tokenStr string) (*UserClaims, error) {
|
||||
claims := &tokenClaims{}
|
||||
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claims.UserClaims, nil
|
||||
}
|
||||
|
||||
// ValidateClaims 校验 claims 是否仍然有效(查 DB:token_version 匹配且未封禁)
|
||||
// 用于中间件在每个请求上做实时权限校验
|
||||
func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, claims.ID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
// token 版本不匹配 → 已被撤销(改密码/封禁/管理员操作)
|
||||
if user.TokenVersion != claims.TokenVersion {
|
||||
return nil, errors.New("token 已失效")
|
||||
}
|
||||
// 实时校验封禁状态(不依赖 JWT 中的缓存值)
|
||||
if user.Banned {
|
||||
return nil, errors.New("账号已被封禁")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GenerateCSRFToken 生成随机 CSRF token
|
||||
func GenerateCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateRandomToken 生成随机令牌字符串
|
||||
func generateRandomToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateRefreshToken 创建并存储 refresh token
|
||||
func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
|
||||
token := generateRandomToken()
|
||||
rt := &model.RefreshToken{
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenTTL),
|
||||
}
|
||||
if err := s.db.Create(rt).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken 校验 refresh token 并返回所属用户
|
||||
func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
|
||||
var rt model.RefreshToken
|
||||
if err := s.db.Where("token = ?", token).First(&rt).Error; err != nil {
|
||||
return nil, errors.New("refresh token 无效")
|
||||
}
|
||||
if rt.Revoked {
|
||||
return nil, errors.New("refresh token 已撤销")
|
||||
}
|
||||
if time.Now().After(rt.ExpiresAt) {
|
||||
return nil, errors.New("refresh token 已过期")
|
||||
}
|
||||
var user model.User
|
||||
if err := s.db.First(&user, rt.UserID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
if user.Banned {
|
||||
return nil, errors.New("账号已被封禁")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// RotateRefreshToken 轮转 refresh token:撤销旧的,签发新的
|
||||
func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) {
|
||||
user, err := s.ValidateRefreshToken(oldToken)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
// 撤销旧 token
|
||||
s.db.Model(&model.RefreshToken{}).Where("token = ?", oldToken).Update("revoked", true)
|
||||
// 签发新 access + refresh
|
||||
accessToken, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
newRefresh, err := s.CreateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return accessToken, newRefresh, user, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken 撤销单个 refresh token(登出时用)
|
||||
func (s *AuthService) RevokeRefreshToken(token string) {
|
||||
s.db.Model(&model.RefreshToken{}).Where("token = ?", token).Update("revoked", true)
|
||||
}
|
||||
|
||||
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁时用)
|
||||
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) {
|
||||
s.db.Model(&model.RefreshToken{}).Where("user_id = ?", userID).Update("revoked", true)
|
||||
}
|
||||
|
||||
// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效
|
||||
// 用于:改密码、封禁用户、管理员强制下线
|
||||
func (s *AuthService) IncrementTokenVersion(userID uint) error {
|
||||
result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1"))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
s.RevokeAllUserRefreshTokens(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码:校验旧密码,更新新密码,递增 token_version 使旧 token 失效
|
||||
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)); err != nil {
|
||||
return errors.New("旧密码错误")
|
||||
}
|
||||
if len(newPassword) < 6 {
|
||||
return errors.New("新密码至少 6 位")
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("密码更新失败")
|
||||
}
|
||||
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
|
||||
return s.IncrementTokenVersion(userID)
|
||||
}
|
||||
31
backend/service/board.go
Normal file
31
backend/service/board.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BoardService 板块服务
|
||||
type BoardService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBoardService(db *gorm.DB) *BoardService {
|
||||
return &BoardService{db: db}
|
||||
}
|
||||
|
||||
// List 获取所有板块(按 sort_order 排序)
|
||||
func (s *BoardService) List() ([]model.Board, error) {
|
||||
var boards []model.Board
|
||||
err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error
|
||||
return boards, err
|
||||
}
|
||||
|
||||
// Get 根据 ID 获取板块
|
||||
func (s *BoardService) Get(id uint) (*model.Board, error) {
|
||||
var board model.Board
|
||||
if err := s.db.First(&board, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &board, nil
|
||||
}
|
||||
122
backend/service/comment.go
Normal file
122
backend/service/comment.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CommentService 评论服务
|
||||
type CommentService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommentService(db *gorm.DB) *CommentService {
|
||||
return &CommentService{db: db}
|
||||
}
|
||||
|
||||
// ListByPost 获取帖子的评论列表
|
||||
func (s *CommentService) ListByPost(postID uint) ([]model.Comment, error) {
|
||||
var comments []model.Comment
|
||||
err := s.db.Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
// Create 创建评论
|
||||
func (s *CommentService) Create(userID, postID uint, content string) (*model.Comment, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("评论内容不能为空")
|
||||
}
|
||||
|
||||
// 检查帖子是否存在且未锁定评论
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
}
|
||||
|
||||
comment := &model.Comment{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
Status: model.ContentStatusPublished,
|
||||
}
|
||||
if err := s.db.Create(comment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||
// 预加载用户
|
||||
s.db.Preload("User").First(comment, comment.ID)
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
|
||||
type UserCommentItem struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListByUser 获取指定用户发表的评论(含所属帖子标题)
|
||||
func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
query := s.db.Table("comments").
|
||||
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
|
||||
Joins("JOIN posts ON posts.id = comments.post_id").
|
||||
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var items []UserCommentItem
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// CountByUser 统计用户评论数(仅统计未删除帖子上的评论,与列表保持一致)
|
||||
func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Table("comments").
|
||||
Joins("JOIN posts ON posts.id = comments.post_id").
|
||||
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Delete 删除评论(仅作者或管理员可操作)
|
||||
func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||
var comment model.Comment
|
||||
if err := s.db.First(&comment, commentID).Error; err != nil {
|
||||
return errors.New("评论不存在")
|
||||
}
|
||||
// 权限校验:作者本人或管理员
|
||||
if comment.UserID != userID && role != RoleAdmin {
|
||||
return errors.New("无权限删除此评论")
|
||||
}
|
||||
// 软删除
|
||||
if err := s.db.Delete(&comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
|
||||
return nil
|
||||
}
|
||||
77
backend/service/like.go
Normal file
77
backend/service/like.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LikeService 点赞服务
|
||||
type LikeService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLikeService(db *gorm.DB) *LikeService {
|
||||
return &LikeService{db: db}
|
||||
}
|
||||
|
||||
// Toggle 切换点赞状态:已点赞则取消,未点赞则点赞
|
||||
// 返回 (liked, likeCount, error)
|
||||
func (s *LikeService) Toggle(postID, userID uint) (bool, int, error) {
|
||||
// 检查帖子是否存在
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return false, 0, errors.New("帖子不存在")
|
||||
}
|
||||
|
||||
var like model.Like
|
||||
err := s.db.Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
|
||||
|
||||
if err == nil {
|
||||
// 已点赞 → 取消点赞
|
||||
if err := s.db.Delete(&like).Error; err != nil {
|
||||
return false, post.LikeCount, err
|
||||
}
|
||||
newCount := post.LikeCount - 1
|
||||
if newCount < 0 {
|
||||
newCount = 0
|
||||
}
|
||||
s.db.Model(&post).UpdateColumn("like_count", newCount)
|
||||
return false, newCount, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, post.LikeCount, err
|
||||
}
|
||||
|
||||
// 未点赞 → 点赞
|
||||
newLike := model.Like{PostID: postID, UserID: userID}
|
||||
if err := s.db.Create(&newLike).Error; err != nil {
|
||||
return false, post.LikeCount, err
|
||||
}
|
||||
newCount := post.LikeCount + 1
|
||||
s.db.Model(&post).UpdateColumn("like_count", newCount)
|
||||
return true, newCount, nil
|
||||
}
|
||||
|
||||
// HasLiked 判断用户是否已点赞某帖子
|
||||
func (s *LikeService) HasLiked(postID, userID uint) bool {
|
||||
var count int64
|
||||
s.db.Model(&model.Like{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// BatchHasLiked 批量查询用户对多个帖子的点赞状态,返回 postID -> bool
|
||||
func (s *LikeService) BatchHasLiked(postIDs []uint, userID uint) map[uint]bool {
|
||||
result := make(map[uint]bool)
|
||||
if len(postIDs) == 0 || userID == 0 {
|
||||
return result
|
||||
}
|
||||
var likes []model.Like
|
||||
s.db.Where("post_id IN ? AND user_id = ?", postIDs, userID).Find(&likes)
|
||||
for _, l := range likes {
|
||||
result[l.PostID] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
82
backend/service/notification.go
Normal file
82
backend/service/notification.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationService 通知服务
|
||||
type NotificationService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewNotificationService(db *gorm.DB) *NotificationService {
|
||||
return &NotificationService{db: db}
|
||||
}
|
||||
|
||||
// Create 创建通知(actorID == userID 时跳过,不通知自己)
|
||||
func (s *NotificationService) Create(userID, actorID uint, notifType string, postID, commentID uint, content string) {
|
||||
if userID == 0 || actorID == 0 || userID == actorID {
|
||||
return
|
||||
}
|
||||
// 截断内容预览
|
||||
if len(content) > 200 {
|
||||
content = content[:200]
|
||||
}
|
||||
n := &model.Notification{
|
||||
UserID: userID,
|
||||
ActorID: actorID,
|
||||
Type: notifType,
|
||||
PostID: postID,
|
||||
CommentID: commentID,
|
||||
Content: content,
|
||||
IsRead: false,
|
||||
}
|
||||
// 通知创建失败不影响主流程,忽略错误
|
||||
_ = s.db.Create(n).Error
|
||||
}
|
||||
|
||||
// List 获取用户通知列表(分页,按时间倒序)
|
||||
func (s *NotificationService) List(userID uint, page, size int) ([]model.Notification, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
query := s.db.Model(&model.Notification{}).Where("user_id = ?", userID)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []model.Notification
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(size).
|
||||
Preload("Actor").Preload("Post").Find(&list).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// UnreadCount 获取未读通知数
|
||||
func (s *NotificationService) UnreadCount(userID uint) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// MarkRead 标记单条通知为已读(校验归属)
|
||||
func (s *NotificationService) MarkRead(id, userID uint) error {
|
||||
result := s.db.Model(&model.Notification{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Update("is_read", true)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// MarkAllRead 标记用户所有通知为已读
|
||||
func (s *NotificationService) MarkAllRead(userID uint) error {
|
||||
result := s.db.Model(&model.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Update("is_read", true)
|
||||
return result.Error
|
||||
}
|
||||
287
backend/service/post.go
Normal file
287
backend/service/post.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PostService 帖子服务
|
||||
type PostService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPostService(db *gorm.DB) *PostService {
|
||||
return &PostService{db: db}
|
||||
}
|
||||
|
||||
// PostListQuery 帖子列表查询参数
|
||||
type PostListQuery struct {
|
||||
BoardID uint
|
||||
Page int
|
||||
Size int
|
||||
Sort string // latest | hot
|
||||
Keyword string // 搜索关键词
|
||||
}
|
||||
|
||||
// PostListItem 帖子列表项(不含正文)
|
||||
type PostListItem struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// List 获取帖子列表
|
||||
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.Size < 1 || q.Size > 50 {
|
||||
q.Size = 20
|
||||
}
|
||||
|
||||
query := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
|
||||
if q.BoardID > 0 {
|
||||
query = query.Where("board_id = ?", q.BoardID)
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var posts []model.Post
|
||||
offset := (q.Page - 1) * q.Size
|
||||
order := "pinned DESC, created_at DESC"
|
||||
if q.Sort == "hot" {
|
||||
order = "pinned DESC, (like_count + comment_count * 2) 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
|
||||
}
|
||||
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// ListByUser 获取指定用户发布的帖子(公开已发布)
|
||||
func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
query := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var posts []model.Post
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
||||
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return post.UserID, nil
|
||||
}
|
||||
|
||||
// CountByUser 统计用户发帖数
|
||||
func (s *PostService) CountByUser(userID uint) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
|
||||
func (s *PostService) TogglePin(id uint) (int, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, id).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var newPinned int
|
||||
if post.Pinned > 0 {
|
||||
newPinned = 0
|
||||
} else {
|
||||
newPinned = 1
|
||||
}
|
||||
result := s.db.Model(&post).Update("pinned", newPinned)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return newPinned, nil
|
||||
}
|
||||
|
||||
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
|
||||
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, id).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
newVal := !post.Recommended
|
||||
result := s.db.Model(&post).Update("recommended", newVal)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return newVal, nil
|
||||
}
|
||||
|
||||
// GetByID 获取帖子详情
|
||||
func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 增加浏览量
|
||||
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Create 创建帖子
|
||||
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType string) (*model.Post, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
content = strings.TrimSpace(content)
|
||||
if title == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
if content == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
if boardID == 0 {
|
||||
return nil, errors.New("请选择板块")
|
||||
}
|
||||
|
||||
// 新用户 24h 冷静期校验
|
||||
if err := s.checkNewUserCooldown(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
PostType: postType,
|
||||
Status: model.ContentStatusPublished,
|
||||
}
|
||||
if err := s.db.Create(post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 预加载关联
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// checkNewUserCooldown 新用户发帖 24h 冷静期
|
||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注册不足 24 小时的新用户不能发帖
|
||||
if time.Since(user.CreatedAt) < 24*time.Hour {
|
||||
return errors.New("新用户注册 24 小时后才能发帖")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 更新帖子(仅作者或管理员可操作)
|
||||
func (s *PostService) Update(postID, userID uint, role string, title, content, tags string) (*model.Post, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
}
|
||||
// 权限校验:作者本人或管理员
|
||||
if post.UserID != userID && role != RoleAdmin {
|
||||
return nil, errors.New("无权限编辑此帖子")
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if title != "" {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
updates["title"] = t
|
||||
}
|
||||
if content != "" {
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
updates["content"] = c
|
||||
}
|
||||
updates["tags"] = tags
|
||||
|
||||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Delete 删除帖子(仅作者或管理员可操作)
|
||||
func (s *PostService) Delete(postID, userID uint, role string) error {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return errors.New("帖子不存在")
|
||||
}
|
||||
// 权限校验:作者本人或管理员
|
||||
if post.UserID != userID && role != RoleAdmin {
|
||||
return errors.New("无权限删除此帖子")
|
||||
}
|
||||
// 软删除(gorm DeletedAt)
|
||||
if err := s.db.Delete(&post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
79
backend/service/ratelimit.go
Normal file
79
backend/service/ratelimit.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter 内存速率限制器
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
records map[string][]time.Time
|
||||
limits map[string]int // key -> 每分钟最大请求数
|
||||
cooldown map[string]time.Duration
|
||||
}
|
||||
|
||||
func NewRateLimiter() *RateLimiter {
|
||||
return &RateLimiter{
|
||||
records: make(map[string][]time.Time),
|
||||
limits: make(map[string]int),
|
||||
cooldown: make(map[string]time.Duration),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimit 设置某类请求的每分钟限制
|
||||
func (r *RateLimiter) SetLimit(key string, perMinute int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.limits[key] = perMinute
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
func (r *RateLimiter) Allow(key string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
limit, ok := r.limits[key]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-1 * time.Minute)
|
||||
|
||||
// 清理过期记录
|
||||
records := r.records[key]
|
||||
valid := records[:0]
|
||||
for _, t := range records {
|
||||
if t.After(windowStart) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(valid) >= limit {
|
||||
r.records[key] = valid
|
||||
return false
|
||||
}
|
||||
|
||||
valid = append(valid, now)
|
||||
r.records[key] = valid
|
||||
return true
|
||||
}
|
||||
|
||||
// 速率限制类型常量
|
||||
const (
|
||||
RateLogin = "login"
|
||||
RateRegister = "register"
|
||||
RatePost = "post"
|
||||
RateComment = "comment"
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
func DefaultRateLimiter() *RateLimiter {
|
||||
rl := NewRateLimiter()
|
||||
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
|
||||
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
return rl
|
||||
}
|
||||
Reference in New Issue
Block a user