refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物

将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 03:54:56 +08:00
parent 1414c71dec
commit 9fe299a45f
449 changed files with 0 additions and 52779 deletions

View File

@@ -0,0 +1,37 @@
package service
import "testing"
func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
in := []AsideWidget{
{ID: AsideWidgetFriendLinks, Enabled: true},
{ID: AsideWidgetTagCloud, Enabled: true},
{ID: AsideWidgetRecentComments, Enabled: false},
}
out := NormalizeAsideWidgets(in)
if len(out) != 4 {
t.Fatalf("want 4 widgets, got %d", len(out))
}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
for i, id := range want {
if out[i].ID != id {
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
}
}
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
t.Fatalf("enabled flags mismatch: %+v", out)
}
}
func TestAsideBoolsFromWidgets(t *testing.T) {
widgets := []AsideWidget{
{ID: AsideWidgetRecentComments, Enabled: true},
{ID: AsideWidgetFriendLinks, Enabled: false},
{ID: AsideWidgetTagCloud, Enabled: true},
{ID: AsideWidgetRecentUsers, Enabled: true},
}
bools := asideBoolsFromWidgets(widgets)
if !bools.tagCloud || !bools.recentComments || bools.friendLinks {
t.Fatalf("unexpected bools: %+v", bools)
}
}

170
services/auth.go Normal file
View File

@@ -0,0 +1,170 @@
package service
import (
"errors"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"git.iioio.com/freefire/jiang13-forum/model"
)
// 最近访问写入节流,避免每次 API 都打库
const lastAccessTouchInterval = 5 * time.Minute
var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
const TokenExpire = 7 * 24 * time.Hour
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role model.Role `json:"role"`
jwt.RegisteredClaims
}
type AuthService struct {
jwtSecret string
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
}
// UserCount 当前用户数
func (s *AuthService) UserCount() int64 {
var n int64
model.DB.Model(&model.User{}).Count(&n)
return n
}
// Register 用户注册
func (s *AuthService) Register(username, password, nickname, email string) (*model.User, error) {
if err := ValidateUsername(username); err != nil {
return nil, err
}
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
return nil, err
}
email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil {
return nil, err
}
var exist model.User
if err := model.DB.Where("username = ?", username).First(&exist).Error; err == nil {
return nil, ErrUserExists
}
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
return nil, ErrEmailExists
}
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
if nickname == "" {
nickname = username
}
nickname = s.filter.Filter(nickname)
// 首个注册用户自动成为管理员
role := model.RoleUser
if s.UserCount() == 0 {
role = model.RoleAdmin
}
user := &model.User{
Username: username,
Email: email,
Password: hash,
Nickname: nickname,
Role: role,
}
if err := model.DB.Create(user).Error; err != nil {
return nil, err
}
return user, nil
}
// Login 用户登录,返回 JWT tokenclientIP 写入上次登录记录
func (s *AuthService) Login(username, password, clientIP string) (string, *model.User, error) {
var user model.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
return "", nil, ErrInvalidCred
}
if user.Banned {
return "", nil, ErrUserBanned
}
if !CheckPassword(user.Password, password) {
return "", nil, ErrInvalidCred
}
s.recordLogin(&user, clientIP)
token, err := s.GenerateToken(&user)
return token, &user, err
}
// recordLogin 记录上次登录时间与 IP登录同时视为一次访问失败不影响登录
func (s *AuthService) recordLogin(user *model.User, clientIP string) {
now := time.Now()
ip := clientIP
if len(ip) > 45 {
ip = ip[:45]
}
_ = model.DB.Model(user).Updates(map[string]interface{}{
"last_login_at": now,
"last_login_ip": ip,
"last_access_at": now,
}).Error
user.LastLoginAt = &now
user.LastLoginIP = ip
user.LastAccessAt = &now
lastAccessTouchCache.Store(user.ID, now)
}
// TouchLastAccess 记录最近访问时间(节流写入,失败忽略)
func (s *AuthService) TouchLastAccess(userID uint) {
if userID == 0 {
return
}
now := time.Now()
if v, ok := lastAccessTouchCache.Load(userID); ok {
if t, ok := v.(time.Time); ok && now.Sub(t) < lastAccessTouchInterval {
return
}
}
lastAccessTouchCache.Store(userID, now)
_ = model.DB.Model(&model.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
}
// GenerateToken 生成 JWT
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.jwtSecret))
}
// ParseToken 解析 JWT
func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(s.jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}

52
services/backup.go Normal file
View File

@@ -0,0 +1,52 @@
package service
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type BackupService struct {
dbPath string
dataDir string
}
func NewBackupService(dbPath, dataDir string) *BackupService {
return &BackupService{dbPath: dbPath, dataDir: dataDir}
}
// ExportSQLite 导出 SQLite 备份文件到 data 目录
func (s *BackupService) ExportSQLite() (string, error) {
src, err := os.Open(s.dbPath)
if err != nil {
return "", fmt.Errorf("打开数据库失败: %w", err)
}
defer src.Close()
filename := fmt.Sprintf("jiang13_backup_%s.db", time.Now().Format("20060102_150405"))
destPath := filepath.Join(s.dataDir, filename)
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
return destPath, nil
}
// WriteDefaultFilterWords 写入默认敏感词配置
func WriteDefaultFilterWords(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
content := `# 姜十三论坛敏感词配置,每行一个词,# 开头为注释
违禁词示例
广告刷单
`
return os.WriteFile(path, []byte(content), 0644)
}

272
services/badge.go Normal file
View File

@@ -0,0 +1,272 @@
package service
import (
"errors"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
// BadgeService 徽章定义与发放
type BadgeService struct{}
func NewBadgeService() *BadgeService { return &BadgeService{} }
// ListDefs 列出徽章定义
func (s *BadgeService) ListDefs(includeDisabled bool) ([]model.BadgeDef, error) {
q := model.DB.Order("sort_order asc, id asc")
if !includeDisabled {
q = q.Where("enabled = ?", true)
}
var rows []model.BadgeDef
err := q.Find(&rows).Error
return rows, err
}
// UpsertDef 创建或更新徽章定义(按 code
func (s *BadgeService) UpsertDef(def *model.BadgeDef) error {
if def.Code == "" || def.Name == "" {
return errors.New("徽章代码与名称不能为空")
}
if def.Kind != model.BadgeKindAuto && def.Kind != model.BadgeKindLimited {
return errors.New("无效的徽章类型")
}
var existing model.BadgeDef
err := model.DB.Where("code = ?", def.Code).Limit(1).Find(&existing).Error
if err != nil {
return err
}
if existing.ID == 0 {
return model.DB.Create(def).Error
}
def.ID = existing.ID
return model.DB.Model(&existing).Updates(map[string]interface{}{
"name": def.Name,
"description": def.Description,
"icon": def.Icon,
"kind": def.Kind,
"metric": def.Metric,
"threshold": def.Threshold,
"sort_order": def.SortOrder,
"enabled": def.Enabled,
}).Error
}
// AwardLimited 站长颁发限定徽章
func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error {
var def model.BadgeDef
if err := model.DB.First(&def, badgeID).Error; err != nil {
return errors.New("徽章不存在")
}
if def.Kind != model.BadgeKindLimited {
return errors.New("仅可颁发限定徽章")
}
if !def.Enabled {
return errors.New("徽章已停用")
}
var n int64
model.DB.Model(&model.UserBadge{}).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&n)
if n > 0 {
return errors.New("用户已拥有该徽章")
}
return model.DB.Create(&model.UserBadge{
UserID: userID,
BadgeID: badgeID,
AwardedAt: time.Now(),
AwardedBy: adminID,
}).Error
}
// Revoke 收回徽章
func (s *BadgeService) Revoke(userID, badgeID uint) error {
res := model.DB.Where("user_id = ? AND badge_id = ?", userID, badgeID).Delete(&model.UserBadge{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("用户未拥有该徽章")
}
return nil
}
// ListUserBadges 用户已获徽章(含定义)
func (s *BadgeService) ListUserBadges(userID uint) ([]model.UserBadge, error) {
var rows []model.UserBadge
err := model.DB.Preload("Badge").Where("user_id = ?", userID).
Order("awarded_at desc").Find(&rows).Error
return rows, err
}
// BadgeViews 转为展示视图(最多 limit 枚0=全部)
func BadgeViews(rows []model.UserBadge, limit int) []model.UserBadgeView {
out := make([]model.UserBadgeView, 0, len(rows))
for _, r := range rows {
if r.Badge.ID == 0 || !r.Badge.Enabled {
continue
}
out = append(out, model.UserBadgeView{
Code: r.Badge.Code,
Name: r.Badge.Name,
Description: r.Badge.Description,
Icon: r.Badge.Icon,
Kind: r.Badge.Kind,
})
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
// EvaluateAuto 检查并授予符合条件的自动徽章
func (s *BadgeService) EvaluateAuto(userID uint) error {
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
return err
}
var defs []model.BadgeDef
if err := model.DB.Where("kind = ? AND enabled = ?", model.BadgeKindAuto, true).Find(&defs).Error; err != nil {
return err
}
tenureDays := int(time.Since(user.CreatedAt).Hours() / 24)
var likes int64
_ = model.DB.Model(&model.Post{}).
Select("COALESCE(SUM(like_count), 0)").
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
Scan(&likes).Error
income := user.CreatorIncomeTotal
owned := map[uint]bool{}
var existing []model.UserBadge
_ = model.DB.Where("user_id = ?", userID).Find(&existing).Error
for _, e := range existing {
owned[e.BadgeID] = true
}
for _, d := range defs {
if owned[d.ID] {
continue
}
ok := false
switch d.Metric {
case model.BadgeMetricTenureDays:
ok = tenureDays >= d.Threshold
case model.BadgeMetricLikesReceived:
ok = int(likes) >= d.Threshold
case model.BadgeMetricCreatorIncome:
ok = income >= d.Threshold
}
if !ok {
continue
}
_ = model.DB.Create(&model.UserBadge{
UserID: userID,
BadgeID: d.ID,
AwardedAt: time.Now(),
AwardedBy: 0,
}).Error
}
return nil
}
// AttachBadgeSummaries 批量为用户填充展示用徽章(最多 perUser 枚)
func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
if len(users) == 0 {
return
}
if perUser <= 0 {
perUser = 3
}
ids := make([]uint, 0, len(users))
seen := map[uint]bool{}
for _, u := range users {
if u == nil || u.ID == 0 {
continue
}
u.Level = model.LevelFromExp(u.Exp)
if !seen[u.ID] {
seen[u.ID] = true
ids = append(ids, u.ID)
}
}
if len(ids) == 0 {
return
}
var rows []model.UserBadge
_ = model.DB.Preload("Badge").Where("user_id IN ?", ids).
Order("awarded_at desc").Find(&rows).Error
grouped := map[uint][]model.UserBadgeView{}
for _, r := range rows {
if r.Badge.ID == 0 || !r.Badge.Enabled {
continue
}
list := grouped[r.UserID]
if len(list) >= perUser {
continue
}
list = append(list, model.UserBadgeView{
Code: r.Badge.Code,
Name: r.Badge.Name,
Description: r.Badge.Description,
Icon: r.Badge.Icon,
Kind: r.Badge.Kind,
})
grouped[r.UserID] = list
}
for _, u := range users {
if u == nil || u.ID == 0 {
continue
}
u.Badges = grouped[u.ID]
}
}
// AttachBadgeSummariesOnPosts 给帖子作者填充徽章摘要
func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []model.Post, perUser int) {
users := make([]*model.User, 0, len(posts))
for i := range posts {
if posts[i].User.ID > 0 {
users = append(users, &posts[i].User)
}
}
s.AttachBadgeSummaries(users, perUser)
}
// AttachBadgeSummariesOnComments 给评论作者填充徽章摘要
func (s *BadgeService) AttachBadgeSummariesOnComments(comments []model.Comment, perUser int) {
users := make([]*model.User, 0, len(comments))
for i := range comments {
if comments[i].User.ID > 0 {
users = append(users, &comments[i].User)
}
}
s.AttachBadgeSummaries(users, perUser)
}
// AddExp 增加经验不可为负消耗delta<=0 忽略)
func AddExp(userID uint, delta int) {
if userID == 0 || delta <= 0 {
return
}
_ = model.DB.Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
}
// SetUserLevel 站长设等级(调整 Exp 到门槛)
func SetUserLevel(userID uint, level int) error {
if level < 1 || level > model.MaxLevel() {
return errors.New("等级须在 110")
}
exp := model.ExpForLevel(level)
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("exp", exp).Error
}
// SetVerified 设置认证
func SetVerified(userID uint, verified bool) error {
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
return errors.New("用户不存在")
}
return model.DB.Model(&user).Update("verified", verified).Error
}

87
services/board.go Normal file
View File

@@ -0,0 +1,87 @@
package service
import (
"errors"
"git.iioio.com/freefire/jiang13-forum/model"
)
type BoardService struct{}
func NewBoardService() *BoardService {
return &BoardService{}
}
// BoardWithStats 板块及帖子数量
type BoardWithStats struct {
model.Board
PostCount int `json:"post_count"`
}
func (s *BoardService) List() ([]model.Board, error) {
var boards []model.Board
err := model.DB.Order("sort_order asc, id asc").Find(&boards).Error
return boards, err
}
func (s *BoardService) ListWithStats() ([]BoardWithStats, error) {
boards, err := s.List()
if err != nil {
return nil, err
}
result := make([]BoardWithStats, len(boards))
for i, b := range boards {
var count int64
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
}
func (s *BoardService) GetByID(id uint) (*model.Board, error) {
var board model.Board
if err := model.DB.First(&board, id).Error; err != nil {
return nil, ErrBoardNotFound
}
return &board, nil
}
func (s *BoardService) Create(name, desc, icon string, colorIndex, sortOrder int) (*model.Board, error) {
board := &model.Board{
Name: name,
Description: desc,
Icon: NormalizeBoardIcon(icon),
ColorIndex: NormalizeBoardColorIndex(colorIndex),
SortOrder: sortOrder,
}
return board, model.DB.Create(board).Error
}
func (s *BoardService) Update(id uint, name, desc, icon string, colorIndex, sortOrder int) error {
return model.DB.Model(&model.Board{}).Where("id = ?", id).Updates(map[string]interface{}{
"name": name,
"description": desc,
"icon": NormalizeBoardIcon(icon),
"color_index": NormalizeBoardColorIndex(colorIndex),
"sort_order": sortOrder,
}).Error
}
func (s *BoardService) Delete(id uint) error {
var count int64
model.DB.Model(&model.Post{}).Where("board_id = ?", id).Count(&count)
if count > 0 {
return errors.New("该板块下还有帖子,无法删除")
}
return model.DB.Delete(&model.Board{}, id).Error
}
// EnsureDefaultBoard 若尚无板块则创建默认「综合讨论」,便于全新安装后直接发帖
func (s *BoardService) EnsureDefaultBoard() {
var n int64
if err := model.DB.Model(&model.Board{}).Count(&n).Error; err != nil || n > 0 {
return
}
_, _ = s.Create("综合讨论", "默认板块,欢迎发帖交流", "message-square", 0, 0)
}

36
services/board_icon.go Normal file
View File

@@ -0,0 +1,36 @@
package service
import "strings"
// AllowedBoardIcons 与前端 BOARD_ICON_OPTIONS 的 key 保持一致
var AllowedBoardIcons = map[string]struct{}{
"code-2": {}, "coffee": {}, "help-circle": {}, "message-square": {},
"lightbulb": {}, "book-open": {}, "gamepad-2": {}, "palette": {},
"music": {}, "camera": {}, "heart": {}, "zap": {},
"globe": {}, "users": {}, "briefcase": {}, "graduation-cap": {},
"shopping-bag": {}, "map-pin": {}, "megaphone": {}, "flame": {},
"star": {}, "folder": {}, "wrench": {}, "cpu": {},
}
// NormalizeBoardIcon 校验并规范化板块图标 key非法或空则返回空串
func NormalizeBoardIcon(icon string) string {
icon = strings.TrimSpace(strings.ToLower(icon))
if icon == "" {
return ""
}
if _, ok := AllowedBoardIcons[icon]; !ok {
return ""
}
return icon
}
// NormalizeBoardColorIndex -1 表示自动07 为有效色槽
func NormalizeBoardColorIndex(colorIndex int) int {
if colorIndex < 0 {
return -1
}
if colorIndex > 7 {
return colorIndex % 8
}
return colorIndex
}

150
services/bounty.go Normal file
View File

@@ -0,0 +1,150 @@
package service
import (
"errors"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
var (
ErrBountyNotOpen = errors.New("悬赏已结束或已退回")
ErrBountySelfAward = errors.New("不能采纳自己的回复")
ErrBountyInvalidPoint = errors.New("悬赏积分至少为 1")
ErrBountyRefundBlocked = errors.New("已有用户回复,无法自行取消悬赏,请采纳优质回复或联系管理员")
)
const bountyRefundBlockReason = "已有用户回复,无法自行取消悬赏,请采纳优质回复或联系管理员"
// CountEligibleBountyReplies 统计他人已发布的有效回复数(不含楼主)
func CountEligibleBountyReplies(db *gorm.DB, postID, authorID uint) (int64, error) {
if db == nil {
db = model.DB
}
var n int64
err := db.Model(&model.Comment{}).
Where("post_id = ? AND status = ? AND user_id != ?", postID, model.ContentStatusPublished, authorID).
Count(&n).Error
return n, err
}
// CanRefundBounty 当前查看者是否可取消悬赏(管理员始终可强制取消)
func CanRefundBounty(post *model.Post, viewerIsAdmin bool) (bool, string) {
if post == nil || post.PostType != model.PostTypeBounty {
return false, ""
}
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
return false, ""
}
if viewerIsAdmin {
return true, ""
}
n, err := CountEligibleBountyReplies(model.DB, post.ID, post.UserID)
if err != nil {
return false, ""
}
if n > 0 {
return false, bountyRefundBlockReason
}
return true, ""
}
// EscrowBounty 发帖时托管悬赏积分
func EscrowBounty(tx *gorm.DB, userID, postID uint, points int) error {
if points < 1 {
return ErrBountyInvalidPoint
}
_, err := AdjustPointsTx(tx, userID, -points, model.PointReasonBountyEscrow, "post", postID, "发布悬赏帖")
return err
}
// AwardBounty 采纳评论并发放悬赏
func AwardBounty(postID, operatorID uint, isAdmin bool, commentID uint) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if post.PostType != model.PostTypeBounty {
return errors.New("非悬赏帖")
}
if !isAdmin && post.UserID != operatorID {
return ErrPermissionDenied
}
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
return ErrBountyNotOpen
}
var comment model.Comment
if err := model.DB.First(&comment, commentID).Error; err != nil {
return errors.New("评论不存在")
}
if comment.PostID != postID || comment.Status != model.ContentStatusPublished {
return errors.New("评论无效")
}
if comment.UserID == post.UserID {
return ErrBountySelfAward
}
points := post.BountyPoints
return model.DB.Transaction(func(tx *gorm.DB) error {
if _, err := AdjustPointsTx(tx, comment.UserID, points, model.PointReasonBountyAward, "post", postID, "悬赏采纳"); err != nil {
return err
}
return tx.Model(&post).Updates(map[string]interface{}{
"bounty_status": model.BountyStatusAwarded,
"bounty_comment_id": commentID,
}).Error
})
}
// RefundBounty 取消悬赏并退回积分
func RefundBounty(postID, operatorID uint, isAdmin bool) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if post.PostType != model.PostTypeBounty {
return errors.New("非悬赏帖")
}
if !isAdmin && post.UserID != operatorID {
return ErrPermissionDenied
}
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
return ErrBountyNotOpen
}
if !isAdmin {
n, err := CountEligibleBountyReplies(model.DB, post.ID, post.UserID)
if err != nil {
return err
}
if n > 0 {
return ErrBountyRefundBlocked
}
}
points := post.BountyPoints
return model.DB.Transaction(func(tx *gorm.DB) error {
if _, err := AdjustPointsTx(tx, post.UserID, points, model.PointReasonBountyRefund, "post", postID, "悬赏退回"); err != nil {
return err
}
return tx.Model(&post).Updates(map[string]interface{}{
"bounty_status": model.BountyStatusRefunded,
"bounty_points": 0,
}).Error
})
}
// RefundBountyIfOpen 删帖时自动退回未采纳悬赏
func RefundBountyIfOpen(tx *gorm.DB, post *model.Post) error {
if post == nil || post.PostType != model.PostTypeBounty {
return nil
}
if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
return nil
}
points := post.BountyPoints
if _, err := AdjustPointsTx(tx, post.UserID, points, model.PointReasonBountyRefund, "post", post.ID, "删帖退回悬赏"); err != nil {
return err
}
return tx.Model(post).Updates(map[string]interface{}{
"bounty_status": model.BountyStatusRefunded,
"bounty_points": 0,
}).Error
}

175
services/bounty_test.go Normal file
View File

@@ -0,0 +1,175 @@
package service
import (
"errors"
"testing"
"github.com/glebarez/sqlite"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
func setupBountyTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Post{}, &model.Comment{}, &model.PointLedger{}); err != nil {
t.Fatal(err)
}
prev := model.DB
model.DB = db
t.Cleanup(func() { model.DB = prev })
return db
}
func seedBountyPost(t *testing.T, db *gorm.DB, authorID uint, points int) model.Post {
t.Helper()
post := model.Post{
UserID: authorID,
BoardID: 1,
Title: "悬赏测试",
Content: "内容",
PostType: model.PostTypeBounty,
BountyPoints: points,
BountyStatus: model.BountyStatusOpen,
Status: model.ContentStatusPublished,
}
if err := db.Create(&post).Error; err != nil {
t.Fatal(err)
}
return post
}
func seedUser(t *testing.T, db *gorm.DB, id uint, points int) {
t.Helper()
u := model.User{
ID: id,
Username: "user" + string(rune('0'+id)),
Password: "hash",
Nickname: "测试",
Points: points,
}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
}
func seedComment(t *testing.T, db *gorm.DB, postID, userID uint, floor int, status string) {
t.Helper()
c := model.Comment{
PostID: postID,
UserID: userID,
Floor: floor,
Content: "回复",
Status: status,
}
if err := db.Create(&c).Error; err != nil {
t.Fatal(err)
}
}
func TestCountEligibleBountyReplies(t *testing.T) {
db := setupBountyTestDB(t)
post := seedBountyPost(t, db, 1, 10)
n, err := CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 0 {
t.Fatalf("无回复时期望 0得到 %d err=%v", n, err)
}
seedComment(t, db, post.ID, 1, 1, model.ContentStatusPublished)
n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 0 {
t.Fatalf("楼主自己的回复不应计入,得到 %d", n)
}
seedComment(t, db, post.ID, 2, 2, model.ContentStatusPublished)
n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 1 {
t.Fatalf("他人 published 回复期望 1得到 %d", n)
}
seedComment(t, db, post.ID, 3, 3, model.ContentStatusPending)
n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 1 {
t.Fatalf("pending 回复不应增加计数,得到 %d", n)
}
seedComment(t, db, post.ID, 0, 4, model.ContentStatusPublished)
n, err = CountEligibleBountyReplies(db, post.ID, 1)
if err != nil || n != 2 {
t.Fatalf("游客回复应计入,得到 %d", n)
}
}
func TestCanRefundBounty(t *testing.T) {
db := setupBountyTestDB(t)
post := seedBountyPost(t, db, 1, 5)
can, reason := CanRefundBounty(&post, false)
if !can || reason != "" {
t.Fatalf("无回复时楼主应可退can=%v reason=%q", can, reason)
}
seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished)
can, reason = CanRefundBounty(&post, false)
if can || reason != bountyRefundBlockReason {
t.Fatalf("有他人回复时楼主不可退can=%v reason=%q", can, reason)
}
can, reason = CanRefundBounty(&post, true)
if !can || reason != "" {
t.Fatalf("管理员应可强制退can=%v reason=%q", can, reason)
}
}
func TestRefundBountyBlockedForAuthorWithReplies(t *testing.T) {
db := setupBountyTestDB(t)
seedUser(t, db, 1, 0)
seedUser(t, db, 2, 0)
post := seedBountyPost(t, db, 1, 8)
seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished)
err := RefundBounty(post.ID, 1, false)
if !errors.Is(err, ErrBountyRefundBlocked) {
t.Fatalf("楼主有他人回复时应拒绝退回err=%v", err)
}
}
func TestRefundBountyAllowedWithoutReplies(t *testing.T) {
db := setupBountyTestDB(t)
seedUser(t, db, 1, 0)
post := seedBountyPost(t, db, 1, 6)
if err := RefundBounty(post.ID, 1, false); err != nil {
t.Fatalf("无回复时楼主应可退回err=%v", err)
}
var updated model.Post
if err := db.First(&updated, post.ID).Error; err != nil {
t.Fatal(err)
}
if updated.BountyStatus != model.BountyStatusRefunded || updated.BountyPoints != 0 {
t.Fatalf("状态应为 refunded 且积分为 0得到 status=%s points=%d", updated.BountyStatus, updated.BountyPoints)
}
var author model.User
if err := db.First(&author, 1).Error; err != nil {
t.Fatal(err)
}
if author.Points != 6 {
t.Fatalf("楼主应收回 6 积分,余额=%d", author.Points)
}
}
func TestRefundBountyAdminBypassWithReplies(t *testing.T) {
db := setupBountyTestDB(t)
seedUser(t, db, 1, 0)
seedUser(t, db, 2, 0)
post := seedBountyPost(t, db, 1, 4)
seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished)
if err := RefundBounty(post.ID, 99, true); err != nil {
t.Fatalf("管理员应可强制退回err=%v", err)
}
}

