支持公开用户主页、帖子图缩略图与编辑器图组排版。

新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-01 12:29:50 +08:00
parent 822eef96be
commit 060b7707cb
51 changed files with 3563 additions and 397 deletions

View File

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

View File

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

View File

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

View File

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

View File

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