支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -209,6 +209,7 @@ func (s *CommentService) AdminDelete(commentID uint) error {
|
||||
type RecentCommentItem struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
UserID uint `json:"user_id,omitempty"`
|
||||
Author string `json:"author"`
|
||||
Avatar string `json:"avatar"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
@@ -251,6 +252,7 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
|
||||
out = append(out, RecentCommentItem{
|
||||
ID: c.ID,
|
||||
PostID: c.PostID,
|
||||
UserID: c.UserID,
|
||||
Author: author,
|
||||
Avatar: avatar,
|
||||
Excerpt: excerpt,
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *Po
|
||||
|
||||
type PostListQuery struct {
|
||||
BoardID uint
|
||||
UserID uint // >0 时仅返回该用户的帖子
|
||||
Page int
|
||||
Size int
|
||||
Keyword string
|
||||
@@ -210,6 +211,9 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
if q.BoardID > 0 {
|
||||
db = db.Where("board_id = ?", q.BoardID)
|
||||
}
|
||||
if q.UserID > 0 {
|
||||
db = db.Where("user_id = ?", q.UserID)
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
|
||||
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
|
||||
SettingPasswordMinLen = "password_min_len"
|
||||
SettingAvatarMaxMB = "avatar_max_mb"
|
||||
SettingSignatureMax = "signature_max"
|
||||
|
||||
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
|
||||
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
|
||||
@@ -93,6 +94,7 @@ type ForumLimits struct {
|
||||
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
SignatureMax int `json:"signature_max"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
@@ -109,6 +111,7 @@ type ForumLimitsPublic struct {
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
SignatureMax int `json:"signature_max"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
@@ -143,6 +146,7 @@ var forumSettingDefs = []settingDef{
|
||||
|
||||
{SettingPasswordMinLen, "6", 4, 128},
|
||||
{SettingAvatarMaxMB, "2", 1, 20},
|
||||
{SettingSignatureMax, "200", 0, 512},
|
||||
|
||||
{SettingOpenPostsInNewTab, "1", 0, 1},
|
||||
{SettingOpenContentLinksInNewTab, "1", 0, 1},
|
||||
@@ -196,6 +200,16 @@ type SiteBranding struct {
|
||||
Favicon string `json:"favicon"`
|
||||
}
|
||||
|
||||
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
|
||||
func (b SiteBranding) DocumentTitle() string {
|
||||
name := strings.TrimSpace(b.Name)
|
||||
subtitle := strings.TrimSpace(b.Slogan)
|
||||
if subtitle != "" {
|
||||
return name + " - " + subtitle
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// GiteaSyncConfig Gitea 仓库同步配置
|
||||
type GiteaSyncConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -337,6 +351,7 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
|
||||
PasswordMinLen: s.PasswordMinLen(),
|
||||
AvatarMaxMB: s.AvatarMaxMB(),
|
||||
SignatureMax: s.SignatureMax(),
|
||||
|
||||
OpenPostsInNewTab: s.OpenPostsInNewTab(),
|
||||
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
|
||||
@@ -355,6 +370,7 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
PageSizeDefault: limits.PageSizeDefault,
|
||||
PasswordMinLen: limits.PasswordMinLen,
|
||||
AvatarMaxMB: limits.AvatarMaxMB,
|
||||
SignatureMax: limits.SignatureMax,
|
||||
|
||||
OpenPostsInNewTab: limits.OpenPostsInNewTab,
|
||||
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
|
||||
@@ -378,6 +394,7 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
SettingPageSizeDefault: in.PageSizeDefault,
|
||||
SettingPasswordMinLen: in.PasswordMinLen,
|
||||
SettingAvatarMaxMB: in.AvatarMaxMB,
|
||||
SettingSignatureMax: in.SignatureMax,
|
||||
}
|
||||
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
|
||||
return ErrInvalidSetting
|
||||
@@ -438,6 +455,7 @@ func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPa
|
||||
|
||||
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
|
||||
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
|
||||
func (s *ForumSettingsService) SignatureMax() int { return s.getInt(SettingSignatureMax, 200) }
|
||||
|
||||
func (s *ForumSettingsService) OpenPostsInNewTab() bool {
|
||||
return s.getString(SettingOpenPostsInNewTab, "1") == "1"
|
||||
|
||||
182
service/thumb.go
Normal file
182
service/thumb.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
// 注册解码器
|
||||
_ "image/gif"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const (
|
||||
// PostThumbMaxSide 正文预览图最长边(像素)
|
||||
PostThumbMaxSide = 1280
|
||||
// PostThumbJPEGQuality 预览图 JPEG 质量
|
||||
PostThumbJPEGQuality = 82
|
||||
)
|
||||
|
||||
var thumbLocks sync.Map // 同一原图并发生成时串行化
|
||||
|
||||
// ThumbURLFromUpload 将 /uploads/posts/xxx.jpg 转为 /media/thumb/posts/xxx.jpg
|
||||
func ThumbURLFromUpload(uploadURL string) string {
|
||||
u := strings.TrimSpace(uploadURL)
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(u, "/media/thumb/") {
|
||||
return u
|
||||
}
|
||||
if strings.HasPrefix(u, "/uploads/") {
|
||||
return "/media/thumb/" + strings.TrimPrefix(u, "/uploads/")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WarmPostImageThumb 上传后预热缩略图(失败忽略,首次访问仍会生成)
|
||||
func WarmPostImageThumb(uploadsRoot, relativePath string) {
|
||||
_, _ = EnsureUploadThumb(uploadsRoot, relativePath)
|
||||
}
|
||||
|
||||
// EnsureUploadThumb 确保缩略图存在,返回磁盘路径
|
||||
// relativePath 形如 posts/1_123.jpg(相对 uploads 根目录)
|
||||
func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
|
||||
rel, err := sanitizeUploadRel(relativePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 仅处理帖子正文图
|
||||
if !strings.HasPrefix(rel, "posts/") {
|
||||
return "", errors.New("仅支持帖子图片缩略图")
|
||||
}
|
||||
|
||||
origPath := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
|
||||
if st, err := os.Stat(origPath); err != nil || st.IsDir() {
|
||||
return "", errors.New("原图不存在")
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".jpg")
|
||||
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
|
||||
return thumbPath, nil
|
||||
}
|
||||
|
||||
lockKey := rel
|
||||
muIface, _ := thumbLocks.LoadOrStore(lockKey, &sync.Mutex{})
|
||||
mu := muIface.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// 双检
|
||||
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
|
||||
return thumbPath, nil
|
||||
}
|
||||
|
||||
if err := generateJPEGThumb(origPath, thumbPath, PostThumbMaxSide, PostThumbJPEGQuality); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return thumbPath, nil
|
||||
}
|
||||
|
||||
func thumbFresherThan(thumbPath, origPath string) (bool, error) {
|
||||
ts, err := os.Stat(thumbPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
os_, err := os.Stat(origPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !ts.ModTime().Before(os_.ModTime()), nil
|
||||
}
|
||||
|
||||
func sanitizeUploadRel(relativePath string) (string, error) {
|
||||
rel := strings.TrimSpace(relativePath)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return "", errors.New("非法路径")
|
||||
}
|
||||
cleaned := filepath.Clean(filepath.FromSlash(rel))
|
||||
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
||||
return "", errors.New("非法路径")
|
||||
}
|
||||
return filepath.ToSlash(cleaned), nil
|
||||
}
|
||||
|
||||
func generateJPEGThumb(srcPath, dstPath string, maxSide, quality int) error {
|
||||
f, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
|
||||
out := resizeToMax(img, maxSide)
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := fmt.Sprintf("%s.%d.tmp", dstPath, time.Now().UnixNano())
|
||||
dst, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encErr := jpeg.Encode(dst, out, &jpeg.Options{Quality: quality})
|
||||
closeErr := dst.Close()
|
||||
if encErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return encErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return closeErr
|
||||
}
|
||||
if err := os.Rename(tmp, dstPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resizeToMax(src image.Image, maxSide int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= 0 || h <= 0 {
|
||||
return src
|
||||
}
|
||||
if w <= maxSide && h <= maxSide {
|
||||
return src
|
||||
}
|
||||
var nw, nh int
|
||||
if w >= h {
|
||||
nw = maxSide
|
||||
nh = int(float64(h) * float64(maxSide) / float64(w))
|
||||
} else {
|
||||
nh = maxSide
|
||||
nw = int(float64(w) * float64(maxSide) / float64(h))
|
||||
}
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -49,5 +49,14 @@ func SaveUploadedImage(file *multipart.FileHeader, dir, urlPrefix, namePrefix st
|
||||
}
|
||||
|
||||
prefix := strings.TrimSuffix(urlPrefix, "/")
|
||||
return prefix + "/" + filename, nil
|
||||
url := prefix + "/" + filename
|
||||
|
||||
// 帖子正文图:后台预热缩略图,加速首次打开详情
|
||||
if strings.Contains(prefix, "/posts") {
|
||||
uploadsRoot := filepath.Dir(dir) // .../uploads/posts → .../uploads
|
||||
rel := filepath.ToSlash(filepath.Join(filepath.Base(dir), filename))
|
||||
go WarmPostImageThumb(uploadsRoot, rel)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,40 @@ func (s *UserService) GetByID(id uint) (*model.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UserActivityStats 个人主页活动统计
|
||||
type UserActivityStats struct {
|
||||
PostCount int64 `json:"post_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
FavoriteCount int64 `json:"favorite_count"`
|
||||
LikeReceived int64 `json:"like_received"`
|
||||
}
|
||||
|
||||
// ActivityStats 统计用户发帖、评论、收藏与帖子获赞
|
||||
func (s *UserService) ActivityStats(userID uint) (UserActivityStats, error) {
|
||||
var st UserActivityStats
|
||||
if userID == 0 {
|
||||
return st, errors.New("无效用户")
|
||||
}
|
||||
if err := model.DB.Model(&model.Post{}).Where("user_id = ?", userID).Count(&st.PostCount).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := model.DB.Model(&model.Comment{}).Where("user_id = ?", userID).Count(&st.CommentCount).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := model.DB.Model(&model.PostFavorite{}).Where("user_id = ?", userID).Count(&st.FavoriteCount).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
var likeSum int64
|
||||
if err := model.DB.Model(&model.Post{}).
|
||||
Select("COALESCE(SUM(like_count), 0)").
|
||||
Where("user_id = ?", userID).
|
||||
Scan(&likeSum).Error; err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.LikeReceived = likeSum
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GetByUsername 按用户名查询
|
||||
func (s *UserService) GetByUsername(username string) (*model.User, error) {
|
||||
var user model.User
|
||||
@@ -47,6 +81,22 @@ func (s *UserService) UpdateNickname(userID uint, nickname string) error {
|
||||
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("nickname", nickname).Error
|
||||
}
|
||||
|
||||
// UpdateSignature 修改个人签名
|
||||
func (s *UserService) UpdateSignature(userID uint, signature string) error {
|
||||
signature = strings.TrimSpace(signature)
|
||||
maxLen := s.settings.SignatureMax()
|
||||
if maxLen > 0 {
|
||||
runes := []rune(signature)
|
||||
if len(runes) > maxLen {
|
||||
return fmt.Errorf("签名不能超过 %d 字", maxLen)
|
||||
}
|
||||
}
|
||||
if signature != "" {
|
||||
signature = s.filter.Filter(signature)
|
||||
}
|
||||
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("signature", signature).Error
|
||||
}
|
||||
|
||||
// UpdatePassword 修改密码
|
||||
func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error {
|
||||
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user