158
services/captcha.go Normal file
View File

@@ -0,0 +1,158 @@
package service
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
"strings"
"sync"
"time"
)
const (
captchaLen = 4
captchaTTL = 5 * time.Minute
captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
captchaWidth = 120
captchaHeight = 40
)
type captchaEntry struct {
answer string
expiresAt time.Time
}
// CaptchaService 内存图形验证码
type CaptchaService struct {
mu sync.Mutex
entries map[string]captchaEntry
}
func NewCaptchaService() *CaptchaService {
s := &CaptchaService{entries: make(map[string]captchaEntry)}
go s.cleanup()
return s
}
// Generate 生成验证码,返回 id 与 SVG 图片
func (s *CaptchaService) Generate() (id, svg string, err error) {
answer, err := randomCaptchaText(captchaLen)
if err != nil {
return "", "", err
}
rawID := make([]byte, 16)
if _, err := rand.Read(rawID); err != nil {
return "", "", err
}
id = hex.EncodeToString(rawID)
s.mu.Lock()
s.entries[id] = captchaEntry{
answer: strings.ToUpper(answer),
expiresAt: time.Now().Add(captchaTTL),
}
s.mu.Unlock()
return id, renderCaptchaSVG(answer), nil
}
// Verify 校验验证码(一次性,大小写不敏感)
func (s *CaptchaService) Verify(id, answer string) bool {
if id == "" || answer == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[id]
if !ok {
return false
}
delete(s.entries, id)
if time.Now().After(entry.expiresAt) {
return false
}
return strings.EqualFold(entry.answer, strings.TrimSpace(answer))
}
func (s *CaptchaService) cleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
s.mu.Lock()
for id, entry := range s.entries {
if now.After(entry.expiresAt) {
delete(s.entries, id)
}
}
s.mu.Unlock()
}
}
func randomCaptchaText(n int) (string, error) {
var b strings.Builder
b.Grow(n)
max := big.NewInt(int64(len(captchaChars)))
for i := 0; i < n; i++ {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b.WriteByte(captchaChars[idx.Int64()])
}
return b.String(), nil
}
func renderCaptchaSVG(text string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
captchaWidth, captchaHeight, captchaWidth, captchaHeight,
))
b.WriteString(`<rect width="100%" height="100%" fill="#f4f6f5"/>`)
// 干扰线
for i := 0; i < 4; i++ {
x1, _ := randInt(0, captchaWidth)
y1, _ := randInt(0, captchaHeight)
x2, _ := randInt(0, captchaWidth)
y2, _ := randInt(0, captchaHeight)
color := noiseColor()
b.WriteString(fmt.Sprintf(
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`,
x1, y1, x2, y2, color,
))
}
step := captchaWidth / (len(text) + 1)
for i, ch := range text {
x := step*(i+1) - 6
y, _ := randInt(26, 34)
rot, _ := randInt(-18, 18)
b.WriteString(fmt.Sprintf(
`<text x="%d" y="%d" fill="#2d5a45" font-size="22" font-family="monospace" font-weight="700" transform="rotate(%d %d %d)">%c</text>`,
x, y, rot, x+6, y-6, ch,
))
}
b.WriteString(`</svg>`)
return b.String()
}
func randInt(min, max int) (int, error) {
if max <= min {
return min, nil
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return min, err
}
return min + int(n.Int64()), nil
}
func noiseColor() string {
r, _ := randInt(160, 210)
g, _ := randInt(170, 220)
b, _ := randInt(160, 210)
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}

656
services/comment.go Normal file
View File

@@ -0,0 +1,656 @@
package service
import (
"errors"
"strings"
"time"
"gorm.io/gorm"
"git.iioio.com/freefire/jiang13-forum/model"
)
type CommentService struct {
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewCommentService(filter *SensitiveFilter, settings *ForumSettingsService) *CommentService {
return &CommentService{filter: filter, settings: settings}
}
// HasUserReplied 用户是否已在该帖发表过有效评论(已发布或审核中,不含被拒)
func (s *CommentService) HasUserReplied(postID, userID uint) bool {
if postID == 0 || userID == 0 {
return false
}
var count int64
err := model.DB.Model(&model.Comment{}).
Where("post_id = ? AND user_id = ? AND status IN ?", postID, userID,
[]string{model.ContentStatusPublished, model.ContentStatusPending}).
Limit(1).
Count(&count).Error
return err == nil && count > 0
}
type CommentCreateInput struct {
UserID uint
PostID uint
Content string
ReplyTo *uint
GuestNick string
GuestEmail string
GuestURL string
IsPrivate bool
}
func (s *CommentService) canViewPrivate(c model.Comment, viewerID uint, isAdmin bool, postAuthorID uint, guestSet map[uint]struct{}) bool {
if !c.IsPrivate {
return true
}
if isAdmin {
return true
}
if viewerID > 0 && viewerID == postAuthorID {
return true
}
if c.UserID > 0 && viewerID == c.UserID {
return true
}
if _, ok := guestSet[c.ID]; ok {
return true
}
return false
}
func (s *CommentService) fillReplyTargets(comments []model.Comment, loadMissing bool) {
idMap := make(map[uint]model.Comment, len(comments))
for _, c := range comments {
idMap[c.ID] = c
}
for i := range comments {
if comments[i].ReplyTo == nil {
continue
}
if target, ok := idMap[*comments[i].ReplyTo]; ok {
t := target
comments[i].ReplyTarget = &t
continue
}
if loadMissing {
var target model.Comment
if model.DB.Preload("User").First(&target, *comments[i].ReplyTo).Error == nil {
comments[i].ReplyTarget = &target
}
}
}
}
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
if err != nil {
return nil, err
}
guestSet := make(map[uint]struct{}, len(visibleGuestIDs))
for _, id := range visibleGuestIDs {
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])
}
// 父评论不可见时,回挂到最近可见祖先,避免回复在游客侧变成独立顶层评论
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
}
}
s.fillLiked(visible, viewerID)
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) {
content := SanitizePostHTML(strings.TrimSpace(in.Content))
content = s.filter.Filter(content)
if content == "" {
return nil, errors.New("评论内容不能为空")
}
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
return nil, err
}
var post model.Post
if err := model.DB.First(&post, in.PostID).Error; err != nil {
return nil, ErrPostNotFound
}
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.CommentsLocked {
return nil, ErrPostCommentsLocked
}
// 未公开帖仅作者/管理员可评论
if post.Status != model.ContentStatusPublished && post.Status != "" {
if user.Role != model.RoleAdmin && post.UserID != in.UserID {
return nil, errors.New("帖子审核中,暂不可评论")
}
}
var maxFloor int
model.DB.Model(&model.Comment{}).Where("post_id = ?", in.PostID).Select("COALESCE(MAX(floor), 0)").Scan(&maxFloor)
if in.ReplyTo != nil {
var target model.Comment
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.SkipsModeration() {
status = model.ContentStatusPublished
}
comment := &model.Comment{
PostID: in.PostID,
UserID: in.UserID,
Floor: maxFloor + 1,
Content: content,
ReplyTo: in.ReplyTo,
GuestNick: strings.TrimSpace(in.GuestNick),
GuestEmail: strings.TrimSpace(in.GuestEmail),
GuestURL: strings.TrimSpace(in.GuestURL),
IsPrivate: in.IsPrivate,
Status: status,
}
if err := model.DB.Create(comment).Error; err != nil {
return nil, err
}
if status == model.ContentStatusPublished {
AddExp(in.UserID, 2)
}
return comment, nil
}
// SetStatus 设置评论审核状态
func (s *CommentService) SetStatus(commentID uint, status string) error {
switch status {
case model.ContentStatusPending, model.ContentStatusPublished, model.ContentStatusRejected:
default:
return errors.New("无效的审核状态")
}
var comment model.Comment
if err := model.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
prev := comment.Status
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 status == model.ContentStatusPublished && prev != model.ContentStatusPublished && comment.UserID > 0 {
AddExp(comment.UserID, 2)
}
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
}
// fillLiked 批量标记当前用户是否已点赞
func (s *CommentService) fillLiked(comments []model.Comment, viewerID uint) {
if viewerID == 0 || len(comments) == 0 {
return
}
ids := make([]uint, 0, len(comments))
for _, c := range comments {
ids = append(ids, c.ID)
}
var likes []model.CommentLike
model.DB.Where("user_id = ? AND comment_id IN ?", viewerID, ids).Find(&likes)
likedSet := make(map[uint]struct{}, len(likes))
for _, l := range likes {
likedSet[l.CommentID] = struct{}{}
}
for i := range comments {
_, comments[i].Liked = likedSet[comments[i].ID]
}
}
// ToggleLike 切换评论点赞
func (s *CommentService) ToggleLike(userID, commentID uint) (liked bool, likeCount int, err error) {
var comment model.Comment
if err := model.DB.Select("id", "like_count").First(&comment, commentID).Error; err != nil {
return false, 0, ErrCommentNotFound
}
var like model.CommentLike
result := model.DB.Where("comment_id = ? AND user_id = ?", commentID, userID).Limit(1).Find(&like)
if result.Error != nil {
return false, 0, result.Error
}
if result.RowsAffected > 0 {
if err := model.DB.Delete(&like).Error; err != nil {
return false, 0, err
}
model.DB.Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END"))
_ = model.DB.Select("like_count").First(&comment, commentID)
return false, comment.LikeCount, nil
}
like = model.CommentLike{CommentID: commentID, UserID: userID}
if err := model.DB.Create(&like).Error; err != nil {
return false, 0, err
}
model.DB.Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
_ = model.DB.Select("like_count").First(&comment, commentID)
return true, comment.LikeCount, nil
}
// IsLiked 用户是否已点赞该评论
func (s *CommentService) IsLiked(userID, commentID uint) bool {
if userID == 0 || commentID == 0 {
return false
}
var count int64
model.DB.Model(&model.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count)
return count > 0
}
// 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 s.AdminDelete(commentID)
}
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
var comment model.Comment
if err := model.DB.First(&comment, commentID).Error; err != nil {
return "", false, ErrCommentNotFound
}
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
return "", false, ErrPermissionDenied
}
if !isAdmin {
window := s.settings.CommentEditWindowMinutes()
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Minute {
return "", false, errors.New("已超过可编辑时限")
}
}
content = SanitizePostHTML(strings.TrimSpace(content))
content = s.filter.Filter(content)
if content == "" {
return "", false, errors.New("评论内容不能为空")
}
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
return "", false, err
}
if content == comment.Content {
return content, false, nil
}
enteredPending := false
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 !skipModeration {
updates["status"] = model.ContentStatusPending
enteredPending = true
}
return tx.Model(&comment).Updates(updates).Error
})
if err != nil {
return "", false, err
}
return content, enteredPending, nil
}
// collectReplySubtreeIDs 沿 reply_to BFS 收集子树 ID含 rootID
// softDeletedOnly 为 true 时仅收集已软删节点(用于回收站恢复/永久删除)
func collectReplySubtreeIDs(db *gorm.DB, rootID uint, softDeletedOnly bool) ([]uint, error) {
q := db
if softDeletedOnly {
q = db.Unscoped()
}
ids := []uint{rootID}
seen := map[uint]struct{}{rootID: {}}
frontier := []uint{rootID}
for len(frontier) > 0 {
childQ := q.Model(&model.Comment{}).Select("id").Where("reply_to IN ?", frontier)
if softDeletedOnly {
childQ = childQ.Where("deleted_at IS NOT NULL")
}
var children []model.Comment
if err := childQ.Find(&children).Error; err != nil {
return nil, err
}
frontier = frontier[:0]
for _, c := range children {
if _, ok := seen[c.ID]; ok {
continue
}
seen[c.ID] = struct{}{}
ids = append(ids, c.ID)
frontier = append(frontier, c.ID)
}
}
return ids, nil
}
// AdminDelete 软删除评论及其回复树(进入回收站);修订与点赞保留以便恢复
func (s *CommentService) AdminDelete(commentID uint) error {
var root model.Comment
if err := model.DB.First(&root, commentID).Error; err != nil {
return ErrCommentNotFound
}
ids, err := collectReplySubtreeIDs(model.DB, commentID, false)
if err != nil {
return err
}
return model.DB.Where("id IN ?", ids).Delete(&model.Comment{}).Error
}
// TrashCommentItem 评论回收站列表项
type TrashCommentItem struct {
model.Comment
DeletedAt time.Time `json:"deleted_at"`
}
// ListTrash 列出已软删评论(不含随帖子一并删除的评论,那些在帖子回收站处理)
func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashCommentItem, int64, error) {
if page < 1 {
page = 1
}
size = s.settings.NormalizePageSize(size)
db := model.DB.Unscoped().Model(&model.Comment{}).
Where("comments.deleted_at IS NOT NULL").
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
Preload("User").Preload("Post")
if keyword != "" {
kw, err := s.settings.NormalizeSearchKeyword(keyword)
if err != nil {
return nil, 0, err
}
like := "%" + kw + "%"
db = db.Where("comments.content LIKE ? OR posts.title LIKE ?", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var comments []model.Comment
if err := db.Order("comments.deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
return nil, 0, err
}
out := make([]TrashCommentItem, len(comments))
for i, c := range comments {
out[i] = TrashCommentItem{Comment: c}
if c.DeletedAt.Valid {
out[i].DeletedAt = c.DeletedAt.Time
}
}
return out, total, nil
}
// Restore 从回收站恢复评论及其已软删的回复树
func (s *CommentService) Restore(commentID uint) error {
var comment model.Comment
if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
if !comment.DeletedAt.Valid {
return errors.New("评论未被删除")
}
// 所属帖子必须仍存在且未删除
var post model.Post
if err := model.DB.First(&post, comment.PostID).Error; err != nil {
return errors.New("所属帖子不存在或已在回收站,请先恢复帖子")
}
ids, err := collectReplySubtreeIDs(model.DB, commentID, true)
if err != nil {
return err
}
return model.DB.Unscoped().Model(&model.Comment{}).
Where("id IN ?", ids).
Update("deleted_at", nil).Error
}
// Purge 永久删除回收站中的评论及其已软删回复(含修订、点赞)
func (s *CommentService) Purge(commentID uint) error {
var comment model.Comment
if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
if !comment.DeletedAt.Valid {
return errors.New("仅可彻底删除回收站中的评论,请先删除评论")
}
ids, err := collectReplySubtreeIDs(model.DB, commentID, true)
if err != nil {
return err
}
return model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentRevision{}).Error; err != nil {
return err
}
if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentLike{}).Error; err != nil {
return err
}
return tx.Unscoped().Where("id IN ?", ids).Delete(&model.Comment{}).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"`
Excerpt string `json:"excerpt"`
PostTitle string `json:"post_title"`
CreatedAt string `json:"created_at"`
}
// ListRecentPublic 前台最新公开评论(排除私密、已删帖)
func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error) {
if limit < 1 {
limit = 8
}
var comments []model.Comment
err := model.DB.Preload("User").Preload("Post").
Where("is_private = ? AND status = ?", false, model.ContentStatusPublished).
Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖
Find(&comments).Error
if err != nil {
return nil, err
}
out := make([]RecentCommentItem, 0, limit)
for _, c := range comments {
if c.Post.ID == 0 {
continue
}
author := "游客"
avatar := ""
if c.UserID > 0 && c.User.Nickname != "" {
author = c.User.Nickname
avatar = c.User.Avatar
} else if c.GuestNick != "" {
author = c.GuestNick
}
excerpt := StripHTMLForSearch(c.Content)
excerpt = truncateRunes(excerpt, 64)
if excerpt == "" {
excerpt = "发表了评论"
}
out = append(out, RecentCommentItem{
ID: c.ID,
PostID: c.PostID,
Floor: c.Floor,
UserID: c.UserID,
Author: author,
Avatar: avatar,
Excerpt: excerpt,
PostTitle: c.Post.Title,
// 返回 UTC ISO由前端按本地时区展示避免与后台差 8 小时)
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
})
if len(out) >= limit {
break
}
}
return out, nil
}
func truncateRunes(s string, n int) string {
if n <= 0 || s == "" {
return s
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}
// ListRecent 管理员查看最近评论
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
db.Count(&total)
var comments []model.Comment
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
}
s.fillReplyTargets(comments, true)
return comments, total, err
}

155
services/common.go Normal file
View File

@@ -0,0 +1,155 @@
package service
import (
"errors"
"fmt"
"net/mail"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
var (
ErrUserExists = errors.New("用户名已存在")
ErrEmailExists = errors.New("邮箱已被注册")
ErrInvalidCred = errors.New("用户名或密码错误")
ErrUserBanned = errors.New("账号已被禁言")
ErrWeakPassword = errors.New("密码至少 6 位")
ErrInvalidUsername = errors.New("用户名 2-32 位,支持中文、字母、数字与下划线")
ErrInvalidEmail = errors.New("邮箱格式不正确")
ErrPostNotFound = errors.New("帖子不存在")
ErrCommentNotFound = errors.New("评论不存在")
ErrPermissionDenied = errors.New("无权操作")
ErrBoardNotFound = errors.New("板块不存在")
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
ErrPostEditExpired = errors.New("已超过可编辑时限")
ErrRevisionNotFound = errors.New("历史版本不存在")
ErrInvalidSetting = errors.New("无效的设置值")
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
ErrPostTitleTooLong = errors.New("标题过长")
ErrPostTagsTooLong = errors.New("标签过长")
ErrPostContentTooLong = errors.New("正文过长")
ErrCommentTooLong = errors.New("评论内容过长")
ErrCaptchaInvalid = errors.New("验证码错误或已过期")
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
ErrRegisterClosed = errors.New("论坛暂未开放注册,请联系管理员配置邮件服务")
)
// HashPassword 使用 bcrypt 加密密码
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword 校验密码
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// ValidateUsername 校验用户名:中文/字母/数字/下划线2-32 个字符
func ValidateUsername(username string) error {
n := utf8.RuneCountInString(username)
if n < 2 || n > 32 {
return ErrInvalidUsername
}
for _, r := range username {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
continue
}
return ErrInvalidUsername
}
return nil
}
// NormalizeEmail 规范化邮箱(小写去空格)
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// ValidateEmail 校验邮箱格式
func ValidateEmail(email string) error {
email = NormalizeEmail(email)
if email == "" {
return ErrInvalidEmail
}
addr, err := mail.ParseAddress(email)
if err != nil || addr.Address != email {
return ErrInvalidEmail
}
return nil
}
// ValidatePassword 校验密码强度
func ValidatePassword(password string, minLen int) error {
if minLen <= 0 {
minLen = 6
}
if utf8.RuneCountInString(password) < minLen {
return fmt.Errorf("密码至少 %d 位", minLen)
}
return nil
}
// SensitiveFilter 敏感词过滤器
type SensitiveFilter struct {
mu sync.RWMutex
words []string
}
func NewSensitiveFilter() *SensitiveFilter {
return &SensitiveFilter{
words: []string{"违禁词示例", "广告刷单"},
}
}
// LoadFromFile 从配置文件加载敏感词,每行一个词
func (f *SensitiveFilter) LoadFromFile(path string) {
data, err := osReadFile(path)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
var words []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
words = append(words, line)
}
}
if len(words) > 0 {
f.mu.Lock()
f.words = words
f.mu.Unlock()
}
}
func (f *SensitiveFilter) Filter(text string) string {
f.mu.RLock()
defer f.mu.RUnlock()
result := text
for _, w := range f.words {
if w == "" {
continue
}
replacement := strings.Repeat("*", utf8.RuneCountInString(w))
result = strings.ReplaceAll(result, w, replacement)
}
return result
}
// osReadFile 避免循环依赖,简单封装
func osReadFile(path string) ([]byte, error) {
return readFile(path)
}
// readFile 由 filter_io.go 实现
var readFile = func(path string) ([]byte, error) {
return nil, errors.New("not implemented")
}

81
services/content.go Normal file
View File

@@ -0,0 +1,81 @@
package service
import (
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
var (
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
replyOnlyBlockRe = regexp.MustCompile(`(?is)<reply-only\b[^>]*>([\s\S]*?)</reply-only>`)
pointsOnlyUnwrapRe = regexp.MustCompile(`(?is)<points-only\b[^>]*>([\s\S]*?)</points-only>`)
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
)
// UnwrapContentGateTags 剥离登录/回复/积分可见外壳,保留内部正文(单页等场景禁用门控)
func UnwrapContentGateTags(html string) string {
if html == "" {
return html
}
html = membersOnlyBlockRe.ReplaceAllString(html, "$1")
html = replyOnlyBlockRe.ReplaceAllString(html, "$1")
html = pointsOnlyUnwrapRe.ReplaceAllString(html, "$1")
return html
}
// RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
func RedactMembersOnlyHTML(html string) string {
return redactGatedBlocks(html, membersOnlyBlockRe, "members-only")
}
// RedactReplyOnlyHTML 未回复时移除「回复可见」区块内的正文,保留长度提示供前端展示
func RedactReplyOnlyHTML(html string) string {
return redactGatedBlocks(html, replyOnlyBlockRe, "reply-only")
}
// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见、回复可见与积分解锁正文
func RedactGatedPostHTML(html string) string {
return RedactPointsOnlyHTML(RedactReplyOnlyHTML(RedactMembersOnlyHTML(html)), nil)
}
func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string {
if html == "" {
return html
}
return re.ReplaceAllStringFunc(html, func(full string) string {
m := re.FindStringSubmatch(full)
inner := ""
if len(m) > 1 {
inner = m[1]
}
length := gatedContentLength(inner)
gate := "login"
if tag == "reply-only" {
gate = "reply"
}
return `<` + tag + ` data-gate="` + gate + `" data-locked="true" data-length="` + strconv.Itoa(length) + `"></` + tag + `>`
})
}
func gatedContentLength(html string) int {
text := strings.TrimSpace(htmlTagRe.ReplaceAllString(html, ""))
if text == "" {
return 0
}
return utf8.RuneCountInString(text)
}
// StripHTMLForSearch 剥离 HTML 标签,生成用于全文搜索的纯文本
func StripHTMLForSearch(html string) string {
if html == "" {
return ""
}
html = styleOrScriptRe.ReplaceAllString(html, " ")
text := htmlTagRe.ReplaceAllString(html, " ")
text = strings.ReplaceAll(text, "&nbsp;", " ")
return strings.Join(strings.Fields(text), " ")
}

46
services/content_test.go Normal file
View File

@@ -0,0 +1,46 @@
package service
import (
"strings"
"testing"
)
func TestRedactReplyOnlyHTML(t *testing.T) {
in := `<p>公开</p><reply-only><p>秘密答案</p></reply-only>`
out := RedactReplyOnlyHTML(in)
if strings.Contains(out, "秘密答案") {
t.Fatalf("不应保留回复可见正文,得到: %q", out)
}
if !strings.Contains(out, `data-locked="true"`) || !strings.Contains(out, "reply-only") {
t.Fatalf("应保留锁定壳,得到: %q", out)
}
if !strings.Contains(out, "公开") {
t.Fatalf("不应误删公开段落,得到: %q", out)
}
}
func TestRedactGatedPostHTML(t *testing.T) {
in := `<members-only><p>登录密</p></members-only><reply-only><p>回复密</p></reply-only>`
out := RedactGatedPostHTML(in)
if strings.Contains(out, "登录密") || strings.Contains(out, "回复密") {
t.Fatalf("门控正文应被遮盖,得到: %q", out)
}
}
func TestUnwrapContentGateTags(t *testing.T) {
in := `<p>公开</p>` +
`<members-only data-gate="login"><p>登录密</p></members-only>` +
`<reply-only data-gate="reply"><p>回复密</p></reply-only>` +
`<points-only data-gate="points" data-cost="10"><p>积分密</p></points-only>`
out := UnwrapContentGateTags(in)
for _, tag := range []string{"members-only", "reply-only", "points-only"} {
if strings.Contains(out, tag) {
t.Fatalf("应剥离 %s 外壳,得到: %q", tag, out)
}
}
for _, want := range []string{"公开", "登录密", "回复密", "积分密"} {
if !strings.Contains(out, want) {
t.Fatalf("应保留内部正文 %q得到: %q", want, out)
}
}
}

55
services/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
}

173
services/email_code.go Normal file
View File

