完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。

同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 04:11:38 +08:00
parent 1d273066b0
commit b451703642
73 changed files with 1943 additions and 752 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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, "&nbsp;", " ")
return strings.Join(strings.Fields(text), " ")
}

55
service/filter_words.go Normal file
View 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
}

View File

@@ -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
})
}

View File

@@ -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 {

View File

@@ -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
View 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
View 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
}

View File

@@ -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 管理员列出用户