完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,10 +20,11 @@ type Claims struct {
|
||||
type AuthService struct {
|
||||
jwtSecret string
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewAuthService(jwtSecret string, filter *SensitiveFilter) *AuthService {
|
||||
return &AuthService{jwtSecret: jwtSecret, filter: filter}
|
||||
func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
|
||||
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
@@ -31,7 +32,7 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var exist model.User
|
||||
|
||||
@@ -10,11 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type CommentService struct {
|
||||
filter *SensitiveFilter
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewCommentService(filter *SensitiveFilter) *CommentService {
|
||||
return &CommentService{filter: filter}
|
||||
func NewCommentService(filter *SensitiveFilter, settings *ForumSettingsService) *CommentService {
|
||||
return &CommentService{filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
type CommentCreateInput struct {
|
||||
@@ -98,6 +99,9 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
if content == "" {
|
||||
return nil, errors.New("评论内容不能为空")
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var post model.Post
|
||||
if err := model.DB.First(&post, in.PostID).Error; err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,7 +24,13 @@ var (
|
||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
||||
ErrInvalidSetting = errors.New("无效的设置值")
|
||||
ErrInvalidSetting = errors.New("无效的设置值")
|
||||
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
||||
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
||||
ErrPostTitleTooLong = errors.New("标题过长")
|
||||
ErrPostTagsTooLong = errors.New("标签过长")
|
||||
ErrPostContentTooLong = errors.New("正文过长")
|
||||
ErrCommentTooLong = errors.New("评论内容过长")
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
|
||||
@@ -48,9 +55,12 @@ func ValidateUsername(username string) error {
|
||||
}
|
||||
|
||||
// ValidatePassword 校验密码强度
|
||||
func ValidatePassword(password string) error {
|
||||
if utf8.RuneCountInString(password) < 6 {
|
||||
return ErrWeakPassword
|
||||
func ValidatePassword(password string, minLen int) error {
|
||||
if minLen <= 0 {
|
||||
minLen = 6
|
||||
}
|
||||
if utf8.RuneCountInString(password) < minLen {
|
||||
return fmt.Errorf("密码至少 %d 位", minLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,3 +35,13 @@ func membersContentLength(html string) int {
|
||||
}
|
||||
return utf8.RuneCountInString(text)
|
||||
}
|
||||
|
||||
// StripHTMLForSearch 剥离 HTML 标签,生成用于全文搜索的纯文本
|
||||
func StripHTMLForSearch(html string) string {
|
||||
if html == "" {
|
||||
return ""
|
||||
}
|
||||
text := htmlTagRe.ReplaceAllString(html, " ")
|
||||
text = strings.ReplaceAll(text, " ", " ")
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
55
service/filter_words.go
Normal file
55
service/filter_words.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadFilterWordsFile 读取敏感词配置文件内容
|
||||
func ReadFilterWordsFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// WriteFilterWordsFile 写入敏感词配置并热加载到过滤器
|
||||
func WriteFilterWordsFile(path string, content string, filter *SensitiveFilter) error {
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
filter.LoadFromFile(path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountFilterWords 统计有效敏感词数量(不含空行与注释)
|
||||
func CountFilterWords(content string) int {
|
||||
count := 0
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// FilterWordsPreview 返回前几行敏感词预览
|
||||
func FilterWordsPreview(content string, maxLines int) []string {
|
||||
if maxLines <= 0 {
|
||||
maxLines = 5
|
||||
}
|
||||
var preview []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
preview = append(preview, line)
|
||||
if len(preview) >= maxLines {
|
||||
break
|
||||
}
|
||||
}
|
||||
return preview
|
||||
}
|
||||
@@ -143,8 +143,13 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.Size < 1 {
|
||||
q.Size = 20
|
||||
q.Size = s.settings.NormalizePageSize(q.Size)
|
||||
if q.Keyword != "" {
|
||||
kw, err := s.settings.NormalizeSearchKeyword(q.Keyword)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Keyword = kw
|
||||
}
|
||||
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board")
|
||||
if q.BoardID > 0 {
|
||||
@@ -152,7 +157,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
db = db.Where("title LIKE ? OR content LIKE ? OR tags LIKE ?", kw, kw, kw)
|
||||
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
@@ -188,16 +193,29 @@ func normalizePostSort(sort string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
func (s *PostService) FindByID(id uint) (*model.Post, error) {
|
||||
var post model.Post
|
||||
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
|
||||
if err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
model.DB.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
func (s *PostService) RecordView(id uint) {
|
||||
model.DB.Model(&model.Post{}).Where("id = ?", id).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
}
|
||||
|
||||
func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
post, err := s.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.RecordView(id)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *PostService) Create(userID, boardID uint, title, content, tags string) (*model.Post, error) {
|
||||
title = s.filter.Filter(strings.TrimSpace(title))
|
||||
content = s.filter.Filter(content)
|
||||
@@ -205,12 +223,25 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags string)
|
||||
if title == "" || content == "" {
|
||||
return nil, errors.New("标题和内容不能为空")
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := NewBoardService().GetByID(boardID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
post := &model.Post{
|
||||
BoardID: boardID, UserID: userID,
|
||||
Title: title, Content: content, Tags: tags,
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
ContentPlain: StripHTMLForSearch(content),
|
||||
Tags: tags,
|
||||
}
|
||||
return post, model.DB.Create(post).Error
|
||||
}
|
||||
@@ -229,6 +260,15 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
||||
title = s.filter.Filter(strings.TrimSpace(title))
|
||||
content = s.filter.Filter(content)
|
||||
tags = s.filter.Filter(strings.TrimSpace(tags))
|
||||
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
|
||||
return err
|
||||
}
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
rev := model.PostRevision{
|
||||
PostID: postID, EditorID: userID,
|
||||
@@ -238,7 +278,10 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
||||
return err
|
||||
}
|
||||
return tx.Model(&post).Updates(map[string]interface{}{
|
||||
"title": title, "content": content, "tags": tags,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"content_plain": StripHTMLForSearch(content),
|
||||
"tags": tags,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,39 +9,44 @@ import (
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
records map[string][]time.Time
|
||||
limit int // 窗口内最大次数
|
||||
window time.Duration // 时间窗口
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
func NewRateLimiter(settings *ForumSettingsService) *RateLimiter {
|
||||
r := &RateLimiter{
|
||||
records: make(map[string][]time.Time),
|
||||
limit: limit,
|
||||
window: window,
|
||||
records: make(map[string][]time.Time),
|
||||
settings: settings,
|
||||
}
|
||||
go r.cleanup()
|
||||
return r
|
||||
}
|
||||
|
||||
// Allow 检查 key(如 userID+action)是否允许操作
|
||||
func (r *RateLimiter) Allow(key string) bool {
|
||||
// Allow 检查 action+key 是否允许操作
|
||||
func (r *RateLimiter) Allow(action, key string) bool {
|
||||
limit := r.settings.RateLimitFor(action)
|
||||
window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
|
||||
if limit <= 0 {
|
||||
return true
|
||||
}
|
||||
fullKey := action + ":" + key
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-r.window)
|
||||
times := r.records[key]
|
||||
cutoff := now.Add(-window)
|
||||
times := r.records[fullKey]
|
||||
var valid []time.Time
|
||||
for _, t := range times {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
if len(valid) >= r.limit {
|
||||
r.records[key] = valid
|
||||
if len(valid) >= limit {
|
||||
r.records[fullKey] = valid
|
||||
return false
|
||||
}
|
||||
valid = append(valid, now)
|
||||
r.records[key] = valid
|
||||
r.records[fullKey] = valid
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -49,8 +54,8 @@ func (r *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
r.mu.Lock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-r.window * 2)
|
||||
window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
|
||||
cutoff := time.Now().Add(-window * 2)
|
||||
for k, times := range r.records {
|
||||
var valid []time.Time
|
||||
for _, t := range times {
|
||||
|
||||
@@ -7,11 +7,102 @@ import (
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
// 论坛设置键名
|
||||
const (
|
||||
SettingPostEditWindowHours = "post_edit_window_hours"
|
||||
defaultEditWindowHours = 24
|
||||
|
||||
SettingRateLimitPost = "rate_limit_post"
|
||||
SettingRateLimitComment = "rate_limit_comment"
|
||||
SettingRateLimitRegister = "rate_limit_register"
|
||||
SettingRateLimitLogin = "rate_limit_login"
|
||||
SettingRateLimitWindow = "rate_limit_window_sec"
|
||||
|
||||
SettingPostTitleMax = "post_title_max"
|
||||
SettingPostTagsMax = "post_tags_max"
|
||||
SettingPostContentMax = "post_content_max"
|
||||
|
||||
SettingCommentMax = "comment_max"
|
||||
|
||||
SettingSearchKeywordMin = "search_keyword_min"
|
||||
SettingSearchKeywordMax = "search_keyword_max"
|
||||
|
||||
SettingPageSizeDefault = "page_size_default"
|
||||
SettingPageSizeMax = "page_size_max"
|
||||
|
||||
SettingPasswordMinLen = "password_min_len"
|
||||
SettingAvatarMaxMB = "avatar_max_mb"
|
||||
)
|
||||
|
||||
// ForumLimits 论坛可配置限制(API 传输结构)
|
||||
type ForumLimits struct {
|
||||
PostEditWindowHours int `json:"post_edit_window_hours"`
|
||||
|
||||
RateLimitPost int `json:"rate_limit_post"`
|
||||
RateLimitComment int `json:"rate_limit_comment"`
|
||||
RateLimitRegister int `json:"rate_limit_register"`
|
||||
RateLimitLogin int `json:"rate_limit_login"`
|
||||
RateLimitWindowSec int `json:"rate_limit_window_sec"`
|
||||
|
||||
PostTitleMax int `json:"post_title_max"`
|
||||
PostTagsMax int `json:"post_tags_max"`
|
||||
PostContentMax int `json:"post_content_max"`
|
||||
|
||||
CommentMax int `json:"comment_max"`
|
||||
|
||||
SearchKeywordMin int `json:"search_keyword_min"`
|
||||
SearchKeywordMax int `json:"search_keyword_max"`
|
||||
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
PageSizeMax int `json:"page_size_max"`
|
||||
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
}
|
||||
|
||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||
type ForumLimitsPublic struct {
|
||||
PostTitleMax int `json:"post_title_max"`
|
||||
PostTagsMax int `json:"post_tags_max"`
|
||||
PostContentMax int `json:"post_content_max"`
|
||||
CommentMax int `json:"comment_max"`
|
||||
SearchKeywordMin int `json:"search_keyword_min"`
|
||||
SearchKeywordMax int `json:"search_keyword_max"`
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
}
|
||||
|
||||
type settingDef struct {
|
||||
key string
|
||||
defaultVal string
|
||||
min int
|
||||
max int // 0 表示不限制上限
|
||||
}
|
||||
|
||||
var forumSettingDefs = []settingDef{
|
||||
{SettingPostEditWindowHours, "24", 0, 0},
|
||||
|
||||
{SettingRateLimitPost, "10", 1, 1000},
|
||||
{SettingRateLimitComment, "10", 1, 1000},
|
||||
{SettingRateLimitRegister, "10", 1, 1000},
|
||||
{SettingRateLimitLogin, "10", 1, 1000},
|
||||
{SettingRateLimitWindow, "60", 10, 3600},
|
||||
|
||||
{SettingPostTitleMax, "128", 1, 512},
|
||||
{SettingPostTagsMax, "256", 0, 512},
|
||||
{SettingPostContentMax, "50000", 0, 0},
|
||||
|
||||
{SettingCommentMax, "5000", 1, 50000},
|
||||
|
||||
{SettingSearchKeywordMin, "1", 0, 100},
|
||||
{SettingSearchKeywordMax, "50", 1, 200},
|
||||
|
||||
{SettingPageSizeDefault, "30", 1, 200},
|
||||
{SettingPageSizeMax, "50", 1, 200},
|
||||
|
||||
{SettingPasswordMinLen, "6", 4, 128},
|
||||
{SettingAvatarMaxMB, "2", 1, 20},
|
||||
}
|
||||
|
||||
// ForumSettingsService 论坛全局设置
|
||||
type ForumSettingsService struct {
|
||||
mu sync.RWMutex
|
||||
@@ -24,47 +115,193 @@ func NewForumSettingsService() *ForumSettingsService {
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) ensureDefaults() {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", SettingPostEditWindowHours).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{
|
||||
Key: SettingPostEditWindowHours,
|
||||
Value: strconv.Itoa(defaultEditWindowHours),
|
||||
})
|
||||
for _, def := range forumSettingDefs {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", def.key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PostEditWindowHours 返回用户可编辑帖子的时限(小时),0 表示不限
|
||||
func (s *ForumSettingsService) PostEditWindowHours() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
func (s *ForumSettingsService) getInt(key string, fallback int) int {
|
||||
var setting model.ForumSetting
|
||||
if err := model.DB.First(&setting, "`key` = ?", SettingPostEditWindowHours).Error; err != nil {
|
||||
return defaultEditWindowHours
|
||||
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
|
||||
return fallback
|
||||
}
|
||||
h, err := strconv.Atoi(setting.Value)
|
||||
if err != nil || h < 0 {
|
||||
return defaultEditWindowHours
|
||||
v, err := strconv.Atoi(setting.Value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return h
|
||||
return v
|
||||
}
|
||||
|
||||
// SetPostEditWindowHours 设置编辑时限(小时),0 表示不限
|
||||
func (s *ForumSettingsService) SetPostEditWindowHours(hours int) error {
|
||||
if hours < 0 {
|
||||
func (s *ForumSettingsService) setInt(key string, value int) error {
|
||||
for _, def := range forumSettingDefs {
|
||||
if def.key != key {
|
||||
continue
|
||||
}
|
||||
if value < def.min {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
if def.max > 0 && value > def.max {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return model.DB.Save(&model.ForumSetting{Key: key, Value: strconv.Itoa(value)}).Error
|
||||
}
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
return ForumLimits{
|
||||
PostEditWindowHours: s.PostEditWindowHours(),
|
||||
|
||||
RateLimitPost: s.RateLimitFor("post"),
|
||||
RateLimitComment: s.RateLimitFor("comment"),
|
||||
RateLimitRegister: s.RateLimitFor("register"),
|
||||
RateLimitLogin: s.RateLimitFor("login"),
|
||||
RateLimitWindowSec: s.RateLimitWindowSec(),
|
||||
|
||||
PostTitleMax: s.PostTitleMax(),
|
||||
PostTagsMax: s.PostTagsMax(),
|
||||
PostContentMax: s.PostContentMax(),
|
||||
|
||||
CommentMax: s.CommentMax(),
|
||||
|
||||
SearchKeywordMin: s.SearchKeywordMin(),
|
||||
SearchKeywordMax: s.SearchKeywordMax(),
|
||||
|
||||
PageSizeDefault: s.PageSizeDefault(),
|
||||
PageSizeMax: s.PageSizeMax(),
|
||||
|
||||
PasswordMinLen: s.PasswordMinLen(),
|
||||
AvatarMaxMB: s.AvatarMaxMB(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
limits := s.Limits()
|
||||
return ForumLimitsPublic{
|
||||
PostTitleMax: limits.PostTitleMax,
|
||||
PostTagsMax: limits.PostTagsMax,
|
||||
PostContentMax: limits.PostContentMax,
|
||||
CommentMax: limits.CommentMax,
|
||||
SearchKeywordMin: limits.SearchKeywordMin,
|
||||
SearchKeywordMax: limits.SearchKeywordMax,
|
||||
PasswordMinLen: limits.PasswordMinLen,
|
||||
AvatarMaxMB: limits.AvatarMaxMB,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
updates := map[string]int{
|
||||
SettingPostEditWindowHours: in.PostEditWindowHours,
|
||||
SettingRateLimitPost: in.RateLimitPost,
|
||||
SettingRateLimitComment: in.RateLimitComment,
|
||||
SettingRateLimitRegister: in.RateLimitRegister,
|
||||
SettingRateLimitLogin: in.RateLimitLogin,
|
||||
SettingRateLimitWindow: in.RateLimitWindowSec,
|
||||
SettingPostTitleMax: in.PostTitleMax,
|
||||
SettingPostTagsMax: in.PostTagsMax,
|
||||
SettingPostContentMax: in.PostContentMax,
|
||||
SettingCommentMax: in.CommentMax,
|
||||
SettingSearchKeywordMin: in.SearchKeywordMin,
|
||||
SettingSearchKeywordMax: in.SearchKeywordMax,
|
||||
SettingPageSizeDefault: in.PageSizeDefault,
|
||||
SettingPageSizeMax: in.PageSizeMax,
|
||||
SettingPasswordMinLen: in.PasswordMinLen,
|
||||
SettingAvatarMaxMB: in.AvatarMaxMB,
|
||||
}
|
||||
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return model.DB.Save(&model.ForumSetting{
|
||||
Key: SettingPostEditWindowHours,
|
||||
Value: strconv.Itoa(hours),
|
||||
}).Error
|
||||
if in.PageSizeDefault > in.PageSizeMax {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setInt(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForumSettings 返回所有可配置的论坛设置
|
||||
func (s *ForumSettingsService) ForumSettings() map[string]int {
|
||||
return map[string]int{
|
||||
"post_edit_window_hours": s.PostEditWindowHours(),
|
||||
func (s *ForumSettingsService) PostEditWindowHours() int {
|
||||
return s.getInt(SettingPostEditWindowHours, 24)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) RateLimitFor(action string) int {
|
||||
switch action {
|
||||
case "post":
|
||||
return s.getInt(SettingRateLimitPost, 10)
|
||||
case "comment":
|
||||
return s.getInt(SettingRateLimitComment, 10)
|
||||
case "register":
|
||||
return s.getInt(SettingRateLimitRegister, 10)
|
||||
case "login", "admin_login":
|
||||
return s.getInt(SettingRateLimitLogin, 10)
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) RateLimitWindowSec() int {
|
||||
return s.getInt(SettingRateLimitWindow, 60)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) PostTitleMax() int { return s.getInt(SettingPostTitleMax, 128) }
|
||||
func (s *ForumSettingsService) PostTagsMax() int { return s.getInt(SettingPostTagsMax, 256) }
|
||||
func (s *ForumSettingsService) PostContentMax() int { return s.getInt(SettingPostContentMax, 50000) }
|
||||
func (s *ForumSettingsService) CommentMax() int { return s.getInt(SettingCommentMax, 5000) }
|
||||
|
||||
func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingSearchKeywordMin, 1) }
|
||||
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
|
||||
|
||||
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
|
||||
func (s *ForumSettingsService) PageSizeMax() int { return s.getInt(SettingPageSizeMax, 50) }
|
||||
|
||||
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
|
||||
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
|
||||
|
||||
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
|
||||
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
|
||||
kw := trimRunes(keyword)
|
||||
if kw == "" {
|
||||
return "", nil
|
||||
}
|
||||
minLen := s.SearchKeywordMin()
|
||||
maxLen := s.SearchKeywordMax()
|
||||
runeLen := runeLen(kw)
|
||||
if minLen > 0 && runeLen < minLen {
|
||||
return "", ErrSearchKeywordTooShort
|
||||
}
|
||||
if maxLen > 0 && runeLen > maxLen {
|
||||
return "", ErrSearchKeywordTooLong
|
||||
}
|
||||
return kw, nil
|
||||
}
|
||||
|
||||
// NormalizePageSize 规范化分页大小
|
||||
func (s *ForumSettingsService) NormalizePageSize(size int) int {
|
||||
if size < 1 {
|
||||
return s.PageSizeDefault()
|
||||
}
|
||||
maxSize := s.PageSizeMax()
|
||||
if maxSize > 0 && size > maxSize {
|
||||
return maxSize
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// ValidateTextLength 校验文本长度,max=0 表示不限
|
||||
func (s *ForumSettingsService) ValidateTextLength(text string, max int, tooLongErr error) error {
|
||||
if max <= 0 {
|
||||
return nil
|
||||
}
|
||||
if runeLen(text) > max {
|
||||
return tooLongErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
14
service/settings_util.go
Normal file
14
service/settings_util.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func runeLen(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
|
||||
func trimRunes(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
53
service/upload.go
Normal file
53
service/upload.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var allowedImageExt = map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
}
|
||||
|
||||
// SaveUploadedImage 保存图片到本地目录,返回公开 URL 路径
|
||||
func SaveUploadedImage(file *multipart.FileHeader, dir, urlPrefix, namePrefix string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if !allowedImageExt[ext] {
|
||||
return "", errors.New("仅支持 jpg/png/gif/webp 格式")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%d%s", namePrefix, time.Now().UnixNano(), ext)
|
||||
destPath := filepath.Join(dir, filename)
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
prefix := strings.TrimSuffix(urlPrefix, "/")
|
||||
return prefix + "/" + filename, nil
|
||||
}
|
||||
@@ -3,10 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,11 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
filter *SensitiveFilter
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewUserService(filter *SensitiveFilter) *UserService {
|
||||
return &UserService{filter: filter}
|
||||
func NewUserService(filter *SensitiveFilter, settings *ForumSettingsService) *UserService {
|
||||
return &UserService{filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// GetByID 获取用户信息
|
||||
@@ -51,7 +49,7 @@ func (s *UserService) UpdateNickname(userID uint, nickname string) error {
|
||||
|
||||
// UpdatePassword 修改密码
|
||||
func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error {
|
||||
if err := ValidatePassword(newPass); err != nil {
|
||||
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
|
||||
return err
|
||||
}
|
||||
var user model.User
|
||||
@@ -70,32 +68,11 @@ func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error
|
||||
|
||||
// UploadAvatar 上传头像到本地目录
|
||||
func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, uploadDir string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||
if !allowed[ext] {
|
||||
return "", errors.New("仅支持 jpg/png/gif/webp 格式")
|
||||
}
|
||||
filename := fmt.Sprintf("%d%s", userID, ext)
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
src, err := file.Open()
|
||||
url, err := SaveUploadedImage(file, uploadDir, "/uploads/avatars", fmt.Sprintf("%d", userID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
avatarURL := "/uploads/avatars/" + filename
|
||||
return avatarURL, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", avatarURL).Error
|
||||
return url, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
|
||||
}
|
||||
|
||||
// ListUsers 管理员列出用户
|
||||
|
||||
Reference in New Issue
Block a user