@@ -0,0 +1,173 @@
package service
import (
"crypto/rand"
"errors"
"math/big"
"strings"
"sync"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
)
const (
emailCodeLen = 6
emailCodeTTL = 10 * time.Minute
emailCodeCooldown = 60 * time.Second
EmailCodePurposeRegister = "register"
EmailCodePurposeReset = "reset"
)
// EmailCodeLen 邮箱验证码位数(供 API 告知前端)
const EmailCodeLen = emailCodeLen
type emailCodeEntry struct {
code string
expiresAt time.Time
sentAt time.Time
}
// EmailCodeService 邮箱验证码(按 purpose 隔离)
type EmailCodeService struct {
mu sync.Mutex
entries map[string]emailCodeEntry
mail *MailService
}
func NewEmailCodeService(mail *MailService) *EmailCodeService {
s := &EmailCodeService{
entries: make(map[string]emailCodeEntry),
mail: mail,
}
go s.cleanup()
return s
}
func emailCodeKey(purpose, email string) string {
return purpose + ":" + NormalizeEmail(email)
}
// SendRegisterCode 向邮箱发送注册验证码(邮箱须未注册)
func (s *EmailCodeService) SendRegisterCode(email string) error {
return s.sendCode(EmailCodePurposeRegister, email)
}
// SendResetCode 向邮箱发送重置密码验证码(邮箱须已注册;不存在时仍返回成功以防枚举)
func (s *EmailCodeService) SendResetCode(email string) error {
return s.sendCode(EmailCodePurposeReset, email)
}
func (s *EmailCodeService) sendCode(purpose, email string) error {
email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil {
return err
}
var exist model.User
found := model.DB.Where("email = ?", email).First(&exist).Error == nil
switch purpose {
case EmailCodePurposeRegister:
if found {
return ErrEmailExists
}
case EmailCodePurposeReset:
if !found {
// 防邮箱枚举:假装已发送
return nil
}
default:
return errors.New("无效的验证码用途")
}
key := emailCodeKey(purpose, email)
s.mu.Lock()
if prev, ok := s.entries[key]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
s.mu.Unlock()
return ErrEmailCodeCooldown
}
s.mu.Unlock()
code, err := randomDigits(emailCodeLen)
if err != nil {
return err
}
siteName := "姜十三论坛"
if s.mail != nil && s.mail.settings != nil {
siteName = s.mail.settings.SiteBranding().Name
}
var subject, textBody, htmlBody string
if purpose == EmailCodePurposeReset {
subject, textBody, htmlBody = BuildResetCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
} else {
subject, textBody, htmlBody = BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
}
if err := s.mail.SendHTML(email, subject, textBody, htmlBody); err != nil {
return err
}
s.mu.Lock()
s.entries[key] = emailCodeEntry{
code: code,
expiresAt: time.Now().Add(emailCodeTTL),
sentAt: time.Now(),
}
s.mu.Unlock()
return nil
}
// Verify 校验邮箱验证码(一次性);兼容旧调用 Verify(email, code) 视为注册用途
func (s *EmailCodeService) Verify(email, code string) bool {
return s.VerifyPurpose(EmailCodePurposeRegister, email, code)
}
// VerifyPurpose 按用途校验验证码(一次性)
func (s *EmailCodeService) VerifyPurpose(purpose, email, code string) bool {
email = NormalizeEmail(email)
code = strings.TrimSpace(code)
if purpose == "" || email == "" || code == "" {
return false
}
key := emailCodeKey(purpose, email)
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[key]
if !ok {
return false
}
delete(s.entries, key)
if time.Now().After(entry.expiresAt) {
return false
}
return entry.code == code
}
func (s *EmailCodeService) cleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
s.mu.Lock()
for key, entry := range s.entries {
if now.After(entry.expiresAt) {
delete(s.entries, key)
}
}
s.mu.Unlock()
}
}
func randomDigits(n int) (string, error) {
var b strings.Builder
b.Grow(n)
max := big.NewInt(10)
for i := 0; i < n; i++ {
v, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b.WriteByte(byte('0' + v.Int64()))
}
return b.String(), nil
}

7
services/filter_io.go Normal file
View File

@@ -0,0 +1,7 @@
package service
import "os"
func init() {
readFile = os.ReadFile
}

55
services/filter_words.go Normal file
View File

@@ -0,0 +1,55 @@
package service
import (
"os"
"strings"
)
// ReadFilterWordsFile 读取敏感词配置文件内容
func ReadFilterWordsFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// WriteFilterWordsFile 写入敏感词配置并热加载到过滤器
func WriteFilterWordsFile(path string, content string, filter *SensitiveFilter) error {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return err
}
filter.LoadFromFile(path)
return nil
}
// CountFilterWords 统计有效敏感词数量(不含空行与注释)
func CountFilterWords(content string) int {
count := 0
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
count++
}
}
return count
}
// FilterWordsPreview 返回前几行敏感词预览
func FilterWordsPreview(content string, maxLines int) []string {
if maxLines <= 0 {
maxLines = 5
}
var preview []string
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
preview = append(preview, line)
if len(preview) >= maxLines {
break
}
}
return preview
}

473
services/friend_link.go Normal file
View File

@@ -0,0 +1,473 @@
package service
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
var (
ErrFriendLinkApplyPending = errors.New("该 URL 已有待审核申请")
ErrFriendLinkApplyExists = errors.New("该 URL 已在友情链接中")
ErrFriendLinkApplyNotFound = errors.New("申请不存在")
ErrFriendLinkApplyHandled = errors.New("申请已处理")
ErrFriendLinkApplyFull = errors.New("友情链接已达上限20 条)")
)
const (
maxFriendLinkApplyDesc = 200
)
type FriendLinkApplyListQuery struct {
Page int
Size int
Status string
}
type FriendLinkApplyInput struct {
UserID uint
Name string
URL string
Logo string
LinkOnHomepage bool
ReciprocalPageURL string
OurSiteURL string
}
type FriendLinkApplyCreateResult struct {
Apply *model.FriendLinkApply
}
type FriendLinkApplyService struct {
settings *ForumSettingsService
messages *MessageService
}
func NewFriendLinkApplyService(settings *ForumSettingsService, messages *MessageService) *FriendLinkApplyService {
return &FriendLinkApplyService{settings: settings, messages: messages}
}
func normalizeFriendLinkApplyURL(raw string) (string, error) {
href := strings.TrimSpace(raw)
if href == "" {
return "", errors.New("请填写 URL")
}
u, err := url.Parse(href)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", errors.New("URL 格式无效")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", errors.New("URL 需为 http 或 https")
}
return href, nil
}
func friendLinkURLKey(href string) string {
u, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return strings.ToLower(strings.TrimSpace(href))
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Path = strings.TrimSuffix(u.Path, "/")
return u.String()
}
func (s *FriendLinkApplyService) urlInFriendLinks(href string) bool {
key := friendLinkURLKey(href)
brand := s.settings.SiteBranding()
for _, l := range brand.FriendLinks {
if friendLinkURLKey(l.URL) == key {
return true
}
}
return false
}
// Create 提交友链申请
func (s *FriendLinkApplyService) Create(in FriendLinkApplyInput) (*FriendLinkApplyCreateResult, error) {
name, href, logo, reciprocal, err := s.prepareApplyFields(in, "")
if err != nil {
return nil, err
}
dup, err := s.hasPendingApplyForURL(in.UserID, 0, href)
if err != nil {
return nil, err
}
if dup {
return nil, ErrFriendLinkApplyPending
}
apply := &model.FriendLinkApply{
UserID: in.UserID,
Name: name,
URL: href,
Logo: logo,
ReciprocalPageURL: reciprocal,
LinkOnHomepage: in.LinkOnHomepage,
Status: model.FriendLinkApplyStatusPending,
}
if err := model.DB.Create(apply).Error; err != nil {
return nil, err
}
s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL)
_ = model.DB.Preload("User").First(apply, apply.ID).Error
return &FriendLinkApplyCreateResult{Apply: apply}, nil
}
// PendingCount 待审数量
func (s *FriendLinkApplyService) PendingCount() (int64, error) {
var n int64
err := model.DB.Model(&model.FriendLinkApply{}).
Where("status = ?", model.FriendLinkApplyStatusPending).
Count(&n).Error
return n, err
}
// ListAdmin 管理员列表
func (s *FriendLinkApplyService) ListAdmin(q FriendLinkApplyListQuery) ([]model.FriendLinkApply, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 || q.Size > 50 {
q.Size = 20
}
db := model.DB.Model(&model.FriendLinkApply{})
status := strings.TrimSpace(q.Status)
if status != "" && status != "all" {
db = db.Where("status = ?", status)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.FriendLinkApply
err := db.Preload("User").
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
Offset((q.Page - 1) * q.Size).
Limit(q.Size).
Find(&list).Error
if err != nil {
return nil, 0, err
}
return list, total, nil
}
func (s *FriendLinkApplyService) getPending(id uint) (*model.FriendLinkApply, error) {
var apply model.FriendLinkApply
if err := model.DB.Preload("User").First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrFriendLinkApplyNotFound
}
return nil, err
}
if apply.Status != model.FriendLinkApplyStatusPending {
return nil, ErrFriendLinkApplyHandled
}
return &apply, nil
}
// Approve 通过申请并写入友链
func (s *FriendLinkApplyService) Approve(id uint) (*model.FriendLinkApply, error) {
apply, err := s.getPending(id)
if err != nil {
return nil, err
}
if s.urlInFriendLinks(apply.URL) {
return nil, ErrFriendLinkApplyExists
}
brand := s.settings.SiteBranding()
if len(brand.FriendLinks) >= maxFriendLinks {
return nil, ErrFriendLinkApplyFull
}
nextLinks := append(brand.FriendLinks, FriendLink{
Name: apply.Name,
URL: apply.URL,
Logo: normalizeFriendLinkLogoOptional(apply.Logo),
})
if err := s.settings.UpdateSiteBranding(SiteBranding{
Name: brand.Name,
Slogan: brand.Slogan,
Description: brand.Description,
Keywords: brand.Keywords,
LogoMark: brand.LogoMark,
Logo: brand.Logo,
Favicon: brand.Favicon,
OGImage: brand.OGImage,
ICPBeian: brand.ICPBeian,
ICPBeianURL: brand.ICPBeianURL,
FriendLinks: nextLinks,
}); err != nil {
return nil, err
}
now := time.Now()
if err := model.DB.Model(apply).Updates(map[string]interface{}{
"status": model.FriendLinkApplyStatusApproved,
"reviewed_at": now,
}).Error; err != nil {
return nil, err
}
apply.Status = model.FriendLinkApplyStatusApproved
apply.ReviewedAt = &now
if s.messages != nil && apply.UserID > 0 {
subject := "友情链接申请已通过"
content := fmt.Sprintf(
"你申请的友情链接「%s」%s已通过审核现已展示在友情链接页面。\n\n如有疑问可回复本私信联系管理员。",
apply.Name, apply.URL,
)
_, _ = s.messages.SendSystem(apply.UserID, subject, content, model.MessageKindSystem, nil, nil)
}
return apply, nil
}
// Reject 拒绝申请
func (s *FriendLinkApplyService) Reject(id uint, note string) (*model.FriendLinkApply, error) {
apply, err := s.getPending(id)
if err != nil {
return nil, err
}
note = strings.TrimSpace(note)
now := time.Now()
if err := model.DB.Model(apply).Updates(map[string]interface{}{
"status": model.FriendLinkApplyStatusRejected,
"review_note": note,
"reviewed_at": now,
}).Error; err != nil {
return nil, err
}
apply.Status = model.FriendLinkApplyStatusRejected
apply.ReviewNote = note
apply.ReviewedAt = &now
if s.messages != nil && apply.UserID > 0 {
subject := "友情链接申请未通过"
reason := note
if reason == "" {
reason = "未说明具体原因"
}
content := fmt.Sprintf(
"你申请的友情链接「%s」%s未通过审核。\n\n原因\n%s\n\n如有疑问可回复本私信联系管理员。",
apply.Name, apply.URL, reason,
)
_, _ = s.messages.SendSystem(apply.UserID, subject, content, model.MessageKindReject, nil, nil)
}
return apply, nil
}
func (s *FriendLinkApplyService) prepareApplyFields(in FriendLinkApplyInput, allowPublishedURL string) (name, href, logo, reciprocal string, err error) {
name = strings.TrimSpace(in.Name)
href, err = normalizeFriendLinkApplyURL(in.URL)
if err != nil {
return
}
logo, err = normalizeFriendLinkLogo(in.Logo)
if err != nil {
return
}
if name == "" {
err = errors.New("请填写站点名称")
return
}
if utf8.RuneCountInString(name) > maxFriendLinkName {
err = fmt.Errorf("站点名称最多 %d 字", maxFriendLinkName)
return
}
if s.urlInFriendLinks(href) && friendLinkURLKey(href) != friendLinkURLKey(allowPublishedURL) {
err = ErrFriendLinkApplyExists
return
}
reciprocal = strings.TrimSpace(in.ReciprocalPageURL)
if in.LinkOnHomepage {
reciprocal = href
} else {
reciprocal, err = normalizeFriendLinkApplyURL(reciprocal)
if err != nil {
err = errors.New("请填写添加本站链接的页面地址")
return
}
}
return
}
func (s *FriendLinkApplyService) hasPendingApplyForURL(userID, excludeID uint, href string) (bool, error) {
db := model.DB.Model(&model.FriendLinkApply{}).
Where("user_id = ? AND status = ? AND url = ?", userID, model.FriendLinkApplyStatusPending, href)
if excludeID > 0 {
db = db.Where("id <> ?", excludeID)
}
var pending int64
if err := db.Count(&pending).Error; err != nil {
return false, err
}
return pending > 0, nil
}
func (s *FriendLinkApplyService) removePublishedFriendLink(href string) error {
key := friendLinkURLKey(href)
brand := s.settings.SiteBranding()
next := make([]FriendLink, 0, len(brand.FriendLinks))
removed := false
for _, l := range brand.FriendLinks {
if friendLinkURLKey(l.URL) == key {
removed = true
continue
}
next = append(next, l)
}
if !removed {
return nil
}
return s.settings.UpdateSiteBranding(SiteBranding{
Name: brand.Name,
Slogan: brand.Slogan,
Description: brand.Description,
Keywords: brand.Keywords,
LogoMark: brand.LogoMark,
Logo: brand.Logo,
Favicon: brand.Favicon,
OGImage: brand.OGImage,
ICPBeian: brand.ICPBeian,
ICPBeianURL: brand.ICPBeianURL,
FriendLinks: next,
})
}
// Update 修改并重新提交友链申请(待审 / 已拒绝 / 已通过)
func (s *FriendLinkApplyService) Update(userID, id uint, in FriendLinkApplyInput) (*FriendLinkApplyCreateResult, error) {
var apply model.FriendLinkApply
if err := model.DB.First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrFriendLinkApplyNotFound
}
return nil, err
}
if apply.UserID != userID {
return nil, errors.New("无权操作该申请")
}
if apply.Status != model.FriendLinkApplyStatusPending &&
apply.Status != model.FriendLinkApplyStatusRejected &&
apply.Status != model.FriendLinkApplyStatusApproved {
return nil, errors.New("该申请不可修改")
}
wasApproved := apply.Status == model.FriendLinkApplyStatusApproved
allowPublishedURL := ""
if wasApproved {
allowPublishedURL = apply.URL
}
name, href, logo, reciprocal, err := s.prepareApplyFields(in, allowPublishedURL)
if err != nil {
return nil, err
}
dup, err := s.hasPendingApplyForURL(userID, id, href)
if err != nil {
return nil, err
}
if dup {
return nil, ErrFriendLinkApplyPending
}
if wasApproved {
if err := s.removePublishedFriendLink(apply.URL); err != nil {
return nil, err
}
}
updates := map[string]interface{}{
"name": name,
"url": href,
"logo": logo,
"reciprocal_page_url": reciprocal,
"link_on_homepage": in.LinkOnHomepage,
"reciprocal_verified": false,
"reciprocal_check_note": "",
"reciprocal_checked_at": nil,
"status": model.FriendLinkApplyStatusPending,
"review_note": "",
"reviewed_at": nil,
}
if err := model.DB.Model(&apply).Updates(updates).Error; err != nil {
return nil, err
}
s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL)
_ = model.DB.Preload("User").First(&apply, apply.ID).Error
return &FriendLinkApplyCreateResult{Apply: &apply}, nil
}
// RecheckReciprocal 管理员触发重新检测回链
func (s *FriendLinkApplyService) RecheckReciprocal(id uint, ourSiteURL string) (*model.FriendLinkApply, error) {
if !s.settings.FriendLinkReciprocalCheckEnabled() {
return nil, errors.New("回链检测已关闭")
}
var apply model.FriendLinkApply
if err := model.DB.Preload("User").First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrFriendLinkApplyNotFound
}
return nil, err
}
if strings.TrimSpace(apply.ReciprocalPageURL) == "" {
return nil, errors.New("该申请未填写回链页")
}
ResetReciprocalCheckState(apply.ID)
EnqueueReciprocalCheck(apply.ID, apply.ReciprocalPageURL, ourSiteURL)
apply.ReciprocalVerified = false
apply.ReciprocalCheckNote = ""
apply.ReciprocalCheckedAt = nil
return &apply, nil
}
// startReciprocalCheck 按开关启动回链检测;关闭时标记为已结束,避免前台一直显示「检测中」
func (s *FriendLinkApplyService) startReciprocalCheck(applyID uint, pageURL, ourSiteURL string) {
if s.settings.FriendLinkReciprocalCheckEnabled() {
EnqueueReciprocalCheck(applyID, pageURL, ourSiteURL)
return
}
now := time.Now()
_ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
"reciprocal_verified": false,
"reciprocal_check_note": "",
"reciprocal_checked_at": now,
}).Error
}
// ListMine 当前用户的友链申请
func (s *FriendLinkApplyService) ListMine(userID uint) ([]model.FriendLinkApply, error) {
var list []model.FriendLinkApply
err := model.DB.Where("user_id = ?", userID).
Order("id DESC").
Limit(50).
Find(&list).Error
return list, err
}
// Cancel 撤销待审申请
func (s *FriendLinkApplyService) Cancel(userID, id uint) error {
var apply model.FriendLinkApply
if err := model.DB.First(&apply, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFriendLinkApplyNotFound
}
return err
}
if apply.UserID != userID {
return errors.New("无权操作该申请")
}
if apply.Status != model.FriendLinkApplyStatusPending {
return ErrFriendLinkApplyHandled
}
return model.DB.Delete(&apply).Error
}

View File

@@ -0,0 +1,93 @@
package service
import (
"encoding/json"
"strings"
"git.iioio.com/freefire/jiang13-forum/model"
)
// EnrichFriendLinksLogos 为缺少 LOGO 的已发布友链,从已通过申请中按 URL 回填
func EnrichFriendLinksLogos(links []FriendLink) []FriendLink {
if len(links) == 0 {
return links
}
needKeys := make(map[string]int)
for i, l := range links {
if strings.TrimSpace(l.Logo) != "" {
continue
}
key := friendLinkURLKey(l.URL)
if key == "" {
continue
}
needKeys[key] = i
}
if len(needKeys) == 0 {
return links
}
var applies []model.FriendLinkApply
_ = model.DB.
Where("status = ? AND logo <> ''", model.FriendLinkApplyStatusApproved).
Order("id DESC").
Find(&applies).Error
logoByURL := make(map[string]string, len(applies))
for _, a := range applies {
key := friendLinkURLKey(a.URL)
if key == "" {
continue
}
if _, ok := logoByURL[key]; ok {
continue
}
logo := normalizeFriendLinkLogoOptional(a.Logo)
if logo != "" {
logoByURL[key] = logo
}
}
if len(logoByURL) == 0 {
return links
}
out := make([]FriendLink, len(links))
copy(out, links)
for key, idx := range needKeys {
if logo, ok := logoByURL[key]; ok {
out[idx].Logo = logo
}
}
return out
}
func friendLinksLogoSnapshot(links []FriendLink) string {
type snap struct {
URL string `json:"url"`
Logo string `json:"logo"`
}
items := make([]snap, len(links))
for i, l := range links {
items[i] = snap{URL: friendLinkURLKey(l.URL), Logo: strings.TrimSpace(l.Logo)}
}
b, _ := json.Marshal(items)
return string(b)
}
// maybePersistEnrichedFriendLinks 若回填产生新 LOGO写回 site_friend_links
func (s *ForumSettingsService) maybePersistEnrichedFriendLinks(enriched []FriendLink) error {
raw := s.getString(SettingSiteFriendLinks, "[]")
before := parseFriendLinksJSON(raw)
if friendLinksLogoSnapshot(before) == friendLinksLogoSnapshot(enriched) {
return nil
}
normalized, err := normalizeFriendLinks(enriched)
if err != nil {
return err
}
linksJSON, err := json.Marshal(normalized)
if err != nil {
return err
}
return s.setString(SettingSiteFriendLinks, string(linksJSON))
}

View File

@@ -0,0 +1,269 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const (
reciprocalCheckTimeout = 8 * time.Second // 整次检测硬上限DNS / 抓取 / 解析)
reciprocalFetchTimeout = 5 * time.Second
reciprocalMaxBodyBytes = 512 * 1024
reciprocalMaxHrefs = 4000
)
var hrefRe = regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["']`)
// VerifyReciprocalLink 检测页面 HTML 是否包含指向本站的链接
func VerifyReciprocalLink(pageURL, ourSiteURL string) (verified bool, note string) {
ctx, cancel := context.WithTimeout(context.Background(), reciprocalCheckTimeout)
defer cancel()
return verifyReciprocalLink(ctx, pageURL, ourSiteURL)
}
func verifyReciprocalLink(ctx context.Context, pageURL, ourSiteURL string) (verified bool, note string) {
pageURL = strings.TrimSpace(pageURL)
ourSiteURL = strings.TrimSpace(ourSiteURL)
if pageURL == "" {
return false, "未提供回链页地址"
}
if ourSiteURL == "" {
return false, "本站 URL 未配置"
}
pageParsed, err := normalizeFriendLinkApplyURL(pageURL)
if err != nil {
return false, err.Error()
}
ourParsed, err := url.Parse(ourSiteURL)
if err != nil || ourParsed.Host == "" {
return false, "本站 URL 无效"
}
ourHost := strings.ToLower(strings.TrimSuffix(ourParsed.Host, ":443"))
ourHost = strings.TrimSuffix(ourHost, ":80")
if err := assertSafeFetchURL(ctx, pageParsed); err != nil {
return false, err.Error()
}
body, err := fetchHTMLBody(ctx, pageParsed)
if err != nil {
if isTimeoutErr(err) || ctx.Err() != nil {
return false, "访问回链页超时"
}
return false, fmt.Sprintf("无法访问回链页:%v", err)
}
if ctx.Err() != nil {
return false, "访问回链页超时"
}
if pageContainsLinkToHost(body, pageParsed, ourParsed, ourHost) {
return true, "已检测到本站链接"
}
return false, "未在该页面检测到指向本站的链接"
}
func isTimeoutErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
func assertSafeFetchURL(ctx context.Context, raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("URL 无效")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("仅支持 http/https")
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("URL 无效")
}
lower := strings.ToLower(host)
if lower == "localhost" || strings.HasSuffix(lower, ".localhost") || lower == "0.0.0.0" {
return fmt.Errorf("不允许访问内网地址")
}
ips, err := lookupHostIPs(ctx, host)
if err != nil {
if isTimeoutErr(err) {
return fmt.Errorf("解析域名超时")
}
return fmt.Errorf("无法解析域名")
}
for _, ip := range ips {
if isPrivateOrLoopbackIP(ip) {
return fmt.Errorf("不允许访问内网地址")
}
}
return nil
}
func lookupHostIPs(ctx context.Context, host string) ([]net.IP, error) {
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, a := range addrs {
if a.IP != nil {
ips = append(ips, a.IP)
}
}
return ips, nil
}
func isPrivateOrLoopbackIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
if ip4 := ip.To4(); ip4 != nil {
return ip4[0] == 10 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168) ||
(ip4[0] == 127) ||
(ip4[0] == 169 && ip4[1] == 254) ||
(ip4[0] == 0)
}
return false
}
func fetchHTMLBody(ctx context.Context, rawURL string) (string, error) {
client := &http.Client{
Timeout: reciprocalFetchTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("重定向过多")
}
if err := assertSafeFetchURL(req.Context(), req.URL.String()); err != nil {
return err
}
return nil
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
req.Close = true
req.Header.Set("User-Agent", "Jiang13Forum-FriendLinkCheck/1.0")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
limited := io.LimitReader(resp.Body, reciprocalMaxBodyBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return "", err
}
if len(data) > reciprocalMaxBodyBytes {
return "", fmt.Errorf("页面过大")
}
return string(data), nil
}
func pageContainsLinkToHost(html, pageURL string, ourURL *url.URL, ourHost string) bool {
ourHost = strings.ToLower(ourHost)
ourPath := strings.TrimSuffix(ourURL.Path, "/")
if ourPath == "" {
ourPath = "/"
}
base, err := url.Parse(pageURL)
if err != nil {
return false
}
checkHref := func(href string) bool {
href = strings.TrimSpace(href)
if href == "" || strings.HasPrefix(strings.ToLower(href), "javascript:") || strings.HasPrefix(strings.ToLower(href), "mailto:") {
return false
}
resolved, err := url.Parse(href)
if err != nil {
return false
}
resolved = base.ResolveReference(resolved)
host := strings.ToLower(resolved.Hostname())
if host == "" {
return false
}
host = strings.TrimSuffix(strings.TrimSuffix(host, ":443"), ":80")
if host != ourHost {
return false
}
path := strings.TrimSuffix(resolved.Path, "/")
if path == "" {
path = "/"
}
// 允许首页或完整路径匹配
if ourPath == "/" || path == ourPath || strings.HasPrefix(path, ourPath+"/") {
return true
}
return path == "/" || ourPath == path
}
// 逐条扫描,命中即停;限制条数避免超大页面占用过多 CPU
rest := html
for i := 0; i < reciprocalMaxHrefs; i++ {
loc := hrefRe.FindStringSubmatchIndex(rest)
if loc == nil {
break
}
if loc[2] >= 0 && loc[3] >= loc[2] && checkHref(rest[loc[2]:loc[3]]) {
return true
}
if loc[1] <= 0 {
break
}
rest = rest[loc[1]:]
}
// 兜底:页面源码中包含本站域名
lower := strings.ToLower(html)
if strings.Contains(lower, ourHost) {
return strings.Contains(lower, ourHost+"/") ||
strings.Contains(lower, "://"+ourHost)
}
return false
}
func normalizeFriendLinkLogo(raw string) (string, error) {
logo := strings.TrimSpace(raw)
if logo == "" {
return "", fmt.Errorf("请填写或上传网站 LOGO")
}
if len(logo) > maxFriendLinkURL {
return "", fmt.Errorf("LOGO 地址过长")
}
if strings.HasPrefix(logo, "/uploads/") {
return logo, nil
}
return normalizeFriendLinkApplyURL(logo)
}
// normalizeFriendLinkLogoOptional LOGO 可选(友链列表项)
func normalizeFriendLinkLogoOptional(raw string) string {
logo, err := normalizeFriendLinkLogo(raw)
if err != nil {
return ""
}
return logo
}

