移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

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

View File

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

View File

@@ -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
View 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 有损质量0100
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
}

View File

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

View File

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

View File

@@ -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
View 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
View 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:])
}

View File

@@ -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.goSettingPermalinkEnabled / 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
View 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
}

View File

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

View File

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

View File

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