移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,7 +32,9 @@ func (s *BoardService) ListWithStats() ([]BoardWithStats, error) {
|
||||
result := make([]BoardWithStats, len(boards))
|
||||
for i, b := range boards {
|
||||
var count int64
|
||||
model.DB.Model(&model.Post{}).Where("board_id = ?", b.ID).Count(&count)
|
||||
model.DB.Model(&model.Post{}).
|
||||
Where("board_id = ? AND status = ?", b.ID, model.ContentStatusPublished).
|
||||
Count(&count)
|
||||
result[i] = BoardWithStats{Board: b, PostCount: int(count)}
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -2,11 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
@@ -72,6 +72,16 @@ func (s *CommentService) fillReplyTargets(comments []model.Comment, loadMissing
|
||||
}
|
||||
}
|
||||
|
||||
func canViewComment(c model.Comment, viewerID uint, isAdmin bool) bool {
|
||||
if isAdmin || c.Status == model.ContentStatusPublished || c.Status == "" {
|
||||
return true
|
||||
}
|
||||
if c.Status == model.ContentStatusPending || c.Status == model.ContentStatusRejected {
|
||||
return viewerID > 0 && c.UserID == viewerID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAuthorID uint, visibleGuestIDs []uint) ([]model.Comment, error) {
|
||||
var comments []model.Comment
|
||||
err := model.DB.Preload("User").Where("post_id = ?", postID).Order("floor asc").Find(&comments).Error
|
||||
@@ -84,15 +94,64 @@ func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAut
|
||||
guestSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
allByID := make(map[uint]model.Comment, len(comments))
|
||||
for _, c := range comments {
|
||||
allByID[c.ID] = c
|
||||
}
|
||||
|
||||
visible := make([]model.Comment, 0, len(comments))
|
||||
visibleIDs := make(map[uint]struct{}, len(comments))
|
||||
for i := range comments {
|
||||
if !canViewComment(comments[i], viewerID, isAdmin) {
|
||||
continue
|
||||
}
|
||||
if comments[i].IsPrivate && !s.canViewPrivate(comments[i], viewerID, isAdmin, postAuthorID, guestSet) {
|
||||
comments[i].ContentHidden = true
|
||||
comments[i].Content = ""
|
||||
}
|
||||
visibleIDs[comments[i].ID] = struct{}{}
|
||||
visible = append(visible, comments[i])
|
||||
}
|
||||
|
||||
s.fillReplyTargets(comments, false)
|
||||
return comments, nil
|
||||
// 父评论不可见时,回挂到最近可见祖先,避免回复在游客侧变成独立顶层评论
|
||||
for i := range visible {
|
||||
visible[i].ThreadParentID = resolveThreadParent(visible[i].ReplyTo, visibleIDs, allByID)
|
||||
}
|
||||
|
||||
s.fillReplyTargets(visible, true)
|
||||
for i := range visible {
|
||||
if rt := visible[i].ReplyTarget; rt != nil && !canViewComment(*rt, viewerID, isAdmin) {
|
||||
// 不可见父评论仅保留昵称供 @,不泄露正文
|
||||
rt.Content = ""
|
||||
rt.ContentHidden = true
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// resolveThreadParent 计算嵌套展示父节点:优先直接父评论,否则沿 reply_to 向上找到最近可见祖先
|
||||
func resolveThreadParent(replyTo *uint, visibleIDs map[uint]struct{}, allByID map[uint]model.Comment) *uint {
|
||||
if replyTo == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := visibleIDs[*replyTo]; ok {
|
||||
id := *replyTo
|
||||
return &id
|
||||
}
|
||||
cur := *replyTo
|
||||
for hops := 0; hops < 32; hops++ {
|
||||
parent, ok := allByID[cur]
|
||||
if !ok || parent.ReplyTo == nil {
|
||||
return nil
|
||||
}
|
||||
next := *parent.ReplyTo
|
||||
if _, ok := visibleIDs[next]; ok {
|
||||
id := next
|
||||
return &id
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
@@ -109,32 +168,21 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
|
||||
if in.UserID > 0 {
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, in.UserID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
if user.Banned {
|
||||
return nil, errors.New("账号已被禁言")
|
||||
}
|
||||
} else {
|
||||
nick := strings.TrimSpace(in.GuestNick)
|
||||
if nick == "" {
|
||||
return nil, errors.New("请填写昵称")
|
||||
}
|
||||
if len([]rune(nick)) > 32 {
|
||||
return nil, errors.New("昵称过长")
|
||||
}
|
||||
if email := strings.TrimSpace(in.GuestEmail); email != "" {
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return nil, errors.New("邮箱格式不正确")
|
||||
}
|
||||
}
|
||||
if rawURL := strings.TrimSpace(in.GuestURL); rawURL != "" {
|
||||
u, err := url.ParseRequestURI(rawURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, errors.New("网址格式不正确")
|
||||
}
|
||||
if in.UserID == 0 {
|
||||
return nil, errors.New("请登录后评论")
|
||||
}
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, in.UserID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
if user.Banned {
|
||||
return nil, errors.New("账号已被禁言")
|
||||
}
|
||||
|
||||
// 未公开帖仅作者/管理员可评论
|
||||
if post.Status != model.ContentStatusPublished && post.Status != "" {
|
||||
if user.Role != model.RoleAdmin && post.UserID != in.UserID {
|
||||
return nil, errors.New("帖子审核中,暂不可评论")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +194,14 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
if err := model.DB.Where("id = ? AND post_id = ?", *in.ReplyTo, in.PostID).First(&target).Error; err != nil {
|
||||
return nil, ErrCommentNotFound
|
||||
}
|
||||
if !canViewComment(target, in.UserID, user.Role == model.RoleAdmin) {
|
||||
return nil, ErrCommentNotFound
|
||||
}
|
||||
}
|
||||
|
||||
status := model.ContentStatusPending
|
||||
if user.Role == model.RoleAdmin {
|
||||
status = model.ContentStatusPublished
|
||||
}
|
||||
|
||||
comment := &model.Comment{
|
||||
@@ -158,19 +214,49 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
||||
GuestEmail: strings.TrimSpace(in.GuestEmail),
|
||||
GuestURL: strings.TrimSpace(in.GuestURL),
|
||||
IsPrivate: in.IsPrivate,
|
||||
Status: status,
|
||||
}
|
||||
return comment, model.DB.Create(comment).Error
|
||||
}
|
||||
|
||||
func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
||||
var comment model.Comment
|
||||
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||
// SetStatus 设置评论审核状态
|
||||
func (s *CommentService) SetStatus(commentID uint, status string) error {
|
||||
switch status {
|
||||
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
|
||||
default:
|
||||
return errors.New("无效的审核状态")
|
||||
}
|
||||
res := model.DB.Model(&model.Comment{}).Where("id = ?", commentID).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 获取评论
|
||||
func (s *CommentService) GetByID(id uint) (*model.Comment, error) {
|
||||
var c model.Comment
|
||||
if err := model.DB.Preload("User").Preload("Post").First(&c, id).Error; err != nil {
|
||||
return nil, ErrCommentNotFound
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// PendingCommentCount 待审评论数
|
||||
func (s *CommentService) PendingCommentCount() (int64, error) {
|
||||
var n int64
|
||||
err := model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPending).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
||||
if !isAdmin {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return model.DB.Delete(&comment).Error
|
||||
return s.AdminDelete(commentID)
|
||||
}
|
||||
|
||||
func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, error) {
|
||||
@@ -182,7 +268,7 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
|
||||
return "", ErrPermissionDenied
|
||||
}
|
||||
if !isAdmin {
|
||||
window := s.settings.PostEditWindowHours()
|
||||
window := s.settings.CommentEditWindowHours()
|
||||
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Hour {
|
||||
return "", errors.New("已超过可编辑时限")
|
||||
}
|
||||
@@ -195,20 +281,63 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
|
||||
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := model.DB.Model(&comment).Update("content", content).Error; err != nil {
|
||||
if content == comment.Content {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
rev := model.CommentRevision{
|
||||
CommentID: commentID,
|
||||
EditorID: userID,
|
||||
Content: comment.Content,
|
||||
}
|
||||
if err := tx.Create(&rev).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{"content": content}
|
||||
if !isAdmin {
|
||||
updates["status"] = model.ContentStatusPending
|
||||
}
|
||||
return tx.Model(&comment).Updates(updates).Error
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *CommentService) AdminDelete(commentID uint) error {
|
||||
return model.DB.Delete(&model.Comment{}, commentID).Error
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("comment_id = ?", commentID).Delete(&model.CommentRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.Comment{}, commentID).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListRevisions 评论编辑历史(管理员查看)
|
||||
func (s *CommentService) ListRevisions(commentID uint) ([]model.CommentRevision, error) {
|
||||
if _, err := s.GetByID(commentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var revs []model.CommentRevision
|
||||
err := model.DB.Preload("Editor").
|
||||
Where("comment_id = ?", commentID).
|
||||
Order("id desc").Find(&revs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revs == nil {
|
||||
revs = []model.CommentRevision{}
|
||||
}
|
||||
return revs, nil
|
||||
}
|
||||
|
||||
// RecentCommentItem 右栏「最新评论」条目
|
||||
type RecentCommentItem struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
Floor int `json:"floor"`
|
||||
UserID uint `json:"user_id,omitempty"`
|
||||
Author string `json:"author"`
|
||||
Avatar string `json:"avatar"`
|
||||
@@ -224,7 +353,7 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
|
||||
}
|
||||
var comments []model.Comment
|
||||
err := model.DB.Preload("User").Preload("Post").
|
||||
Where("is_private = ?", false).
|
||||
Where("is_private = ? AND status = ?", false, model.ContentStatusPublished).
|
||||
Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖
|
||||
Find(&comments).Error
|
||||
if err != nil {
|
||||
@@ -252,6 +381,7 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
|
||||
out = append(out, RecentCommentItem{
|
||||
ID: c.ID,
|
||||
PostID: c.PostID,
|
||||
Floor: c.Floor,
|
||||
UserID: c.UserID,
|
||||
Author: author,
|
||||
Avatar: avatar,
|
||||
@@ -278,18 +408,24 @@ func truncateRunes(s string, n int) string {
|
||||
}
|
||||
|
||||
// ListRecent 管理员查看最近评论
|
||||
func (s *CommentService) ListRecent(page, size int) ([]model.Comment, int64, error) {
|
||||
func (s *CommentService) ListRecent(page, size int, status string) ([]model.Comment, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
db := model.DB.Model(&model.Comment{})
|
||||
switch status {
|
||||
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Comment{}).Count(&total)
|
||||
db.Count(&total)
|
||||
var comments []model.Comment
|
||||
err := model.DB.Preload("User").Preload("Post").
|
||||
Order("id desc").Offset((page-1)*size).Limit(size).Find(&comments).Error
|
||||
err := db.Preload("User").Preload("Post").
|
||||
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
|
||||
Offset((page - 1) * size).Limit(size).Find(&comments).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
55
service/crawler.go
Normal file
55
service/crawler.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
// 常见搜索引擎 / 社交预览 / SEO 工具的 User-Agent 片段(小写匹配)
|
||||
var seoCrawlerTokens = []string{
|
||||
"googlebot",
|
||||
"google-inspectiontool",
|
||||
"bingbot",
|
||||
"baiduspider",
|
||||
"yandexbot",
|
||||
"duckduckbot",
|
||||
"slurp", // Yahoo
|
||||
"sogou",
|
||||
"bytespider",
|
||||
"petalbot",
|
||||
"applebot",
|
||||
"facebookexternalhit",
|
||||
"facebot",
|
||||
"twitterbot",
|
||||
"linkedinbot",
|
||||
"discordbot",
|
||||
"telegrambot",
|
||||
"slackbot",
|
||||
"whatsapp",
|
||||
"preview", // 部分通用预览 UA
|
||||
"embedly",
|
||||
"quora link preview",
|
||||
"pinterest",
|
||||
"vkshare",
|
||||
"w3c_validator",
|
||||
"ahrefsbot",
|
||||
"semrushbot",
|
||||
"dotbot",
|
||||
"mj12bot",
|
||||
"gptbot",
|
||||
"claudebot",
|
||||
"anthropic-ai",
|
||||
"chatgpt-user",
|
||||
"oai-searchbot",
|
||||
}
|
||||
|
||||
// IsSEOCrawler 是否为需要服务端 HTML 的爬虫 / 预览 bot(动态渲染)
|
||||
func IsSEOCrawler(userAgent string) bool {
|
||||
ua := strings.ToLower(strings.TrimSpace(userAgent))
|
||||
if ua == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range seoCrawlerTokens {
|
||||
if strings.Contains(ua, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -17,6 +16,9 @@ const (
|
||||
emailCodeCooldown = 60 * time.Second
|
||||
)
|
||||
|
||||
// EmailCodeLen 注册邮箱验证码位数(供 API 告知前端)
|
||||
const EmailCodeLen = emailCodeLen
|
||||
|
||||
type emailCodeEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
@@ -63,9 +65,12 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
subject := "注册验证码"
|
||||
body := fmt.Sprintf("您的注册验证码是:%s\n\n%d 分钟内有效,如非本人操作请忽略。", code, int(emailCodeTTL.Minutes()))
|
||||
if err := s.mail.Send(email, subject, body); err != nil {
|
||||
siteName := "姜十三论坛"
|
||||
if s.mail != nil && s.mail.settings != nil {
|
||||
siteName = s.mail.settings.SiteBranding().Name
|
||||
}
|
||||
subject, textBody, htmlBody := BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
|
||||
if err := s.mail.SendHTML(email, subject, textBody, htmlBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
152
service/image_webp.go
Normal file
152
service/image_webp.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/KarpelesLab/gowebp"
|
||||
|
||||
// 注册解码器
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 图片展示方案(上传时同时保留原图与 WebP,按此决定返回给前端的 URL)
|
||||
const (
|
||||
ImageDeliveryWebP = "webp" // 使用 WebP(默认,省流量)
|
||||
ImageDeliveryOriginal = "original" // 使用原图
|
||||
)
|
||||
|
||||
const (
|
||||
// UploadWebPQuality 上传衍生 WebP 有损质量(0–100)
|
||||
UploadWebPQuality float32 = 82
|
||||
// UploadWebPMethod 编码档位:3 速度与体积较均衡
|
||||
UploadWebPMethod = 3
|
||||
// ThumbWebPQuality 帖子预览图质量
|
||||
ThumbWebPQuality float32 = 80
|
||||
)
|
||||
|
||||
// preparedUpload 原图 + 可选 WebP 衍生
|
||||
type preparedUpload struct {
|
||||
OrigExt string // 含点,如 .jpg
|
||||
OrigContentType string
|
||||
OrigData []byte
|
||||
WebPData []byte // 空表示无衍生(动图 GIF,或原图已是 WebP)
|
||||
}
|
||||
|
||||
// prepareUploadImage 始终保留原图字节;静态图额外生成 WebP 衍生
|
||||
func prepareUploadImage(file *multipart.FileHeader) (*preparedUpload, error) {
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if !allowedImageExt[ext] {
|
||||
return nil, errors.New("仅支持 jpg/png/gif/webp 格式")
|
||||
}
|
||||
if ext == ".jpeg" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
raw, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, errors.New("空图片文件")
|
||||
}
|
||||
|
||||
out := &preparedUpload{
|
||||
OrigExt: ext,
|
||||
OrigContentType: imageContentType(ext),
|
||||
OrigData: raw,
|
||||
}
|
||||
|
||||
// 动图 GIF:只保留原文件
|
||||
if ext == ".gif" && gifFrameCount(raw) > 1 {
|
||||
return out, nil
|
||||
}
|
||||
// 上传已是 WebP:原图即 WebP,不再重复衍生
|
||||
if ext == ".webp" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
|
||||
webpBytes, err := encodeWebPBytes(img, UploadWebPQuality, UploadWebPMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("转换 WebP 失败: %w", err)
|
||||
}
|
||||
out.WebPData = webpBytes
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func gifFrameCount(raw []byte) int {
|
||||
g, err := gif.DecodeAll(bytes.NewReader(raw))
|
||||
if err != nil || g == nil {
|
||||
return 0
|
||||
}
|
||||
return len(g.Image)
|
||||
}
|
||||
|
||||
func encodeWebPBytes(img image.Image, quality float32, method int) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := gowebp.Encode(&buf, img, &gowebp.Options{
|
||||
Lossy: true,
|
||||
Quality: quality,
|
||||
Method: method,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func normalizeImageDelivery(raw string) string {
|
||||
if strings.ToLower(strings.TrimSpace(raw)) == ImageDeliveryOriginal {
|
||||
return ImageDeliveryOriginal
|
||||
}
|
||||
return ImageDeliveryWebP
|
||||
}
|
||||
|
||||
func imageContentType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// siblingUploadExts 同主文件名可能存在的伴生扩展名(删除时一并清理)
|
||||
func siblingUploadExts(ext string) []string {
|
||||
ext = strings.ToLower(ext)
|
||||
all := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
out := make([]string, 0, len(all))
|
||||
for _, e := range all {
|
||||
if e == ext || (ext == ".jpg" && e == ".jpeg") || (ext == ".jpeg" && e == ".jpg") {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -34,6 +34,11 @@ func NewMailService(settings *ForumSettingsService) *MailService {
|
||||
|
||||
// Send 发送纯文本邮件
|
||||
func (m *MailService) Send(to, subject, body string) error {
|
||||
return m.SendHTML(to, subject, body, "")
|
||||
}
|
||||
|
||||
// SendHTML 发送邮件;htmlBody 非空时使用 multipart/alternative
|
||||
func (m *MailService) SendHTML(to, subject, textBody, htmlBody string) error {
|
||||
cfg := m.settings.MailConfig()
|
||||
if !m.settings.MailReady() {
|
||||
return ErrMailNotConfigured
|
||||
@@ -45,16 +50,43 @@ func (m *MailService) Send(to, subject, body string) error {
|
||||
fromHeader = fmt.Sprintf("%s <%s>", encodeMailHeader(name), from)
|
||||
}
|
||||
|
||||
msg := strings.Join([]string{
|
||||
"From: " + fromHeader,
|
||||
"To: " + to,
|
||||
"Subject: " + encodeMailHeader(subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
var msg string
|
||||
if strings.TrimSpace(htmlBody) == "" {
|
||||
msg = strings.Join([]string{
|
||||
"From: " + fromHeader,
|
||||
"To: " + to,
|
||||
"Subject: " + encodeMailHeader(subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
textBody,
|
||||
}, "\r\n")
|
||||
} else {
|
||||
boundary := fmt.Sprintf("j13bound_%d", time.Now().UnixNano())
|
||||
msg = strings.Join([]string{
|
||||
"From: " + fromHeader,
|
||||
"To: " + to,
|
||||
"Subject: " + encodeMailHeader(subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative; boundary=\"" + boundary + "\"",
|
||||
"",
|
||||
"--" + boundary,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
textBody,
|
||||
"",
|
||||
"--" + boundary,
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
htmlBody,
|
||||
"",
|
||||
"--" + boundary + "--",
|
||||
"",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
|
||||
94
service/mail_template.go
Normal file
94
service/mail_template.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildRegisterCodeMail 生成注册验证码邮件(纯文本 + HTML)
|
||||
// 预览文案刻意不把验证码与「10分钟」紧邻,避免邮箱摘要显示成 8 位数字。
|
||||
func BuildRegisterCodeMail(siteName, code string, ttlMinutes int) (subject, textBody, htmlBody string) {
|
||||
siteName = strings.TrimSpace(siteName)
|
||||
if siteName == "" {
|
||||
siteName = "姜十三论坛"
|
||||
}
|
||||
if ttlMinutes <= 0 {
|
||||
ttlMinutes = 10
|
||||
}
|
||||
|
||||
subject = fmt.Sprintf("【%s】注册验证码", siteName)
|
||||
|
||||
// 纯文本:验证码单独成段,数字间加空格,有效期另起一段
|
||||
spaced := strings.Join(strings.Split(code, ""), " ")
|
||||
textBody = fmt.Sprintf(
|
||||
"你好,\n\n你正在注册 %s。请在注册页填写以下验证码:\n\n%s\n\n(共 %d 位数字)\n\n有效期:%d 分钟。\n如非本人操作,请忽略本邮件。\n\n— %s\n",
|
||||
siteName, spaced, len(code), ttlMinutes, siteName,
|
||||
)
|
||||
|
||||
safeSite := html.EscapeString(siteName)
|
||||
safeCode := html.EscapeString(code)
|
||||
// 预览摘要:不含验证码数字,避免与有效期粘连
|
||||
preheader := html.EscapeString(fmt.Sprintf("完成 %s 注册:请填写邮件中的验证码,有效期 %d 分钟。", siteName, ttlMinutes))
|
||||
|
||||
htmlBody = fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>%s</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f5f7fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#1f2937;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">%s</div>
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f5f7fa;padding:28px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:20px 28px;background:linear-gradient(135deg,#18a058,#138f4c);color:#ffffff;">
|
||||
<div style="font-size:18px;font-weight:700;letter-spacing:0.02em;">%s</div>
|
||||
<div style="margin-top:4px;font-size:13px;opacity:0.92;">注册邮箱验证</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;">你好,</p>
|
||||
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">你正在注册 <strong style="color:#111827;">%s</strong>。请在注册页面输入下方验证码:</p>
|
||||
<div style="margin:0 0 8px;text-align:center;font-size:12px;color:#6b7280;letter-spacing:0.08em;">验 证 码</div>
|
||||
<div style="margin:0 auto 8px;max-width:280px;padding:16px 12px;text-align:center;background:#edfbf3;border:1px solid rgba(24,160,88,0.28);border-radius:10px;font-size:28px;font-weight:700;letter-spacing:0.35em;color:#138f4c;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">
|
||||
%s
|
||||
</div>
|
||||
<p style="margin:0 0 20px;text-align:center;font-size:12px;color:#9ca3af;">共 %d 位数字,请完整输入</p>
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:#f8fafc;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:12px 14px;font-size:13px;line-height:1.6;color:#4b5563;">
|
||||
<strong style="color:#111827;">有效期</strong>:%d 分钟<br />
|
||||
超时请返回注册页重新获取验证码。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">如非本人操作,请忽略本邮件。请勿将验证码告知他人。</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
|
||||
此邮件由 %s 自动发送,请勿直接回复
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
html.EscapeString(subject),
|
||||
preheader,
|
||||
safeSite,
|
||||
safeSite,
|
||||
safeCode,
|
||||
len(code),
|
||||
ttlMinutes,
|
||||
safeSite,
|
||||
)
|
||||
return subject, textBody, htmlBody
|
||||
}
|
||||
466
service/media.go
Normal file
466
service/media.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
// MediaItem 管理端媒体资源条目
|
||||
type MediaItem struct {
|
||||
Category string `json:"category"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
ContentType string `json:"content_type"`
|
||||
StorageType string `json:"storage_type,omitempty"`
|
||||
}
|
||||
|
||||
// MediaListResult 媒体列表分页结果
|
||||
type MediaListResult struct {
|
||||
Files []MediaItem `json:"files"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
StorageType string `json:"storage_type"`
|
||||
CategoryCounts map[string]int `json:"category_counts"`
|
||||
}
|
||||
|
||||
var mediaCategories = []string{
|
||||
UploadCategoryAvatars,
|
||||
UploadCategoryPosts,
|
||||
UploadCategorySite,
|
||||
}
|
||||
|
||||
// ListMedia 从数据库索引列出媒体(上传/删除时维护;启动时会扫盘回填)
|
||||
func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaListResult, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("上传存储未初始化")
|
||||
}
|
||||
if model.DB == nil {
|
||||
return nil, errors.New("数据库未初始化")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 24
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if category == "" || category == "all" {
|
||||
category = "all"
|
||||
} else if !validMediaCategory(category) {
|
||||
return nil, errors.New("无效的分类")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
|
||||
// 索引为空时先同步一次,避免升级后首次打开空白
|
||||
var indexed int64
|
||||
_ = model.DB.Model(&model.Media{}).Count(&indexed).Error
|
||||
if indexed == 0 {
|
||||
_, _ = s.SyncMediaIndex()
|
||||
}
|
||||
|
||||
counts := map[string]int{
|
||||
UploadCategoryAvatars: 0,
|
||||
UploadCategoryPosts: 0,
|
||||
UploadCategorySite: 0,
|
||||
}
|
||||
type catCount struct {
|
||||
Category string
|
||||
Cnt int
|
||||
}
|
||||
var rows []catCount
|
||||
if err := model.DB.Model(&model.Media{}).
|
||||
Select("category, count(*) as cnt").
|
||||
Group("category").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
counts[r.Category] = r.Cnt
|
||||
}
|
||||
|
||||
dbq := model.DB.Model(&model.Media{})
|
||||
if category != "all" {
|
||||
dbq = dbq.Where("category = ?", category)
|
||||
}
|
||||
if query != "" {
|
||||
like := "%" + query + "%"
|
||||
dbq = dbq.Where("name LIKE ? OR url LIKE ?", like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := dbq.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = int((total + int64(size) - 1) / int64(size))
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
var records []model.Media
|
||||
offset := (page - 1) * size
|
||||
if err := dbq.Order("created_at desc, id desc").Offset(offset).Limit(size).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]MediaItem, 0, len(records))
|
||||
for _, r := range records {
|
||||
mod := r.UpdatedAt
|
||||
if mod.IsZero() {
|
||||
mod = r.CreatedAt
|
||||
}
|
||||
files = append(files, MediaItem{
|
||||
Category: r.Category,
|
||||
Name: r.Name,
|
||||
URL: r.URL,
|
||||
Size: r.Size,
|
||||
ModifiedAt: mod.UTC(),
|
||||
ContentType: r.ContentType,
|
||||
StorageType: r.StorageType,
|
||||
})
|
||||
}
|
||||
|
||||
mode, _, _, _ := s.snapshot()
|
||||
storageType := config.StorageTypeLocal
|
||||
if mode == config.StorageTypeS3 {
|
||||
storageType = config.StorageTypeS3
|
||||
}
|
||||
|
||||
return &MediaListResult{
|
||||
Files: files,
|
||||
Total: int(total),
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
StorageType: storageType,
|
||||
CategoryCounts: counts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteMedia 按 URL 批量删除媒体(含伴生扩展名与数据库索引)
|
||||
func (s *UploadStore) DeleteMedia(urls []string) (int, error) {
|
||||
if s == nil {
|
||||
return 0, errors.New("上传存储未初始化")
|
||||
}
|
||||
n := 0
|
||||
seen := map[string]bool{}
|
||||
for _, u := range urls {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
if !s.isManagedMediaURL(u) {
|
||||
continue
|
||||
}
|
||||
s.DeleteByURL(u)
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数
|
||||
func (s *UploadStore) SyncMediaIndex() (int, error) {
|
||||
if s == nil || model.DB == nil {
|
||||
return 0, errors.New("存储或数据库未初始化")
|
||||
}
|
||||
mode, _, _, _ := s.snapshot()
|
||||
storageType := config.StorageTypeLocal
|
||||
var items []MediaItem
|
||||
var err error
|
||||
if mode == config.StorageTypeS3 {
|
||||
storageType = config.StorageTypeS3
|
||||
items, err = s.listMediaS3("all")
|
||||
} else {
|
||||
items, err = s.listMediaLocal("all")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
n := 0
|
||||
for _, it := range items {
|
||||
seen[it.URL] = struct{}{}
|
||||
if err := s.upsertMediaRecord(it.Category, it.Name, it.URL, it.Size, it.ContentType, storageType, nil); err != nil {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
|
||||
// 清理当前后端下已不存在的索引(其它后端记录保留)
|
||||
var stale []model.Media
|
||||
_ = model.DB.Where("storage_type = ?", storageType).Find(&stale).Error
|
||||
for _, row := range stale {
|
||||
if _, ok := seen[row.URL]; ok {
|
||||
continue
|
||||
}
|
||||
_ = model.DB.Delete(&model.Media{}, row.ID).Error
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64, contentType, storageType string, userID *uint) error {
|
||||
if model.DB == nil || strings.TrimSpace(url) == "" {
|
||||
return nil
|
||||
}
|
||||
category = strings.TrimSpace(category)
|
||||
name = strings.TrimSpace(name)
|
||||
url = strings.TrimSpace(url)
|
||||
if storageType == "" {
|
||||
storageType = config.StorageTypeLocal
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = imageContentType(strings.ToLower(filepath.Ext(name)))
|
||||
}
|
||||
|
||||
var existing model.Media
|
||||
err := model.DB.Where("url = ?", url).First(&existing).Error
|
||||
if err == nil {
|
||||
updates := map[string]interface{}{
|
||||
"category": category,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"content_type": contentType,
|
||||
"storage_type": storageType,
|
||||
}
|
||||
if userID != nil {
|
||||
updates["user_id"] = *userID
|
||||
}
|
||||
return model.DB.Model(&existing).Updates(updates).Error
|
||||
}
|
||||
|
||||
rec := model.Media{
|
||||
Category: category,
|
||||
Name: name,
|
||||
URL: url,
|
||||
Size: size,
|
||||
ContentType: contentType,
|
||||
StorageType: storageType,
|
||||
UserID: userID,
|
||||
}
|
||||
return model.DB.Create(&rec).Error
|
||||
}
|
||||
|
||||
func (s *UploadStore) deleteMediaRecords(urls []string) {
|
||||
if model.DB == nil || len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
clean := make([]string, 0, len(urls))
|
||||
seen := map[string]bool{}
|
||||
for _, u := range urls {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
clean = append(clean, u)
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return
|
||||
}
|
||||
_ = model.DB.Where("url IN ?", clean).Delete(&model.Media{}).Error
|
||||
}
|
||||
|
||||
func (s *UploadStore) resolveSiblingPublicURLs(rawURL string) []string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil
|
||||
}
|
||||
var rel string
|
||||
var basePrefix string // 拼回公开 URL 的前缀(含 category 前的部分)
|
||||
|
||||
if strings.HasPrefix(rawURL, "/uploads/") {
|
||||
path := rawURL
|
||||
if i := strings.Index(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
rel = strings.TrimPrefix(path, "/uploads/")
|
||||
basePrefix = "/uploads/"
|
||||
} else {
|
||||
_, publicBase, _, _ := s.snapshot()
|
||||
r, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||||
if !ok {
|
||||
return []string{rawURL}
|
||||
}
|
||||
rel = r
|
||||
basePrefix = strings.TrimRight(publicBase, "/") + "/"
|
||||
}
|
||||
|
||||
siblings := uploadSiblingRels(rel)
|
||||
if len(siblings) == 0 {
|
||||
return []string{rawURL}
|
||||
}
|
||||
out := make([]string, 0, len(siblings))
|
||||
for _, sib := range siblings {
|
||||
out = append(out, basePrefix+sib)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUploaderID(category, namePrefix string) *uint {
|
||||
if category != UploadCategoryAvatars && category != UploadCategoryPosts {
|
||||
return nil
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(namePrefix), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil
|
||||
}
|
||||
u := uint(id)
|
||||
return &u
|
||||
}
|
||||
|
||||
func (s *UploadStore) isManagedMediaURL(rawURL string) bool {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/uploads/") {
|
||||
rel := strings.TrimPrefix(rawURL, "/uploads/")
|
||||
cat, _, ok := splitCategoryName(rel)
|
||||
return ok && validMediaCategory(cat)
|
||||
}
|
||||
_, publicBase, _, backend := s.snapshot()
|
||||
if backend == nil || publicBase == "" {
|
||||
return false
|
||||
}
|
||||
rel, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cat, _, ok := splitCategoryName(rel)
|
||||
return ok && validMediaCategory(cat)
|
||||
}
|
||||
|
||||
func (s *UploadStore) listMediaLocal(category string) ([]MediaItem, error) {
|
||||
cats := mediaCategories
|
||||
if category != "all" {
|
||||
cats = []string{category}
|
||||
}
|
||||
var out []MediaItem
|
||||
root := s.UploadsRoot()
|
||||
for _, cat := range cats {
|
||||
dir := filepath.Join(root, cat)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedImageExt[ext] {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, MediaItem{
|
||||
Category: cat,
|
||||
Name: name,
|
||||
URL: "/uploads/" + cat + "/" + name,
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC(),
|
||||
ContentType: imageContentType(ext),
|
||||
StorageType: config.StorageTypeLocal,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *UploadStore) listMediaS3(category string) ([]MediaItem, error) {
|
||||
_, publicBase, keyPrefix, backend := s.snapshot()
|
||||
if backend == nil {
|
||||
return nil, errors.New("对象存储未就绪")
|
||||
}
|
||||
cats := mediaCategories
|
||||
if category != "all" {
|
||||
cats = []string{category}
|
||||
}
|
||||
var out []MediaItem
|
||||
ctx := context.Background()
|
||||
for _, cat := range cats {
|
||||
prefix := keyPrefix + cat + "/"
|
||||
for obj := range backend.client.ListObjects(ctx, backend.bucket, minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
}) {
|
||||
if obj.Err != nil {
|
||||
return nil, obj.Err
|
||||
}
|
||||
if strings.HasSuffix(obj.Key, "/") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(obj.Key, prefix)
|
||||
if name == "" || strings.Contains(name, "/") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedImageExt[ext] {
|
||||
continue
|
||||
}
|
||||
out = append(out, MediaItem{
|
||||
Category: cat,
|
||||
Name: name,
|
||||
URL: publicBase + "/" + cat + "/" + name,
|
||||
Size: obj.Size,
|
||||
ModifiedAt: obj.LastModified.UTC(),
|
||||
ContentType: imageContentType(ext),
|
||||
StorageType: config.StorageTypeS3,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validMediaCategory(cat string) bool {
|
||||
switch cat {
|
||||
case UploadCategoryAvatars, UploadCategoryPosts, UploadCategorySite:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func splitCategoryName(rel string) (category, name string, ok bool) {
|
||||
rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/"))
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
parts := strings.SplitN(rel, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.Contains(parts[1], "/") {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
354
service/message.go
Normal file
354
service/message.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCannotMessageSelf = errors.New("不能给自己发私信")
|
||||
)
|
||||
|
||||
type MessageService struct {
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewMessageService(filter *SensitiveFilter, settings *ForumSettingsService) *MessageService {
|
||||
return &MessageService{filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
type MessageSendInput struct {
|
||||
FromUserID uint
|
||||
ToUserID uint
|
||||
Subject string
|
||||
Content string
|
||||
Kind string
|
||||
RelatedPostID *uint
|
||||
RelatedReportID *uint
|
||||
}
|
||||
|
||||
// Send 发送私信(用户互发或系统通知)
|
||||
func (s *MessageService) Send(in MessageSendInput) (*model.PrivateMessage, error) {
|
||||
if in.ToUserID == 0 {
|
||||
return nil, errors.New("收件人不存在")
|
||||
}
|
||||
if in.FromUserID > 0 && in.FromUserID == in.ToUserID {
|
||||
return nil, ErrCannotMessageSelf
|
||||
}
|
||||
if in.FromUserID > 0 {
|
||||
var to model.User
|
||||
if err := model.DB.Select("id", "banned").First(&to, in.ToUserID).Error; err != nil {
|
||||
return nil, errors.New("收件人不存在")
|
||||
}
|
||||
if to.Banned {
|
||||
return nil, errors.New("对方账号已被禁言,暂时无法私信")
|
||||
}
|
||||
}
|
||||
|
||||
subject := strings.TrimSpace(in.Subject)
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if content == "" {
|
||||
return nil, errors.New("请填写内容")
|
||||
}
|
||||
// 会话式私信可不填标题,用正文摘要兜底
|
||||
if subject == "" {
|
||||
subject = truncateRunes(content, 40)
|
||||
}
|
||||
if utf8.RuneCountInString(subject) > 80 {
|
||||
return nil, errors.New("标题过长")
|
||||
}
|
||||
if utf8.RuneCountInString(content) > 4000 {
|
||||
return nil, errors.New("内容过长")
|
||||
}
|
||||
|
||||
if s.filter != nil {
|
||||
subject = s.filter.Filter(subject)
|
||||
content = s.filter.Filter(content)
|
||||
}
|
||||
|
||||
kind := in.Kind
|
||||
if kind == "" {
|
||||
if in.FromUserID == 0 {
|
||||
kind = model.MessageKindSystem
|
||||
} else {
|
||||
kind = model.MessageKindUser
|
||||
}
|
||||
}
|
||||
|
||||
msg := &model.PrivateMessage{
|
||||
FromUserID: in.FromUserID,
|
||||
ToUserID: in.ToUserID,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Kind: kind,
|
||||
RelatedPostID: in.RelatedPostID,
|
||||
RelatedReportID: in.RelatedReportID,
|
||||
IsRead: false,
|
||||
}
|
||||
if err := model.DB.Create(msg).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = model.DB.Preload("FromUser").Preload("ToUser").First(msg, msg.ID).Error
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SendSystem 系统私信(管理员/系统 → 用户)
|
||||
func (s *MessageService) SendSystem(toUserID uint, subject, content, kind string, relatedPostID, relatedReportID *uint) (*model.PrivateMessage, error) {
|
||||
if kind == "" {
|
||||
kind = model.MessageKindSystem
|
||||
}
|
||||
return s.Send(MessageSendInput{
|
||||
FromUserID: 0,
|
||||
ToUserID: toUserID,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Kind: kind,
|
||||
RelatedPostID: relatedPostID,
|
||||
RelatedReportID: relatedReportID,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkAllRead 全部标为已读
|
||||
func (s *MessageService) MarkAllRead(userID uint) error {
|
||||
return model.DB.Model(&model.PrivateMessage{}).
|
||||
Where("to_user_id = ? AND is_read = ?", userID, false).
|
||||
Update("is_read", true).Error
|
||||
}
|
||||
|
||||
// UnreadCount 未读数
|
||||
func (s *MessageService) UnreadCount(userID uint) (int64, error) {
|
||||
var n int64
|
||||
err := model.DB.Model(&model.PrivateMessage{}).
|
||||
Where("to_user_id = ? AND is_read = ?", userID, false).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// MessageConversation 按对方聚合的会话摘要
|
||||
type MessageConversation struct {
|
||||
PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知
|
||||
PeerUser *model.User `json:"peer_user,omitempty"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
LastMessage *model.PrivateMessage `json:"last_message,omitempty"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConversationListQuery struct {
|
||||
UserID uint
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type ConversationMessagesQuery struct {
|
||||
UserID uint
|
||||
PeerID uint // 0 = 系统通知
|
||||
Page int
|
||||
Size int
|
||||
Before uint // 可选:加载更早消息(id < Before)
|
||||
}
|
||||
|
||||
// ListConversations 会话列表(按对方聚合,最近消息优先)
|
||||
func (s *MessageService) ListConversations(q ConversationListQuery) ([]MessageConversation, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
q.Size = s.settings.NormalizePageSize(q.Size)
|
||||
|
||||
type peerRow struct {
|
||||
PeerID uint
|
||||
LastID uint
|
||||
}
|
||||
var rows []peerRow
|
||||
// peer_id:系统通知为 0;否则为对话另一方
|
||||
err := model.DB.Raw(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN from_user_id = 0 THEN 0
|
||||
WHEN from_user_id = ? THEN to_user_id
|
||||
ELSE from_user_id
|
||||
END AS peer_id,
|
||||
MAX(id) AS last_id
|
||||
FROM private_messages
|
||||
WHERE to_user_id = ? OR from_user_id = ?
|
||||
GROUP BY peer_id
|
||||
ORDER BY last_id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, q.UserID, q.UserID, q.UserID, q.Size, (q.Page-1)*q.Size).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
err = model.DB.Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN from_user_id = 0 THEN 0
|
||||
WHEN from_user_id = ? THEN to_user_id
|
||||
ELSE from_user_id
|
||||
END AS peer_id
|
||||
FROM private_messages
|
||||
WHERE to_user_id = ? OR from_user_id = ?
|
||||
GROUP BY peer_id
|
||||
)
|
||||
`, q.UserID, q.UserID, q.UserID).Scan(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []MessageConversation{}, total, nil
|
||||
}
|
||||
|
||||
lastIDs := make([]uint, len(rows))
|
||||
peerIDs := make([]uint, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
lastIDs[i] = r.LastID
|
||||
if r.PeerID > 0 {
|
||||
peerIDs = append(peerIDs, r.PeerID)
|
||||
}
|
||||
}
|
||||
|
||||
var lastMsgs []model.PrivateMessage
|
||||
if err := model.DB.Preload("FromUser").Preload("ToUser").
|
||||
Where("id IN ?", lastIDs).Find(&lastMsgs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
msgByID := make(map[uint]model.PrivateMessage, len(lastMsgs))
|
||||
for i := range lastMsgs {
|
||||
msgByID[lastMsgs[i].ID] = lastMsgs[i]
|
||||
}
|
||||
|
||||
usersByID := make(map[uint]model.User)
|
||||
if len(peerIDs) > 0 {
|
||||
var users []model.User
|
||||
if err := model.DB.Where("id IN ?", peerIDs).Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range users {
|
||||
usersByID[users[i].ID] = users[i]
|
||||
}
|
||||
}
|
||||
|
||||
type unreadRow struct {
|
||||
PeerID uint
|
||||
Cnt int64
|
||||
}
|
||||
var unreadRows []unreadRow
|
||||
_ = model.DB.Raw(`
|
||||
SELECT
|
||||
CASE WHEN from_user_id = 0 THEN 0 ELSE from_user_id END AS peer_id,
|
||||
COUNT(*) AS cnt
|
||||
FROM private_messages
|
||||
WHERE to_user_id = ? AND is_read = 0
|
||||
GROUP BY peer_id
|
||||
`, q.UserID).Scan(&unreadRows)
|
||||
unreadByPeer := make(map[uint]int64, len(unreadRows))
|
||||
for _, u := range unreadRows {
|
||||
unreadByPeer[u.PeerID] = u.Cnt
|
||||
}
|
||||
|
||||
out := make([]MessageConversation, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
msg, ok := msgByID[r.LastID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
conv := MessageConversation{
|
||||
PeerUserID: r.PeerID,
|
||||
IsSystem: r.PeerID == 0,
|
||||
LastMessage: &msg,
|
||||
UnreadCount: unreadByPeer[r.PeerID],
|
||||
UpdatedAt: msg.CreatedAt,
|
||||
}
|
||||
if r.PeerID > 0 {
|
||||
if u, ok := usersByID[r.PeerID]; ok {
|
||||
uu := u
|
||||
conv.PeerUser = &uu
|
||||
}
|
||||
}
|
||||
out = append(out, conv)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// ListConversationMessages 某会话内消息(时间正序,支持 Before 向上翻页)
|
||||
func (s *MessageService) ListConversationMessages(q ConversationMessagesQuery) ([]model.PrivateMessage, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
q.Size = s.settings.NormalizePageSize(q.Size)
|
||||
|
||||
countDB := model.DB.Model(&model.PrivateMessage{})
|
||||
if q.PeerID == 0 {
|
||||
countDB = countDB.Where("from_user_id = 0 AND to_user_id = ?", q.UserID)
|
||||
} else {
|
||||
countDB = countDB.Where(
|
||||
"(from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)",
|
||||
q.UserID, q.PeerID, q.PeerID, q.UserID,
|
||||
)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
qdb := model.DB.Preload("FromUser").Preload("ToUser")
|
||||
if q.PeerID == 0 {
|
||||
qdb = qdb.Where("from_user_id = 0 AND to_user_id = ?", q.UserID)
|
||||
} else {
|
||||
qdb = qdb.Where(
|
||||
"(from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)",
|
||||
q.UserID, q.PeerID, q.PeerID, q.UserID,
|
||||
)
|
||||
}
|
||||
if q.Before > 0 {
|
||||
qdb = qdb.Where("id < ?", q.Before)
|
||||
}
|
||||
|
||||
var list []model.PrivateMessage
|
||||
// 先按 id desc 取一页,再反转为正序(聊天从旧到新)
|
||||
err := qdb.Order("id desc").Limit(q.Size).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 {
|
||||
list[i], list[j] = list[j], list[i]
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// MarkConversationRead 将会话内未读标为已读
|
||||
func (s *MessageService) MarkConversationRead(userID, peerID uint) error {
|
||||
db := model.DB.Model(&model.PrivateMessage{}).
|
||||
Where("to_user_id = ? AND is_read = ?", userID, false)
|
||||
if peerID == 0 {
|
||||
db = db.Where("from_user_id = 0")
|
||||
} else {
|
||||
db = db.Where("from_user_id = ?", peerID)
|
||||
}
|
||||
return db.Update("is_read", true).Error
|
||||
}
|
||||
|
||||
// FormatRejectContent 拒帖私信正文
|
||||
func FormatRejectContent(postTitle string, postID uint, reason string) string {
|
||||
return fmt.Sprintf(
|
||||
"你的帖子《%s》(#%d)未通过审核。\n\n原因:\n%s\n\n如有疑问,可回复本私信联系管理员。",
|
||||
postTitle, postID, strings.TrimSpace(reason),
|
||||
)
|
||||
}
|
||||
|
||||
// FormatCommentRejectContent 拒评论私信正文
|
||||
func FormatCommentRejectContent(postTitle string, postID uint, floor int, reason string) string {
|
||||
return fmt.Sprintf(
|
||||
"你在帖子《%s》(#%d)中的评论(#%d 楼)未通过审核。\n\n原因:\n%s\n\n如有疑问,可回复本私信联系管理员。",
|
||||
postTitle, postID, floor, strings.TrimSpace(reason),
|
||||
)
|
||||
}
|
||||
@@ -165,24 +165,12 @@ func FindEnabledOAuthClient(clientID string) (*model.OAuthClient, error) {
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// VerifyOAuthClientSecret 校验客户端密钥(支持 bcrypt;兼容尚未哈希的历史明文)
|
||||
// VerifyOAuthClientSecret 校验客户端密钥(bcrypt 哈希)
|
||||
func VerifyOAuthClientSecret(row *model.OAuthClient, secret string) bool {
|
||||
if row == nil || secret == "" || row.ClientSecretHash == "" {
|
||||
return false
|
||||
}
|
||||
hash := row.ClientSecretHash
|
||||
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
|
||||
return CheckPassword(hash, secret)
|
||||
}
|
||||
// 遗留明文:校验通过后就地升级为哈希
|
||||
if hash == secret {
|
||||
if newHash, err := HashPassword(secret); err == nil {
|
||||
_ = model.DB.Model(row).Update("client_secret_hash", newHash).Error
|
||||
row.ClientSecretHash = newHash
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return CheckPassword(row.ClientSecretHash, secret)
|
||||
}
|
||||
|
||||
func toOAuthClientView(row model.OAuthClient, plainSecret string) OAuthClientView {
|
||||
@@ -213,41 +201,3 @@ func CountEnabledOAuthClients() int64 {
|
||||
model.DB.Model(&model.OAuthClient{}).Where("enabled = ?", true).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// MigrateLegacyOIDCClient 将旧版 ForumSetting 单客户端迁入 oauth_clients(仅一次)
|
||||
func (s *ForumSettingsService) MigrateLegacyOIDCClient() {
|
||||
if CountEnabledOAuthClients() > 0 {
|
||||
// 仍清理遗留明文密钥字段
|
||||
s.clearLegacyOAuthSecrets()
|
||||
return
|
||||
}
|
||||
clientID := strings.TrimSpace(s.getString(SettingOAuthClientID, ""))
|
||||
secret := s.getString(SettingOAuthClientSecret, "")
|
||||
uris := normalizeRedirectURIs(s.getString(SettingOAuthRedirectURIs, ""))
|
||||
if clientID == "" || secret == "" || uris == "" {
|
||||
return
|
||||
}
|
||||
hash := secret
|
||||
if !(strings.HasPrefix(secret, "$2a$") || strings.HasPrefix(secret, "$2b$") || strings.HasPrefix(secret, "$2y$")) {
|
||||
h, err := HashPassword(secret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hash = h
|
||||
}
|
||||
_ = model.DB.Create(&model.OAuthClient{
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: hash,
|
||||
Name: "Gitea",
|
||||
RedirectURIs: uris,
|
||||
Enabled: true,
|
||||
}).Error
|
||||
s.clearLegacyOAuthSecrets()
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) clearLegacyOAuthSecrets() {
|
||||
// 清空遗留明文,避免双源配置
|
||||
if s.getString(SettingOAuthClientSecret, "") != "" {
|
||||
_ = s.setString(SettingOAuthClientSecret, "")
|
||||
}
|
||||
}
|
||||
|
||||
140
service/permalink.go
Normal file
140
service/permalink.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingPermalinkEnabled = "permalink_enabled"
|
||||
SettingPermalinkExt = "permalink_ext"
|
||||
DefaultPermalinkExt = "html"
|
||||
)
|
||||
|
||||
var (
|
||||
permalinkExtRe = regexp.MustCompile(`(?i)^[a-z0-9]{1,16}$`)
|
||||
// /post/123 或 /post/123.html
|
||||
postPermalinkRe = regexp.MustCompile(`^/post/(\d+)(?:\.([A-Za-z0-9]{1,16}))?/?$`)
|
||||
userPermalinkRe = regexp.MustCompile(`^/user/(\d+)(?:\.([A-Za-z0-9]{1,16}))?/?$`)
|
||||
)
|
||||
|
||||
// PermalinkConfig 伪静态(固定链接)配置
|
||||
type PermalinkConfig struct {
|
||||
Enabled bool `json:"permalink_enabled"`
|
||||
Ext string `json:"permalink_ext"` // 不含点,如 html / htm
|
||||
}
|
||||
|
||||
// NormalizePermalinkExt 规范化后缀:去点、小写、仅字母数字
|
||||
func NormalizePermalinkExt(raw string) (string, bool) {
|
||||
ext := strings.TrimSpace(raw)
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
ext = strings.ToLower(ext)
|
||||
if ext == "" {
|
||||
ext = DefaultPermalinkExt
|
||||
}
|
||||
if !permalinkExtRe.MatchString(ext) {
|
||||
return "", false
|
||||
}
|
||||
return ext, true
|
||||
}
|
||||
|
||||
// Permalink 读取伪静态配置
|
||||
func (s *ForumSettingsService) Permalink() PermalinkConfig {
|
||||
ext, ok := NormalizePermalinkExt(s.getString(SettingPermalinkExt, DefaultPermalinkExt))
|
||||
if !ok {
|
||||
ext = DefaultPermalinkExt
|
||||
}
|
||||
return PermalinkConfig{
|
||||
Enabled: s.getString(SettingPermalinkEnabled, "0") == "1",
|
||||
Ext: ext,
|
||||
}
|
||||
}
|
||||
|
||||
// Suffix 返回带点后缀(未启用时为空)
|
||||
func (p PermalinkConfig) Suffix() string {
|
||||
if !p.Enabled {
|
||||
return ""
|
||||
}
|
||||
ext, ok := NormalizePermalinkExt(p.Ext)
|
||||
if !ok {
|
||||
ext = DefaultPermalinkExt
|
||||
}
|
||||
return "." + ext
|
||||
}
|
||||
|
||||
// PostPath 帖子规范路径
|
||||
func (p PermalinkConfig) PostPath(id uint) string {
|
||||
return fmt.Sprintf("/post/%d%s", id, p.Suffix())
|
||||
}
|
||||
|
||||
// UserPath 用户规范路径
|
||||
func (p PermalinkConfig) UserPath(id uint) string {
|
||||
return fmt.Sprintf("/user/%d%s", id, p.Suffix())
|
||||
}
|
||||
|
||||
// PermalinkMatch 路径解析结果
|
||||
type PermalinkMatch struct {
|
||||
ID uint
|
||||
Ext string // 请求里的后缀(无点);无后缀为空
|
||||
Canonical string // 当前配置下的规范路径
|
||||
OK bool
|
||||
}
|
||||
|
||||
// MatchPostPath 解析帖子公开路径(不含 /edit)
|
||||
func (p PermalinkConfig) MatchPostPath(path string) PermalinkMatch {
|
||||
m := postPermalinkRe.FindStringSubmatch(path)
|
||||
if len(m) < 2 {
|
||||
return PermalinkMatch{}
|
||||
}
|
||||
id64, err := strconv.ParseUint(m[1], 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
return PermalinkMatch{}
|
||||
}
|
||||
ext := ""
|
||||
if len(m) > 2 {
|
||||
ext = strings.ToLower(m[2])
|
||||
}
|
||||
id := uint(id64)
|
||||
return PermalinkMatch{
|
||||
ID: id,
|
||||
Ext: ext,
|
||||
Canonical: p.PostPath(id),
|
||||
OK: true,
|
||||
}
|
||||
}
|
||||
|
||||
// MatchUserPath 解析用户公开路径
|
||||
func (p PermalinkConfig) MatchUserPath(path string) PermalinkMatch {
|
||||
m := userPermalinkRe.FindStringSubmatch(path)
|
||||
if len(m) < 2 {
|
||||
return PermalinkMatch{}
|
||||
}
|
||||
id64, err := strconv.ParseUint(m[1], 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
return PermalinkMatch{}
|
||||
}
|
||||
ext := ""
|
||||
if len(m) > 2 {
|
||||
ext = strings.ToLower(m[2])
|
||||
}
|
||||
id := uint(id64)
|
||||
return PermalinkMatch{
|
||||
ID: id,
|
||||
Ext: ext,
|
||||
Canonical: p.UserPath(id),
|
||||
OK: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsCanonicalRedirect 当前请求路径是否应 301 到规范 URL
|
||||
func (m PermalinkMatch) NeedsCanonicalRedirect(requestPath string) bool {
|
||||
if !m.OK {
|
||||
return false
|
||||
}
|
||||
// 去掉末尾 / 再比
|
||||
req := strings.TrimSuffix(requestPath, "/")
|
||||
can := strings.TrimSuffix(m.Canonical, "/")
|
||||
return req != can
|
||||
}
|
||||
287
service/post.go
287
service/post.go
@@ -20,12 +20,15 @@ func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *Po
|
||||
}
|
||||
|
||||
type PostListQuery struct {
|
||||
BoardID uint
|
||||
UserID uint // >0 时仅返回该用户的帖子
|
||||
Page int
|
||||
Size int
|
||||
Keyword string
|
||||
Sort string // latest | reply | hot
|
||||
BoardID uint
|
||||
UserID uint // >0 时仅返回该用户的帖子
|
||||
Page int
|
||||
Size int
|
||||
Keyword string
|
||||
Sort string // latest | reply | hot
|
||||
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
||||
ViewerIsAdmin bool
|
||||
Status string // 管理端筛选:pending|published|rejected|all;空则按可见性规则
|
||||
}
|
||||
|
||||
// PostListItem 帖子列表项(含评论数等扩展字段)
|
||||
@@ -67,7 +70,8 @@ func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
|
||||
}
|
||||
var rows []row
|
||||
model.DB.Model(&model.Comment{}).Select("post_id, count(*) as count").
|
||||
Where("post_id IN ?", postIDs).Group("post_id").Scan(&rows)
|
||||
Where("post_id IN ? AND status = ?", postIDs, model.ContentStatusPublished).
|
||||
Group("post_id").Scan(&rows)
|
||||
m := make(map[uint]int)
|
||||
for _, r := range rows {
|
||||
m[r.PostID] = r.Count
|
||||
@@ -83,7 +87,7 @@ func (s *PostService) lastReplyMap(postIDs []uint) map[uint]*time.Time {
|
||||
var rows []row
|
||||
model.DB.Model(&model.Comment{}).
|
||||
Select("post_id, MAX(created_at) as last_reply").
|
||||
Where("post_id IN ?", postIDs).
|
||||
Where("post_id IN ? AND status = ?", postIDs, model.ContentStatusPublished).
|
||||
Group("post_id").
|
||||
Scan(&rows)
|
||||
m := make(map[uint]*time.Time, len(rows))
|
||||
@@ -119,6 +123,7 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
||||
}
|
||||
var posts []model.Post
|
||||
err := model.DB.Preload("User").Preload("Board").
|
||||
Where("status = ?", model.ContentStatusPublished).
|
||||
Order("like_count desc, view_count desc").Limit(limit).Find(&posts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -149,7 +154,7 @@ func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
|
||||
var rows []struct{ Tags string }
|
||||
if err := model.DB.Model(&model.Post{}).
|
||||
Select("tags").
|
||||
Where("tags <> '' AND tags IS NOT NULL").
|
||||
Where("status = ? AND tags <> '' AND tags IS NOT NULL", model.ContentStatusPublished).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,10 +196,48 @@ func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
|
||||
|
||||
func (s *PostService) CommentCount(postID uint) int {
|
||||
var count int64
|
||||
model.DB.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&count)
|
||||
model.DB.Model(&model.Comment{}).
|
||||
Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
|
||||
Count(&count)
|
||||
return int(count)
|
||||
}
|
||||
|
||||
// CanViewPost 是否可查看该帖(pending/rejected 仅作者与管理员)
|
||||
func CanViewPost(post *model.Post, viewerID uint, isAdmin bool) bool {
|
||||
if post == nil {
|
||||
return false
|
||||
}
|
||||
if isAdmin || post.Status == model.ContentStatusPublished || post.Status == "" {
|
||||
return true
|
||||
}
|
||||
if post.Status == model.ContentStatusPending || post.Status == model.ContentStatusRejected {
|
||||
return viewerID > 0 && post.UserID == viewerID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyPostVisibility(db *gorm.DB, q PostListQuery) *gorm.DB {
|
||||
if q.ViewerIsAdmin {
|
||||
switch q.Status {
|
||||
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
|
||||
return db.Where("status = ?", q.Status)
|
||||
case "all", "":
|
||||
return db
|
||||
default:
|
||||
return db
|
||||
}
|
||||
}
|
||||
if q.ViewerID > 0 {
|
||||
return db.Where(
|
||||
"status = ? OR (status IN ? AND user_id = ?)",
|
||||
model.ContentStatusPublished,
|
||||
[]string{model.ContentStatusPending, model.ContentStatusRejected},
|
||||
q.ViewerID,
|
||||
)
|
||||
}
|
||||
return db.Where("status = ?", model.ContentStatusPublished)
|
||||
}
|
||||
|
||||
func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
@@ -208,6 +251,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
q.Keyword = kw
|
||||
}
|
||||
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board")
|
||||
db = applyPostVisibility(db, q)
|
||||
if q.BoardID > 0 {
|
||||
db = db.Where("board_id = ?", q.BoardID)
|
||||
}
|
||||
@@ -224,14 +268,16 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
db = db.Order("pinned desc")
|
||||
switch normalizePostSort(q.Sort) {
|
||||
case "reply":
|
||||
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底
|
||||
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论)
|
||||
db = db.Order(`(
|
||||
SELECT COUNT(*) FROM comments
|
||||
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
|
||||
AND comments.status = 'published'
|
||||
) > 0 DESC`)
|
||||
db = db.Order(`(
|
||||
SELECT MAX(created_at) FROM comments
|
||||
WHERE comments.post_id = posts.id AND comments.deleted_at IS NULL
|
||||
AND comments.status = 'published'
|
||||
) DESC`)
|
||||
db = db.Order("posts.created_at DESC")
|
||||
case "hot":
|
||||
@@ -275,7 +321,7 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *PostService) Create(userID, boardID uint, title, content, tags string) (*model.Post, error) {
|
||||
func (s *PostService) Create(userID, boardID uint, title, content, tags string, isAdmin bool) (*model.Post, error) {
|
||||
title = s.filter.Filter(strings.TrimSpace(title))
|
||||
content = s.filter.Filter(content)
|
||||
tags = s.filter.Filter(strings.TrimSpace(tags))
|
||||
@@ -294,6 +340,10 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags string)
|
||||
if _, err := NewBoardService().GetByID(boardID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := model.ContentStatusPending
|
||||
if isAdmin {
|
||||
status = model.ContentStatusPublished
|
||||
}
|
||||
post := &model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
@@ -301,11 +351,13 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags string)
|
||||
Content: content,
|
||||
ContentPlain: StripHTMLForSearch(content),
|
||||
Tags: tags,
|
||||
Status: status,
|
||||
}
|
||||
return post, model.DB.Create(post).Error
|
||||
}
|
||||
|
||||
func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags string) error {
|
||||
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
|
||||
func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags string, boardID uint) error {
|
||||
var post model.Post
|
||||
if err := model.DB.First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
@@ -328,6 +380,13 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
||||
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
|
||||
return err
|
||||
}
|
||||
nextBoardID := post.BoardID
|
||||
if boardID > 0 && boardID != post.BoardID {
|
||||
if _, err := NewBoardService().GetByID(boardID); err != nil {
|
||||
return err
|
||||
}
|
||||
nextBoardID = boardID
|
||||
}
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
rev := model.PostRevision{
|
||||
PostID: postID, EditorID: userID,
|
||||
@@ -336,15 +395,45 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
||||
if err := tx.Create(&rev).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&post).Updates(map[string]interface{}{
|
||||
updates := map[string]interface{}{
|
||||
"board_id": nextBoardID,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"content_plain": StripHTMLForSearch(content),
|
||||
"tags": tags,
|
||||
}).Error
|
||||
}
|
||||
// 普通用户修改后重新进入审核
|
||||
if !isAdmin {
|
||||
updates["status"] = model.ContentStatusPending
|
||||
}
|
||||
return tx.Model(&post).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
// SetStatus 设置帖子审核状态
|
||||
func (s *PostService) SetStatus(postID uint, status string) error {
|
||||
switch status {
|
||||
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
|
||||
default:
|
||||
return errors.New("无效的审核状态")
|
||||
}
|
||||
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingPostCount 待审帖数量
|
||||
func (s *PostService) PendingPostCount() (int64, error) {
|
||||
var n int64
|
||||
err := model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPending).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CanEdit 判断当前用户是否可编辑帖子
|
||||
func (s *PostService) CanEdit(post *model.Post, isAdmin bool) bool {
|
||||
return s.checkEditable(post, isAdmin) == nil
|
||||
@@ -428,32 +517,148 @@ func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, erro
|
||||
return &rev, nil
|
||||
}
|
||||
|
||||
// Delete 软删除帖子及其评论(进入回收站);点赞/收藏保留以便恢复。仅管理员可删。
|
||||
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
|
||||
if !isAdmin {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
var post model.Post
|
||||
if err := model.DB.First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
if !isAdmin && post.UserID != userID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("post_id = ?", postID).Delete(&model.Comment{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("post_id = ?", postID).Delete(&model.PostLike{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("post_id = ?", postID).Delete(&model.PostFavorite{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&post).Error
|
||||
})
|
||||
}
|
||||
|
||||
// TrashPostItem 回收站列表项
|
||||
type TrashPostItem struct {
|
||||
PostListItem
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// ListTrash 列出已软删帖子
|
||||
func (s *PostService) ListTrash(page, size int, keyword string) ([]TrashPostItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size = s.settings.NormalizePageSize(size)
|
||||
db := model.DB.Unscoped().Model(&model.Post{}).
|
||||
Where("deleted_at IS NOT NULL").
|
||||
Preload("User").Preload("Board")
|
||||
if keyword != "" {
|
||||
kw, err := s.settings.NormalizeSearchKeyword(keyword)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
like := "%" + kw + "%"
|
||||
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", like, like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var posts []model.Post
|
||||
if err := db.Order("deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return []TrashPostItem{}, total, nil
|
||||
}
|
||||
ids := make([]uint, len(posts))
|
||||
for i, p := range posts {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
// 评论已软删,统计需 Unscoped
|
||||
type row struct {
|
||||
PostID uint
|
||||
Cnt int
|
||||
}
|
||||
var rows []row
|
||||
_ = model.DB.Unscoped().Model(&model.Comment{}).
|
||||
Select("post_id, COUNT(*) as cnt").
|
||||
Where("post_id IN ?", ids).
|
||||
Group("post_id").Scan(&rows)
|
||||
countMap := make(map[uint]int, len(rows))
|
||||
for _, r := range rows {
|
||||
countMap[r.PostID] = r.Cnt
|
||||
}
|
||||
out := make([]TrashPostItem, len(posts))
|
||||
for i, p := range posts {
|
||||
item := PostListItem{Post: p, CommentCount: countMap[p.ID]}
|
||||
out[i] = TrashPostItem{PostListItem: item}
|
||||
if p.DeletedAt.Valid {
|
||||
out[i].DeletedAt = p.DeletedAt.Time
|
||||
}
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// Restore 从回收站恢复帖子及评论
|
||||
func (s *PostService) Restore(postID uint) error {
|
||||
var post model.Post
|
||||
if err := model.DB.Unscoped().First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
if !post.DeletedAt.Valid {
|
||||
return errors.New("帖子未被删除")
|
||||
}
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Unscoped().Model(&model.Comment{}).
|
||||
Where("post_id = ? AND deleted_at IS NOT NULL", postID).
|
||||
Update("deleted_at", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Model(&post).Update("deleted_at", nil).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Purge 永久删除回收站中的帖子(含评论、点赞、收藏、修订)
|
||||
func (s *PostService) Purge(postID uint) error {
|
||||
var post model.Post
|
||||
if err := model.DB.Unscoped().First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
if !post.DeletedAt.Valid {
|
||||
return errors.New("仅可彻底删除回收站中的帖子,请先删除帖子")
|
||||
}
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var commentIDs []uint
|
||||
if err := tx.Unscoped().Model(&model.Comment{}).Where("post_id = ?", postID).Pluck("id", &commentIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(commentIDs) > 0 {
|
||||
if err := tx.Where("comment_id IN ?", commentIDs).Delete(&model.CommentRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&model.Comment{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&model.PostLike{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("post_id = ?", postID).Delete(&model.PostFavorite{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("post_id = ?", postID).Delete(&model.PostRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Delete(&post).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PostService) SetPinned(postID uint, pinned bool) error {
|
||||
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
|
||||
}
|
||||
|
||||
func (s *PostService) SetFeatured(postID uint, featured bool) error {
|
||||
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("featured", featured).Error
|
||||
}
|
||||
|
||||
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
|
||||
var like model.PostLike
|
||||
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
|
||||
@@ -511,11 +716,41 @@ func (s *PostService) ListFavorites(userID uint, page, size int) ([]model.PostFa
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
// 仅统计可查看的收藏(已公开,或本人未公开帖)
|
||||
base := model.DB.Model(&model.PostFavorite{}).
|
||||
Joins("JOIN posts ON posts.id = post_favorites.post_id AND posts.deleted_at IS NULL").
|
||||
Where("post_favorites.user_id = ?", userID).
|
||||
Where("posts.status = ? OR posts.user_id = ?", model.ContentStatusPublished, userID)
|
||||
var total int64
|
||||
model.DB.Model(&model.PostFavorite{}).Where("user_id = ?", userID).Count(&total)
|
||||
base.Count(&total)
|
||||
var favs []model.PostFavorite
|
||||
err := model.DB.Preload("Post.User").Preload("Post.Board").
|
||||
Where("user_id = ?", userID).Order("id desc").
|
||||
Joins("JOIN posts ON posts.id = post_favorites.post_id AND posts.deleted_at IS NULL").
|
||||
Where("post_favorites.user_id = ?", userID).
|
||||
Where("posts.status = ? OR posts.user_id = ?", model.ContentStatusPublished, userID).
|
||||
Order("post_favorites.id desc").
|
||||
Offset((page - 1) * size).Limit(size).Find(&favs).Error
|
||||
return favs, total, err
|
||||
}
|
||||
|
||||
// SitemapPost 站点地图用的轻量帖子字段
|
||||
type SitemapPost struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ListSitemap 按更新时间倒序列出帖子(供 sitemap)
|
||||
func (s *PostService) ListSitemap(limit int) ([]SitemapPost, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5000
|
||||
}
|
||||
var rows []SitemapPost
|
||||
err := model.DB.Model(&model.Post{}).
|
||||
Select("id, created_at, updated_at").
|
||||
Where("status = ?", model.ContentStatusPublished).
|
||||
Order("updated_at desc, id desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
263
service/report.go
Normal file
263
service/report.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReportNotFound = errors.New("举报不存在")
|
||||
ErrReportAlreadyExists = errors.New("你已举报过该帖子,请等待处理")
|
||||
ErrCannotReportOwnPost = errors.New("不能举报自己的帖子")
|
||||
)
|
||||
|
||||
type ReportService struct {
|
||||
filter *SensitiveFilter
|
||||
settings *ForumSettingsService
|
||||
messages *MessageService
|
||||
posts *PostService
|
||||
}
|
||||
|
||||
func NewReportService(
|
||||
filter *SensitiveFilter,
|
||||
settings *ForumSettingsService,
|
||||
messages *MessageService,
|
||||
posts *PostService,
|
||||
) *ReportService {
|
||||
return &ReportService{filter: filter, settings: settings, messages: messages, posts: posts}
|
||||
}
|
||||
|
||||
func normalizeReportReason(reason string) (string, error) {
|
||||
switch strings.TrimSpace(reason) {
|
||||
case model.ReportReasonSpam,
|
||||
model.ReportReasonAbuse,
|
||||
model.ReportReasonIllegal,
|
||||
model.ReportReasonIrrelevant,
|
||||
model.ReportReasonOther:
|
||||
return reason, nil
|
||||
default:
|
||||
return "", errors.New("请选择有效的举报原因")
|
||||
}
|
||||
}
|
||||
|
||||
func ReportReasonLabel(reason string) string {
|
||||
switch reason {
|
||||
case model.ReportReasonSpam:
|
||||
return "垃圾广告"
|
||||
case model.ReportReasonAbuse:
|
||||
return "人身攻击 / 辱骂"
|
||||
case model.ReportReasonIllegal:
|
||||
return "违法违规"
|
||||
case model.ReportReasonIrrelevant:
|
||||
return "内容无关 / 灌水"
|
||||
case model.ReportReasonOther:
|
||||
return "其他"
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
// Create 用户举报帖子
|
||||
func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (*model.PostReport, error) {
|
||||
reason, err := normalizeReportReason(reason)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail = strings.TrimSpace(detail)
|
||||
if utf8.RuneCountInString(detail) > 500 {
|
||||
return nil, errors.New("补充说明过长")
|
||||
}
|
||||
if s.filter != nil && detail != "" {
|
||||
detail = s.filter.Filter(detail)
|
||||
}
|
||||
|
||||
var post model.Post
|
||||
if err := model.DB.Select("id", "user_id", "title").First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if post.UserID == reporterID {
|
||||
return nil, ErrCannotReportOwnPost
|
||||
}
|
||||
|
||||
var existing int64
|
||||
model.DB.Model(&model.PostReport{}).
|
||||
Where("post_id = ? AND reporter_id = ? AND status = ?", postID, reporterID, model.ReportStatusPending).
|
||||
Count(&existing)
|
||||
if existing > 0 {
|
||||
return nil, ErrReportAlreadyExists
|
||||
}
|
||||
|
||||
rep := &model.PostReport{
|
||||
PostID: postID,
|
||||
ReporterID: reporterID,
|
||||
Reason: reason,
|
||||
Detail: detail,
|
||||
Status: model.ReportStatusPending,
|
||||
}
|
||||
if err := model.DB.Create(rep).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = model.DB.Preload("Post").Preload("Reporter").First(rep, rep.ID).Error
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
type ReportListQuery struct {
|
||||
Status string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
// ListAdmin 管理员举报列表
|
||||
func (s *ReportService) ListAdmin(q ReportListQuery) ([]model.PostReport, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
q.Size = s.settings.NormalizePageSize(q.Size)
|
||||
|
||||
db := model.DB.Model(&model.PostReport{})
|
||||
if q.Status != "" && q.Status != "all" {
|
||||
db = db.Where("status = ?", q.Status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var list []model.PostReport
|
||||
err := db.Preload("Post", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Unscoped()
|
||||
}).Preload("Post.User").Preload("Reporter").Preload("Handler").
|
||||
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
|
||||
Offset((q.Page - 1) * q.Size).
|
||||
Limit(q.Size).
|
||||
Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// PendingCount 待处理举报数
|
||||
func (s *ReportService) PendingCount() (int64, error) {
|
||||
var n int64
|
||||
err := model.DB.Model(&model.PostReport{}).
|
||||
Where("status = ?", model.ReportStatusPending).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
type HandleReportInput struct {
|
||||
ReportID uint
|
||||
HandlerID uint
|
||||
Action string // dismiss | resolve | reject_post
|
||||
HandleNote string
|
||||
RejectReason string // reject_post 时必填,发给作者
|
||||
}
|
||||
|
||||
// Handle 处理举报
|
||||
func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error) {
|
||||
var rep model.PostReport
|
||||
if err := model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Unscoped()
|
||||
}).First(&rep, in.ReportID).Error; err != nil {
|
||||
return nil, ErrReportNotFound
|
||||
}
|
||||
if rep.Status != model.ReportStatusPending {
|
||||
return nil, errors.New("该举报已处理")
|
||||
}
|
||||
|
||||
note := strings.TrimSpace(in.HandleNote)
|
||||
if utf8.RuneCountInString(note) > 500 {
|
||||
return nil, errors.New("处理备注过长")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
handlerID := in.HandlerID
|
||||
rep.HandlerID = &handlerID
|
||||
rep.HandleNote = note
|
||||
rep.HandledAt = &now
|
||||
|
||||
postID := rep.PostID
|
||||
postTitle := ""
|
||||
authorID := uint(0)
|
||||
if rep.Post.ID > 0 {
|
||||
postTitle = rep.Post.Title
|
||||
authorID = rep.Post.UserID
|
||||
}
|
||||
|
||||
switch in.Action {
|
||||
case "dismiss":
|
||||
rep.Status = model.ReportStatusDismissed
|
||||
case "resolve":
|
||||
rep.Status = model.ReportStatusResolved
|
||||
case "reject_post":
|
||||
reason := strings.TrimSpace(in.RejectReason)
|
||||
if reason == "" {
|
||||
return nil, errors.New("请填写拒绝原因(将私信通知作者)")
|
||||
}
|
||||
if utf8.RuneCountInString(reason) > 1000 {
|
||||
return nil, errors.New("拒绝原因过长")
|
||||
}
|
||||
if err := s.posts.SetStatus(postID, model.ContentStatusRejected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.Status = model.ReportStatusResolved
|
||||
if note == "" {
|
||||
rep.HandleNote = "已拒绝该帖并通知作者"
|
||||
}
|
||||
if authorID > 0 {
|
||||
pid := postID
|
||||
rid := rep.ID
|
||||
_, _ = s.messages.SendSystem(
|
||||
authorID,
|
||||
fmt.Sprintf("帖子《%s》未通过审核", postTitle),
|
||||
FormatRejectContent(postTitle, postID, reason),
|
||||
model.MessageKindReject,
|
||||
&pid,
|
||||
&rid,
|
||||
)
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("无效的处理操作")
|
||||
}
|
||||
|
||||
if err := model.DB.Save(&rep).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 通知举报人处理结果
|
||||
resultText := "已忽略"
|
||||
if rep.Status == model.ReportStatusResolved {
|
||||
if in.Action == "reject_post" {
|
||||
resultText = "已核实并下架该帖"
|
||||
} else {
|
||||
resultText = "已处理"
|
||||
}
|
||||
}
|
||||
content := fmt.Sprintf(
|
||||
"你举报的帖子《%s》(#%d)已处理:%s。",
|
||||
postTitle, postID, resultText,
|
||||
)
|
||||
if note != "" {
|
||||
content += "\n\n管理员备注:\n" + note
|
||||
}
|
||||
pid := postID
|
||||
rid := rep.ID
|
||||
_, _ = s.messages.SendSystem(
|
||||
rep.ReporterID,
|
||||
"举报处理结果通知",
|
||||
content,
|
||||
model.MessageKindReportResult,
|
||||
&pid,
|
||||
&rid,
|
||||
)
|
||||
|
||||
_ = model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Unscoped()
|
||||
}).Preload("Reporter").Preload("Handler").First(&rep, rep.ID).Error
|
||||
return &rep, nil
|
||||
}
|
||||
121
service/seo.go
Normal file
121
service/seo.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
var (
|
||||
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||||
)
|
||||
|
||||
// SitemapURL 站点地图条目
|
||||
type SitemapURL struct {
|
||||
Loc string
|
||||
LastMod time.Time
|
||||
ChangeFreq string
|
||||
Priority string
|
||||
}
|
||||
|
||||
// SitePublicBaseURL 公开站点根地址(无尾斜杠)
|
||||
// 优先 OIDC / 配置中的 RootURL,否则根据请求 Host 推断
|
||||
func (s *ForumSettingsService) SitePublicBaseURL(cfgRoot, requestOrigin string) string {
|
||||
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
|
||||
if root == "" {
|
||||
root = normalizeRootURL(cfgRoot)
|
||||
}
|
||||
if root == "" {
|
||||
root = normalizeRootURL(requestOrigin)
|
||||
}
|
||||
return strings.TrimRight(root, "/")
|
||||
}
|
||||
|
||||
// AbsoluteURL 将相对路径拼成绝对 URL
|
||||
func AbsoluteURL(base, pathOrURL string) string {
|
||||
pathOrURL = strings.TrimSpace(pathOrURL)
|
||||
if pathOrURL == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
||||
return pathOrURL
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
if !strings.HasPrefix(pathOrURL, "/") {
|
||||
pathOrURL = "/" + pathOrURL
|
||||
}
|
||||
return base + pathOrURL
|
||||
}
|
||||
|
||||
// TruncateRunes 按 rune 截断并加省略号
|
||||
func TruncateRunes(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if max <= 0 || s == "" {
|
||||
return s
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if max < 2 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
|
||||
// ExcerptFromHTML 从 HTML 生成摘要(剥离标签)
|
||||
func ExcerptFromHTML(htmlContent string, maxRunes int) string {
|
||||
plain := StripHTMLForSearch(htmlContent)
|
||||
return TruncateRunes(plain, maxRunes)
|
||||
}
|
||||
|
||||
// FirstImageURL 提取正文中第一张图片的 src
|
||||
func FirstImageURL(htmlContent string) string {
|
||||
m := imgSrcRe.FindStringSubmatch(htmlContent)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
src := strings.TrimSpace(m[1])
|
||||
// 忽略 data: 内联图
|
||||
if strings.HasPrefix(src, "data:") {
|
||||
return ""
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// DisplayName 用户展示名
|
||||
func DisplayName(u *model.User) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
if n := strings.TrimSpace(u.Nickname); n != "" {
|
||||
return n
|
||||
}
|
||||
return strings.TrimSpace(u.Username)
|
||||
}
|
||||
|
||||
// QueryBoardHome 板块首页相对路径
|
||||
func QueryBoardHome(boardID uint) string {
|
||||
if boardID == 0 {
|
||||
return "/"
|
||||
}
|
||||
return "/?board=" + url.QueryEscape(itoaUint(boardID))
|
||||
}
|
||||
|
||||
func itoaUint(n uint) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -10,7 +13,8 @@ import (
|
||||
|
||||
// 论坛设置键名
|
||||
const (
|
||||
SettingPostEditWindowHours = "post_edit_window_hours"
|
||||
SettingPostEditWindowHours = "post_edit_window_hours"
|
||||
SettingCommentEditWindowHours = "comment_edit_window_hours"
|
||||
|
||||
SettingRateLimitPost = "rate_limit_post"
|
||||
SettingRateLimitComment = "rate_limit_comment"
|
||||
@@ -36,6 +40,8 @@ const (
|
||||
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
|
||||
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
|
||||
|
||||
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
|
||||
|
||||
SettingSMTPEnabled = "smtp_enabled"
|
||||
SettingSMTPHost = "smtp_host"
|
||||
SettingSMTPPort = "smtp_port"
|
||||
@@ -45,27 +51,39 @@ const (
|
||||
SettingSMTPFromName = "smtp_from_name"
|
||||
SettingSMTPEncryption = "smtp_encryption"
|
||||
|
||||
SettingOIDCEnabled = "oidc_enabled"
|
||||
SettingOIDCRootURL = "oidc_root_url"
|
||||
SettingOIDCGroupClaim = "oidc_group_claim"
|
||||
SettingOIDCAdminGroup = "oidc_admin_group"
|
||||
SettingOIDCUserGroup = "oidc_user_group"
|
||||
// 遗留单客户端字段(仅用于迁移到 oauth_clients)
|
||||
SettingOAuthClientID = "oauth_client_id"
|
||||
SettingOAuthClientSecret = "oauth_client_secret"
|
||||
SettingOAuthRedirectURIs = "oauth_redirect_uris"
|
||||
SettingOIDCEnabled = "oidc_enabled"
|
||||
SettingOIDCRootURL = "oidc_root_url"
|
||||
SettingOIDCGroupClaim = "oidc_group_claim"
|
||||
SettingOIDCAdminGroup = "oidc_admin_group"
|
||||
SettingOIDCUserGroup = "oidc_user_group"
|
||||
|
||||
SettingGiteaSyncEnabled = "gitea_sync_enabled"
|
||||
SettingGiteaBaseURL = "gitea_base_url"
|
||||
SettingGiteaToken = "gitea_token"
|
||||
SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"
|
||||
|
||||
SettingSiteName = "site_name"
|
||||
SettingSiteNameEN = "site_name_en"
|
||||
SettingSiteSlogan = "site_slogan"
|
||||
SettingSiteLogoMark = "site_logo_mark"
|
||||
SettingSiteLogo = "site_logo"
|
||||
SettingSiteFavicon = "site_favicon"
|
||||
SettingStorageType = "storage_type"
|
||||
SettingStorageEndpoint = "storage_endpoint"
|
||||
SettingStorageRegion = "storage_region"
|
||||
SettingStorageBucket = "storage_bucket"
|
||||
SettingStorageAccessKey = "storage_access_key"
|
||||
SettingStorageSecretKey = "storage_secret_key"
|
||||
SettingStoragePublicBaseURL = "storage_public_base_url"
|
||||
SettingStoragePrefix = "storage_prefix"
|
||||
SettingStorageForcePathStyle = "storage_force_path_style"
|
||||
SettingStorageImageDelivery = "storage_image_delivery"
|
||||
|
||||
SettingSiteName = "site_name"
|
||||
SettingSiteSlogan = "site_slogan"
|
||||
SettingSiteDescription = "site_description"
|
||||
SettingSiteKeywords = "site_keywords"
|
||||
SettingSiteLogoMark = "site_logo_mark"
|
||||
SettingSiteLogo = "site_logo"
|
||||
SettingSiteFavicon = "site_favicon"
|
||||
SettingSiteOGImage = "site_og_image"
|
||||
SettingSiteICPBeian = "site_icp_beian"
|
||||
SettingSiteICPBeianURL = "site_icp_beian_url"
|
||||
SettingSiteFriendLinks = "site_friend_links"
|
||||
|
||||
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||||
pageSizeAPIMax = 100
|
||||
@@ -73,7 +91,8 @@ const (
|
||||
|
||||
// ForumLimits 论坛可配置限制(API 传输结构)
|
||||
type ForumLimits struct {
|
||||
PostEditWindowHours int `json:"post_edit_window_hours"`
|
||||
PostEditWindowHours int `json:"post_edit_window_hours"`
|
||||
CommentEditWindowHours int `json:"comment_edit_window_hours"`
|
||||
|
||||
RateLimitPost int `json:"rate_limit_post"`
|
||||
RateLimitComment int `json:"rate_limit_comment"`
|
||||
@@ -98,6 +117,9 @@ type ForumLimits struct {
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
|
||||
PermalinkEnabled bool `json:"permalink_enabled"`
|
||||
PermalinkExt string `json:"permalink_ext"`
|
||||
}
|
||||
|
||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||
@@ -113,8 +135,13 @@ type ForumLimitsPublic struct {
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
SignatureMax int `json:"signature_max"`
|
||||
|
||||
CommentEditWindowHours int `json:"comment_edit_window_hours"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
|
||||
PermalinkEnabled bool `json:"permalink_enabled"`
|
||||
PermalinkExt string `json:"permalink_ext"`
|
||||
}
|
||||
|
||||
type settingDef struct {
|
||||
@@ -126,6 +153,7 @@ type settingDef struct {
|
||||
|
||||
var forumSettingDefs = []settingDef{
|
||||
{SettingPostEditWindowHours, "24", 0, 0},
|
||||
{SettingCommentEditWindowHours, "24", 0, 0},
|
||||
|
||||
{SettingRateLimitPost, "10", 1, 1000},
|
||||
{SettingRateLimitComment, "10", 1, 1000},
|
||||
@@ -164,14 +192,11 @@ var mailSettingDefaults = map[string]string{
|
||||
}
|
||||
|
||||
var oidcSettingDefaults = map[string]string{
|
||||
SettingOIDCEnabled: "0",
|
||||
SettingOIDCRootURL: "",
|
||||
SettingOIDCGroupClaim: "groups",
|
||||
SettingOIDCAdminGroup: "gitea-admin",
|
||||
SettingOIDCUserGroup: "gitea-users",
|
||||
SettingOAuthClientID: "",
|
||||
SettingOAuthClientSecret: "",
|
||||
SettingOAuthRedirectURIs: "",
|
||||
SettingOIDCEnabled: "0",
|
||||
SettingOIDCRootURL: "",
|
||||
SettingOIDCGroupClaim: "groups",
|
||||
SettingOIDCAdminGroup: "gitea-admin",
|
||||
SettingOIDCUserGroup: "gitea-users",
|
||||
}
|
||||
|
||||
var giteaSettingDefaults = map[string]string{
|
||||
@@ -181,23 +206,64 @@ var giteaSettingDefaults = map[string]string{
|
||||
SettingGiteaSyncIntervalMin: "60",
|
||||
}
|
||||
|
||||
var siteBrandingDefaults = map[string]string{
|
||||
SettingSiteName: "姜十三论坛",
|
||||
SettingSiteNameEN: "Jiang13 Forum",
|
||||
SettingSiteSlogan: "拾三一隅,自在交流",
|
||||
SettingSiteLogoMark: "姜",
|
||||
SettingSiteLogo: "",
|
||||
SettingSiteFavicon: "",
|
||||
var storageSettingDefaults = map[string]string{
|
||||
SettingStorageType: "local",
|
||||
SettingStorageEndpoint: "",
|
||||
SettingStorageRegion: "us-east-1",
|
||||
SettingStorageBucket: "",
|
||||
SettingStorageAccessKey: "",
|
||||
SettingStorageSecretKey: "",
|
||||
SettingStoragePublicBaseURL: "",
|
||||
SettingStoragePrefix: "",
|
||||
SettingStorageForcePathStyle: "1",
|
||||
SettingStorageImageDelivery: ImageDeliveryWebP,
|
||||
}
|
||||
|
||||
// SiteBranding 站点品牌配置(名称、Logo、Favicon 等)
|
||||
var siteBrandingDefaults = map[string]string{
|
||||
SettingSiteName: "姜十三论坛",
|
||||
SettingSiteSlogan: "拾三一隅,自在交流",
|
||||
SettingSiteDescription: "",
|
||||
SettingSiteKeywords: "",
|
||||
SettingSiteLogoMark: "姜",
|
||||
SettingSiteLogo: "",
|
||||
SettingSiteFavicon: "",
|
||||
SettingSiteOGImage: "",
|
||||
SettingSiteICPBeian: "",
|
||||
SettingSiteICPBeianURL: "https://beian.miit.gov.cn/",
|
||||
SettingSiteFriendLinks: "[]",
|
||||
}
|
||||
|
||||
const (
|
||||
maxFriendLinks = 20
|
||||
maxFriendLinkName = 32
|
||||
maxFriendLinkURL = 512
|
||||
maxICPBeianLen = 64
|
||||
maxICPBeianURLLen = 512
|
||||
maxSiteDescriptionLen = 500
|
||||
maxSiteKeywordsLen = 200
|
||||
maxSiteKeywordItems = 20
|
||||
defaultICPBeianURL = "https://beian.miit.gov.cn/"
|
||||
)
|
||||
|
||||
// FriendLink 页脚友情链接
|
||||
type FriendLink struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SiteBranding 站点品牌配置(名称、Logo、Favicon、页脚等)
|
||||
type SiteBranding struct {
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
Slogan string `json:"slogan"`
|
||||
LogoMark string `json:"logo_mark"`
|
||||
Logo string `json:"logo"`
|
||||
Favicon string `json:"favicon"`
|
||||
Name string `json:"name"`
|
||||
Slogan string `json:"slogan"`
|
||||
Description string `json:"description"` // 站点简介(SEO / 首页可见)
|
||||
Keywords string `json:"keywords"` // SEO keywords,逗号分隔
|
||||
LogoMark string `json:"logo_mark"`
|
||||
Logo string `json:"logo"`
|
||||
Favicon string `json:"favicon"`
|
||||
OGImage string `json:"og_image"` // 默认社交分享图(Open Graph)
|
||||
ICPBeian string `json:"icp_beian"`
|
||||
ICPBeianURL string `json:"icp_beian_url"`
|
||||
FriendLinks []FriendLink `json:"friend_links"`
|
||||
}
|
||||
|
||||
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
|
||||
@@ -210,6 +276,29 @@ func (b SiteBranding) DocumentTitle() string {
|
||||
return name
|
||||
}
|
||||
|
||||
// MetaDescription 用于 meta description:优先简介,其次标语
|
||||
func (b SiteBranding) MetaDescription() string {
|
||||
if d := strings.TrimSpace(b.Description); d != "" {
|
||||
return d
|
||||
}
|
||||
return strings.TrimSpace(b.Slogan)
|
||||
}
|
||||
|
||||
// MetaKeywords 用于 meta keywords
|
||||
func (b SiteBranding) MetaKeywords() string {
|
||||
return strings.TrimSpace(b.Keywords)
|
||||
}
|
||||
|
||||
// DefaultShareImage 默认社交预览图:专用 OG 图 → Logo → Favicon
|
||||
func (b SiteBranding) DefaultShareImage() string {
|
||||
for _, u := range []string{b.OGImage, b.Logo, b.Favicon} {
|
||||
if s := strings.TrimSpace(u); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GiteaSyncConfig Gitea 仓库同步配置
|
||||
type GiteaSyncConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -275,6 +364,13 @@ func (s *ForumSettingsService) ensureDefaults() {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range storageSettingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range siteBrandingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
@@ -329,8 +425,10 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
permalink := s.Permalink()
|
||||
return ForumLimits{
|
||||
PostEditWindowHours: s.PostEditWindowHours(),
|
||||
PostEditWindowHours: s.PostEditWindowHours(),
|
||||
CommentEditWindowHours: s.CommentEditWindowHours(),
|
||||
|
||||
RateLimitPost: s.RateLimitFor("post"),
|
||||
RateLimitComment: s.RateLimitFor("comment"),
|
||||
@@ -355,6 +453,9 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
|
||||
OpenPostsInNewTab: s.OpenPostsInNewTab(),
|
||||
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
|
||||
|
||||
PermalinkEnabled: permalink.Enabled,
|
||||
PermalinkExt: permalink.Ext,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,15 +473,21 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
AvatarMaxMB: limits.AvatarMaxMB,
|
||||
SignatureMax: limits.SignatureMax,
|
||||
|
||||
CommentEditWindowHours: limits.CommentEditWindowHours,
|
||||
|
||||
OpenPostsInNewTab: limits.OpenPostsInNewTab,
|
||||
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
|
||||
|
||||
PermalinkEnabled: limits.PermalinkEnabled,
|
||||
PermalinkExt: limits.PermalinkExt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
updates := map[string]int{
|
||||
SettingPostEditWindowHours: in.PostEditWindowHours,
|
||||
SettingRateLimitPost: in.RateLimitPost,
|
||||
SettingPostEditWindowHours: in.PostEditWindowHours,
|
||||
SettingCommentEditWindowHours: in.CommentEditWindowHours,
|
||||
SettingRateLimitPost: in.RateLimitPost,
|
||||
SettingRateLimitComment: in.RateLimitComment,
|
||||
SettingRateLimitRegister: in.RateLimitRegister,
|
||||
SettingRateLimitLogin: in.RateLimitLogin,
|
||||
@@ -407,6 +514,7 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
boolUpdates := map[string]bool{
|
||||
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
|
||||
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
|
||||
SettingPermalinkEnabled: in.PermalinkEnabled,
|
||||
}
|
||||
for key, on := range boolUpdates {
|
||||
v := "0"
|
||||
@@ -417,6 +525,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ext, ok := NormalizePermalinkExt(in.PermalinkExt)
|
||||
if !ok {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
if err := s.setString(SettingPermalinkExt, ext); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -424,6 +539,10 @@ func (s *ForumSettingsService) PostEditWindowHours() int {
|
||||
return s.getInt(SettingPostEditWindowHours, 24)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) CommentEditWindowHours() int {
|
||||
return s.getInt(SettingCommentEditWindowHours, 24)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) RateLimitFor(action string) int {
|
||||
switch action {
|
||||
case "post":
|
||||
@@ -597,7 +716,7 @@ func (s *ForumSettingsService) UpdateOIDCConfig(in OIDCConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedOIDCFromINI 若库中尚未配置,则用 app.ini 种子一次(便于迁移)
|
||||
// SeedOIDCFromINI 若库中尚未配置,则用 app.ini 种子一次(直写 oauth_clients)
|
||||
func (s *ForumSettingsService) SeedOIDCFromINI(rootURL, clientID, clientSecret, redirectURIsCSV string) {
|
||||
rootURL = normalizeRootURL(rootURL)
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
@@ -609,16 +728,14 @@ func (s *ForumSettingsService) SeedOIDCFromINI(rootURL, clientID, clientSecret,
|
||||
if s.getString(SettingOIDCRootURL, "") == "" && rootURL != "" {
|
||||
_ = s.setString(SettingOIDCRootURL, rootURL)
|
||||
}
|
||||
if s.getString(SettingOAuthClientID, "") == "" && clientID != "" {
|
||||
_ = s.setString(SettingOAuthClientID, clientID)
|
||||
if CountEnabledOAuthClients() == 0 && clientID != "" && clientSecret != "" && uris != "" {
|
||||
_, _ = s.CreateOAuthClient(OAuthClientInput{
|
||||
ClientID: clientID,
|
||||
Name: "Gitea",
|
||||
RedirectURIs: uris,
|
||||
ClientSecret: clientSecret,
|
||||
})
|
||||
}
|
||||
if s.getString(SettingOAuthClientSecret, "") == "" && clientSecret != "" {
|
||||
_ = s.setString(SettingOAuthClientSecret, clientSecret)
|
||||
}
|
||||
if s.getString(SettingOAuthRedirectURIs, "") == "" && uris != "" {
|
||||
_ = s.setString(SettingOAuthRedirectURIs, uris)
|
||||
}
|
||||
s.MigrateLegacyOIDCClient()
|
||||
if s.getString(SettingOIDCEnabled, "0") == "0" &&
|
||||
s.getString(SettingOIDCRootURL, "") != "" &&
|
||||
CountEnabledOAuthClients() > 0 {
|
||||
@@ -714,6 +831,162 @@ func (s *ForumSettingsService) SeedGiteaFromINI(baseURL, token string, enabled b
|
||||
}
|
||||
}
|
||||
|
||||
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
|
||||
func (s *ForumSettingsService) StorageConfig() StorageConfig {
|
||||
secret := s.getString(SettingStorageSecretKey, "")
|
||||
region := strings.TrimSpace(s.getString(SettingStorageRegion, "us-east-1"))
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
cfg := StorageConfig{
|
||||
Type: normalizeStorageType(s.getString(SettingStorageType, "local")),
|
||||
Endpoint: strings.TrimSpace(s.getString(SettingStorageEndpoint, "")),
|
||||
Region: region,
|
||||
Bucket: strings.TrimSpace(s.getString(SettingStorageBucket, "")),
|
||||
AccessKey: strings.TrimSpace(s.getString(SettingStorageAccessKey, "")),
|
||||
SecretKey: secret,
|
||||
PublicBaseURL: normalizeRootURL(s.getString(SettingStoragePublicBaseURL, "")),
|
||||
Prefix: normalizeObjectPrefix(s.getString(SettingStoragePrefix, "")),
|
||||
ForcePathStyle: s.getString(SettingStorageForcePathStyle, "1") == "1",
|
||||
HasSecretKey: secret != "",
|
||||
ImageDelivery: normalizeImageDelivery(s.getString(SettingStorageImageDelivery, ImageDeliveryWebP)),
|
||||
}
|
||||
cfg.Ready = cfg.Type == "local" || (cfg.Endpoint != "" && cfg.Bucket != "" &&
|
||||
cfg.AccessKey != "" && cfg.HasSecretKey && cfg.PublicBaseURL != "")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ImageDelivery 图片展示方案:webp | original
|
||||
func (s *ForumSettingsService) ImageDelivery() string {
|
||||
return normalizeImageDelivery(s.getString(SettingStorageImageDelivery, ImageDeliveryWebP))
|
||||
}
|
||||
|
||||
// StorageConfigPublic 管理端回显(不含 Secret Key 明文)
|
||||
func (s *ForumSettingsService) StorageConfigPublic() StorageConfig {
|
||||
cfg := s.StorageConfig()
|
||||
cfg.SecretKey = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// UpdateStorageConfig 更新存储配置;Secret Key 为空表示保持原值
|
||||
func (s *ForumSettingsService) UpdateStorageConfig(in StorageConfig) error {
|
||||
typ := normalizeStorageType(in.Type)
|
||||
if typ != "local" && typ != "s3" {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
endpoint := strings.TrimSpace(in.Endpoint)
|
||||
region := strings.TrimSpace(in.Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
bucket := strings.TrimSpace(in.Bucket)
|
||||
accessKey := strings.TrimSpace(in.AccessKey)
|
||||
publicBase := normalizeRootURL(in.PublicBaseURL)
|
||||
prefix := normalizeObjectPrefix(in.Prefix)
|
||||
forcePath := "0"
|
||||
if in.ForcePathStyle {
|
||||
forcePath = "1"
|
||||
}
|
||||
|
||||
if typ == "s3" {
|
||||
if endpoint == "" || bucket == "" || publicBase == "" {
|
||||
return errors.New("启用 S3 时须填写 Endpoint、Bucket 与公开访问地址")
|
||||
}
|
||||
if _, _, err := parseS3Endpoint(endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(publicBase, "http://") && !strings.HasPrefix(publicBase, "https://") {
|
||||
return errors.New("公开访问地址须以 http:// 或 https:// 开头")
|
||||
}
|
||||
existingSecret := s.getString(SettingStorageSecretKey, "")
|
||||
secret := strings.TrimSpace(in.SecretKey)
|
||||
if accessKey == "" {
|
||||
accessKey = strings.TrimSpace(s.getString(SettingStorageAccessKey, ""))
|
||||
}
|
||||
if secret == "" {
|
||||
secret = existingSecret
|
||||
}
|
||||
if accessKey == "" || secret == "" {
|
||||
return errors.New("启用 S3 时须填写 Access Key 与 Secret Key")
|
||||
}
|
||||
}
|
||||
|
||||
delivery := normalizeImageDelivery(in.ImageDelivery)
|
||||
updates := map[string]string{
|
||||
SettingStorageType: typ,
|
||||
SettingStorageEndpoint: endpoint,
|
||||
SettingStorageRegion: region,
|
||||
SettingStorageBucket: bucket,
|
||||
SettingStorageAccessKey: accessKey,
|
||||
SettingStoragePublicBaseURL: publicBase,
|
||||
SettingStoragePrefix: prefix,
|
||||
SettingStorageForcePathStyle: forcePath,
|
||||
SettingStorageImageDelivery: delivery,
|
||||
}
|
||||
if strings.TrimSpace(in.SecretKey) != "" {
|
||||
updates[SettingStorageSecretKey] = strings.TrimSpace(in.SecretKey)
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedStorageFromINI 若库中尚未配置 S3,则用 app.ini 种子一次
|
||||
func (s *ForumSettingsService) SeedStorageFromINI(storageType, endpoint, region, bucket, accessKey, secretKey, publicBaseURL, prefix string, forcePathStyle bool) {
|
||||
storageType = normalizeStorageType(storageType)
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
region = strings.TrimSpace(region)
|
||||
bucket = strings.TrimSpace(bucket)
|
||||
accessKey = strings.TrimSpace(accessKey)
|
||||
secretKey = strings.TrimSpace(secretKey)
|
||||
publicBaseURL = normalizeRootURL(publicBaseURL)
|
||||
prefix = normalizeObjectPrefix(prefix)
|
||||
|
||||
if storageType == "local" && endpoint == "" && bucket == "" && accessKey == "" && secretKey == "" && publicBaseURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if s.getString(SettingStorageEndpoint, "") == "" && endpoint != "" {
|
||||
_ = s.setString(SettingStorageEndpoint, endpoint)
|
||||
}
|
||||
if s.getString(SettingStorageRegion, "") == "" && region != "" {
|
||||
_ = s.setString(SettingStorageRegion, region)
|
||||
}
|
||||
if s.getString(SettingStorageBucket, "") == "" && bucket != "" {
|
||||
_ = s.setString(SettingStorageBucket, bucket)
|
||||
}
|
||||
if s.getString(SettingStorageAccessKey, "") == "" && accessKey != "" {
|
||||
_ = s.setString(SettingStorageAccessKey, accessKey)
|
||||
}
|
||||
if s.getString(SettingStorageSecretKey, "") == "" && secretKey != "" {
|
||||
_ = s.setString(SettingStorageSecretKey, secretKey)
|
||||
}
|
||||
if s.getString(SettingStoragePublicBaseURL, "") == "" && publicBaseURL != "" {
|
||||
_ = s.setString(SettingStoragePublicBaseURL, publicBaseURL)
|
||||
}
|
||||
if s.getString(SettingStoragePrefix, "") == "" && prefix != "" {
|
||||
_ = s.setString(SettingStoragePrefix, prefix)
|
||||
}
|
||||
// 仅当库中仍为默认 local 且 INI 明确为 s3、且关键字段齐全时切到 s3
|
||||
if storageType == "s3" &&
|
||||
normalizeStorageType(s.getString(SettingStorageType, "local")) == "local" &&
|
||||
s.getString(SettingStorageEndpoint, "") != "" &&
|
||||
s.getString(SettingStorageBucket, "") != "" &&
|
||||
s.getString(SettingStorageAccessKey, "") != "" &&
|
||||
s.getString(SettingStorageSecretKey, "") != "" &&
|
||||
s.getString(SettingStoragePublicBaseURL, "") != "" {
|
||||
_ = s.setString(SettingStorageType, "s3")
|
||||
if forcePathStyle {
|
||||
_ = s.setString(SettingStorageForcePathStyle, "1")
|
||||
} else {
|
||||
_ = s.setString(SettingStorageForcePathStyle, "0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SiteBranding 读取站点品牌配置
|
||||
func (s *ForumSettingsService) SiteBranding() SiteBranding {
|
||||
name := strings.TrimSpace(s.getString(SettingSiteName, siteBrandingDefaults[SettingSiteName]))
|
||||
@@ -729,17 +1002,23 @@ func (s *ForumSettingsService) SiteBranding() SiteBranding {
|
||||
if len(runes) > 1 {
|
||||
mark = string(runes[0])
|
||||
}
|
||||
links := parseFriendLinksJSON(s.getString(SettingSiteFriendLinks, "[]"))
|
||||
return SiteBranding{
|
||||
Name: name,
|
||||
NameEN: strings.TrimSpace(s.getString(SettingSiteNameEN, siteBrandingDefaults[SettingSiteNameEN])),
|
||||
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
|
||||
LogoMark: mark,
|
||||
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
|
||||
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
|
||||
Name: name,
|
||||
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
|
||||
Description: strings.TrimSpace(s.getString(SettingSiteDescription, "")),
|
||||
Keywords: strings.TrimSpace(s.getString(SettingSiteKeywords, "")),
|
||||
LogoMark: mark,
|
||||
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
|
||||
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
|
||||
OGImage: strings.TrimSpace(s.getString(SettingSiteOGImage, "")),
|
||||
ICPBeian: strings.TrimSpace(s.getString(SettingSiteICPBeian, "")),
|
||||
ICPBeianURL: strings.TrimSpace(s.getString(SettingSiteICPBeianURL, defaultICPBeianURL)),
|
||||
FriendLinks: links,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSiteBranding 更新品牌文案;Logo/Favicon URL 由上传接口单独写入
|
||||
// UpdateSiteBranding 更新品牌文案与页脚信息;Logo/Favicon URL 由上传接口单独写入
|
||||
func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
@@ -756,19 +1035,43 @@ func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
|
||||
if len(runes) > 1 {
|
||||
mark = string(runes[0])
|
||||
}
|
||||
nameEN := strings.TrimSpace(in.NameEN)
|
||||
if len([]rune(nameEN)) > 64 {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
slogan := strings.TrimSpace(in.Slogan)
|
||||
if len([]rune(slogan)) > 200 {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
description := strings.TrimSpace(in.Description)
|
||||
if len([]rune(description)) > maxSiteDescriptionLen {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
keywords, err := normalizeSiteKeywords(in.Keywords)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
icp := strings.TrimSpace(in.ICPBeian)
|
||||
if len([]rune(icp)) > maxICPBeianLen {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
icpURL, err := normalizeOptionalHTTPURL(in.ICPBeianURL, defaultICPBeianURL, maxICPBeianURLLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
links, err := normalizeFriendLinks(in.FriendLinks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
linksJSON, err := json.Marshal(links)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]string{
|
||||
SettingSiteName: name,
|
||||
SettingSiteNameEN: nameEN,
|
||||
SettingSiteSlogan: slogan,
|
||||
SettingSiteLogoMark: mark,
|
||||
SettingSiteName: name,
|
||||
SettingSiteSlogan: slogan,
|
||||
SettingSiteDescription: description,
|
||||
SettingSiteKeywords: keywords,
|
||||
SettingSiteLogoMark: mark,
|
||||
SettingSiteICPBeian: icp,
|
||||
SettingSiteICPBeianURL: icpURL,
|
||||
SettingSiteFriendLinks: string(linksJSON),
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
@@ -788,6 +1091,134 @@ func (s *ForumSettingsService) SetSiteFavicon(url string) error {
|
||||
return s.setString(SettingSiteFavicon, strings.TrimSpace(url))
|
||||
}
|
||||
|
||||
// SetSiteOGImage 写入默认社交分享图 URL(空串表示清除)
|
||||
func (s *ForumSettingsService) SetSiteOGImage(url string) error {
|
||||
return s.setString(SettingSiteOGImage, strings.TrimSpace(url))
|
||||
}
|
||||
|
||||
// normalizeSiteKeywords 统一中英文分隔符,去重并限制数量/长度
|
||||
func normalizeSiteKeywords(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len([]rune(raw)) > maxSiteKeywordsLen {
|
||||
return "", ErrInvalidSetting
|
||||
}
|
||||
raw = strings.NewReplacer(",", ",", "、", ",", ";", ",", ";", ",").Replace(raw)
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) > maxSiteKeywordItems {
|
||||
return "", ErrInvalidSetting
|
||||
}
|
||||
return strings.Join(out, ","), nil
|
||||
}
|
||||
|
||||
// JoinSEOKeywords 合并页面级与站点级关键词(逗号分隔)
|
||||
func JoinSEOKeywords(parts ...string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
for _, p := range strings.Split(strings.NewReplacer(",", ",", "、", ",").Replace(part), ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// normalizeOptionalHTTPURL 空值回落到 defaultURL;非空须为 http(s)
|
||||
func normalizeOptionalHTTPURL(raw, defaultURL string, maxLen int) (string, error) {
|
||||
href := strings.TrimSpace(raw)
|
||||
if href == "" {
|
||||
return defaultURL, nil
|
||||
}
|
||||
if len(href) > maxLen {
|
||||
return "", ErrInvalidSetting
|
||||
}
|
||||
u, err := url.Parse(href)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", ErrInvalidSetting
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", ErrInvalidSetting
|
||||
}
|
||||
return href, nil
|
||||
}
|
||||
|
||||
func parseFriendLinksJSON(raw string) []FriendLink {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return []FriendLink{}
|
||||
}
|
||||
var links []FriendLink
|
||||
if err := json.Unmarshal([]byte(raw), &links); err != nil {
|
||||
return []FriendLink{}
|
||||
}
|
||||
out, err := normalizeFriendLinks(links)
|
||||
if err != nil {
|
||||
return []FriendLink{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeFriendLinks(in []FriendLink) ([]FriendLink, error) {
|
||||
if len(in) > maxFriendLinks {
|
||||
return nil, ErrInvalidSetting
|
||||
}
|
||||
out := make([]FriendLink, 0, len(in))
|
||||
for _, item := range in {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
href := strings.TrimSpace(item.URL)
|
||||
if name == "" && href == "" {
|
||||
continue
|
||||
}
|
||||
if name == "" || href == "" {
|
||||
return nil, ErrInvalidSetting
|
||||
}
|
||||
if len([]rune(name)) > maxFriendLinkName || len(href) > maxFriendLinkURL {
|
||||
return nil, ErrInvalidSetting
|
||||
}
|
||||
u, err := url.Parse(href)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, ErrInvalidSetting
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return nil, ErrInvalidSetting
|
||||
}
|
||||
out = append(out, FriendLink{Name: name, URL: href})
|
||||
}
|
||||
if out == nil {
|
||||
out = []FriendLink{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeRootURL(raw string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
}
|
||||
|
||||
466
service/storage.go
Normal file
466
service/storage.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
)
|
||||
|
||||
// UploadCategory 上传分类目录名
|
||||
const (
|
||||
UploadCategoryAvatars = "avatars"
|
||||
UploadCategoryPosts = "posts"
|
||||
UploadCategorySite = "site"
|
||||
)
|
||||
|
||||
// StorageConfig 上传存储配置(管理后台 / 内部使用)
|
||||
type StorageConfig struct {
|
||||
Type string `json:"type"` // local | s3
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key,omitempty"` // 更新时传入;回显时为空
|
||||
PublicBaseURL string `json:"public_base_url"`
|
||||
Prefix string `json:"prefix"`
|
||||
ForcePathStyle bool `json:"force_path_style"`
|
||||
HasSecretKey bool `json:"has_secret_key"`
|
||||
Ready bool `json:"ready"`
|
||||
// ImageDelivery 展示方案:webp(默认)| original;上传始终保留原图
|
||||
ImageDelivery string `json:"image_delivery"`
|
||||
}
|
||||
|
||||
// UploadStore 统一上传存储(本地或 S3 兼容),支持运行时热切换
|
||||
type UploadStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings *ForumSettingsService
|
||||
mode string
|
||||
s3 *s3Backend
|
||||
publicBase string
|
||||
keyPrefix string
|
||||
}
|
||||
|
||||
type s3Backend struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// NewUploadStore 创建本地默认存储;调用 Apply / ReloadFromSettings 切换后端
|
||||
func NewUploadStore(dataDir string, settings *ForumSettingsService) *UploadStore {
|
||||
return &UploadStore{
|
||||
dataDir: dataDir,
|
||||
settings: settings,
|
||||
mode: config.StorageTypeLocal,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadFromSettings 按数据库配置重建存储客户端
|
||||
func (s *UploadStore) ReloadFromSettings(settings *ForumSettingsService) error {
|
||||
if settings == nil {
|
||||
return errors.New("设置服务未初始化")
|
||||
}
|
||||
return s.Apply(settings.StorageConfig())
|
||||
}
|
||||
|
||||
// Apply 应用存储配置(失败时保持原配置不变)
|
||||
func (s *UploadStore) Apply(cfg StorageConfig) error {
|
||||
if s == nil {
|
||||
return errors.New("上传存储未初始化")
|
||||
}
|
||||
typ := normalizeStorageType(cfg.Type)
|
||||
if typ == config.StorageTypeLocal {
|
||||
s.mu.Lock()
|
||||
s.mode = config.StorageTypeLocal
|
||||
s.s3 = nil
|
||||
s.publicBase = ""
|
||||
s.keyPrefix = ""
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateStorageConfigForApply(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint, secure, err := parseS3Endpoint(cfg.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
lookup := minio.BucketLookupDNS
|
||||
if cfg.ForcePathStyle {
|
||||
lookup = minio.BucketLookupPath
|
||||
}
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(strings.TrimSpace(cfg.AccessKey), cfg.SecretKey, ""),
|
||||
Secure: secure,
|
||||
Region: region,
|
||||
BucketLookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化 S3 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.mode = config.StorageTypeS3
|
||||
s.s3 = &s3Backend{client: client, bucket: strings.TrimSpace(cfg.Bucket)}
|
||||
s.publicBase = normalizeRootURL(cfg.PublicBaseURL)
|
||||
s.keyPrefix = normalizeObjectPrefix(cfg.Prefix)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStorageConfigForApply(cfg StorageConfig) error {
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return errors.New("S3 Endpoint 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||||
return errors.New("S3 Bucket 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessKey) == "" {
|
||||
return errors.New("S3 Access Key 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.SecretKey) == "" {
|
||||
return errors.New("S3 Secret Key 不能为空")
|
||||
}
|
||||
if normalizeRootURL(cfg.PublicBaseURL) == "" {
|
||||
return errors.New("公开访问地址 PUBLIC_BASE_URL 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeStorageType(raw string) string {
|
||||
t := strings.ToLower(strings.TrimSpace(raw))
|
||||
if t == config.StorageTypeS3 {
|
||||
return config.StorageTypeS3
|
||||
}
|
||||
return config.StorageTypeLocal
|
||||
}
|
||||
|
||||
func normalizeObjectPrefix(raw string) string {
|
||||
p := strings.TrimSpace(raw)
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(p, "/") + "/"
|
||||
}
|
||||
|
||||
// snapshot 读取当前后端快照(调用方勿修改返回指针)
|
||||
func (s *UploadStore) snapshot() (mode, publicBase, keyPrefix string, s3 *s3Backend) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.mode, s.publicBase, s.keyPrefix, s.s3
|
||||
}
|
||||
|
||||
// IsLocal 是否本地磁盘存储
|
||||
func (s *UploadStore) IsLocal() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
mode, _, _, backend := s.snapshot()
|
||||
return mode != config.StorageTypeS3 || backend == nil
|
||||
}
|
||||
|
||||
// UploadsRoot 本地 uploads 根目录
|
||||
func (s *UploadStore) UploadsRoot() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(s.dataDir, "uploads")
|
||||
}
|
||||
|
||||
// SaveImage 保存图片:始终保留原图;静态图额外写 WebP 衍生,按展示方案返回 URL
|
||||
func (s *UploadStore) SaveImage(file *multipart.FileHeader, category, namePrefix string) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("上传存储未初始化")
|
||||
}
|
||||
category = strings.Trim(category, "/")
|
||||
if category == "" {
|
||||
return "", errors.New("无效的上传分类")
|
||||
}
|
||||
|
||||
prepared, err := prepareUploadImage(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base := fmt.Sprintf("%s_%d", namePrefix, time.Now().UnixNano())
|
||||
origName := base + prepared.OrigExt
|
||||
webpName := base + ".webp"
|
||||
|
||||
delivery := ImageDeliveryWebP
|
||||
if s.settings != nil {
|
||||
delivery = s.settings.ImageDelivery()
|
||||
}
|
||||
|
||||
mode, publicBase, keyPrefix, backend := s.snapshot()
|
||||
useS3 := mode == config.StorageTypeS3
|
||||
if useS3 && backend == nil {
|
||||
return "", errors.New("对象存储未就绪,请检查管理后台「对象存储」配置")
|
||||
}
|
||||
|
||||
// 1) 写原图
|
||||
if useS3 {
|
||||
if err := s.putBytesS3(backend, keyPrefix, category, origName, prepared.OrigContentType, prepared.OrigData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := s.putBytesLocal(category, origName, prepared.OrigData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 写 WebP 衍生(若有)
|
||||
hasWebP := len(prepared.WebPData) > 0
|
||||
if hasWebP {
|
||||
if useS3 {
|
||||
if err := s.putBytesS3(backend, keyPrefix, category, webpName, "image/webp", prepared.WebPData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := s.putBytesLocal(category, webpName, prepared.WebPData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按展示方案选择返回 URL
|
||||
returnName := origName
|
||||
if delivery == ImageDeliveryWebP && (hasWebP || prepared.OrigExt == ".webp") {
|
||||
if hasWebP {
|
||||
returnName = webpName
|
||||
} else {
|
||||
returnName = origName // 原图已是 webp
|
||||
}
|
||||
}
|
||||
|
||||
publicURL := s.publicURL(useS3, publicBase, category, returnName)
|
||||
|
||||
// 写入媒体索引(原图 + WebP 衍生均登记)
|
||||
storageType := config.StorageTypeLocal
|
||||
if useS3 {
|
||||
storageType = config.StorageTypeS3
|
||||
}
|
||||
uploader := parseUploaderID(category, namePrefix)
|
||||
origURL := s.publicURL(useS3, publicBase, category, origName)
|
||||
_ = s.upsertMediaRecord(category, origName, origURL, int64(len(prepared.OrigData)), prepared.OrigContentType, storageType, uploader)
|
||||
if hasWebP {
|
||||
webpURL := s.publicURL(useS3, publicBase, category, webpName)
|
||||
_ = s.upsertMediaRecord(category, webpName, webpURL, int64(len(prepared.WebPData)), "image/webp", storageType, uploader)
|
||||
}
|
||||
|
||||
// 缩略图优先用 WebP 衍生(更小);否则用返回文件
|
||||
if category == UploadCategoryPosts && !useS3 {
|
||||
thumbFile := returnName
|
||||
if hasWebP {
|
||||
thumbFile = webpName
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join(category, thumbFile))
|
||||
go WarmPostImageThumb(s.UploadsRoot(), rel)
|
||||
}
|
||||
return publicURL, nil
|
||||
}
|
||||
|
||||
func (s *UploadStore) publicURL(useS3 bool, publicBase, category, filename string) string {
|
||||
if useS3 {
|
||||
return publicBase + "/" + category + "/" + filename
|
||||
}
|
||||
return "/uploads/" + category + "/" + filename
|
||||
}
|
||||
|
||||
func (s *UploadStore) putBytesLocal(category, filename string, data []byte) error {
|
||||
dir := filepath.Join(s.UploadsRoot(), category)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, filename), data, 0644)
|
||||
}
|
||||
|
||||
func (s *UploadStore) putBytesS3(backend *s3Backend, keyPrefix, category, filename, contentType string, data []byte) error {
|
||||
key := keyPrefix + category + "/" + filename
|
||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||
_, err := backend.client.PutObject(
|
||||
context.Background(),
|
||||
backend.bucket,
|
||||
key,
|
||||
bytes.NewReader(data),
|
||||
int64(len(data)),
|
||||
opts,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("上传到对象存储失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByURL 删除本站管理的上传文件(非本站 URL 则忽略)
|
||||
func (s *UploadStore) DeleteByURL(rawURL string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
siblingURLs := s.resolveSiblingPublicURLs(rawURL)
|
||||
|
||||
// 始终尝试清理本地 /uploads/…(兼容切换到 S3 前的旧文件)
|
||||
s.deleteLocalByURL(rawURL)
|
||||
|
||||
_, publicBase, keyPrefix, backend := s.snapshot()
|
||||
if backend != nil {
|
||||
s.deleteS3ByURL(backend, publicBase, keyPrefix, rawURL)
|
||||
}
|
||||
|
||||
s.deleteMediaRecords(siblingURLs)
|
||||
}
|
||||
|
||||
func (s *UploadStore) deleteLocalByURL(rawURL string) {
|
||||
path := rawURL
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return
|
||||
}
|
||||
if i := strings.Index(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
const prefix = "/uploads/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return
|
||||
}
|
||||
rel := strings.TrimPrefix(path, prefix)
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
for _, candidate := range uploadSiblingRels(relSlash) {
|
||||
full := filepath.Join(s.UploadsRoot(), filepath.FromSlash(candidate))
|
||||
_ = os.Remove(full)
|
||||
if strings.HasPrefix(candidate, UploadCategoryPosts+"/") {
|
||||
thumbDir := filepath.Join(s.UploadsRoot(), ".thumbs")
|
||||
_ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".webp"))
|
||||
_ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".jpg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UploadStore) deleteS3ByURL(backend *s3Backend, publicBase, keyPrefix, rawURL string) {
|
||||
if backend == nil || publicBase == "" {
|
||||
return
|
||||
}
|
||||
rel, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(rel, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return
|
||||
}
|
||||
for _, candidate := range uploadSiblingRels(parts[0] + "/" + parts[1]) {
|
||||
key := keyPrefix + candidate
|
||||
_ = backend.client.RemoveObject(context.Background(), backend.bucket, key, minio.RemoveObjectOptions{})
|
||||
}
|
||||
}
|
||||
|
||||
// uploadSiblingRels 返回同一主文件名下的自身与伴生扩展名路径(rel 使用 /)
|
||||
func uploadSiblingRels(rel string) []string {
|
||||
rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/"))
|
||||
ext := ""
|
||||
if i := strings.LastIndex(rel, "."); i >= 0 && i > strings.LastIndex(rel, "/") {
|
||||
ext = strings.ToLower(rel[i:])
|
||||
}
|
||||
stem := rel
|
||||
if ext != "" {
|
||||
stem = rel[:len(rel)-len(ext)]
|
||||
}
|
||||
if stem == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, 6)
|
||||
add := func(e string) {
|
||||
p := stem + e
|
||||
if seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
if ext != "" {
|
||||
add(ext)
|
||||
}
|
||||
for _, e := range siblingUploadExts(ext) {
|
||||
add(e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseS3Endpoint(raw string) (host string, secure bool, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false, errors.New("S3 ENDPOINT 不能为空")
|
||||
}
|
||||
secure = true
|
||||
if strings.Contains(raw, "://") {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("S3 ENDPOINT 无效: %w", err)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", false, errors.New("S3 ENDPOINT 无效")
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http":
|
||||
secure = false
|
||||
case "https":
|
||||
secure = true
|
||||
default:
|
||||
return "", false, fmt.Errorf("S3 ENDPOINT 不支持协议 %q", u.Scheme)
|
||||
}
|
||||
return u.Host, secure, nil
|
||||
}
|
||||
host = raw
|
||||
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
|
||||
secure = false
|
||||
}
|
||||
return host, secure, nil
|
||||
}
|
||||
|
||||
func relativeUnderPublicBase(rawURL, publicBase string) (string, bool) {
|
||||
publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/")
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if publicBase == "" || rawURL == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(rawURL, publicBase+"/") {
|
||||
rel := strings.TrimPrefix(rawURL, publicBase+"/")
|
||||
if i := strings.Index(rel, "?"); i >= 0 {
|
||||
rel = rel[:i]
|
||||
}
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return "", false
|
||||
}
|
||||
return rel, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
|
||||
// 注册解码器
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
@@ -23,13 +23,11 @@ import (
|
||||
const (
|
||||
// PostThumbMaxSide 正文预览图最长边(像素)
|
||||
PostThumbMaxSide = 1280
|
||||
// PostThumbJPEGQuality 预览图 JPEG 质量
|
||||
PostThumbJPEGQuality = 82
|
||||
)
|
||||
|
||||
var thumbLocks sync.Map // 同一原图并发生成时串行化
|
||||
|
||||
// ThumbURLFromUpload 将 /uploads/posts/xxx.jpg 转为 /media/thumb/posts/xxx.jpg
|
||||
// ThumbURLFromUpload 将 /uploads/posts/xxx.webp 转为 /media/thumb/posts/xxx.webp
|
||||
func ThumbURLFromUpload(uploadURL string) string {
|
||||
u := strings.TrimSpace(uploadURL)
|
||||
if u == "" {
|
||||
@@ -50,7 +48,7 @@ func WarmPostImageThumb(uploadsRoot, relativePath string) {
|
||||
}
|
||||
|
||||
// EnsureUploadThumb 确保缩略图存在,返回磁盘路径
|
||||
// relativePath 形如 posts/1_123.jpg(相对 uploads 根目录)
|
||||
// relativePath 形如 posts/1_123.webp(相对 uploads 根目录)
|
||||
func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
|
||||
rel, err := sanitizeUploadRel(relativePath)
|
||||
if err != nil {
|
||||
@@ -66,7 +64,7 @@ func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
|
||||
return "", errors.New("原图不存在")
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".jpg")
|
||||
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".webp")
|
||||
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
|
||||
return thumbPath, nil
|
||||
}
|
||||
@@ -82,7 +80,7 @@ func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
|
||||
return thumbPath, nil
|
||||
}
|
||||
|
||||
if err := generateJPEGThumb(origPath, thumbPath, PostThumbMaxSide, PostThumbJPEGQuality); err != nil {
|
||||
if err := generateWebPThumb(origPath, thumbPath, PostThumbMaxSide); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return thumbPath, nil
|
||||
@@ -114,7 +112,7 @@ func sanitizeUploadRel(relativePath string) (string, error) {
|
||||
return filepath.ToSlash(cleaned), nil
|
||||
}
|
||||
|
||||
func generateJPEGThumb(srcPath, dstPath string, maxSide, quality int) error {
|
||||
func generateWebPThumb(srcPath, dstPath string, maxSide int) error {
|
||||
f, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -127,25 +125,18 @@ func generateJPEGThumb(srcPath, dstPath string, maxSide, quality int) error {
|
||||
}
|
||||
|
||||
out := resizeToMax(img, maxSide)
|
||||
data, err := encodeWebPBytes(out, ThumbWebPQuality, UploadWebPMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
if err := os.WriteFile(tmp, data, 0644); 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
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var allowedImageExt = map[string]bool{
|
||||
@@ -19,44 +12,7 @@ var allowedImageExt = map[string]bool{
|
||||
".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, "/")
|
||||
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
|
||||
// SaveUploadedImage 保存图片到当前存储后端,返回公开 URL
|
||||
func SaveUploadedImage(store *UploadStore, file *multipart.FileHeader, category, namePrefix string) (string, error) {
|
||||
return store.SaveImage(file, category, namePrefix)
|
||||
}
|
||||
|
||||
@@ -116,13 +116,23 @@ func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error
|
||||
return model.DB.Model(&user).Update("password", hash).Error
|
||||
}
|
||||
|
||||
// UploadAvatar 上传头像到本地目录
|
||||
func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, uploadDir string) (string, error) {
|
||||
url, err := SaveUploadedImage(file, uploadDir, "/uploads/avatars", fmt.Sprintf("%d", userID))
|
||||
// UploadAvatar 上传头像;成功后删除用户旧头像文件,避免磁盘/对象存储堆积
|
||||
func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, store *UploadStore) (string, error) {
|
||||
var user model.User
|
||||
if err := model.DB.Select("id", "avatar").First(&user, userID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
url, err := SaveUploadedImage(store, file, UploadCategoryAvatars, fmt.Sprintf("%d", userID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if old := strings.TrimSpace(user.Avatar); old != "" && old != url {
|
||||
store.DeleteByURL(old)
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// ListUsers 管理员列出用户
|
||||
@@ -151,3 +161,24 @@ func (s *UserService) BanUser(userID uint, banned bool) error {
|
||||
}
|
||||
return model.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// SitemapUser 站点地图用的轻量用户字段
|
||||
type SitemapUser struct {
|
||||
ID uint
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ListSitemap 列出未禁言用户(供 sitemap)
|
||||
func (s *UserService) ListSitemap(limit int) ([]SitemapUser, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5000
|
||||
}
|
||||
var rows []SitemapUser
|
||||
err := model.DB.Model(&model.User{}).
|
||||
Select("id, updated_at").
|
||||
Where("banned = ?", false).
|
||||
Order("updated_at desc, id desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user