View File

@@ -0,0 +1,62 @@
package service
import (
"sync"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
)
const reciprocalCheckConcurrency = 3
var (
reciprocalCheckMu sync.Mutex
reciprocalCheckGen = map[uint]uint64{}
reciprocalCheckSem = make(chan struct{}, reciprocalCheckConcurrency)
)
func init() {
for i := 0; i < reciprocalCheckConcurrency; i++ {
reciprocalCheckSem <- struct{}{}
}
}
// EnqueueReciprocalCheck 异步检测回链;同一申请多次入队时仅保留最后一次结果
func EnqueueReciprocalCheck(applyID uint, pageURL, ourSiteURL string) {
reciprocalCheckMu.Lock()
reciprocalCheckGen[applyID]++
gen := reciprocalCheckGen[applyID]
reciprocalCheckMu.Unlock()
go runReciprocalCheck(applyID, gen, pageURL, ourSiteURL)
}
func runReciprocalCheck(applyID uint, gen uint64, pageURL, ourSiteURL string) {
reciprocalCheckSem <- struct{}{}
defer func() { <-reciprocalCheckSem }()
verified, note := VerifyReciprocalLink(pageURL, ourSiteURL)
now := time.Now()
reciprocalCheckMu.Lock()
if reciprocalCheckGen[applyID] != gen {
reciprocalCheckMu.Unlock()
return
}
reciprocalCheckMu.Unlock()
_ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
"reciprocal_verified": verified,
"reciprocal_check_note": note,
"reciprocal_checked_at": now,
}).Error
}
// ResetReciprocalCheckState 重置为检测中,供重新检测使用
func ResetReciprocalCheckState(applyID uint) {
_ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
"reciprocal_verified": false,
"reciprocal_check_note": "",
"reciprocal_checked_at": nil,
}).Error
}

View File

@@ -0,0 +1,46 @@
package service
import (
"net/url"
"strings"
"testing"
"time"
)
func TestPageContainsLinkToHost(t *testing.T) {
our, err := url.Parse("https://forum.example.com")
if err != nil {
t.Fatal(err)
}
page := "https://friend.example/links.html"
host := "forum.example.com"
if !pageContainsLinkToHost(`<a href="https://forum.example.com/">本站</a>`, page, our, host) {
t.Fatal("应检测到绝对回链")
}
if pageContainsLinkToHost(`<a href="https://other.example/">其他</a>`, page, our, host) {
t.Fatal("不应把外站当成回链")
}
}
func TestPageContainsLinkToHost_LargeHTMLFast(t *testing.T) {
our, err := url.Parse("https://forum.example.com")
if err != nil {
t.Fatal(err)
}
var b strings.Builder
b.Grow(512 * 1024)
for b.Len() < 400*1024 {
b.WriteString(`<a href="https://noise.example/page">x</a>`)
}
b.WriteString(`<a href="https://forum.example.com/">本站</a>`)
html := b.String()
start := time.Now()
if !pageContainsLinkToHost(html, "https://friend.example/", our, "forum.example.com") {
t.Fatal("应在大量无关链接中找到回链")
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("解析耗时过长: %s", elapsed)
}
}

559
services/gitea.go Normal file
View File

@@ -0,0 +1,559 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
)
var (
ErrGiteaNotConfigured = errors.New("Gitea 同步未配置或未启用")
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
)
// GiteaOwnerView 仓库关联的论坛用户摘要(列表展示头像/徽标)
type GiteaOwnerView struct {
ID uint `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Role model.Role `json:"role"`
Verified bool `json:"verified"`
Exp int `json:"exp"`
Level int `json:"level"`
Badges []model.UserBadgeView `json:"badges,omitempty"`
}
// GiteaRepoView 前台展示
type GiteaRepoView struct {
ID uint `json:"id"`
GiteaID int64 `json:"gitea_id"`
OwnerLogin string `json:"owner_login"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
Language string `json:"language"`
StarsCount int `json:"stars_count"`
ForksCount int `json:"forks_count"`
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
ForumUserID *uint `json:"forum_user_id,omitempty"`
Owner *GiteaOwnerView `json:"owner,omitempty"`
SyncedAt time.Time `json:"synced_at"`
}
// GiteaService 从 Gitea API 同步会员公开仓库
type GiteaService struct {
settings *ForumSettingsService
client *http.Client
mu sync.Mutex
syncing bool
stopCh chan struct{}
wg sync.WaitGroup
}
func NewGiteaService(settings *ForumSettingsService) *GiteaService {
return &GiteaService{
settings: settings,
client: &http.Client{
Timeout: 30 * time.Second,
},
stopCh: make(chan struct{}),
}
}
// StartBackgroundSync 按配置间隔后台同步;失败只记日志
func (g *GiteaService) StartBackgroundSync() {
g.wg.Add(1)
go func() {
defer g.wg.Done()
// 启动后稍等再首次尝试,避免拖慢启动
timer := time.NewTimer(15 * time.Second)
defer timer.Stop()
for {
select {
case <-g.stopCh:
return
case <-timer.C:
if _, err := g.SyncRepos(); err != nil && !errors.Is(err, ErrGiteaNotConfigured) && !errors.Is(err, ErrGiteaSyncBusy) {
log.Printf("[gitea] 后台同步失败: %v", err)
}
cfg := g.settings.GiteaSyncConfig()
interval := time.Duration(cfg.SyncIntervalMin) * time.Minute
if interval < 5*time.Minute {
interval = 5 * time.Minute
}
timer.Reset(interval)
}
}
}()
}
// Stop 停止后台同步
func (g *GiteaService) Stop() {
select {
case <-g.stopCh:
default:
close(g.stopCh)
}
g.wg.Wait()
}
// ListPublic 列出已绑定论坛用户的公开仓库q 模糊匹配仓库字段与论坛昵称/用户名
func (g *GiteaService) ListPublic(page, size int, q string) ([]GiteaRepoView, int64, error) {
if page < 1 {
page = 1
}
if size < 1 {
size = 30
}
if size > 100 {
size = 100
}
// 打开列表时自愈:按 owner_login≈username 回填缺失的 forum_user_id
BackfillForumUserIDs()
q = strings.TrimSpace(q)
db := model.DB.Model(&model.GiteaRepo{}).Where("private = ? AND forum_user_id IS NOT NULL AND forum_user_id > 0", false)
if q != "" {
like := "%" + escapeLikePattern(q) + "%"
db = db.Where(
`(full_name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\' OR owner_login LIKE ? ESCAPE '\'
OR forum_user_id IN (
SELECT id FROM users WHERE nickname LIKE ? ESCAPE '\' OR username LIKE ? ESCAPE '\'
))`,
like, like, like, like, like,
)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.GiteaRepo
err := db.Order("updated_at_remote desc, id desc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error
if err != nil {
return nil, 0, err
}
out := make([]GiteaRepoView, 0, len(rows))
for _, r := range rows {
out = append(out, toGiteaRepoView(r))
}
return out, total, nil
}
// BackfillForumUserIDs 将缺失 forum_user_id 的公开仓按 owner_login忽略大小写匹配论坛 username
func BackfillForumUserIDs() int {
var rows []model.GiteaRepo
if err := model.DB.Where("private = ? AND (forum_user_id IS NULL OR forum_user_id = 0)", false).
Find(&rows).Error; err != nil || len(rows) == 0 {
return 0
}
var users []model.User
if err := model.DB.Select("id", "username").Where("banned = ?", false).Find(&users).Error; err != nil || len(users) == 0 {
return 0
}
byLogin := make(map[string]uint, len(users))
for _, u := range users {
key := strings.ToLower(strings.TrimSpace(u.Username))
if key == "" {
continue
}
byLogin[key] = u.ID
}
n := 0
for i := range rows {
key := strings.ToLower(strings.TrimSpace(rows[i].OwnerLogin))
uid, ok := byLogin[key]
if !ok || uid == 0 {
continue
}
if err := model.DB.Model(&rows[i]).Update("forum_user_id", uid).Error; err != nil {
log.Printf("[gitea] 回填 forum_user_id 失败 repo=%s: %v", rows[i].FullName, err)
continue
}
n++
}
if n > 0 {
log.Printf("[gitea] 回填 forum_user_id%d 条", n)
}
return n
}
// AttachGiteaOwners 为列表项批量填充论坛用户摘要;丢弃无法解析到论坛用户的条目。
// 优先 forum_user_id缺失时按 owner_login≈username 兜底,并回写 forum_user_id。
func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoView {
if len(list) == 0 {
return list
}
idSet := make(map[uint]struct{})
ids := make([]uint, 0, len(list))
loginSet := make(map[string]struct{})
logins := make([]string, 0, len(list))
for _, item := range list {
if item.ForumUserID != nil && *item.ForumUserID > 0 {
id := *item.ForumUserID
if _, ok := idSet[id]; !ok {
idSet[id] = struct{}{}
ids = append(ids, id)
}
continue
}
key := strings.ToLower(strings.TrimSpace(item.OwnerLogin))
if key == "" {
continue
}
if _, ok := loginSet[key]; ok {
continue
}
loginSet[key] = struct{}{}
logins = append(logins, key)
}
byID := make(map[uint]*model.User)
byLogin := make(map[string]*model.User)
ptrs := make([]*model.User, 0, len(ids)+len(logins))
if len(ids) > 0 {
var users []model.User
if err := model.DB.Where("id IN ? AND banned = ?", ids, false).Find(&users).Error; err == nil {
for i := range users {
u := &users[i]
byID[u.ID] = u
ptrs = append(ptrs, u)
key := strings.ToLower(strings.TrimSpace(u.Username))
if key != "" {
byLogin[key] = u
}
}
}
}
if len(logins) > 0 {
var users []model.User
if err := model.DB.Where("banned = ? AND LOWER(username) IN ?", false, logins).Find(&users).Error; err == nil {
for i := range users {
u := &users[i]
key := strings.ToLower(strings.TrimSpace(u.Username))
if key == "" {
continue
}
if _, exists := byLogin[key]; exists {
continue
}
byLogin[key] = u
byID[u.ID] = u
ptrs = append(ptrs, u)
}
}
}
if len(ptrs) == 0 {
return nil
}
// 去重 ptrs
seenPtr := make(map[uint]struct{}, len(ptrs))
uniq := make([]*model.User, 0, len(ptrs))
for _, u := range ptrs {
if u == nil || u.ID == 0 {
continue
}
if _, ok := seenPtr[u.ID]; ok {
continue
}
seenPtr[u.ID] = struct{}{}
uniq = append(uniq, u)
}
if badge != nil {
badge.AttachBadgeSummaries(uniq, 3)
} else {
for _, u := range uniq {
u.Level = model.LevelFromExp(u.Exp)
}
}
out := make([]GiteaRepoView, 0, len(list))
for i := range list {
item := list[i]
var u *model.User
if item.ForumUserID != nil && *item.ForumUserID > 0 {
u = byID[*item.ForumUserID]
}
if u == nil {
key := strings.ToLower(strings.TrimSpace(item.OwnerLogin))
u = byLogin[key]
if u != nil {
uid := u.ID
item.ForumUserID = &uid
// 回写缺失关联,便于下次列表过滤命中
_ = model.DB.Model(&model.GiteaRepo{}).Where("id = ?", item.ID).
Update("forum_user_id", uid).Error
}
}
if u == nil {
continue
}
nick := strings.TrimSpace(u.Nickname)
if nick == "" {
nick = u.Username
}
item.Owner = &GiteaOwnerView{
ID: u.ID,
Nickname: nick,
Avatar: u.Avatar,
Role: u.Role,
Verified: u.Verified,
Exp: u.Exp,
Level: model.LevelFromExp(u.Exp),
Badges: u.Badges,
}
out = append(out, item)
}
return out
}
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
func (g *GiteaService) SyncRepos() (int, error) {
cfg := g.settings.GiteaSyncConfig()
if !cfg.Ready {
return 0, ErrGiteaNotConfigured
}
g.mu.Lock()
if g.syncing {
g.mu.Unlock()
return 0, ErrGiteaSyncBusy
}
g.syncing = true
g.mu.Unlock()
defer func() {
g.mu.Lock()
g.syncing = false
g.mu.Unlock()
}()
// 同步前先回填历史缺失关联
BackfillForumUserIDs()
var users []model.User
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
return 0, err
}
seen := make(map[int64]struct{})
syncedOwners := make(map[string]struct{})
now := time.Now()
upserted := 0
for _, u := range users {
username := strings.TrimSpace(u.Username)
if username == "" {
continue
}
repos, err := g.fetchUserPublicRepos(cfg.BaseURL, cfg.Token, username)
if err != nil {
// 用户在 Gitea 不存在等:跳过,不中断整次同步
log.Printf("[gitea] 跳过用户 %s: %v", username, err)
continue
}
syncedOwners[strings.ToLower(username)] = struct{}{}
uid := u.ID
for _, gr := range repos {
if gr.Private {
continue
}
seen[gr.ID] = struct{}{}
owner := gr.Owner.Login
if owner == "" {
owner = username
}
row := model.GiteaRepo{
GiteaID: gr.ID,
OwnerLogin: owner,
Name: gr.Name,
FullName: gr.FullName,
Description: truncStr(gr.Description, 2048),
HTMLURL: gr.HTMLURL,
Language: truncStr(gr.Language, 64),
StarsCount: gr.StarsCount,
ForksCount: gr.ForksCount,
Private: false,
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
ForumUserID: &uid,
SyncedAt: now,
}
var existing model.GiteaRepo
err := model.DB.Where("gitea_id = ?", gr.ID).First(&existing).Error
if err != nil {
if err := model.DB.Create(&row).Error; err != nil {
log.Printf("[gitea] 创建仓库失败 %s: %v", gr.FullName, err)
continue
}
} else {
row.ID = existing.ID
if err := model.DB.Model(&existing).Updates(map[string]any{
"owner_login": row.OwnerLogin,
"name": row.Name,
"full_name": row.FullName,
"description": row.Description,
"html_url": row.HTMLURL,
"language": row.Language,
"stars_count": row.StarsCount,
"forks_count": row.ForksCount,
"private": false,
"updated_at_remote": row.UpdatedAtRemote,
"forum_user_id": uid, // 写死 uint避免 *uint 进 map 未落库
"synced_at": row.SyncedAt,
}).Error; err != nil {
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
continue
}
}
upserted++
}
}
// 仅清理本次成功同步到的 owner 下、却未再出现的旧记录
if len(syncedOwners) > 0 {
var all []model.GiteaRepo
if err := model.DB.Where("private = ?", false).Find(&all).Error; err == nil {
for _, r := range all {
if _, ok := syncedOwners[strings.ToLower(r.OwnerLogin)]; !ok {
continue
}
if _, ok := seen[r.GiteaID]; !ok {
_ = model.DB.Delete(&r).Error
}
}
}
}
// 同步后再回填一次(覆盖 owner_login 大小写等边角)
BackfillForumUserIDs()
log.Printf("[gitea] 同步完成upsert %d 个公开仓库", upserted)
return upserted, nil
}
type giteaAPIRepo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
Language string `json:"language"`
StarsCount int `json:"stars_count"`
ForksCount int `json:"forks_count"`
Private bool `json:"private"`
UpdatedAt string `json:"updated_at"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
func (g *GiteaService) fetchUserPublicRepos(baseURL, token, username string) ([]giteaAPIRepo, error) {
var all []giteaAPIRepo
page := 1
for {
u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/api/v1/users/" + url.PathEscape(username) + "/repos")
if err != nil {
return nil, err
}
q := u.Query()
q.Set("page", strconv.Itoa(page))
q.Set("limit", "50")
u.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/json")
resp, err := g.client.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
_ = resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("用户不存在")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200))
}
var pageRepos []giteaAPIRepo
if err := json.Unmarshal(body, &pageRepos); err != nil {
return nil, err
}
if len(pageRepos) == 0 {
break
}
all = append(all, pageRepos...)
if len(pageRepos) < 50 {
break
}
page++
if page > 20 {
break
}
}
return all, nil
}
func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView {
return GiteaRepoView{
ID: r.ID,
GiteaID: r.GiteaID,
OwnerLogin: r.OwnerLogin,
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
HTMLURL: r.HTMLURL,
Language: r.Language,
StarsCount: r.StarsCount,
ForksCount: r.ForksCount,
UpdatedAtRemote: r.UpdatedAtRemote,
ForumUserID: r.ForumUserID,
SyncedAt: r.SyncedAt,
}
}
func parseGiteaTime(raw string) *time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return &t
}
}
return nil
}
func truncStr(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max]
}

152
services/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
}

150
services/lottery_post.go Normal file
View File

@@ -0,0 +1,150 @@
package service
import (
"crypto/rand"
"errors"
"math/big"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
var (
ErrLotteryAlreadyDrawn = errors.New("已开奖")
ErrLotteryNotEnough = errors.New("参与人数不足")
)
// PostLotteryView 帖内抽奖视图
type PostLotteryView struct {
WinnerCount int `json:"winner_count"`
Status string `json:"status"`
ParticipantCount int `json:"participant_count"`
Winners []PostLotteryWinnerView `json:"winners,omitempty"`
}
type PostLotteryWinnerView struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
CommentID uint `json:"comment_id"`
}
// InitPostLottery 初始化抽奖帖
func InitPostLottery(postID uint, winnerCount int) error {
if winnerCount < 1 || winnerCount > 20 {
return errors.New("开奖人数需 1-20")
}
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Updates(map[string]interface{}{
"lottery_winner_count": winnerCount,
"lottery_status": model.PostLotteryStatusOpen,
}).Error
}
// GetPostLotteryView 获取抽奖视图
func GetPostLotteryView(post *model.Post) (*PostLotteryView, error) {
if post == nil || post.PostType != model.PostTypeLottery {
return nil, nil
}
participants, err := lotteryParticipants(post.ID, post.UserID)
if err != nil {
return nil, err
}
view := &PostLotteryView{
WinnerCount: post.LotteryWinnerCount,
Status: post.LotteryStatus,
ParticipantCount: len(participants),
}
if post.LotteryStatus == model.PostLotteryStatusDrawn {
var winners []model.PostLotteryWinner
model.DB.Preload("User").Where("post_id = ?", post.ID).Find(&winners)
for _, w := range winners {
view.Winners = append(view.Winners, PostLotteryWinnerView{
UserID: w.UserID, Username: w.User.Username, Nickname: w.User.Nickname,
CommentID: w.CommentID,
})
}
}
return view, nil
}
func lotteryParticipants(postID, authorID uint) ([]model.Comment, error) {
var comments []model.Comment
err := model.DB.Where("post_id = ? AND status = ? AND user_id <> ?", postID, model.ContentStatusPublished, authorID).
Order("id ASC").Find(&comments).Error
if err != nil {
return nil, err
}
seen := map[uint]bool{}
var unique []model.Comment
for _, c := range comments {
if seen[c.UserID] {
continue
}
seen[c.UserID] = true
unique = append(unique, c)
}
return unique, nil
}
// DrawPostLottery 开奖
func DrawPostLottery(postID, operatorID uint, isAdmin bool) (*PostLotteryView, error) {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound
}
if post.PostType != model.PostTypeLottery {
return nil, errors.New("非抽奖帖")
}
if !isAdmin && post.UserID != operatorID {
return nil, ErrPermissionDenied
}
if post.LotteryStatus == model.PostLotteryStatusDrawn {
return nil, ErrLotteryAlreadyDrawn
}
participants, err := lotteryParticipants(postID, post.UserID)
if err != nil {
return nil, err
}
need := post.LotteryWinnerCount
if need < 1 {
need = 1
}
if len(participants) < need {
return nil, ErrLotteryNotEnough
}
picked := randomPickComments(participants, need)
err = model.DB.Transaction(func(tx *gorm.DB) error {
for _, c := range picked {
w := model.PostLotteryWinner{PostID: postID, UserID: c.UserID, CommentID: c.ID}
if err := tx.Create(&w).Error; err != nil {
return err
}
}
return tx.Model(&post).Update("lottery_status", model.PostLotteryStatusDrawn).Error
})
if err != nil {
return nil, err
}
post.LotteryStatus = model.PostLotteryStatusDrawn
return GetPostLotteryView(&post)
}
func randomPickComments(comments []model.Comment, n int) []model.Comment {
pool := append([]model.Comment{}, comments...)
out := make([]model.Comment, 0, n)
for i := 0; i < n && len(pool) > 0; i++ {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool))))
if err != nil {
idx = big.NewInt(0)
}
j := int(idx.Int64())
out = append(out, pool[j])
pool = append(pool[:j], pool[j+1:]...)
}
return out
}
// DeleteLotteryData 删帖清理
func DeleteLotteryData(tx *gorm.DB, postID uint) {
tx.Where("post_id = ?", postID).Delete(&model.PostLotteryWinner{})
}

212
services/mail.go Normal file
View File

@@ -0,0 +1,212 @@
package service
import (
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// MailConfig 邮件 SMTP 配置
type MailConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"` // 更新时传入;回显时为空
From string `json:"from"`
FromName string `json:"from_name"`
Encryption string `json:"encryption"`
HasPassword bool `json:"has_password"`
}
// MailService 基于 SMTP 发信
type MailService struct {
settings *ForumSettingsService
}
func NewMailService(settings *ForumSettingsService) *MailService {
return &MailService{settings: settings}
}
// 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
}
from := strings.TrimSpace(cfg.From)
fromHeader := from
if name := strings.TrimSpace(cfg.FromName); name != "" {
fromHeader = fmt.Sprintf("%s <%s>", encodeMailHeader(name), from)
}
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)
switch normalizeEncryption(cfg.Encryption) {
case "ssl":
return sendSMTPWithTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg), true)
case "starttls":
return sendSMTPStartTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg))
default:
return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
}
}
func normalizeEncryption(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "ssl", "tls":
return "ssl"
case "starttls":
return "starttls"
default:
return "none"
}
}
func sendSMTPWithTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte, implicitTLS bool) error {
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("连接邮件服务器失败: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Close()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("邮件认证失败: %w", err)
}
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := client.Rcpt(rcpt); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
_ = implicitTLS
return client.Quit()
}
func sendSMTPStartTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := net.DialTimeout("tcp", addr, 15*time.Second)
if err != nil {
return fmt.Errorf("连接邮件服务器失败: %w", err)
}
client, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
if err := client.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("STARTTLS 失败: %w", err)
}
}
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("邮件认证失败: %w", err)
}
}
}
if err := client.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := client.Rcpt(rcpt); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
// encodeMailHeader 简单编码含非 ASCII 的邮件头
func encodeMailHeader(s string) string {
for _, r := range s {
if r > 127 {
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
}
}
return s
}

386
services/mail_template.go Normal file
View File

@@ -0,0 +1,386 @@
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
}
// BuildResetCodeMail 生成重置密码验证码邮件
func BuildResetCodeMail(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
}
// BuildReplyMail 生成「收到新回复」提醒邮件
// displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。
func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) {
siteName = strings.TrimSpace(siteName)
if siteName == "" {
siteName = "姜十三论坛"
}
authorName = strings.TrimSpace(authorName)
if authorName == "" {
authorName = "有人"
}
postTitle = strings.TrimSpace(postTitle)
if postTitle == "" {
postTitle = "未知帖子"
}
subject = fmt.Sprintf("【%s】收到新回复", siteName)
bodyLine := FormatReplyContent(authorName, postTitle, displayFloor, isNested)
textBody = fmt.Sprintf("你好,\n\n%s\n", bodyLine)
if excerpt != "" {
textBody += "\n摘要\n" + excerpt + "\n"
}
textBody += fmt.Sprintf("\n帖子《%s》\n", postTitle)
if link != "" {
textBody += "链接:" + link + "\n"
}
textBody += fmt.Sprintf("\n— %s\n", siteName)
safeSite := html.EscapeString(siteName)
safeBody := html.EscapeString(bodyLine)
safeTitle := html.EscapeString(postTitle)
safeExcerpt := html.EscapeString(excerpt)
safeLink := html.EscapeString(link)
preheader := html.EscapeString(fmt.Sprintf("%s 回复了你在《%s》中的内容", authorName, postTitle))
linkBlock := ""
if link != "" {
linkBlock = fmt.Sprintf(`
<p style="margin:0 0 12px;text-align:center;">
<a href="%s" style="display:inline-block;padding:10px 18px;background:#18a058;color:#ffffff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">查看讨论</a>
</p>`, safeLink)
}
excerptBlock := ""
if excerpt != "" {
excerptBlock = fmt.Sprintf(`
<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;">%s</td>
</tr>
</table>`, safeExcerpt)
}
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;">%s</p>
%s
%s
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
帖子:《%s》<br />
<span style="display:inline-block;margin-top:6px;">此邮件由 %s 自动发送,请勿直接回复</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
html.EscapeString(subject),
preheader,
safeSite,
safeBody,
excerptBlock,
linkBlock,
safeTitle,
safeSite,
)
return subject, textBody, htmlBody
}
// BuildModerationMail 生成「待审核」提醒邮件kindLabel 为「帖子」或「评论」
// displayFloor 为可见顶层楼号;评论场景 isNested 区分顶层/子回复文案。
func BuildModerationMail(siteName, kindLabel, authorName, postTitle string, postID uint, displayFloor int, isNested bool, adminLink string) (subject, textBody, htmlBody string) {
siteName = strings.TrimSpace(siteName)
if siteName == "" {
siteName = "姜十三论坛"
}
kindLabel = strings.TrimSpace(kindLabel)
if kindLabel == "" {
kindLabel = "内容"
}
authorName = strings.TrimSpace(authorName)
if authorName == "" {
authorName = "用户"
}
postTitle = strings.TrimSpace(postTitle)
if postTitle == "" {
postTitle = "未知帖子"
}
subject = fmt.Sprintf("【%s】新的待审核%s", siteName, kindLabel)
var detail string
switch {
case kindLabel == "评论" && isNested && displayFloor > 0:
detail = fmt.Sprintf("用户 %s 在《%s》#%d 楼下提交了待审核回复", authorName, postTitle, displayFloor)
case kindLabel == "评论" && displayFloor > 0:
detail = fmt.Sprintf("用户 %s 在《%s》提交了待审核 #%d 楼评论", authorName, postTitle, displayFloor)
default:
detail = fmt.Sprintf("用户 %s 提交了待审核%s《%s》#%d", authorName, kindLabel, postTitle, postID)
}
textBody = fmt.Sprintf("你好,\n\n%s。\n请尽快前往管理后台处理。\n", detail)
textBody += fmt.Sprintf("\n帖子《%s》\n", postTitle)
if adminLink != "" {
textBody += "链接:" + adminLink + "\n"
}
textBody += fmt.Sprintf("\n— %s\n", siteName)
safeSite := html.EscapeString(siteName)
safeKind := html.EscapeString(kindLabel)
safeDetail := html.EscapeString(detail)
safeTitle := html.EscapeString(postTitle)
safeLink := html.EscapeString(adminLink)
preheader := html.EscapeString(fmt.Sprintf("有新的待审核%s需要处理", kindLabel))
linkBlock := ""
if adminLink != "" {
linkBlock = fmt.Sprintf(`
<p style="margin:0 0 12px;text-align:center;">
<a href="%s" style="display:inline-block;padding:10px 18px;background:#18a058;color:#ffffff;text-decoration:none;border-radius:8px;font-size:14px;font-weight:600;">前往审核</a>
</p>`, safeLink)
}
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;">%s。</p>
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">请尽快前往管理后台处理该%s。</p>
%s
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
帖子:《%s》<br />
<span style="display:inline-block;margin-top:6px;">此邮件由 %s 自动发送,请勿直接回复</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
html.EscapeString(subject),
preheader,
safeSite,
safeDetail,
safeKind,
linkBlock,
safeTitle,
safeSite,
)
return subject, textBody, htmlBody
}

466
services/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
}

65
services/mention.go Normal file
View File

@@ -0,0 +1,65 @@
package service
import (
"regexp"
"strings"
"git.iioio.com/freefire/jiang13-forum/model"
)
const maxMentionsPerContent = 10
// 与前端 highlightMentions 字符集对齐(字母数字下划线中文,兼容历史 -
// Go RE2 不支持 JS 的 \uXXXX需用 \x{HHHH}
var mentionPattern = regexp.MustCompile(`@([0-9A-Za-z_\x{4e00}-\x{9fa5}-]+)`)
// ExtractMentionNames 从纯文本提取 @提及名(去重、保序)
func ExtractMentionNames(text string) []string {
matches := mentionPattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
name := strings.TrimSpace(m[1])
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, name)
if len(out) >= maxMentionsPerContent {
break
}
}
return out
}
// ResolveMentionUserIDs 将提及名解析为用户 ID优先 username其次 nickname排除 excludeUserID
func ResolveMentionUserIDs(names []string, excludeUserID uint) []uint {
if len(names) == 0 {
return nil
}
ids := make([]uint, 0, len(names))
seen := make(map[uint]struct{}, len(names))
for _, name := range names {
var u model.User
err := model.DB.Select("id").Where("username = ?", name).First(&u).Error
if err != nil {
err = model.DB.Select("id").Where("nickname = ?", name).First(&u).Error
}
if err != nil || u.ID == 0 || u.ID == excludeUserID {
continue
}
if _, ok := seen[u.ID]; ok {
continue
}
seen[u.ID] = struct{}{}
ids = append(ids, u.ID)
}
return ids
}

14
services/mention_test.go Normal file
View File

@@ -0,0 +1,14 @@
package service
import (
"reflect"
"testing"
)
func TestExtractMentionNames(t *testing.T) {
got := ExtractMentionNames("hi @alice 和 @小明_x 以及 @bob-1")
want := []string{"alice", "小明_x", "bob-1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

407
services/message.go Normal file
View File

@@ -0,0 +1,407 @@
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
}
// UnreadCounts 未读总数,以及私信 / 系统通知分项
func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) {
err = model.DB.Model(&model.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false).
Count(&total).Error
if err != nil {
return 0, 0, 0, err
}
err = model.DB.Model(&model.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false).
Count(&notify).Error
if err != nil {
return 0, 0, 0, err
}
dm = total - notify
if dm < 0 {
dm = 0
}
return total, dm, notify, nil
}
// ListNotifications 系统通知列表(按时间倒序,非聊天气泡)
func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]model.PrivateMessage, int64, error) {
if page < 1 {
page = 1
}
size = s.settings.NormalizePageSize(size)
db := model.DB.Model(&model.PrivateMessage{}).
Where("from_user_id = 0 AND to_user_id = ?", userID)
kind = strings.TrimSpace(kind)
if kind != "" && kind != "all" {
db = db.Where("kind = ?", kind)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.PrivateMessage
err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error
if err != nil {
return nil, 0, err
}
if list == nil {
list = []model.PrivateMessage{}
}
return list, total, nil
}
// MarkNotificationsRead 将系统通知全部标为已读
func (s *MessageService) MarkNotificationsRead(userID uint) error {
return s.MarkConversationRead(userID, 0)
}
// 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),
)
}

380
services/notify.go Normal file
View File

@@ -0,0 +1,380 @@
package service
import (
"fmt"
"strings"
"unicode/utf8"
"git.iioio.com/freefire/jiang13-forum/model"
)
// NotifyService 站内消息 + 邮件提醒编排
type NotifyService struct {
messages *MessageService
mail *MailService
settings *ForumSettingsService
}
func NewNotifyService(messages *MessageService, mail *MailService, settings *ForumSettingsService) *NotifyService {
return &NotifyService{messages: messages, mail: mail, settings: settings}
}
// 后台执行通知,不阻塞 HTTP 响应panic 仅记日志
func (s *NotifyService) goNotify(fn func()) {
if s == nil || fn == nil {
return
}
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("notify: 后台任务异常: %v\n", r)
}
}()
fn()
}()
}
// AsyncNotifyCommentPublished 异步:评论公开后通知被回复者或楼主
func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentPublished(&cp) })
}
// AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户
func (s *NotifyService) AsyncNotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentMentions(&cp) })
}
// AsyncNotifyPendingPost 异步:待审帖通知管理员
func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) {
if s == nil || post == nil {
return
}
cp := *post
s.goNotify(func() { s.NotifyPendingPost(&cp) })
}
// AsyncNotifyPendingComment 异步:待审评论通知管理员
func (s *NotifyService) AsyncNotifyPendingComment(comment *model.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyPendingComment(&cp) })
}
// NotifyCommentPublished 评论公开后通知被回复者或楼主
func (s *NotifyService) NotifyCommentPublished(comment *model.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPublished {
return
}
post, err := s.loadPost(comment.PostID)
if err != nil {
return
}
toUserID, err := s.resolveReplyRecipient(comment, post)
if err != nil || toUserID == 0 || toUserID == comment.UserID {
return
}
authorName := s.commentAuthorName(comment)
title := post.Title
if title == "" {
title = "未知帖子"
}
displayFloor := s.resolveDisplayFloor(comment)
isNested := comment.ReplyTo != nil && *comment.ReplyTo > 0
subject := "收到新回复"
content := FormatReplyContent(authorName, title, displayFloor, isNested)
pid := comment.PostID
_, _ = s.messages.SendSystem(toUserID, subject, content, model.MessageKindReply, &pid, nil)
s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content)
}
// NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人)
func (s *NotifyService) NotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPublished {
return
}
names := ExtractMentionNames(comment.Content)
ids := ResolveMentionUserIDs(names, comment.UserID)
if len(ids) == 0 {
return
}
post, err := s.loadPost(comment.PostID)
if err != nil {
return
}
// 已作为回复对象收到通知的用户不再重复发 mention
replyTo, _ := s.resolveReplyRecipient(comment, post)
authorName := s.commentAuthorName(comment)
title := post.Title
if title == "" {
title = "未知帖子"
}
displayFloor := s.resolveDisplayFloor(comment)
pid := comment.PostID
subject := "有人 @了你"
content := FormatMentionContent(authorName, title, displayFloor)
for _, uid := range ids {
if uid == 0 || uid == comment.UserID || uid == replyTo {
continue
}
_, _ = s.messages.SendSystem(uid, subject, content, model.MessageKindMention, &pid, nil)
}
}
// NotifyPendingPost 新帖进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingPost(post *model.Post) {
if s == nil || post == nil || post.Status != model.ContentStatusPending {
return
}
title := strings.TrimSpace(post.Title)
if title == "" {
title = "无标题"
}
authorName := s.userDisplayName(post.UserID)
subject := "新的待审核帖子"
content := FormatPendingPostContent(authorName, title, post.ID)
pid := post.ID
s.notifyAdmins(subject, content, model.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) {
return BuildModerationMail(siteName, "帖子", authorName, title, post.ID, 0, false, AbsoluteURL(baseURL, "/admin/posts"))
})
}
// NotifyPendingComment 新评论进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingComment(comment *model.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPending {
return
}
post, err := s.loadPost(comment.PostID)
if err != nil {
return
}
title := strings.TrimSpace(post.Title)
if title == "" {
title = "未知帖子"
}
authorName := s.commentAuthorName(comment)
subject := "新的待审核评论"
displayFloor := s.resolveDisplayFloor(comment)
isNested := comment.ReplyTo != nil && *comment.ReplyTo > 0
content := FormatPendingCommentContent(authorName, title, displayFloor, isNested)
pid := comment.PostID
s.notifyAdmins(subject, content, model.MessageKindModeration, &pid, func(siteName, baseURL string) (string, string, string) {
return BuildModerationMail(siteName, "评论", authorName, title, comment.PostID, displayFloor, isNested, AbsoluteURL(baseURL, "/admin/comments"))
})
}
func (s *NotifyService) notifyAdmins(
subject, content, kind string,
relatedPostID *uint,
buildMail func(siteName, baseURL string) (subj, text, html string),
) {
admins, err := s.listAdmins()
if err != nil || len(admins) == 0 {
return
}
seenEmail := make(map[string]struct{})
siteName := s.siteName()
baseURL := s.settings.SitePublicBaseURL("")
mailSubj, mailText, mailHTML := "", "", ""
if s.mail != nil && s.settings.MailReady() {
mailSubj, mailText, mailHTML = buildMail(siteName, baseURL)
}
for _, admin := range admins {
_, _ = s.messages.SendSystem(admin.ID, subject, content, kind, relatedPostID, nil)
email := strings.TrimSpace(admin.Email)
if email == "" || mailSubj == "" {
continue
}
key := strings.ToLower(email)
if _, ok := seenEmail[key]; ok {
continue
}
seenEmail[key] = struct{}{}
_ = s.mail.SendHTML(email, mailSubj, mailText, mailHTML)
}
}
func (s *NotifyService) sendReplyMail(toUserID uint, authorName, postTitle string, postID uint, displayFloor int, isNested bool, rawContent string) {
if s.mail == nil || !s.settings.MailReady() {
return
}
var user model.User
if err := model.DB.Select("id", "email", "nickname", "username").First(&user, toUserID).Error; err != nil {
return
}
email := strings.TrimSpace(user.Email)
if email == "" {
return
}
siteName := s.siteName()
baseURL := s.settings.SitePublicBaseURL("")
postPath := s.settings.Permalink().PostPath(postID)
link := AbsoluteURL(baseURL, postPath)
excerpt := truncateNotifyExcerpt(rawContent, 120)
subj, text, html := BuildReplyMail(siteName, authorName, postTitle, displayFloor, isNested, excerpt, link)
_ = s.mail.SendHTML(email, subj, text, html)
}
func (s *NotifyService) resolveReplyRecipient(comment *model.Comment, post *model.Post) (uint, error) {
if comment.ReplyTo != nil && *comment.ReplyTo > 0 {
var target model.Comment
if err := model.DB.Select("id", "user_id", "post_id").
Where("id = ? AND post_id = ?", *comment.ReplyTo, comment.PostID).
First(&target).Error; err != nil {
return 0, err
}
if target.UserID > 0 {
return target.UserID, nil
}
// 游客评论无用户账号,回退到楼主
}
return post.UserID, nil
}
// resolveDisplayFloor 解析页面可见的顶层楼号(子回复沿 reply_to 上溯)
func (s *NotifyService) resolveDisplayFloor(comment *model.Comment) int {
if comment == nil {
return 0
}
if comment.ReplyTo == nil || *comment.ReplyTo == 0 {
return comment.Floor
}
curID := *comment.ReplyTo
seen := make(map[uint]struct{}, 8)
for i := 0; i < 64; i++ {
if _, ok := seen[curID]; ok {
break
}
seen[curID] = struct{}{}
var ancestor model.Comment
if err := model.DB.Select("id", "floor", "reply_to").
Where("id = ? AND post_id = ?", curID, comment.PostID).
First(&ancestor).Error; err != nil {
return comment.Floor
}
if ancestor.ReplyTo == nil || *ancestor.ReplyTo == 0 {
return ancestor.Floor
}
curID = *ancestor.ReplyTo
}
return comment.Floor
}
func (s *NotifyService) loadPost(postID uint) (*model.Post, error) {
var post model.Post
if err := model.DB.Select("id", "user_id", "title", "status").First(&post, postID).Error; err != nil {
return nil, err
}
return &post, nil
}
func (s *NotifyService) listAdmins() ([]model.User, error) {
var admins []model.User
err := model.DB.Select("id", "email", "nickname", "username").
Where("role = ? AND banned = ?", model.RoleAdmin, false).
Find(&admins).Error
return admins, err
}
func (s *NotifyService) siteName() string {
name := strings.TrimSpace(s.settings.SiteBranding().Name)
if name == "" {
return "姜十三论坛"
}
return name
}
func (s *NotifyService) commentAuthorName(comment *model.Comment) string {
if comment.UserID > 0 {
if comment.User.ID == comment.UserID {
if n := DisplayName(&comment.User); n != "" {
return n
}
}
return s.userDisplayName(comment.UserID)
}
if nick := strings.TrimSpace(comment.GuestNick); nick != "" {
return nick
}
return "游客"
}
func (s *NotifyService) userDisplayName(userID uint) string {
if userID == 0 {
return "用户"
}
var u model.User
if err := model.DB.Select("id", "nickname", "username").First(&u, userID).Error; err != nil {
return fmt.Sprintf("用户 #%d", userID)
}
if n := DisplayName(&u); n != "" {
return n
}
return fmt.Sprintf("用户 #%d", userID)
}
// FormatReplyContent 回复站内私信正文floor 为可见顶层楼号)
func FormatReplyContent(authorName, postTitle string, displayFloor int, isNested bool) string {
if isNested {
return fmt.Sprintf("%s 在《%s》#%d 楼下回复了你。", authorName, postTitle, displayFloor)
}
return fmt.Sprintf("%s 在《%s》发表了 #%d 楼。", authorName, postTitle, displayFloor)
}
// FormatMentionContent @提及站内通知正文
func FormatMentionContent(authorName, postTitle string, displayFloor int) string {
return fmt.Sprintf("%s 在《%s》#%d 楼中提到了你。", authorName, postTitle, displayFloor)
}
// FormatPendingPostContent 待审帖站内私信正文
func FormatPendingPostContent(authorName, postTitle string, postID uint) string {
return fmt.Sprintf(
"用户 %s 提交了待审核帖子《%s》#%d请前往管理后台处理。",
authorName, postTitle, postID,
)
}
// FormatPendingCommentContent 待审评论站内私信正文floor 为可见顶层楼号)
func FormatPendingCommentContent(authorName, postTitle string, displayFloor int, isNested bool) string {
if isNested {
return fmt.Sprintf(
"用户 %s 在《%s》#%d 楼下提交了待审核回复,请前往管理后台处理。",
authorName, postTitle, displayFloor,
)
}
return fmt.Sprintf(
"用户 %s 在《%s》提交了待审核 #%d 楼评论,请前往管理后台处理。",
authorName, postTitle, displayFloor,
)
}
func truncateNotifyExcerpt(raw string, maxRunes int) string {
plain := strings.TrimSpace(StripHTMLForSearch(raw))
plain = strings.Join(strings.Fields(plain), " ")
if plain == "" {
return ""
}
if maxRunes <= 0 || utf8.RuneCountInString(plain) <= maxRunes {
return plain
}
runes := []rune(plain)
return string(runes[:maxRunes]) + "…"
}

203
services/oauth_clients.go Normal file
View File

@@ -0,0 +1,203 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
)
var (
ErrOAuthClientNotFound = errors.New("OAuth 应用不存在")
ErrOAuthClientExists = errors.New("client_id 已存在")
ErrOAuthClientInvalid = errors.New("OAuth 应用参数无效")
)
// OAuthClientView 管理端展示(不含密钥哈希)
type OAuthClientView struct {
ID uint `json:"id"`
ClientID string `json:"client_id"`
Name string `json:"name"`
RedirectURIs string `json:"redirect_uris"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// ClientSecret 仅在创建或轮换时返回一次明文
ClientSecret string `json:"client_secret,omitempty"`
}
// OAuthClientInput 创建/更新请求
type OAuthClientInput struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
RedirectURIs string `json:"redirect_uris"`
Enabled *bool `json:"enabled"`
// ClientSecret 留空:创建时自动生成;更新时表示不改
ClientSecret string `json:"client_secret"`
// RotateSecret 更新时为 true 则重新生成密钥
RotateSecret bool `json:"rotate_secret"`
}
// ListOAuthClients 列出全部 OAuth 应用
func (s *ForumSettingsService) ListOAuthClients() ([]OAuthClientView, error) {
var rows []model.OAuthClient
if err := model.DB.Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]OAuthClientView, 0, len(rows))
for _, r := range rows {
out = append(out, toOAuthClientView(r, ""))
}
return out, nil
}
// CreateOAuthClient 创建应用;返回含明文密钥的视图
func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthClientView, error) {
clientID := strings.TrimSpace(in.ClientID)
name := strings.TrimSpace(in.Name)
uris := normalizeRedirectURIs(in.RedirectURIs)
if clientID == "" || name == "" || uris == "" {
return nil, ErrOAuthClientInvalid
}
var n int64
model.DB.Model(&model.OAuthClient{}).Where("client_id = ?", clientID).Count(&n)
if n > 0 {
return nil, ErrOAuthClientExists
}
plain := strings.TrimSpace(in.ClientSecret)
if plain == "" {
var err error
plain, err = generateClientSecret()
if err != nil {
return nil, err
}
}
hash, err := HashPassword(plain)
if err != nil {
return nil, err
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
row := model.OAuthClient{
ClientID: clientID,
ClientSecretHash: hash,
Name: name,
RedirectURIs: uris,
Enabled: enabled,
}
if err := model.DB.Create(&row).Error; err != nil {
return nil, err
}
v := toOAuthClientView(row, plain)
return &v, nil
}
// UpdateOAuthClient 更新应用
func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (*OAuthClientView, error) {
var row model.OAuthClient
if err := model.DB.First(&row, id).Error; err != nil {
return nil, ErrOAuthClientNotFound
}
name := strings.TrimSpace(in.Name)
uris := normalizeRedirectURIs(in.RedirectURIs)
if name == "" || uris == "" {
return nil, ErrOAuthClientInvalid
}
row.Name = name
row.RedirectURIs = uris
if in.Enabled != nil {
row.Enabled = *in.Enabled
}
plain := ""
if in.RotateSecret {
var err error
plain, err = generateClientSecret()
if err != nil {
return nil, err
}
hash, err := HashPassword(plain)
if err != nil {
return nil, err
}
row.ClientSecretHash = hash
} else if strings.TrimSpace(in.ClientSecret) != "" {
plain = strings.TrimSpace(in.ClientSecret)
hash, err := HashPassword(plain)
if err != nil {
return nil, err
}
row.ClientSecretHash = hash
}
if err := model.DB.Save(&row).Error; err != nil {
return nil, err
}
v := toOAuthClientView(row, plain)
return &v, nil
}
// DeleteOAuthClient 删除应用
func (s *ForumSettingsService) DeleteOAuthClient(id uint) error {
res := model.DB.Delete(&model.OAuthClient{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrOAuthClientNotFound
}
return nil
}
// FindEnabledOAuthClient 按 client_id 查找已启用应用
func FindEnabledOAuthClient(clientID string) (*model.OAuthClient, error) {
var row model.OAuthClient
if err := model.DB.Where("client_id = ? AND enabled = ?", clientID, true).First(&row).Error; err != nil {
return nil, ErrOIDCInvalidClient
}
return &row, nil
}
// VerifyOAuthClientSecret 校验客户端密钥bcrypt 哈希)
func VerifyOAuthClientSecret(row *model.OAuthClient, secret string) bool {
if row == nil || secret == "" || row.ClientSecretHash == "" {
return false
}
return CheckPassword(row.ClientSecretHash, secret)
}
func toOAuthClientView(row model.OAuthClient, plainSecret string) OAuthClientView {
return OAuthClientView{
ID: row.ID,
ClientID: row.ClientID,
Name: row.Name,
RedirectURIs: row.RedirectURIs,
Enabled: row.Enabled,
HasSecret: row.ClientSecretHash != "",
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ClientSecret: plainSecret,
}
}
func generateClientSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// CountEnabledOAuthClients 已启用客户端数量
func CountEnabledOAuthClients() int64 {
var n int64
model.DB.Model(&model.OAuthClient{}).Where("enabled = ?", true).Count(&n)
return n
}

634
services/oidc.go Normal file
View File

@@ -0,0 +1,634 @@
package service
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/model"
)
const (
oidcAuthCodeTTL = 5 * time.Minute
oidcAccessTokenTTL = time.Hour
oidcIDTokenTTL = time.Hour
oidcRSABits = 2048
oidcKeyID = "jiang13-oidc-1"
)
var (
ErrOIDCNotConfigured = errors.New("OIDC 未配置(请在管理后台启用并至少创建一个 OAuth 应用)")
ErrOIDCInvalidClient = errors.New("无效的 client_id 或 client_secret")
ErrOIDCInvalidRedirect = errors.New("redirect_uri 未登记")
ErrOIDCInvalidRequest = errors.New("授权请求参数无效")
ErrOIDCInvalidGrant = errors.New("授权码无效或已过期")
ErrOIDCInvalidToken = errors.New("access_token 无效")
ErrOIDCUserBanned = errors.New("账号已被禁言,无法授权")
ErrOIDCPKCEFailed = errors.New("PKCE 校验失败")
ErrOIDCInvalidLogout = errors.New("post_logout_redirect_uri 未登记")
)
// OIDCService 论坛作为 OpenID Connect Provider
type OIDCService struct {
cfg *config.Config
settings *ForumSettingsService
mu sync.RWMutex
privateKey *rsa.PrivateKey
}
// NewOIDCService 创建并加载/生成 RSA 密钥
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
s := &OIDCService{cfg: cfg, settings: settings}
if err := s.loadOrCreateKey(); err != nil {
return nil, err
}
return s, nil
}
func (s *OIDCService) runtime() OIDCConfig {
if s.settings != nil {
return s.settings.OIDCConfig()
}
return OIDCConfig{}
}
func (s *OIDCService) loadOrCreateKey() error {
keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
block, _ := pem.Decode(data)
if block == nil {
return fmt.Errorf("解析 OIDC RSA 密钥失败")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
if err2 != nil {
return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
}
var ok bool
key, ok = parsed.(*rsa.PrivateKey)
if !ok {
return fmt.Errorf("OIDC 密钥不是 RSA")
}
}
s.privateKey = key
return nil
}
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
if err != nil {
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
}
s.privateKey = key
return nil
}
// Enabled 是否可对外提供 OIDC
func (s *OIDCService) Enabled() bool {
return s.runtime().Ready
}
// Issuer 返回 OIDC issuer
func (s *OIDCService) Issuer() string {
return s.runtime().RootURL
}
// Discovery 返回 OpenID Provider Metadata
func (s *OIDCService) Discovery() (map[string]any, error) {
rt := s.runtime()
if !rt.Ready {
return nil, ErrOIDCNotConfigured
}
base := rt.RootURL
return map[string]any{
"issuer": base,
"authorization_endpoint": base + "/oauth/authorize",
"token_endpoint": base + "/oauth/token",
"userinfo_endpoint": base + "/oauth/userinfo",
"jwks_uri": base + "/oauth/jwks",
"end_session_endpoint": base + "/oauth/logout",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"claims_supported": []string{
"sub", "name", "preferred_username", "email", "email_verified", "picture", "groups",
},
"code_challenge_methods_supported": []string{"S256", "plain"},
}, nil
}
// JWKS 返回 JSON Web Key Set
func (s *OIDCService) JWKS() (map[string]any, error) {
s.mu.RLock()
key := s.privateKey
s.mu.RUnlock()
if key == nil {
return nil, ErrOIDCNotConfigured
}
pub := key.PublicKey
return map[string]any{
"keys": []map[string]string{
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": oidcKeyID,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
},
},
}, nil
}
func bigIntBytes(e int) []byte {
if e == 0 {
return []byte{0}
}
var b []byte
for v := e; v > 0; v >>= 8 {
b = append([]byte{byte(v & 0xff)}, b...)
}
return b
}
// AuthorizeRequest 授权端点查询参数
type AuthorizeRequest struct {
ClientID string
RedirectURI string
ResponseType string
Scope string
State string
Nonce string
CodeChallenge string
CodeChallengeMethod string
}
// ValidateAuthorize 校验授权请求(不要求已登录)
func (s *OIDCService) ValidateAuthorize(req AuthorizeRequest) error {
rt := s.runtime()
if !rt.Ready {
return ErrOIDCNotConfigured
}
client, err := FindEnabledOAuthClient(req.ClientID)
if err != nil {
return ErrOIDCInvalidClient
}
if req.ResponseType != "code" {
return ErrOIDCInvalidRequest
}
if !redirectAllowed(client.RedirectURIs, req.RedirectURI) {
return ErrOIDCInvalidRedirect
}
if !hasScope(req.Scope, "openid") {
return ErrOIDCInvalidRequest
}
if req.CodeChallenge != "" {
m := strings.ToUpper(req.CodeChallengeMethod)
if m == "" {
m = "PLAIN"
}
if m != "S256" && m != "PLAIN" {
return ErrOIDCInvalidRequest
}
}
return nil
}
func hasScope(scope, want string) bool {
for _, p := range strings.Fields(scope) {
if p == want {
return true
}
}
return false
}
func redirectAllowed(redirectURIsCSV, uri string) bool {
for _, allowed := range splitRedirectURIs(redirectURIsCSV) {
if allowed == uri {
return true
}
}
return false
}
// IssueAuthCode 已登录用户签发授权码,返回带 code/state 的回调 URL
func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string, error) {
if err := s.ValidateAuthorize(req); err != nil {
return "", err
}
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
return "", ErrOIDCInvalidRequest
}
if user.Banned {
return "", ErrOIDCUserBanned
}
code, err := randomToken(32)
if err != nil {
return "", err
}
method := strings.ToUpper(req.CodeChallengeMethod)
if req.CodeChallenge != "" && method == "" {
method = "PLAIN"
}
rec := &model.OAuthAuthCode{
Code: code,
ClientID: req.ClientID,
UserID: user.ID,
RedirectURI: req.RedirectURI,
Scope: req.Scope,
Nonce: req.Nonce,
CodeChallenge: req.CodeChallenge,
CodeChallengeMethod: method,
ExpiresAt: time.Now().Add(oidcAuthCodeTTL),
}
if err := model.DB.Create(rec).Error; err != nil {
return "", err
}
u, err := url.Parse(req.RedirectURI)
if err != nil {
return "", ErrOIDCInvalidRedirect
}
q := u.Query()
q.Set("code", code)
if req.State != "" {
q.Set("state", req.State)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
// TokenRequest 换票请求
type TokenRequest struct {
GrantType string
Code string
RedirectURI string
ClientID string
ClientSecret string
CodeVerifier string
}
// TokenResponse OAuth token 响应
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token,omitempty"`
Scope string `json:"scope,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// ExchangeCode 授权码换 token
func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
rt := s.runtime()
if !rt.Ready {
return nil, ErrOIDCNotConfigured
}
if req.GrantType != "authorization_code" {
return nil, ErrOIDCInvalidRequest
}
client, err := FindEnabledOAuthClient(req.ClientID)
if err != nil || !VerifyOAuthClientSecret(client, req.ClientSecret) {
return nil, ErrOIDCInvalidClient
}
var rec model.OAuthAuthCode
if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil {
return nil, ErrOIDCInvalidGrant
}
if rec.Used || time.Now().After(rec.ExpiresAt) {
// 重放:作废同用户同客户端未过期码
if rec.Used {
_ = model.DB.Model(&model.OAuthAuthCode{}).
Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?",
rec.ClientID, rec.UserID, false, time.Now()).
Update("used", true).Error
}
return nil, ErrOIDCInvalidGrant
}
if rec.ClientID != req.ClientID || rec.RedirectURI != req.RedirectURI {
return nil, ErrOIDCInvalidGrant
}
if err := verifyPKCE(rec.CodeChallenge, rec.CodeChallengeMethod, req.CodeVerifier); err != nil {
return nil, err
}
rec.Used = true
_ = model.DB.Save(&rec).Error
var user model.User
if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned {
return nil, ErrOIDCInvalidGrant
}
access, err := s.signAccessToken(&user, rec.Scope, req.ClientID)
if err != nil {
return nil, err
}
idToken, err := s.signIDToken(&user, rec.Scope, req.ClientID, rec.Nonce)
if err != nil {
return nil, err
}
return &TokenResponse{
AccessToken: access,
TokenType: "Bearer",
ExpiresIn: int(oidcAccessTokenTTL.Seconds()),
IDToken: idToken,
Scope: rec.Scope,
}, nil
}
func verifyPKCE(challenge, method, verifier string) error {
if challenge == "" {
return nil
}
if verifier == "" {
return ErrOIDCPKCEFailed
}
switch strings.ToUpper(method) {
case "S256":
sum := sha256.Sum256([]byte(verifier))
calc := base64.RawURLEncoding.EncodeToString(sum[:])
if calc != challenge {
return ErrOIDCPKCEFailed
}
case "PLAIN", "":
if verifier != challenge {
return ErrOIDCPKCEFailed
}
default:
return ErrOIDCPKCEFailed
}
return nil
}
type oidcAccessClaims struct {
Scope string `json:"scope,omitempty"`
ClientID string `json:"client_id,omitempty"`
jwt.RegisteredClaims
}
type oidcIDClaims struct {
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Picture string `json:"picture,omitempty"`
Groups []string `json:"groups,omitempty"`
Nonce string `json:"nonce,omitempty"`
jwt.RegisteredClaims
}
func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) {
now := time.Now()
issuer := s.Issuer()
claims := oidcAccessClaims{
Scope: scope,
ClientID: clientID,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: strconv.FormatUint(uint64(user.ID), 10),
Audience: []string{clientID},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(oidcAccessTokenTTL)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t.Header["kid"] = oidcKeyID
s.mu.RLock()
defer s.mu.RUnlock()
return t.SignedString(s.privateKey)
}
func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) {
now := time.Now()
issuer := s.Issuer()
claims := oidcIDClaims{
Nonce: nonce,
Groups: s.userGroups(user),
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuer,
Subject: strconv.FormatUint(uint64(user.ID), 10),
Audience: []string{clientID},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(oidcIDTokenTTL)),
},
}
if hasScope(scope, "profile") || scope == "" || hasScope(scope, "openid") {
claims.Name = user.Nickname
if claims.Name == "" {
claims.Name = user.Username
}
claims.PreferredUsername = user.Username
claims.Picture = s.absoluteURL(user.Avatar)
}
if hasScope(scope, "email") || hasScope(scope, "openid") {
claims.Email = user.Email
claims.EmailVerified = user.Email != ""
}
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t.Header["kid"] = oidcKeyID
s.mu.RLock()
defer s.mu.RUnlock()
return t.SignedString(s.privateKey)
}
func (s *OIDCService) userGroups(user *model.User) []string {
rt := s.runtime()
groups := make([]string, 0, 2)
if rt.UserGroup != "" {
groups = append(groups, rt.UserGroup)
}
if user.Role == model.RoleAdmin && rt.AdminGroup != "" {
groups = append(groups, rt.AdminGroup)
}
return groups
}
// UserInfo 根据 access_token 返回用户声明
func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) {
claims, err := s.parseAccessToken(accessToken)
if err != nil {
return nil, err
}
uid, err := strconv.ParseUint(claims.Subject, 10, 64)
if err != nil {
return nil, ErrOIDCInvalidToken
}
var user model.User
if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned {
return nil, ErrOIDCInvalidToken
}
rt := s.runtime()
out := map[string]any{
"sub": strconv.FormatUint(uint64(user.ID), 10),
}
if hasScope(claims.Scope, "profile") || claims.Scope == "" {
name := user.Nickname
if name == "" {
name = user.Username
}
out["name"] = name
out["preferred_username"] = user.Username
if pic := s.absoluteURL(user.Avatar); pic != "" {
out["picture"] = pic
}
}
if hasScope(claims.Scope, "email") || hasScope(claims.Scope, "openid") {
if user.Email != "" {
out["email"] = user.Email
out["email_verified"] = true
}
}
if _, ok := out["preferred_username"]; !ok {
out["preferred_username"] = user.Username
out["name"] = user.Nickname
if out["name"] == "" {
out["name"] = user.Username
}
}
groups := s.userGroups(&user)
if len(groups) > 0 {
claim := rt.GroupClaim
if claim == "" {
claim = "groups"
}
out[claim] = groups
if claim != "groups" {
out["groups"] = groups
}
}
return out, nil
}
// ResolveLogoutRedirect 校验并返回登出后跳转地址(空表示回首页)
func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string) (string, error) {
uri := strings.TrimSpace(postLogoutRedirectURI)
if uri == "" {
return "/", nil
}
var clients []model.OAuthClient
if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil {
return "", err
}
allowed := false
for _, c := range clients {
if redirectAllowed(c.RedirectURIs, uri) {
allowed = true
break
}
// 允许同 host 下任意已登记前缀的登出回调Gitea 常用 / 根路径)
for _, reg := range splitRedirectURIs(c.RedirectURIs) {
if sameOrigin(reg, uri) {
allowed = true
break
}
}
if allowed {
break
}
}
if !allowed {
return "", ErrOIDCInvalidLogout
}
u, err := url.Parse(uri)
if err != nil {
return "", ErrOIDCInvalidLogout
}
if state != "" {
q := u.Query()
q.Set("state", state)
u.RawQuery = q.Encode()
}
return u.String(), nil
}
func sameOrigin(a, b string) bool {
ua, err1 := url.Parse(a)
ub, err2 := url.Parse(b)
if err1 != nil || err2 != nil {
return false
}
return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
}
func (s *OIDCService) parseAccessToken(tokenStr string) (*oidcAccessClaims, error) {
s.mu.RLock()
key := s.privateKey
s.mu.RUnlock()
if key == nil {
return nil, ErrOIDCNotConfigured
}
tok, err := jwt.ParseWithClaims(tokenStr, &oidcAccessClaims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodRS256 {
return nil, ErrOIDCInvalidToken
}
return &key.PublicKey, nil
})
if err != nil || !tok.Valid {
return nil, ErrOIDCInvalidToken
}
claims, ok := tok.Claims.(*oidcAccessClaims)
if !ok {
return nil, ErrOIDCInvalidToken
}
if claims.Issuer != s.Issuer() {
return nil, ErrOIDCInvalidToken
}
return claims, nil
}
func (s *OIDCService) absoluteURL(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
base := s.Issuer()
if base == "" {
return path
}
return base + path
}
func randomToken(nBytes int) (string, error) {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

219
services/permalink.go Normal file
View File

@@ -0,0 +1,219 @@
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}$`)
slugPermalinkRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$`)
// /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}))?/?$`)
boardPermalinkRe = regexp.MustCompile(`^/board/(\d+)(?:\.([A-Za-z0-9]{1,16}))?/?$`)
pagePermalinkRe = regexp.MustCompile(`^/page/([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])(?:\.([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())
}
// BoardPath 板块规范路径
func (p PermalinkConfig) BoardPath(id uint) string {
return fmt.Sprintf("/board/%d%s", id, p.Suffix())
}
// PagePath 自定义单页规范路径
func (p PermalinkConfig) PagePath(slug string) string {
slug = strings.TrimSpace(strings.ToLower(slug))
if slug == "" {
return "/"
}
return fmt.Sprintf("/page/%s%s", slug, p.Suffix())
}
// NormalizePageSlug 校验单页 slug
func NormalizePageSlug(raw string) (string, bool) {
slug := strings.TrimSpace(strings.ToLower(raw))
if slug == "" || len(slug) > 64 {
return "", false
}
if !slugPermalinkRe.MatchString(slug) {
return "", false
}
return slug, true
}
// 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,
}
}
// MatchBoardPath 解析板块公开路径
func (p PermalinkConfig) MatchBoardPath(path string) PermalinkMatch {
m := boardPermalinkRe.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.BoardPath(id),
OK: true,
}
}
// PagePermalinkMatch slug 型路径解析结果
type PagePermalinkMatch struct {
Slug string
Ext string
Canonical string
OK bool
}
// MatchPagePath 解析自定义单页路径
func (p PermalinkConfig) MatchPagePath(path string) PagePermalinkMatch {
m := pagePermalinkRe.FindStringSubmatch(path)
if len(m) < 2 {
return PagePermalinkMatch{}
}
slug := strings.ToLower(m[1])
ext := ""
if len(m) > 2 {
ext = strings.ToLower(m[2])
}
return PagePermalinkMatch{
Slug: slug,
Ext: ext,
Canonical: p.PagePath(slug),
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
}

272
services/points.go Normal file
View File

@@ -0,0 +1,272 @@
package service
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInsufficientPoints = errors.New("积分不足")
ErrAlreadyCheckedIn = errors.New("今日已签到")
ErrAlreadyLottery = errors.New("今日已抽奖")
ErrInvalidPointsDelta = errors.New("无效的积分变动")
)
// PointsService 积分钱包、签到、抽奖
type PointsService struct{}
func NewPointsService() *PointsService { return &PointsService{} }
func todayLocal() string {
return time.Now().Format("2006-01-02")
}
// AdjustPointsTx 在已有事务内调整积分并写流水;返回变动后余额
func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
if delta == 0 {
var u model.User
if err := tx.Select("points").First(&u, userID).Error; err != nil {
return 0, err
}
return u.Points, nil
}
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
return 0, err
}
newBal := user.Points + delta
if newBal < 0 {
return 0, ErrInsufficientPoints
}
if err := tx.Model(&user).Update("points", newBal).Error; err != nil {
return 0, err
}
led := model.PointLedger{
UserID: userID,
Delta: delta,
Balance: newBal,
Reason: reason,
RefType: refType,
RefID: refID,
Note: note,
}
if err := tx.Create(&led).Error; err != nil {
return 0, err
}
return newBal, nil
}
// AdjustPoints 独立事务调整积分
func (s *PointsService) AdjustPoints(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
var bal int
err := model.DB.Transaction(func(tx *gorm.DB) error {
var e error
bal, e = AdjustPointsTx(tx, userID, delta, reason, refType, refID, note)
return e
})
return bal, err
}
// AdminAdjust 站长调账
func (s *PointsService) AdminAdjust(userID uint, delta int, note string) (int, error) {
if delta == 0 {
return 0, ErrInvalidPointsDelta
}
return s.AdjustPoints(userID, delta, model.PointReasonAdminAdjust, "admin", 0, note)
}
// CheckInStatus 今日签到状态
type CheckInStatus struct {
CheckedIn bool `json:"checked_in"`
Streak int `json:"streak"`
TodayPoints int `json:"today_points"` // 若已签到为实得;否则为预计可得
Day string `json:"day"`
}
func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) {
day := todayLocal()
st := CheckInStatus{Day: day}
var row model.CheckIn
err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error
if err != nil {
return st, err
}
if row.ID > 0 {
st.CheckedIn = true
st.Streak = row.Streak
st.TodayPoints = row.Points
return st, nil
}
streak := s.computeNextStreak(userID, day)
st.Streak = streak
st.TodayPoints = checkInReward(streak)
return st, nil
}
func (s *PointsService) computeNextStreak(userID uint, today string) int {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
var prev model.CheckIn
model.DB.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev)
if prev.ID > 0 {
return prev.Streak + 1
}
return 1
}
func checkInReward(streak int) int {
// 基础 5连续每日 +1封顶 15
pts := 5 + (streak - 1)
if pts > 15 {
pts = 15
}
if pts < 5 {
pts = 5
}
return pts
}
// CheckIn 每日签到
func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) {
day := todayLocal()
var out CheckInStatus
err := model.DB.Transaction(func(tx *gorm.DB) error {
var existing model.CheckIn
if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
return err
}
if existing.ID > 0 {
return ErrAlreadyCheckedIn
}
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
var prev model.CheckIn
_ = tx.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev).Error
streak := 1
if prev.ID > 0 {
streak = prev.Streak + 1
}
pts := checkInReward(streak)
row := model.CheckIn{UserID: userID, Day: day, Points: pts, Streak: streak}
if err := tx.Create(&row).Error; err != nil {
return err
}
if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonCheckIn, "check_in", row.ID, fmt.Sprintf("连续签到 %d 天", streak)); err != nil {
return err
}
out = CheckInStatus{CheckedIn: true, Streak: streak, TodayPoints: pts, Day: day}
return nil
})
return out, err
}
// LotteryPrize 奖池项
type LotteryPrize struct {
Points int `json:"points"`
Weight int `json:"weight"`
}
var defaultLotteryPool = []LotteryPrize{
{Points: 0, Weight: 40},
{Points: 2, Weight: 30},
{Points: 5, Weight: 18},
{Points: 10, Weight: 10},
{Points: 20, Weight: 2},
}
// LotteryStatus 抽奖状态
type LotteryStatus struct {
Drawn bool `json:"drawn"`
Points int `json:"points"` // 今日已抽中
Day string `json:"day"`
Pool []LotteryPrize `json:"pool"`
Cost int `json:"cost"` // 抽奖消耗,首版 0
}
func (s *PointsService) GetLotteryStatus(userID uint) (LotteryStatus, error) {
day := todayLocal()
st := LotteryStatus{Day: day, Pool: defaultLotteryPool, Cost: 0}
var row model.LotteryDraw
if err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error; err != nil {
return st, err
}
if row.ID > 0 {
st.Drawn = true
st.Points = row.Points
}
return st, nil
}
func pickLottery(pool []LotteryPrize) (int, error) {
total := 0
for _, p := range pool {
total += p.Weight
}
if total <= 0 {
return 0, errors.New("奖池无效")
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
if err != nil {
return 0, err
}
v := int(n.Int64())
for _, p := range pool {
if v < p.Weight {
return p.Points, nil
}
v -= p.Weight
}
return pool[len(pool)-1].Points, nil
}
// DrawLottery 每日抽奖
func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) {
day := todayLocal()
var out LotteryStatus
err := model.DB.Transaction(func(tx *gorm.DB) error {
var existing model.LotteryDraw
if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
return err
}
if existing.ID > 0 {
return ErrAlreadyLottery
}
pts, err := pickLottery(defaultLotteryPool)
if err != nil {
return err
}
row := model.LotteryDraw{UserID: userID, Day: day, Points: pts}
if err := tx.Create(&row).Error; err != nil {
return err
}
if pts > 0 {
if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonLottery, "lottery", row.ID, "每日抽奖"); err != nil {
return err
}
}
out = LotteryStatus{Drawn: true, Points: pts, Day: day, Pool: defaultLotteryPool, Cost: 0}
return nil
})
return out, err
}
// ListLedger 积分流水
func (s *PointsService) ListLedger(userID uint, page, size int) ([]model.PointLedger, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
var total int64
model.DB.Model(&model.PointLedger{}).Where("user_id = ?", userID).Count(&total)
var rows []model.PointLedger
err := model.DB.Where("user_id = ?", userID).Order("id desc").
Offset((page - 1) * size).Limit(size).Find(&rows).Error
return rows, total, err
}

280
services/poll.go Normal file
View File

@@ -0,0 +1,280 @@
package service
import (
"encoding/json"
"errors"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
var (
ErrPollClosed = errors.New("投票已结束")
ErrPollAlreadyVoted = errors.New("已投过票")
ErrPollInvalidVote = errors.New("无效的投票选项")
)
const (
pollEndsAtMinLead = 5 * time.Minute
pollEndsAtMaxWindow = 365 * 24 * time.Hour
)
// PollOptionInput 创建投票时的选项
type PollOptionInput struct {
Text string `json:"text"`
}
// PollView 投票帖详情视图
type PollView struct {
Multi bool `json:"multi"`
MaxChoices int `json:"max_choices"`
Closed bool `json:"closed"`
EndsAt *time.Time `json:"ends_at,omitempty"`
Options []PollOptionView `json:"options"`
MyOptionIDs []uint `json:"my_option_ids,omitempty"`
TotalVotes int `json:"total_votes"`
}
type PollOptionView struct {
ID uint `json:"id"`
Text string `json:"text"`
VoteCount int `json:"vote_count"`
Percent int `json:"percent,omitempty"`
}
// CreatePollForPost 为投票帖创建投票配置与选项
func CreatePollForPost(tx *gorm.DB, postID uint, multi bool, maxChoices int, endsAt *time.Time, options []PollOptionInput) error {
if len(options) < 2 || len(options) > 10 {
return errors.New("投票选项需 2-10 个")
}
if !multi {
maxChoices = 1
} else if maxChoices < 1 || maxChoices > len(options) {
maxChoices = len(options)
}
poll := model.Poll{PostID: postID, Multi: multi, MaxChoices: maxChoices, EndsAt: endsAt}
if err := tx.Create(&poll).Error; err != nil {
return err
}
for i, opt := range options {
text := strings.TrimSpace(opt.Text)
if text == "" {
return errors.New("投票选项不能为空")
}
if len([]rune(text)) > 64 {
return errors.New("投票选项最多 64 字")
}
row := model.PollOption{PostID: postID, Text: text, SortOrder: i}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
}
// ParsePollOptionsJSON 解析发帖表单中的 poll_options JSON
func ParsePollOptionsJSON(raw string) ([]PollOptionInput, bool, int, *time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, false, 1, nil, errors.New("投票选项不能为空")
}
var payload struct {
Multi bool `json:"multi"`
MaxChoices int `json:"max_choices"`
EndsAt string `json:"ends_at"`
Options []PollOptionInput `json:"options"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, false, 1, nil, err
}
endsAt, err := parsePollEndsAt(payload.EndsAt)
if err != nil {
return nil, false, 1, nil, err
}
return payload.Options, payload.Multi, payload.MaxChoices, endsAt, nil
}
func parsePollEndsAt(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var parsed time.Time
var ok bool
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, raw); err == nil {
parsed = t
ok = true
break
}
}
if !ok {
return nil, errors.New("投票截止时间格式无效")
}
now := time.Now()
if !parsed.After(now.Add(pollEndsAtMinLead)) {
return nil, errors.New("投票截止时间须晚于当前时间至少 5 分钟")
}
if parsed.After(now.Add(pollEndsAtMaxWindow)) {
return nil, errors.New("投票截止时间不能超过 365 天")
}
utc := parsed.UTC()
return &utc, nil
}
// closePollIfExpired 若已过截止时间则自动关闭投票
func closePollIfExpired(postID uint) error {
var poll model.Poll
if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
return err
}
if poll.Closed || poll.EndsAt == nil {
return nil
}
if time.Now().Before(*poll.EndsAt) {
return nil
}
res := model.DB.Model(&poll).Where("post_id = ? AND closed = ?", postID, false).Update("closed", true)
return res.Error
}
// GetPollView 获取投票视图
func GetPollView(postID, viewerID uint) (*PollView, error) {
if err := closePollIfExpired(postID); err != nil {
return nil, err
}
var poll model.Poll
if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
return nil, err
}
var opts []model.PollOption
if err := model.DB.Where("post_id = ?", postID).Order("sort_order ASC, id ASC").Find(&opts).Error; err != nil {
return nil, err
}
total := 0
for _, o := range opts {
total += o.VoteCount
}
showResults := poll.Closed
var myIDs []uint
if viewerID > 0 {
var votes []model.PollVote
model.DB.Where("post_id = ? AND user_id = ?", postID, viewerID).Find(&votes)
for _, v := range votes {
myIDs = append(myIDs, v.OptionID)
}
if len(myIDs) > 0 {
showResults = true
}
}
views := make([]PollOptionView, len(opts))
for i, o := range opts {
v := PollOptionView{ID: o.ID, Text: o.Text, VoteCount: o.VoteCount}
if showResults && total > 0 {
v.Percent = o.VoteCount * 100 / total
}
views[i] = v
}
return &PollView{
Multi: poll.Multi, MaxChoices: poll.MaxChoices, Closed: poll.Closed,
EndsAt: poll.EndsAt, Options: views, MyOptionIDs: myIDs, TotalVotes: total,
}, nil
}
// VotePoll 用户投票
func VotePoll(postID, userID uint, optionIDs []uint) error {
if userID == 0 {
return ErrPermissionDenied
}
if err := closePollIfExpired(postID); err != nil {
return err
}
var poll model.Poll
if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
return err
}
if poll.Closed {
return ErrPollClosed
}
var existing int64
model.DB.Model(&model.PollVote{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&existing)
if existing > 0 {
return ErrPollAlreadyVoted
}
if len(optionIDs) == 0 {
return ErrPollInvalidVote
}
if !poll.Multi && len(optionIDs) != 1 {
return errors.New("本投票为单选")
}
if poll.Multi && len(optionIDs) > poll.MaxChoices {
return errors.New("超出最多可选数")
}
seen := map[uint]bool{}
for _, oid := range optionIDs {
if oid == 0 || seen[oid] {
return ErrPollInvalidVote
}
seen[oid] = true
var opt model.PollOption
if err := model.DB.Where("id = ? AND post_id = ?", oid, postID).First(&opt).Error; err != nil {
return ErrPollInvalidVote
}
}
return model.DB.Transaction(func(tx *gorm.DB) error {
for _, oid := range optionIDs {
v := model.PollVote{PostID: postID, OptionID: oid, UserID: userID}
if err := tx.Create(&v).Error; err != nil {
return err
}
if err := tx.Model(&model.PollOption{}).Where("id = ?", oid).
UpdateColumn("vote_count", gorm.Expr("vote_count + 1")).Error; err != nil {
return err
}
}
return nil
})
}
// ClosePoll 结束投票
func ClosePoll(postID, userID uint, isAdmin bool, postAuthorID uint) error {
if !isAdmin && userID != postAuthorID {
return ErrPermissionDenied
}
res := model.DB.Model(&model.Poll{}).Where("post_id = ?", postID).Update("closed", true)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("投票不存在")
}
return nil
}
// LockPollOptions 编辑时锁定选项(已发布帖不允许改选项文案)
func LockPollOptions(postID uint) bool {
var n int64
model.DB.Model(&model.PollVote{}).Where("post_id = ?", postID).Count(&n)
return n > 0
}
// EnsurePollExists 检查投票帖是否有 poll 记录
func EnsurePollExists(postID uint) bool {
var n int64
model.DB.Model(&model.Poll{}).Where("post_id = ?", postID).Count(&n)
return n > 0
}
// DeletePollData 删帖时清理投票数据
func DeletePollData(tx *gorm.DB, postID uint) {
tx.Where("post_id = ?", postID).Delete(&model.PollVote{})
tx.Where("post_id = ?", postID).Delete(&model.PollOption{})
tx.Where("post_id = ?", postID).Delete(&model.Poll{})
}

947
services/post.go Normal file
View File

@@ -0,0 +1,947 @@
package service
import (
"errors"
"sort"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
type PostService struct {
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *PostService {
return &PostService{filter: filter, settings: settings}
}
func normalizePostType(raw string) string {
switch strings.TrimSpace(raw) {
case model.PostTypeQuestion:
return model.PostTypeQuestion
case model.PostTypePoll:
return model.PostTypePoll
case model.PostTypeBounty:
return model.PostTypeBounty
case model.PostTypeLottery:
return model.PostTypeLottery
default:
return model.PostTypeNormal
}
}
func isSpecialPostType(t string) bool {
return t == model.PostTypePoll || t == model.PostTypeBounty || t == model.PostTypeLottery
}
type PostListQuery struct {
BoardID uint
UserID uint // >0 时仅返回该用户的帖子
Page int
Size int
Keyword string
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE
Author string // 作者用户名或昵称(解析为 UserID
TitleOnly bool // 关键词仅匹配标题
Sort string // latest | reply | hot
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool
Status string // 管理端筛选pending|published|rejected|all空则按可见性规则
}
// PostListItem 帖子列表项(含评论数等扩展字段)
type PostListItem struct {
model.Post
CommentCount int `json:"comment_count"`
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
LastReplyUser *model.User `json:"last_reply_user,omitempty"`
LastReplyGuestNick string `json:"last_reply_guest_nick,omitempty"`
}
type lastReplyInfo struct {
At *time.Time
User *model.User
GuestNick string
}
func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error) {
posts, total, err := s.List(q)
if err != nil {
return nil, 0, err
}
if len(posts) == 0 {
return []PostListItem{}, total, nil
}
ids := make([]uint, len(posts))
for i, p := range posts {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyInfoMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
info := replyMap[p.ID]
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: info.At,
LastReplyUser: info.User,
LastReplyGuestNick: info.GuestNick,
}
}
return items, total, nil
}
func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
type row struct {
PostID uint
Count int
}
var rows []row
model.DB.Model(&model.Comment{}).Select("post_id, count(*) as count").
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
}
return m
}
func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo {
m := make(map[uint]lastReplyInfo, len(postIDs))
if len(postIDs) == 0 {
return m
}
type idRow struct {
PostID uint
MaxID uint
}
var idRows []idRow
model.DB.Model(&model.Comment{}).
Select("post_id, MAX(id) as max_id").
Where("post_id IN ? AND status = ?", postIDs, model.ContentStatusPublished).
Group("post_id").
Scan(&idRows)
if len(idRows) == 0 {
return m
}
commentIDs := make([]uint, len(idRows))
for i, r := range idRows {
commentIDs[i] = r.MaxID
}
var comments []model.Comment
if err := model.DB.Preload("User").Where("id IN ?", commentIDs).Find(&comments).Error; err != nil {
return m
}
for i := range comments {
c := &comments[i]
info := lastReplyInfo{At: &c.CreatedAt}
if c.UserID > 0 && c.User.ID > 0 {
u := c.User
info.User = &u
} else {
nick := strings.TrimSpace(c.GuestNick)
if nick == "" {
nick = "游客"
}
info.GuestNick = nick
}
m[c.PostID] = info
}
return m
}
// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 {
limit = 10
}
since := time.Now().Add(-7 * 24 * time.Hour)
var posts []model.Post
err := model.DB.Preload("User").Preload("Board").
Where("status = ?", model.ContentStatusPublished).
Where(`EXISTS (
SELECT 1 FROM comments
WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL
AND comments.status = ?
AND comments.created_at >= ?
)`, model.ContentStatusPublished, since).
Order(`(
SELECT MAX(created_at) FROM comments
WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL
AND comments.status = 'published'
) DESC`).
Limit(limit).Find(&posts).Error
if err != nil {
return nil, err
}
ids := make([]uint, len(posts))
for i, p := range posts {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyInfoMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
info := replyMap[p.ID]
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: info.At,
LastReplyUser: info.User,
LastReplyGuestNick: info.GuestNick,
}
}
return items, nil
}
// TagCount 标签及其出现次数
type TagCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
// PopularTags 聚合帖子标签,按热度降序返回
func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
if limit <= 0 {
limit = 40
}
var rows []struct{ Tags string }
if err := model.DB.Model(&model.Post{}).
Select("tags").
Where("status = ? AND tags <> '' AND tags IS NOT NULL", model.ContentStatusPublished).
Find(&rows).Error; err != nil {
return nil, err
}
counts := make(map[string]int)
// 保留首次出现的原始大小写作为展示名
display := make(map[string]string)
for _, row := range rows {
for _, part := range strings.FieldsFunc(row.Tags, func(r rune) bool {
return r == ',' || r == ''
}) {
name := strings.TrimSpace(part)
if name == "" {
continue
}
key := strings.ToLower(name)
counts[key]++
if _, ok := display[key]; !ok {
display[key] = name
}
}
}
list := make([]TagCount, 0, len(counts))
for key, n := range counts {
list = append(list, TagCount{Name: display[key], Count: n})
}
sort.Slice(list, func(i, j int) bool {
if list[i].Count != list[j].Count {
return list[i].Count > list[j].Count
}
return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name)
})
if len(list) > limit {
list = list[:limit]
}
return list, nil
}
func (s *PostService) CommentCount(postID uint) int {
var count int64
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
}
q.Size = s.settings.NormalizePageSize(q.Size)
if q.Keyword != "" {
kw, err := s.settings.NormalizeSearchKeyword(q.Keyword)
if err != nil {
return nil, 0, err
}
q.Keyword = kw
}
if q.UserID == 0 {
if author := strings.TrimSpace(q.Author); author != "" {
if uid, ok := resolveAuthorUserID(author); ok {
q.UserID = uid
} else {
return []model.Post{}, 0, nil
}
}
}
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)
}
if q.UserID > 0 {
db = db.Where("user_id = ?", q.UserID)
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
if q.TitleOnly {
db = db.Where("title LIKE ?", kw)
} else {
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
}
}
if tag := strings.TrimSpace(q.Tag); tag != "" {
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
escaped := escapeLikePattern(strings.ToLower(tag))
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), '', ','), ', ', ','), ' ,', ',') || ',')"
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
}
var total int64
db.Count(&total)
var posts []model.Post
db = db.Order("pinned desc")
if q.BoardID > 0 {
db = db.Order("board_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":
db = db.Order("like_count desc, view_count desc")
default:
db = db.Order("id desc")
}
err := db.Order("id desc").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&posts).Error
return posts, total, err
}
func normalizePostSort(sort string) string {
switch sort {
case "reply", "hot":
return sort
default:
return "latest"
}
}
// escapeLikePattern 转义 LIKE 通配符,配合 ESCAPE '\'
func escapeLikePattern(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
// resolveAuthorUserID 按用户名精确匹配,否则按昵称精确匹配(优先用户名)
func resolveAuthorUserID(author string) (uint, bool) {
author = strings.TrimSpace(author)
if author == "" {
return 0, false
}
var u model.User
if err := model.DB.Select("id").Where("username = ?", author).First(&u).Error; err == nil {
return u.ID, true
}
if err := model.DB.Select("id").Where("nickname = ?", author).First(&u).Error; err == nil {
return u.ID, true
}
return 0, false
}
func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
if err != nil {
return nil, ErrPostNotFound
}
return &post, nil
}
func (s *PostService) RecordView(id uint) {
model.DB.Model(&model.Post{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
}
func (s *PostService) GetByID(id uint) (*model.Post, error) {
post, err := s.FindByID(id)
if err != nil {
return nil, err
}
s.RecordView(id)
return post, nil
}
func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, skipModeration bool) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(SanitizePostHTML(content))
tags = s.filter.Filter(strings.TrimSpace(tags))
postType = normalizePostType(postType)
if title == "" || content == "" {
return nil, errors.New("标题和内容不能为空")
}
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return nil, err
}
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
return nil, err
}
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
return nil, err
}
if _, err := NewBoardService().GetByID(boardID); err != nil {
return nil, err
}
status := model.ContentStatusPending
if skipModeration {
status = model.ContentStatusPublished
}
post := &model.Post{
BoardID: boardID,
UserID: userID,
Title: title,
Content: content,
ContentPlain: StripHTMLForSearch(RedactGatedPostHTML(content)),
Tags: tags,
PostType: postType,
QuestionResolved: false,
Status: status,
}
if err := model.DB.Create(post).Error; err != nil {
return nil, err
}
if status == model.ContentStatusPublished {
AddExp(userID, 10)
}
return post, nil
}
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
// postType 为空时保持原类型;改为非 question 时清除已解决标记。
func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool, title, content, tags, postType string, boardID uint) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied
}
if err := s.checkEditable(&post, isAdmin); err != nil {
return err
}
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(SanitizePostHTML(content))
tags = s.filter.Filter(strings.TrimSpace(tags))
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return err
}
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
return err
}
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
return err
}
nextBoardID := post.BoardID
if boardID > 0 && boardID != post.BoardID {
if _, err := NewBoardService().GetByID(boardID); err != nil {
return err
}
nextBoardID = boardID
}
nextType := post.PostType
if strings.TrimSpace(postType) != "" {
nextType = normalizePostType(postType)
}
// 不允许修改特殊帖子类型(含 poll→normal、normal→poll
if isSpecialPostType(post.PostType) && nextType != post.PostType {
return errors.New("不能修改特殊帖子类型")
}
if isSpecialPostType(nextType) && post.PostType != nextType {
return errors.New("不能改为特殊帖子类型")
}
nextResolved := post.QuestionResolved
if nextType != model.PostTypeQuestion {
nextResolved = false
}
return model.DB.Transaction(func(tx *gorm.DB) error {
rev := model.PostRevision{
PostID: postID, EditorID: userID,
Title: post.Title, Content: post.Content, Tags: post.Tags,
}
if err := tx.Create(&rev).Error; err != nil {
return err
}
updates := map[string]interface{}{
"board_id": nextBoardID,
"title": title,
"content": content,
"content_plain": StripHTMLForSearch(RedactGatedPostHTML(content)),
"tags": tags,
"post_type": nextType,
"question_resolved": nextResolved,
}
// 非免审用户修改后重新进入审核
if !skipModeration {
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("无效的审核状态")
}
var post model.Post
if err := model.DB.Select("id", "user_id", "status").First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
prev := post.Status
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
}
// 首次变为已发布时加经验
if status == model.ContentStatusPublished && prev != model.ContentStatusPublished {
AddExp(post.UserID, 10)
}
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
}
// EditBlockReason 返回不可编辑的原因(可编辑时返回空字符串)
func (s *PostService) EditBlockReason(post *model.Post, isAdmin bool) string {
if err := s.checkEditable(post, isAdmin); err != nil {
return err.Error()
}
return ""
}
func (s *PostService) checkEditable(post *model.Post, isAdmin bool) error {
if isAdmin {
return nil
}
if post.EditLocked {
return ErrPostEditLocked
}
window := s.settings.PostEditWindowHours()
if window > 0 && time.Since(post.CreatedAt) > time.Duration(window)*time.Hour {
return ErrPostEditExpired
}
return nil
}
// CanUserEdit 判断指定用户是否可编辑帖子
func (s *PostService) CanUserEdit(post *model.Post, userID uint, isAdmin bool) bool {
if userID == 0 {
return false
}
if !isAdmin && post.UserID != userID {
return false
}
return s.CanEdit(post, isAdmin)
}
// UserEditBlockReason 返回用户不可编辑的原因
func (s *PostService) UserEditBlockReason(post *model.Post, userID uint, isAdmin bool) string {
if userID == 0 {
return "请先登录"
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied.Error()
}
return s.EditBlockReason(post, isAdmin)
}
func (s *PostService) SetEditLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("edit_locked", locked)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrPostNotFound
}
return nil
}
// SetCommentsLocked 锁定/解锁讨论(禁止新评论)
func (s *PostService) SetCommentsLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("comments_locked", locked)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrPostNotFound
}
return nil
}
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
var revs []model.PostRevision
err := model.DB.Preload("Editor").Where("post_id = ?", postID).
Order("id desc").Find(&revs).Error
if err != nil {
return nil, err
}
if revs == nil {
revs = []model.PostRevision{}
}
return revs, nil
}
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) {
var rev model.PostRevision
err := model.DB.Preload("Editor").
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
if err != nil {
return nil, ErrRevisionNotFound
}
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
}
return model.DB.Transaction(func(tx *gorm.DB) error {
if err := RefundBountyIfOpen(tx, &post); err != nil {
return err
}
DeletePollData(tx, postID)
DeleteLotteryData(tx, postID)
if err := tx.Where("post_id = ?", postID).Delete(&model.Comment{}).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) SetBoardPinned(postID uint, boardPinned bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("board_pinned", boardPinned).Error
}
func (s *PostService) SetFeatured(postID uint, featured bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("featured", featured).Error
}
// SetQuestionResolved 标记问答帖已解决 / 未解决(作者或管理员)
func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, resolved bool) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if !isAdmin && post.UserID != userID {
return ErrPermissionDenied
}
if post.PostType != model.PostTypeQuestion {
return errors.New("仅问答帖可标记解决状态")
}
return model.DB.Model(&post).Update("question_resolved", resolved).Error
}
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
var post model.Post
if err := model.DB.Select("id", "user_id").First(&post, postID).Error; err != nil {
return false, ErrPostNotFound
}
var like model.PostLike
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
if result.Error != nil {
return false, result.Error
}
if result.RowsAffected > 0 {
model.DB.Delete(&like)
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count - 1"))
return false, nil
}
like = model.PostLike{PostID: postID, UserID: userID}
if err := model.DB.Create(&like).Error; err != nil {
return false, err
}
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
// 他人点赞给作者加经验;自赞不计
if userID != post.UserID {
AddExp(post.UserID, 1)
go func() {
_ = NewBadgeService().EvaluateAuto(post.UserID)
}()
}
return true, nil
}
func (s *PostService) IsLiked(userID, postID uint) bool {
var count int64
model.DB.Model(&model.PostLike{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
func (s *PostService) ToggleFavorite(userID, postID uint) (faved bool, err error) {
var fav model.PostFavorite
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&fav)
if result.Error != nil {
return false, result.Error
}
if result.RowsAffected > 0 {
if err := model.DB.Delete(&fav).Error; err != nil {
return false, err
}
return false, nil
}
fav = model.PostFavorite{PostID: postID, UserID: userID}
if err := model.DB.Create(&fav).Error; err != nil {
return false, err
}
return true, nil
}
func (s *PostService) IsFavorited(userID, postID uint) bool {
var count int64
model.DB.Model(&model.PostFavorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
func (s *PostService) ListFavorites(userID uint, page, size int) ([]model.PostFavorite, int64, error) {
if page < 1 {
page = 1
}
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
base.Count(&total)
var favs []model.PostFavorite
err := model.DB.Preload("Post.User").Preload("Post.Board").
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
}

69
services/post_special.go Normal file
View File

@@ -0,0 +1,69 @@
package service
import (
"errors"
"strconv"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
// PostCreateExtras 特殊帖创建附加参数
type PostCreateExtras struct {
PollOptionsJSON string
BountyPoints int
LotteryWinnerCount int
}
// FinalizeSpecialPostCreate 创建帖后初始化投票/悬赏/抽奖
func FinalizeSpecialPostCreate(post *model.Post, userID uint, extras PostCreateExtras) error {
if post == nil {
return errors.New("帖子不存在")
}
return model.DB.Transaction(func(tx *gorm.DB) error {
switch post.PostType {
case model.PostTypePoll:
opts, multi, maxChoices, endsAt, err := ParsePollOptionsJSON(extras.PollOptionsJSON)
if err != nil {
return err
}
return CreatePollForPost(tx, post.ID, multi, maxChoices, endsAt, opts)
case model.PostTypeBounty:
if extras.BountyPoints < 1 {
return ErrBountyInvalidPoint
}
if err := tx.Model(post).Updates(map[string]interface{}{
"bounty_points": extras.BountyPoints,
"bounty_status": model.BountyStatusOpen,
}).Error; err != nil {
return err
}
return EscrowBounty(tx, userID, post.ID, extras.BountyPoints)
case model.PostTypeLottery:
count := extras.LotteryWinnerCount
if count < 1 {
count = 1
}
if count > 20 {
return errors.New("开奖人数最多 20")
}
return tx.Model(post).Updates(map[string]interface{}{
"lottery_winner_count": count,
"lottery_status": model.PostLotteryStatusOpen,
}).Error
default:
return nil
}
})
}
// ParsePostExtrasFromForm 从表单解析特殊帖参数
func ParsePostExtrasFromForm(pollJSON, bountyRaw, lotteryRaw string) PostCreateExtras {
bounty, _ := strconv.Atoi(bountyRaw)
lottery, _ := strconv.Atoi(lotteryRaw)
return PostCreateExtras{
PollOptionsJSON: pollJSON,
BountyPoints: bounty,
LotteryWinnerCount: lottery,
}
}

88
services/ratelimit.go Normal file
View File

@@ -0,0 +1,88 @@
package service
import (
"sync"
"time"
)
// RateLimiter 简单内存限流器,防止重复刷屏
type RateLimiter struct {
mu sync.Mutex
records map[string][]time.Time
settings *ForumSettingsService
}
func NewRateLimiter(settings *ForumSettingsService) *RateLimiter {
r := &RateLimiter{
records: make(map[string][]time.Time),
settings: settings,
}
go r.cleanup()
return r
}
// Allow 检查 action+key 是否允许操作
func (r *RateLimiter) Allow(action, key string) bool {
limit := r.limitFor(action)
window := r.windowFor(action)
if limit <= 0 {
return true
}
fullKey := action + ":" + key
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-window)
times := r.records[fullKey]
var valid []time.Time
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= limit {
r.records[fullKey] = valid
return false
}
valid = append(valid, now)
r.records[fullKey] = valid
return true
}
func (r *RateLimiter) limitFor(action string) int {
if action == "friend_link" {
return 5
}
return r.settings.RateLimitFor(action)
}
func (r *RateLimiter) windowFor(action string) time.Duration {
if action == "friend_link" {
return time.Hour
}
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
}
func (r *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
r.mu.Lock()
// 使用最大窗口清理,覆盖友链 1 小时窗口
cutoff := time.Now().Add(-time.Hour * 2)
for k, times := range r.records {
var valid []time.Time
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(r.records, k)
} else {
r.records[k] = valid
}
}
r.mu.Unlock()
}
}

363
services/report.go Normal file
View File

@@ -0,0 +1,363 @@
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("不能举报自己的帖子")
ErrCannotReportOwnComment = errors.New("不能举报自己的评论")
)
type ReportService struct {
filter *SensitiveFilter
settings *ForumSettingsService
messages *MessageService
posts *PostService
comments *CommentService
}
func NewReportService(
filter *SensitiveFilter,
settings *ForumSettingsService,
messages *MessageService,
posts *PostService,
comments *CommentService,
) *ReportService {
return &ReportService{filter: filter, settings: settings, messages: messages, posts: posts, comments: comments}
}
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
}
// CreateCommentReport 用户举报评论
func (s *ReportService) CreateCommentReport(reporterID, commentID 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)
}
comment, err := s.comments.GetByID(commentID)
if err != nil {
return nil, err
}
if comment.UserID > 0 && comment.UserID == reporterID {
return nil, ErrCannotReportOwnComment
}
var existing int64
model.DB.Model(&model.PostReport{}).
Where("comment_id = ? AND reporter_id = ? AND status = ?", commentID, reporterID, model.ReportStatusPending).
Count(&existing)
if existing > 0 {
return nil, ErrReportAlreadyExists
}
cid := commentID
rep := &model.PostReport{
PostID: comment.PostID,
CommentID: &cid,
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("Comment").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("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped()
}).Preload("Comment.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 | reject_comment
HandleNote string
RejectReason string // reject_post / reject_comment 时发给作者
}
// 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()
}).Preload("Comment", 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
}
isCommentReport := rep.CommentID != nil && *rep.CommentID > 0
commentAuthorID := uint(0)
commentFloor := 0
if isCommentReport && rep.Comment != nil {
commentAuthorID = rep.Comment.UserID
commentFloor = rep.Comment.Floor
}
switch in.Action {
case "dismiss":
rep.Status = model.ReportStatusDismissed
case "resolve":
rep.Status = model.ReportStatusResolved
case "reject_post":
if isCommentReport {
return nil, errors.New("评论举报请使用「拒绝该评论」")
}
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,
)
}
case "reject_comment":
if !isCommentReport {
return nil, errors.New("仅评论举报可拒绝评论")
}
reason := strings.TrimSpace(in.RejectReason)
if reason == "" {
return nil, errors.New("请填写拒绝原因(将私信通知作者)")
}
if utf8.RuneCountInString(reason) > 1000 {
return nil, errors.New("拒绝原因过长")
}
if err := s.comments.SetStatus(*rep.CommentID, model.ContentStatusRejected); err != nil {
return nil, err
}
rep.Status = model.ReportStatusResolved
if note == "" {
rep.HandleNote = "已拒绝该评论并通知作者"
}
if commentAuthorID > 0 {
pid := postID
rid := rep.ID
body := fmt.Sprintf("你在帖子《%s》下的评论#%d未通过审核。\n\n原因\n%s", postTitle, commentFloor, reason)
_, _ = s.messages.SendSystem(
commentAuthorID,
fmt.Sprintf("评论未通过审核 · 《%s》", postTitle),
body,
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 {
switch in.Action {
case "reject_post":
resultText = "已核实并下架该帖"
case "reject_comment":
resultText = "已核实并处理该评论"
default:
resultText = "已处理"
}
}
targetDesc := fmt.Sprintf("帖子《%s》#%d", postTitle, postID)
if isCommentReport {
targetDesc = fmt.Sprintf("帖子《%s》下的评论#%d", postTitle, commentFloor)
}
content := fmt.Sprintf("你举报的%s已处理%s。", targetDesc, 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("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped()
}).Preload("Reporter").Preload("Handler").First(&rep, rep.ID).Error
return &rep, nil
}

50
services/sanitize_html.go Normal file
View File

@@ -0,0 +1,50 @@
package service
import (
"sync"
"github.com/microcosm-cc/bluemonday"
)
var (
postHTMLPolicyOnce sync.Once
postHTMLPolicy *bluemonday.Policy
)
// postContentHTMLPolicy 帖子正文白名单:对齐前端编辑器产出,禁止 style/script 等泄漏或执行向量。
func postContentHTMLPolicy() *bluemonday.Policy {
postHTMLPolicyOnce.Do(func() {
p := bluemonday.UGCPolicy()
// TipTap / Markdown 转换会用到的结构
p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only", "points-only")
p.AllowAttrs("class").OnElements(
"p", "div", "span", "pre", "code", "img", "a",
"h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
"members-only", "reply-only", "points-only",
)
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
p.AllowAttrs(
"data-locked", "data-length", "data-gate", "data-cost", "data-block-key",
"data-code-copy", "data-code-fold", "data-lang", "data-full",
"data-code-style", "data-line-numbers", "data-collapsed",
"data-line-count", "data-lineno-digits",
"data-image-group", "data-layout", "data-display",
"data-clear-float",
).Globally()
p.AllowAttrs("target", "rel").OnElements("a")
// bluemonday 默认会剥 style 标签与 style 属性;此处不再放行
postHTMLPolicy = p
})
return postHTMLPolicy
}
// SanitizePostHTML 清洗帖子 HTML防止 <style> 等污染整页或脚本注入。
func SanitizePostHTML(html string) string {
if html == "" {
return ""
}
return postContentHTMLPolicy().Sanitize(html)
}

View File

@@ -0,0 +1,58 @@
package service
import (
"strings"
"testing"
)
func TestSanitizePostHTML_StripsStyleLeak(t *testing.T) {
in := "<center>你好</center>\n<style>\n* {color:red}\n</style>"
out := SanitizePostHTML(in)
if strings.Contains(strings.ToLower(out), "<style") {
t.Fatalf("应剥离 style 标签,得到: %q", out)
}
if strings.Contains(out, "color:red") {
t.Fatalf("不应保留 CSS 文本,得到: %q", out)
}
if !strings.Contains(out, "你好") {
t.Fatalf("应保留正文,得到: %q", out)
}
}
func TestSanitizePostHTML_StripsInlineStyleAndScript(t *testing.T) {
in := `<p style="color:red">段落</p><script>alert(1)</script><img src="/uploads/posts/a.jpg" data-display="wide" class="article-img">`
out := SanitizePostHTML(in)
if strings.Contains(strings.ToLower(out), "style=") {
t.Fatalf("应剥离 style 属性,得到: %q", out)
}
if strings.Contains(strings.ToLower(out), "<script") {
t.Fatalf("应剥离 script得到: %q", out)
}
if !strings.Contains(out, "data-display") {
t.Fatalf("应保留 data-display得到: %q", out)
}
}
func TestSanitizePostHTML_KeepsMembersOnlyAndImageGroup(t *testing.T) {
in := `<members-only data-gate="login"><p>密</p></members-only>` +
`<reply-only data-gate="reply"><p>回复可见</p></reply-only>` +
`<div data-image-group data-layout="cols-2" class="image-group"><img src="/uploads/posts/a.jpg" alt="x"></div>` +
`<p data-clear-float class="article-clear-float">清浮动</p>`
out := SanitizePostHTML(in)
for _, want := range []string{"members-only", "reply-only", "data-gate", "回复可见", "data-image-group", "data-layout", "data-clear-float", "清浮动"} {
if !strings.Contains(out, want) {
t.Fatalf("缺少 %q得到: %q", want, out)
}
}
}
func TestStripHTMLForSearch_DropsStyleText(t *testing.T) {
in := "<center>你好</center><style>* {color:red}</style>"
out := StripHTMLForSearch(in)
if strings.Contains(out, "color") || strings.Contains(out, "red") {
t.Fatalf("摘要不应含 CSS得到: %q", out)
}
if out != "你好" {
t.Fatalf("期望 %q得到 %q", "你好", out)
}
}

130
services/seo.go Normal file
View File

@@ -0,0 +1,130 @@
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 中的 ROOT_URL否则根据请求 Host 推断
func (s *ForumSettingsService) SitePublicBaseURL(requestOrigin string) string {
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
if root == "" {
root = normalizeRootURL(requestOrigin)
}
return strings.TrimRight(root, "/")
}
// AbsoluteURL 将相对路径拼成绝对 URL。
// base 为空或非 http(s) 时返回空串,避免邮件等场景出现无法点击的相对路径。
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(strings.TrimSpace(base), "/")
if base == "" || (!strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://")) {
return ""
}
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, p PermalinkConfig) string {
if boardID == 0 {
return "/"
}
return p.BoardPath(boardID)
}
// LegacyQueryBoardHome 旧版 query 形式(/?board=id仅用于 301 重定向
func LegacyQueryBoardHome(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:])
}

1500
services/settings.go Normal file

File diff suppressed because it is too large Load Diff

14
services/settings_util.go Normal file
View File

@@ -0,0 +1,14 @@
package service
import (
"strings"
"unicode/utf8"
)
func runeLen(s string) int {
return utf8.RuneCountInString(s)
}
func trimRunes(s string) string {
return strings.TrimSpace(s)
}

187
services/site_page.go Normal file
View File

@@ -0,0 +1,187 @@
package service
import (
"errors"
"strings"
"git.iioio.com/freefire/jiang13-forum/model"
)
var (
ErrSitePageNotFound = errors.New("单页不存在")
ErrSitePageSlugUsed = errors.New("slug 已被占用")
)
// SitePageService 自定义单页
type SitePageService struct {
filter *SensitiveFilter
}
func NewSitePageService(filter *SensitiveFilter) *SitePageService {
return &SitePageService{filter: filter}
}
// SitePageSummary 公开列表摘要
type SitePageSummary struct {
ID uint `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
ShowInFooter bool `json:"show_in_footer"`
ShowInNav bool `json:"show_in_nav"`
SortOrder int `json:"sort_order"`
}
func (s *SitePageService) ListPublished() ([]SitePageSummary, error) {
var rows []model.SitePage
err := model.DB.Where("published = ?", true).
Order("sort_order ASC, id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]SitePageSummary, len(rows))
for i, p := range rows {
out[i] = SitePageSummary{
ID: p.ID, Title: p.Title, Slug: p.Slug,
ShowInFooter: p.ShowInFooter, ShowInNav: p.ShowInNav, SortOrder: p.SortOrder,
}
}
return out, nil
}
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
var rows []model.SitePage
err := model.DB.Order("sort_order ASC, id ASC").Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*model.SitePage, error) {
slug, ok := NormalizePageSlug(slug)
if !ok {
return nil, ErrSitePageNotFound
}
var page model.SitePage
q := model.DB.Where("slug = ?", slug)
if !allowUnpublished {
q = q.Where("published = ?", true)
}
if err := q.First(&page).Error; err != nil {
return nil, ErrSitePageNotFound
}
page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content))
return &page, nil
}
func (s *SitePageService) GetByID(id uint) (*model.SitePage, error) {
var page model.SitePage
if err := model.DB.First(&page, id).Error; err != nil {
return nil, ErrSitePageNotFound
}
page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content))
return &page, nil
}
type SitePageInput struct {
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
Published bool `json:"published"`
SortOrder int `json:"sort_order"`
ShowInFooter bool `json:"show_in_footer"`
ShowInNav bool `json:"show_in_nav"`
}
func (s *SitePageService) Create(in SitePageInput) (*model.SitePage, error) {
page, err := s.normalizeInput(in)
if err != nil {
return nil, err
}
var exists int64
model.DB.Model(&model.SitePage{}).Where("slug = ?", page.Slug).Count(&exists)
if exists > 0 {
return nil, ErrSitePageSlugUsed
}
if err := model.DB.Create(page).Error; err != nil {
return nil, err
}
return page, nil
}
func (s *SitePageService) Update(id uint, in SitePageInput) error {
page, err := s.GetByID(id)
if err != nil {
return err
}
next, err := s.normalizeInput(in)
if err != nil {
return err
}
var exists int64
model.DB.Model(&model.SitePage{}).Where("slug = ? AND id <> ?", next.Slug, id).Count(&exists)
if exists > 0 {
return ErrSitePageSlugUsed
}
return model.DB.Model(page).Updates(map[string]interface{}{
"title": next.Title,
"slug": next.Slug,
"content": next.Content,
"published": next.Published,
"sort_order": next.SortOrder,
"show_in_footer": next.ShowInFooter,
"show_in_nav": next.ShowInNav,
}).Error
}
func (s *SitePageService) Delete(id uint) error {
res := model.DB.Delete(&model.SitePage{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrSitePageNotFound
}
return nil
}
// SetPublished 仅切换发布状态(列表快捷操作)
func (s *SitePageService) SetPublished(id uint, published bool) error {
page, err := s.GetByID(id)
if err != nil {
return err
}
return model.DB.Model(page).Update("published", published).Error
}
func (s *SitePageService) ListSitemap(limit int) ([]model.SitePage, error) {
if limit <= 0 {
limit = 500
}
var rows []model.SitePage
err := model.DB.Where("published = ?", true).
Order("updated_at DESC").Limit(limit).Find(&rows).Error
return rows, err
}
func (s *SitePageService) normalizeInput(in SitePageInput) (*model.SitePage, error) {
title := s.filter.Filter(strings.TrimSpace(in.Title))
slug, ok := NormalizePageSlug(in.Slug)
if !ok {
return nil, errors.New("slug 格式无效2-64 位小写字母、数字、连字符)")
}
// 单页不支持登录/回复/积分可见:保存前剥离外壳,保留内部正文
content := s.filter.Filter(SanitizePostHTML(UnwrapContentGateTags(in.Content)))
if title == "" {
return nil, errors.New("标题不能为空")
}
if content == "" {
return nil, errors.New("正文不能为空")
}
return &model.SitePage{
Title: title, Slug: slug, Content: content,
Published: in.Published, SortOrder: in.SortOrder,
ShowInFooter: in.ShowInFooter, ShowInNav: in.ShowInNav,
}, nil
}

466
services/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
}

173
services/thumb.go Normal file
View File

@@ -0,0 +1,173 @@
package service
import (
"errors"
"fmt"
"image"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
// 注册解码器
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp"
)
const (
// PostThumbMaxSide 正文预览图最长边(像素)
PostThumbMaxSide = 1280
)
var thumbLocks sync.Map // 同一原图并发生成时串行化
// ThumbURLFromUpload 将 /uploads/posts/xxx.webp 转为 /media/thumb/posts/xxx.webp
func ThumbURLFromUpload(uploadURL string) string {
u := strings.TrimSpace(uploadURL)
if u == "" {
return ""
}
if strings.HasPrefix(u, "/media/thumb/") {
return u
}
if strings.HasPrefix(u, "/uploads/") {
return "/media/thumb/" + strings.TrimPrefix(u, "/uploads/")
}
return ""
}
// WarmPostImageThumb 上传后预热缩略图(失败忽略,首次访问仍会生成)
func WarmPostImageThumb(uploadsRoot, relativePath string) {
_, _ = EnsureUploadThumb(uploadsRoot, relativePath)
}
// EnsureUploadThumb 确保缩略图存在,返回磁盘路径
// relativePath 形如 posts/1_123.webp相对 uploads 根目录)
func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
rel, err := sanitizeUploadRel(relativePath)
if err != nil {
return "", err
}
// 仅处理帖子正文图
if !strings.HasPrefix(rel, "posts/") {
return "", errors.New("仅支持帖子图片缩略图")
}
origPath := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
if st, err := os.Stat(origPath); err != nil || st.IsDir() {
return "", errors.New("原图不存在")
}
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".webp")
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
return thumbPath, nil
}
lockKey := rel
muIface, _ := thumbLocks.LoadOrStore(lockKey, &sync.Mutex{})
mu := muIface.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// 双检
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
return thumbPath, nil
}
if err := generateWebPThumb(origPath, thumbPath, PostThumbMaxSide); err != nil {
return "", err
}
return thumbPath, nil
}
func thumbFresherThan(thumbPath, origPath string) (bool, error) {
ts, err := os.Stat(thumbPath)
if err != nil {
return false, err
}
os_, err := os.Stat(origPath)
if err != nil {
return false, err
}
return !ts.ModTime().Before(os_.ModTime()), nil
}
func sanitizeUploadRel(relativePath string) (string, error) {
rel := strings.TrimSpace(relativePath)
rel = strings.TrimPrefix(rel, "/")
rel = strings.ReplaceAll(rel, "\\", "/")
if rel == "" || strings.Contains(rel, "..") {
return "", errors.New("非法路径")
}
cleaned := filepath.Clean(filepath.FromSlash(rel))
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
return "", errors.New("非法路径")
}
return filepath.ToSlash(cleaned), nil
}
func generateWebPThumb(srcPath, dstPath string, maxSide int) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return fmt.Errorf("解码图片失败: %w", err)
}
out := resizeToMax(img, maxSide)
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())
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
if err := os.Rename(tmp, dstPath); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func resizeToMax(src image.Image, maxSide int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return src
}
if w <= maxSide && h <= maxSide {
return src
}
var nw, nh int
if w >= h {
nw = maxSide
nh = int(float64(h) * float64(maxSide) / float64(w))
} else {
nh = maxSide
nw = int(float64(w) * float64(maxSide) / float64(h))
}
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}

248
services/unlock.go Normal file
View File

@@ -0,0 +1,248 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
"gorm.io/gorm"
)
const (
// CreatorSharePercent 作者分成比例(读者支付的百分比)
CreatorSharePercent = 70
// SockpuppetAccountAgeDays 短龄号判定天数(同 IP 互刷拒绝分成)
SockpuppetAccountAgeDays = 7
)
var (
ErrBlockNotFound = errors.New("付费块不存在")
ErrAlreadyUnlocked = errors.New("已解锁")
ErrSuspiciousTrade = errors.New("检测到异常关联账号,无法完成解锁分成")
)
var pointsOnlyBlockRe = regexp.MustCompile(`(?is)<points-only\b([^>]*)>([\s\S]*?)</points-only>`)
// PointsOnlyBlock 解析出的付费块
type PointsOnlyBlock struct {
Key string
Cost int
Inner string
AttrRaw string
}
// ParsePointsOnlyBlocks 按出现顺序解析付费块block_key = sha256(inner)[:16]
func ParsePointsOnlyBlocks(html string) []PointsOnlyBlock {
matches := pointsOnlyBlockRe.FindAllStringSubmatch(html, -1)
out := make([]PointsOnlyBlock, 0, len(matches))
for _, m := range matches {
attrs := m[1]
inner := m[2]
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
sum := sha256.Sum256([]byte(inner))
key := hex.EncodeToString(sum[:])[:16]
out = append(out, PointsOnlyBlock{Key: key, Cost: cost, Inner: inner, AttrRaw: attrs})
}
return out
}
func parseDataCost(attrs string) int {
re := regexp.MustCompile(`(?i)data-cost\s*=\s*["']?(\d+)`)
m := re.FindStringSubmatch(attrs)
if len(m) < 2 {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// FindPointsBlock 按 key 查找块
func FindPointsBlock(html, blockKey string) (PointsOnlyBlock, bool) {
for _, b := range ParsePointsOnlyBlocks(html) {
if b.Key == blockKey {
return b, true
}
}
return PointsOnlyBlock{}, false
}
// RedactPointsOnlyHTML 遮盖未解锁付费块unlocked 为已解锁的 block_key 集合
func RedactPointsOnlyHTML(html string, unlocked map[string]bool) string {
if html == "" {
return html
}
return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
m := pointsOnlyBlockRe.FindStringSubmatch(full)
if len(m) < 3 {
return full
}
attrs, inner := m[1], m[2]
sum := sha256.Sum256([]byte(inner))
key := hex.EncodeToString(sum[:])[:16]
if unlocked != nil && unlocked[key] {
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
}
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
length := gatedContentLength(inner)
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="true" data-length="%d"></points-only>`, cost, key, length)
})
}
// ListUnlockedKeys 用户在某帖已解锁的 block_key
func ListUnlockedKeys(userID, postID uint) (map[string]bool, error) {
out := map[string]bool{}
if userID == 0 || postID == 0 {
return out, nil
}
var rows []model.PostContentUnlock
if err := model.DB.Select("block_key").Where("user_id = ? AND post_id = ?", userID, postID).Find(&rows).Error; err != nil {
return out, err
}
for _, r := range rows {
out[r.BlockKey] = true
}
return out, nil
}
// UnlockResult 解锁结果
type UnlockResult struct {
BlockKey string `json:"block_key"`
Cost int `json:"cost"`
AuthorShare int `json:"author_share"`
PointsBalance int `json:"points_balance"`
InnerHTML string `json:"inner_html"`
}
// UnlockPointsBlock 积分解锁付费块
func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, error) {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return nil, errors.New("帖子不存在")
}
block, ok := FindPointsBlock(post.Content, blockKey)
if !ok {
return nil, ErrBlockNotFound
}
// 作者自己免费解锁记录(无分成)
if readerID == post.UserID {
var n int64
model.DB.Model(&model.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n)
if n == 0 {
_ = model.DB.Create(&model.PostContentUnlock{
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0,
}).Error
}
return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil
}
var existing model.PostContentUnlock
model.DB.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&existing)
if existing.ID > 0 {
return nil, ErrAlreadyUnlocked
}
var reader, author model.User
if err := model.DB.First(&reader, readerID).Error; err != nil {
return nil, err
}
if err := model.DB.First(&author, post.UserID).Error; err != nil {
return nil, err
}
// 短龄号 + 同登录 IP拒绝整单防互刷套现
if suspiciousUnlockPair(&reader, &author) {
return nil, ErrSuspiciousTrade
}
cost := block.Cost
authorShare := cost * CreatorSharePercent / 100
var bal int
err := model.DB.Transaction(func(tx *gorm.DB) error {
var again model.PostContentUnlock
if err := tx.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&again).Error; err != nil {
return err
}
if again.ID > 0 {
return ErrAlreadyUnlocked
}
var e error
bal, e = AdjustPointsTx(tx, readerID, -cost, model.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容")
if e != nil {
return e
}
if authorShare > 0 {
if _, e = AdjustPointsTx(tx, author.ID, authorShare, model.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil {
return e
}
if e = tx.Model(&model.User{}).Where("id = ?", author.ID).
UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil {
return e
}
}
return tx.Create(&model.PostContentUnlock{
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: cost,
}).Error
})
if err != nil {
return nil, err
}
// 异步检查作者徽章
go func() {
_ = NewBadgeService().EvaluateAuto(author.ID)
}()
return &UnlockResult{
BlockKey: blockKey, Cost: cost, AuthorShare: authorShare,
PointsBalance: bal, InnerHTML: block.Inner,
}, nil
}
func suspiciousUnlockPair(reader, author *model.User) bool {
if reader == nil || author == nil {
return false
}
ipR := strings.TrimSpace(reader.LastLoginIP)
ipA := strings.TrimSpace(author.LastLoginIP)
if ipR == "" || ipA == "" || ipR != ipA {
return false
}
cutoff := time.Now().AddDate(0, 0, -SockpuppetAccountAgeDays)
return reader.CreatedAt.After(cutoff) && author.CreatedAt.After(cutoff)
}
// RevealAllPointsOnly 作者/站长:保留正文并写入 block-key标记未锁定
func RevealAllPointsOnly(html string) string {
if html == "" {
return html
}
return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
m := pointsOnlyBlockRe.FindStringSubmatch(full)
if len(m) < 3 {
return full
}
attrs, inner := m[1], m[2]
sum := sha256.Sum256([]byte(inner))
key := hex.EncodeToString(sum[:])[:16]
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
})
}

18
services/upload.go Normal file
View File

@@ -0,0 +1,18 @@
package service
import (
"mime/multipart"
)
var allowedImageExt = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
// SaveUploadedImage 保存图片到当前存储后端,返回公开 URL
func SaveUploadedImage(store *UploadStore, file *multipart.FileHeader, category, namePrefix string) (string, error) {
return store.SaveImage(file, category, namePrefix)
}

313
services/user.go Normal file
View File

@@ -0,0 +1,313 @@
package service
import (
"errors"
"fmt"
"mime/multipart"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/model"
)
type UserService struct {
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewUserService(filter *SensitiveFilter, settings *ForumSettingsService) *UserService {
return &UserService{filter: filter, settings: settings}
}
// GetByID 获取用户信息
func (s *UserService) GetByID(id uint) (*model.User, error) {
var user model.User
if err := model.DB.First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
// UserActivityStats 个人主页活动统计
type UserActivityStats struct {
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
FavoriteCount int64 `json:"favorite_count"`
LikeReceived int64 `json:"like_received"`
}
// ActivityStats 统计用户发帖、评论、收藏与帖子获赞
func (s *UserService) ActivityStats(userID uint) (UserActivityStats, error) {
var st UserActivityStats
if userID == 0 {
return st, errors.New("无效用户")
}
if err := model.DB.Model(&model.Post{}).Where("user_id = ?", userID).Count(&st.PostCount).Error; err != nil {
return st, err
}
if err := model.DB.Model(&model.Comment{}).Where("user_id = ?", userID).Count(&st.CommentCount).Error; err != nil {
return st, err
}
if err := model.DB.Model(&model.PostFavorite{}).Where("user_id = ?", userID).Count(&st.FavoriteCount).Error; err != nil {
return st, err
}
var likeSum int64
if err := model.DB.Model(&model.Post{}).
Select("COALESCE(SUM(like_count), 0)").
Where("user_id = ?", userID).
Scan(&likeSum).Error; err != nil {
return st, err
}
st.LikeReceived = likeSum
return st, nil
}
// GetByUsername 按用户名查询
func (s *UserService) GetByUsername(username string) (*model.User, error) {
var user model.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// GetByEmail 按邮箱查询
func (s *UserService) GetByEmail(email string) (*model.User, error) {
email = NormalizeEmail(email)
var user model.User
if err := model.DB.Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// ResetPasswordByEmail 通过邮箱重置密码(已通过验证码校验)
func (s *UserService) ResetPasswordByEmail(email, newPass string) error {
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
return err
}
user, err := s.GetByEmail(email)
if err != nil {
return errors.New("用户不存在")
}
hash, err := HashPassword(newPass)
if err != nil {
return err
}
return model.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("password", hash).Error
}
// SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱
func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]model.User, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return []model.User{}, nil
}
if limit <= 0 || limit > 20 {
limit = 8
}
like := "%" + keyword + "%"
var users []model.User
err := model.DB.Select("id", "username", "nickname", "avatar", "role", "verified").
Where("username LIKE ? OR nickname LIKE ?", like, like).
Order("username ASC").
Limit(limit).
Find(&users).Error
if err != nil {
return nil, err
}
if users == nil {
users = []model.User{}
}
return users, nil
}
// RecentUserItem 右栏「最新注册」条目
type RecentUserItem struct {
ID uint `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
CreatedAt string `json:"created_at"`
}
// ListRecentRegistered 前台最新注册用户(排除封禁)
func (s *UserService) ListRecentRegistered(limit int) ([]RecentUserItem, error) {
if limit < 1 {
limit = 8
}
var users []model.User
err := model.DB.Select("id", "username", "nickname", "avatar", "created_at").
Where("banned = ?", false).
Order("created_at DESC, id DESC").
Limit(limit).
Find(&users).Error
if err != nil {
return nil, err
}
out := make([]RecentUserItem, 0, len(users))
for _, u := range users {
nick := strings.TrimSpace(u.Nickname)
if nick == "" {
nick = u.Username
}
out = append(out, RecentUserItem{
ID: u.ID,
Nickname: nick,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
})
}
return out, nil
}
// UpdateNickname 修改昵称
func (s *UserService) UpdateNickname(userID uint, nickname string) error {
nickname = strings.TrimSpace(nickname)
if nickname == "" {
return errors.New("昵称不能为空")
}
nickname = s.filter.Filter(nickname)
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("nickname", nickname).Error
}
// UpdateSignature 修改个人签名
func (s *UserService) UpdateSignature(userID uint, signature string) error {
signature = strings.TrimSpace(signature)
maxLen := s.settings.SignatureMax()
if maxLen > 0 {
runes := []rune(signature)
if len(runes) > maxLen {
return fmt.Errorf("签名不能超过 %d 字", maxLen)
}
}
if signature != "" {
signature = s.filter.Filter(signature)
}
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("signature", signature).Error
}
// UpdatePassword 修改密码
func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error {
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
return err
}
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
return err
}
if !CheckPassword(user.Password, oldPass) {
return errors.New("原密码错误")
}
hash, err := HashPassword(newPass)
if err != nil {
return err
}
return model.DB.Model(&user).Update("password", hash).Error
}
// 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
}
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 管理员列出用户
// UserListQuery 后台用户列表筛选
type UserListQuery struct {
Page int
Size int
Keyword string // 匹配用户名/昵称/邮箱
Filter string // all | verified | banned | admin
}
func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 {
q.Size = 20
}
if q.Size > 100 {
q.Size = 100
}
db := model.DB.Model(&model.User{})
kw := strings.TrimSpace(q.Keyword)
if kw != "" {
like := "%" + kw + "%"
if id, err := strconv.ParseUint(kw, 10, 64); err == nil {
db = db.Where("id = ? OR username LIKE ? OR nickname LIKE ? OR email LIKE ?", id, like, like, like)
} else {
db = db.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like)
}
}
switch strings.TrimSpace(q.Filter) {
case "verified":
db = db.Where("verified = ? AND role <> ?", true, model.RoleAdmin)
case "banned":
db = db.Where("banned = ?", true)
case "admin":
db = db.Where("role = ?", model.RoleAdmin)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []model.User
offset := (q.Page - 1) * q.Size
err := db.Order("id desc").Offset(offset).Limit(q.Size).Find(&users).Error
return users, total, err
}
// BanUser 禁言用户
func (s *UserService) BanUser(userID uint, banned bool) error {
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
return errors.New("用户不存在")
}
if user.Role == model.RoleAdmin {
return errors.New("不能禁言管理员账号")
}
now := time.Now()
updates := map[string]interface{}{"banned": banned}
if banned {
updates["banned_at"] = &now
}
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
}