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:
37
services/aside_widgets_test.go
Normal file
37
services/aside_widgets_test.go
Normal 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
170
services/auth.go
Normal 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 token;clientIP 写入上次登录记录
|
||||
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
52
services/backup.go
Normal 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
272
services/badge.go
Normal 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("等级须在 1–10")
|
||||
}
|
||||
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
87
services/board.go
Normal 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
36
services/board_icon.go
Normal 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 表示自动;0–7 为有效色槽
|
||||
func NormalizeBoardColorIndex(colorIndex int) int {
|
||||
if colorIndex < 0 {
|
||||
return -1
|
||||
}
|
||||
if colorIndex > 7 {
|
||||
return colorIndex % 8
|
||||
}
|
||||
return colorIndex
|
||||
}
|
||||
150
services/bounty.go
Normal file
150
services/bounty.go
Normal 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
175
services/bounty_test.go
Normal 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
158
services/captcha.go
Normal 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
656
services/comment.go
Normal 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
155
services/common.go
Normal 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
81
services/content.go
Normal 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, " ", " ")
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
46
services/content_test.go
Normal file
46
services/content_test.go
Normal 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
55
services/crawler.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
// 常见搜索引擎 / 社交预览 / SEO 工具的 User-Agent 片段(小写匹配)
|
||||
var seoCrawlerTokens = []string{
|
||||
"googlebot",
|
||||
"google-inspectiontool",
|
||||
"bingbot",
|
||||
"baiduspider",
|
||||
"yandexbot",
|
||||
"duckduckbot",
|
||||
"slurp", // Yahoo
|
||||
"sogou",
|
||||
"bytespider",
|
||||
"petalbot",
|
||||
"applebot",
|
||||
"facebookexternalhit",
|
||||
"facebot",
|
||||
"twitterbot",
|
||||
"linkedinbot",
|
||||
"discordbot",
|
||||
"telegrambot",
|
||||
"slackbot",
|
||||
"whatsapp",
|
||||
"preview", // 部分通用预览 UA
|
||||
"embedly",
|
||||
"quora link preview",
|
||||
"pinterest",
|
||||
"vkshare",
|
||||
"w3c_validator",
|
||||
"ahrefsbot",
|
||||
"semrushbot",
|
||||
"dotbot",
|
||||
"mj12bot",
|
||||
"gptbot",
|
||||
"claudebot",
|
||||
"anthropic-ai",
|
||||
"chatgpt-user",
|
||||
"oai-searchbot",
|
||||
}
|
||||
|
||||
// IsSEOCrawler 是否为需要服务端 HTML 的爬虫 / 预览 bot(动态渲染)
|
||||
func IsSEOCrawler(userAgent string) bool {
|
||||
ua := strings.ToLower(strings.TrimSpace(userAgent))
|
||||
if ua == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range seoCrawlerTokens {
|
||||
if strings.Contains(ua, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
173
services/email_code.go
Normal file
173
services/email_code.go
Normal 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
7
services/filter_io.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package service
|
||||
|
||||
import "os"
|
||||
|
||||
func init() {
|
||||
readFile = os.ReadFile
|
||||
}
|
||||
55
services/filter_words.go
Normal file
55
services/filter_words.go
Normal 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
473
services/friend_link.go
Normal 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
|
||||
}
|
||||
93
services/friend_link_enrich.go
Normal file
93
services/friend_link_enrich.go
Normal 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))
|
||||
}
|
||||
269
services/friend_link_reciprocal.go
Normal file
269
services/friend_link_reciprocal.go
Normal 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
|
||||
}
|
||||
62
services/friend_link_reciprocal_async.go
Normal file
62
services/friend_link_reciprocal_async.go
Normal 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
|
||||
}
|
||||
46
services/friend_link_reciprocal_test.go
Normal file
46
services/friend_link_reciprocal_test.go
Normal 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
559
services/gitea.go
Normal 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
152
services/image_webp.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/KarpelesLab/gowebp"
|
||||
|
||||
// 注册解码器
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 图片展示方案(上传时同时保留原图与 WebP,按此决定返回给前端的 URL)
|
||||
const (
|
||||
ImageDeliveryWebP = "webp" // 使用 WebP(默认,省流量)
|
||||
ImageDeliveryOriginal = "original" // 使用原图
|
||||
)
|
||||
|
||||
const (
|
||||
// UploadWebPQuality 上传衍生 WebP 有损质量(0–100)
|
||||
UploadWebPQuality float32 = 82
|
||||
// UploadWebPMethod 编码档位:3 速度与体积较均衡
|
||||
UploadWebPMethod = 3
|
||||
// ThumbWebPQuality 帖子预览图质量
|
||||
ThumbWebPQuality float32 = 80
|
||||
)
|
||||
|
||||
// preparedUpload 原图 + 可选 WebP 衍生
|
||||
type preparedUpload struct {
|
||||
OrigExt string // 含点,如 .jpg
|
||||
OrigContentType string
|
||||
OrigData []byte
|
||||
WebPData []byte // 空表示无衍生(动图 GIF,或原图已是 WebP)
|
||||
}
|
||||
|
||||
// prepareUploadImage 始终保留原图字节;静态图额外生成 WebP 衍生
|
||||
func prepareUploadImage(file *multipart.FileHeader) (*preparedUpload, error) {
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if !allowedImageExt[ext] {
|
||||
return nil, errors.New("仅支持 jpg/png/gif/webp 格式")
|
||||
}
|
||||
if ext == ".jpeg" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
raw, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, errors.New("空图片文件")
|
||||
}
|
||||
|
||||
out := &preparedUpload{
|
||||
OrigExt: ext,
|
||||
OrigContentType: imageContentType(ext),
|
||||
OrigData: raw,
|
||||
}
|
||||
|
||||
// 动图 GIF:只保留原文件
|
||||
if ext == ".gif" && gifFrameCount(raw) > 1 {
|
||||
return out, nil
|
||||
}
|
||||
// 上传已是 WebP:原图即 WebP,不再重复衍生
|
||||
if ext == ".webp" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
|
||||
webpBytes, err := encodeWebPBytes(img, UploadWebPQuality, UploadWebPMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("转换 WebP 失败: %w", err)
|
||||
}
|
||||
out.WebPData = webpBytes
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func gifFrameCount(raw []byte) int {
|
||||
g, err := gif.DecodeAll(bytes.NewReader(raw))
|
||||
if err != nil || g == nil {
|
||||
return 0
|
||||
}
|
||||
return len(g.Image)
|
||||
}
|
||||
|
||||
func encodeWebPBytes(img image.Image, quality float32, method int) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := gowebp.Encode(&buf, img, &gowebp.Options{
|
||||
Lossy: true,
|
||||
Quality: quality,
|
||||
Method: method,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func normalizeImageDelivery(raw string) string {
|
||||
if strings.ToLower(strings.TrimSpace(raw)) == ImageDeliveryOriginal {
|
||||
return ImageDeliveryOriginal
|
||||
}
|
||||
return ImageDeliveryWebP
|
||||
}
|
||||
|
||||
func imageContentType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// siblingUploadExts 同主文件名可能存在的伴生扩展名(删除时一并清理)
|
||||
func siblingUploadExts(ext string) []string {
|
||||
ext = strings.ToLower(ext)
|
||||
all := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
out := make([]string, 0, len(all))
|
||||
for _, e := range all {
|
||||
if e == ext || (ext == ".jpg" && e == ".jpeg") || (ext == ".jpeg" && e == ".jpg") {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
150
services/lottery_post.go
Normal file
150
services/lottery_post.go
Normal 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
212
services/mail.go
Normal 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
386
services/mail_template.go
Normal 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
466
services/media.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
// MediaItem 管理端媒体资源条目
|
||||
type MediaItem struct {
|
||||
Category string `json:"category"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
ContentType string `json:"content_type"`
|
||||
StorageType string `json:"storage_type,omitempty"`
|
||||
}
|
||||
|
||||
// MediaListResult 媒体列表分页结果
|
||||
type MediaListResult struct {
|
||||
Files []MediaItem `json:"files"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
StorageType string `json:"storage_type"`
|
||||
CategoryCounts map[string]int `json:"category_counts"`
|
||||
}
|
||||
|
||||
var mediaCategories = []string{
|
||||
UploadCategoryAvatars,
|
||||
UploadCategoryPosts,
|
||||
UploadCategorySite,
|
||||
}
|
||||
|
||||
// ListMedia 从数据库索引列出媒体(上传/删除时维护;启动时会扫盘回填)
|
||||
func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaListResult, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("上传存储未初始化")
|
||||
}
|
||||
if model.DB == nil {
|
||||
return nil, errors.New("数据库未初始化")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 24
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if category == "" || category == "all" {
|
||||
category = "all"
|
||||
} else if !validMediaCategory(category) {
|
||||
return nil, errors.New("无效的分类")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
|
||||
// 索引为空时先同步一次,避免升级后首次打开空白
|
||||
var indexed int64
|
||||
_ = model.DB.Model(&model.Media{}).Count(&indexed).Error
|
||||
if indexed == 0 {
|
||||
_, _ = s.SyncMediaIndex()
|
||||
}
|
||||
|
||||
counts := map[string]int{
|
||||
UploadCategoryAvatars: 0,
|
||||
UploadCategoryPosts: 0,
|
||||
UploadCategorySite: 0,
|
||||
}
|
||||
type catCount struct {
|
||||
Category string
|
||||
Cnt int
|
||||
}
|
||||
var rows []catCount
|
||||
if err := model.DB.Model(&model.Media{}).
|
||||
Select("category, count(*) as cnt").
|
||||
Group("category").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
counts[r.Category] = r.Cnt
|
||||
}
|
||||
|
||||
dbq := model.DB.Model(&model.Media{})
|
||||
if category != "all" {
|
||||
dbq = dbq.Where("category = ?", category)
|
||||
}
|
||||
if query != "" {
|
||||
like := "%" + query + "%"
|
||||
dbq = dbq.Where("name LIKE ? OR url LIKE ?", like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := dbq.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = int((total + int64(size) - 1) / int64(size))
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
var records []model.Media
|
||||
offset := (page - 1) * size
|
||||
if err := dbq.Order("created_at desc, id desc").Offset(offset).Limit(size).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]MediaItem, 0, len(records))
|
||||
for _, r := range records {
|
||||
mod := r.UpdatedAt
|
||||
if mod.IsZero() {
|
||||
mod = r.CreatedAt
|
||||
}
|
||||
files = append(files, MediaItem{
|
||||
Category: r.Category,
|
||||
Name: r.Name,
|
||||
URL: r.URL,
|
||||
Size: r.Size,
|
||||
ModifiedAt: mod.UTC(),
|
||||
ContentType: r.ContentType,
|
||||
StorageType: r.StorageType,
|
||||
})
|
||||
}
|
||||
|
||||
mode, _, _, _ := s.snapshot()
|
||||
storageType := config.StorageTypeLocal
|
||||
if mode == config.StorageTypeS3 {
|
||||
storageType = config.StorageTypeS3
|
||||
}
|
||||
|
||||
return &MediaListResult{
|
||||
Files: files,
|
||||
Total: int(total),
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
StorageType: storageType,
|
||||
CategoryCounts: counts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteMedia 按 URL 批量删除媒体(含伴生扩展名与数据库索引)
|
||||
func (s *UploadStore) DeleteMedia(urls []string) (int, error) {
|
||||
if s == nil {
|
||||
return 0, errors.New("上传存储未初始化")
|
||||
}
|
||||
n := 0
|
||||
seen := map[string]bool{}
|
||||
for _, u := range urls {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
if !s.isManagedMediaURL(u) {
|
||||
continue
|
||||
}
|
||||
s.DeleteByURL(u)
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数
|
||||
func (s *UploadStore) SyncMediaIndex() (int, error) {
|
||||
if s == nil || model.DB == nil {
|
||||
return 0, errors.New("存储或数据库未初始化")
|
||||
}
|
||||
mode, _, _, _ := s.snapshot()
|
||||
storageType := config.StorageTypeLocal
|
||||
var items []MediaItem
|
||||
var err error
|
||||
if mode == config.StorageTypeS3 {
|
||||
storageType = config.StorageTypeS3
|
||||
items, err = s.listMediaS3("all")
|
||||
} else {
|
||||
items, err = s.listMediaLocal("all")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
n := 0
|
||||
for _, it := range items {
|
||||
seen[it.URL] = struct{}{}
|
||||
if err := s.upsertMediaRecord(it.Category, it.Name, it.URL, it.Size, it.ContentType, storageType, nil); err != nil {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
|
||||
// 清理当前后端下已不存在的索引(其它后端记录保留)
|
||||
var stale []model.Media
|
||||
_ = model.DB.Where("storage_type = ?", storageType).Find(&stale).Error
|
||||
for _, row := range stale {
|
||||
if _, ok := seen[row.URL]; ok {
|
||||
continue
|
||||
}
|
||||
_ = model.DB.Delete(&model.Media{}, row.ID).Error
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *UploadStore) upsertMediaRecord(category, name, url string, size int64, contentType, storageType string, userID *uint) error {
|
||||
if model.DB == nil || strings.TrimSpace(url) == "" {
|
||||
return nil
|
||||
}
|
||||
category = strings.TrimSpace(category)
|
||||
name = strings.TrimSpace(name)
|
||||
url = strings.TrimSpace(url)
|
||||
if storageType == "" {
|
||||
storageType = config.StorageTypeLocal
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = imageContentType(strings.ToLower(filepath.Ext(name)))
|
||||
}
|
||||
|
||||
var existing model.Media
|
||||
err := model.DB.Where("url = ?", url).First(&existing).Error
|
||||
if err == nil {
|
||||
updates := map[string]interface{}{
|
||||
"category": category,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"content_type": contentType,
|
||||
"storage_type": storageType,
|
||||
}
|
||||
if userID != nil {
|
||||
updates["user_id"] = *userID
|
||||
}
|
||||
return model.DB.Model(&existing).Updates(updates).Error
|
||||
}
|
||||
|
||||
rec := model.Media{
|
||||
Category: category,
|
||||
Name: name,
|
||||
URL: url,
|
||||
Size: size,
|
||||
ContentType: contentType,
|
||||
StorageType: storageType,
|
||||
UserID: userID,
|
||||
}
|
||||
return model.DB.Create(&rec).Error
|
||||
}
|
||||
|
||||
func (s *UploadStore) deleteMediaRecords(urls []string) {
|
||||
if model.DB == nil || len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
clean := make([]string, 0, len(urls))
|
||||
seen := map[string]bool{}
|
||||
for _, u := range urls {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
clean = append(clean, u)
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return
|
||||
}
|
||||
_ = model.DB.Where("url IN ?", clean).Delete(&model.Media{}).Error
|
||||
}
|
||||
|
||||
func (s *UploadStore) resolveSiblingPublicURLs(rawURL string) []string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil
|
||||
}
|
||||
var rel string
|
||||
var basePrefix string // 拼回公开 URL 的前缀(含 category 前的部分)
|
||||
|
||||
if strings.HasPrefix(rawURL, "/uploads/") {
|
||||
path := rawURL
|
||||
if i := strings.Index(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
rel = strings.TrimPrefix(path, "/uploads/")
|
||||
basePrefix = "/uploads/"
|
||||
} else {
|
||||
_, publicBase, _, _ := s.snapshot()
|
||||
r, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||||
if !ok {
|
||||
return []string{rawURL}
|
||||
}
|
||||
rel = r
|
||||
basePrefix = strings.TrimRight(publicBase, "/") + "/"
|
||||
}
|
||||
|
||||
siblings := uploadSiblingRels(rel)
|
||||
if len(siblings) == 0 {
|
||||
return []string{rawURL}
|
||||
}
|
||||
out := make([]string, 0, len(siblings))
|
||||
for _, sib := range siblings {
|
||||
out = append(out, basePrefix+sib)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUploaderID(category, namePrefix string) *uint {
|
||||
if category != UploadCategoryAvatars && category != UploadCategoryPosts {
|
||||
return nil
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(namePrefix), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil
|
||||
}
|
||||
u := uint(id)
|
||||
return &u
|
||||
}
|
||||
|
||||
func (s *UploadStore) isManagedMediaURL(rawURL string) bool {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/uploads/") {
|
||||
rel := strings.TrimPrefix(rawURL, "/uploads/")
|
||||
cat, _, ok := splitCategoryName(rel)
|
||||
return ok && validMediaCategory(cat)
|
||||
}
|
||||
_, publicBase, _, backend := s.snapshot()
|
||||
if backend == nil || publicBase == "" {
|
||||
return false
|
||||
}
|
||||
rel, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cat, _, ok := splitCategoryName(rel)
|
||||
return ok && validMediaCategory(cat)
|
||||
}
|
||||
|
||||
func (s *UploadStore) listMediaLocal(category string) ([]MediaItem, error) {
|
||||
cats := mediaCategories
|
||||
if category != "all" {
|
||||
cats = []string{category}
|
||||
}
|
||||
var out []MediaItem
|
||||
root := s.UploadsRoot()
|
||||
for _, cat := range cats {
|
||||
dir := filepath.Join(root, cat)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedImageExt[ext] {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, MediaItem{
|
||||
Category: cat,
|
||||
Name: name,
|
||||
URL: "/uploads/" + cat + "/" + name,
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC(),
|
||||
ContentType: imageContentType(ext),
|
||||
StorageType: config.StorageTypeLocal,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *UploadStore) listMediaS3(category string) ([]MediaItem, error) {
|
||||
_, publicBase, keyPrefix, backend := s.snapshot()
|
||||
if backend == nil {
|
||||
return nil, errors.New("对象存储未就绪")
|
||||
}
|
||||
cats := mediaCategories
|
||||
if category != "all" {
|
||||
cats = []string{category}
|
||||
}
|
||||
var out []MediaItem
|
||||
ctx := context.Background()
|
||||
for _, cat := range cats {
|
||||
prefix := keyPrefix + cat + "/"
|
||||
for obj := range backend.client.ListObjects(ctx, backend.bucket, minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
}) {
|
||||
if obj.Err != nil {
|
||||
return nil, obj.Err
|
||||
}
|
||||
if strings.HasSuffix(obj.Key, "/") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(obj.Key, prefix)
|
||||
if name == "" || strings.Contains(name, "/") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !allowedImageExt[ext] {
|
||||
continue
|
||||
}
|
||||
out = append(out, MediaItem{
|
||||
Category: cat,
|
||||
Name: name,
|
||||
URL: publicBase + "/" + cat + "/" + name,
|
||||
Size: obj.Size,
|
||||
ModifiedAt: obj.LastModified.UTC(),
|
||||
ContentType: imageContentType(ext),
|
||||
StorageType: config.StorageTypeS3,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validMediaCategory(cat string) bool {
|
||||
switch cat {
|
||||
case UploadCategoryAvatars, UploadCategoryPosts, UploadCategorySite:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func splitCategoryName(rel string) (category, name string, ok bool) {
|
||||
rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/"))
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
parts := strings.SplitN(rel, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.Contains(parts[1], "/") {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
65
services/mention.go
Normal file
65
services/mention.go
Normal 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
14
services/mention_test.go
Normal 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
407
services/message.go
Normal 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(¬ify).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
380
services/notify.go
Normal 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
203
services/oauth_clients.go
Normal 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
634
services/oidc.go
Normal 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
219
services/permalink.go
Normal 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
272
services/points.go
Normal 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
280
services/poll.go
Normal 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
947
services/post.go
Normal 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
69
services/post_special.go
Normal 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
88
services/ratelimit.go
Normal 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
363
services/report.go
Normal 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
50
services/sanitize_html.go
Normal 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)
|
||||
}
|
||||
58
services/sanitize_html_test.go
Normal file
58
services/sanitize_html_test.go
Normal 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
130
services/seo.go
Normal 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
1500
services/settings.go
Normal file
File diff suppressed because it is too large
Load Diff
14
services/settings_util.go
Normal file
14
services/settings_util.go
Normal 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
187
services/site_page.go
Normal 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
466
services/storage.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
)
|
||||
|
||||
// UploadCategory 上传分类目录名
|
||||
const (
|
||||
UploadCategoryAvatars = "avatars"
|
||||
UploadCategoryPosts = "posts"
|
||||
UploadCategorySite = "site"
|
||||
)
|
||||
|
||||
// StorageConfig 上传存储配置(管理后台 / 内部使用)
|
||||
type StorageConfig struct {
|
||||
Type string `json:"type"` // local | s3
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key,omitempty"` // 更新时传入;回显时为空
|
||||
PublicBaseURL string `json:"public_base_url"`
|
||||
Prefix string `json:"prefix"`
|
||||
ForcePathStyle bool `json:"force_path_style"`
|
||||
HasSecretKey bool `json:"has_secret_key"`
|
||||
Ready bool `json:"ready"`
|
||||
// ImageDelivery 展示方案:webp(默认)| original;上传始终保留原图
|
||||
ImageDelivery string `json:"image_delivery"`
|
||||
}
|
||||
|
||||
// UploadStore 统一上传存储(本地或 S3 兼容),支持运行时热切换
|
||||
type UploadStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings *ForumSettingsService
|
||||
mode string
|
||||
s3 *s3Backend
|
||||
publicBase string
|
||||
keyPrefix string
|
||||
}
|
||||
|
||||
type s3Backend struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// NewUploadStore 创建本地默认存储;调用 Apply / ReloadFromSettings 切换后端
|
||||
func NewUploadStore(dataDir string, settings *ForumSettingsService) *UploadStore {
|
||||
return &UploadStore{
|
||||
dataDir: dataDir,
|
||||
settings: settings,
|
||||
mode: config.StorageTypeLocal,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadFromSettings 按数据库配置重建存储客户端
|
||||
func (s *UploadStore) ReloadFromSettings(settings *ForumSettingsService) error {
|
||||
if settings == nil {
|
||||
return errors.New("设置服务未初始化")
|
||||
}
|
||||
return s.Apply(settings.StorageConfig())
|
||||
}
|
||||
|
||||
// Apply 应用存储配置(失败时保持原配置不变)
|
||||
func (s *UploadStore) Apply(cfg StorageConfig) error {
|
||||
if s == nil {
|
||||
return errors.New("上传存储未初始化")
|
||||
}
|
||||
typ := normalizeStorageType(cfg.Type)
|
||||
if typ == config.StorageTypeLocal {
|
||||
s.mu.Lock()
|
||||
s.mode = config.StorageTypeLocal
|
||||
s.s3 = nil
|
||||
s.publicBase = ""
|
||||
s.keyPrefix = ""
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateStorageConfigForApply(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint, secure, err := parseS3Endpoint(cfg.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
lookup := minio.BucketLookupDNS
|
||||
if cfg.ForcePathStyle {
|
||||
lookup = minio.BucketLookupPath
|
||||
}
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(strings.TrimSpace(cfg.AccessKey), cfg.SecretKey, ""),
|
||||
Secure: secure,
|
||||
Region: region,
|
||||
BucketLookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化 S3 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.mode = config.StorageTypeS3
|
||||
s.s3 = &s3Backend{client: client, bucket: strings.TrimSpace(cfg.Bucket)}
|
||||
s.publicBase = normalizeRootURL(cfg.PublicBaseURL)
|
||||
s.keyPrefix = normalizeObjectPrefix(cfg.Prefix)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStorageConfigForApply(cfg StorageConfig) error {
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return errors.New("S3 Endpoint 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||||
return errors.New("S3 Bucket 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessKey) == "" {
|
||||
return errors.New("S3 Access Key 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.SecretKey) == "" {
|
||||
return errors.New("S3 Secret Key 不能为空")
|
||||
}
|
||||
if normalizeRootURL(cfg.PublicBaseURL) == "" {
|
||||
return errors.New("公开访问地址 PUBLIC_BASE_URL 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeStorageType(raw string) string {
|
||||
t := strings.ToLower(strings.TrimSpace(raw))
|
||||
if t == config.StorageTypeS3 {
|
||||
return config.StorageTypeS3
|
||||
}
|
||||
return config.StorageTypeLocal
|
||||
}
|
||||
|
||||
func normalizeObjectPrefix(raw string) string {
|
||||
p := strings.TrimSpace(raw)
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(p, "/") + "/"
|
||||
}
|
||||
|
||||
// snapshot 读取当前后端快照(调用方勿修改返回指针)
|
||||
func (s *UploadStore) snapshot() (mode, publicBase, keyPrefix string, s3 *s3Backend) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.mode, s.publicBase, s.keyPrefix, s.s3
|
||||
}
|
||||
|
||||
// IsLocal 是否本地磁盘存储
|
||||
func (s *UploadStore) IsLocal() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
mode, _, _, backend := s.snapshot()
|
||||
return mode != config.StorageTypeS3 || backend == nil
|
||||
}
|
||||
|
||||
// UploadsRoot 本地 uploads 根目录
|
||||
func (s *UploadStore) UploadsRoot() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(s.dataDir, "uploads")
|
||||
}
|
||||
|
||||
// SaveImage 保存图片:始终保留原图;静态图额外写 WebP 衍生,按展示方案返回 URL
|
||||
func (s *UploadStore) SaveImage(file *multipart.FileHeader, category, namePrefix string) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("上传存储未初始化")
|
||||
}
|
||||
category = strings.Trim(category, "/")
|
||||
if category == "" {
|
||||
return "", errors.New("无效的上传分类")
|
||||
}
|
||||
|
||||
prepared, err := prepareUploadImage(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base := fmt.Sprintf("%s_%d", namePrefix, time.Now().UnixNano())
|
||||
origName := base + prepared.OrigExt
|
||||
webpName := base + ".webp"
|
||||
|
||||
delivery := ImageDeliveryWebP
|
||||
if s.settings != nil {
|
||||
delivery = s.settings.ImageDelivery()
|
||||
}
|
||||
|
||||
mode, publicBase, keyPrefix, backend := s.snapshot()
|
||||
useS3 := mode == config.StorageTypeS3
|
||||
if useS3 && backend == nil {
|
||||
return "", errors.New("对象存储未就绪,请检查管理后台「对象存储」配置")
|
||||
}
|
||||
|
||||
// 1) 写原图
|
||||
if useS3 {
|
||||
if err := s.putBytesS3(backend, keyPrefix, category, origName, prepared.OrigContentType, prepared.OrigData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := s.putBytesLocal(category, origName, prepared.OrigData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 写 WebP 衍生(若有)
|
||||
hasWebP := len(prepared.WebPData) > 0
|
||||
if hasWebP {
|
||||
if useS3 {
|
||||
if err := s.putBytesS3(backend, keyPrefix, category, webpName, "image/webp", prepared.WebPData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := s.putBytesLocal(category, webpName, prepared.WebPData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按展示方案选择返回 URL
|
||||
returnName := origName
|
||||
if delivery == ImageDeliveryWebP && (hasWebP || prepared.OrigExt == ".webp") {
|
||||
if hasWebP {
|
||||
returnName = webpName
|
||||
} else {
|
||||
returnName = origName // 原图已是 webp
|
||||
}
|
||||
}
|
||||
|
||||
publicURL := s.publicURL(useS3, publicBase, category, returnName)
|
||||
|
||||
// 写入媒体索引(原图 + WebP 衍生均登记)
|
||||
storageType := config.StorageTypeLocal
|
||||
if useS3 {
|
||||
storageType = config.StorageTypeS3
|
||||
}
|
||||
uploader := parseUploaderID(category, namePrefix)
|
||||
origURL := s.publicURL(useS3, publicBase, category, origName)
|
||||
_ = s.upsertMediaRecord(category, origName, origURL, int64(len(prepared.OrigData)), prepared.OrigContentType, storageType, uploader)
|
||||
if hasWebP {
|
||||
webpURL := s.publicURL(useS3, publicBase, category, webpName)
|
||||
_ = s.upsertMediaRecord(category, webpName, webpURL, int64(len(prepared.WebPData)), "image/webp", storageType, uploader)
|
||||
}
|
||||
|
||||
// 缩略图优先用 WebP 衍生(更小);否则用返回文件
|
||||
if category == UploadCategoryPosts && !useS3 {
|
||||
thumbFile := returnName
|
||||
if hasWebP {
|
||||
thumbFile = webpName
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join(category, thumbFile))
|
||||
go WarmPostImageThumb(s.UploadsRoot(), rel)
|
||||
}
|
||||
return publicURL, nil
|
||||
}
|
||||
|
||||
func (s *UploadStore) publicURL(useS3 bool, publicBase, category, filename string) string {
|
||||
if useS3 {
|
||||
return publicBase + "/" + category + "/" + filename
|
||||
}
|
||||
return "/uploads/" + category + "/" + filename
|
||||
}
|
||||
|
||||
func (s *UploadStore) putBytesLocal(category, filename string, data []byte) error {
|
||||
dir := filepath.Join(s.UploadsRoot(), category)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, filename), data, 0644)
|
||||
}
|
||||
|
||||
func (s *UploadStore) putBytesS3(backend *s3Backend, keyPrefix, category, filename, contentType string, data []byte) error {
|
||||
key := keyPrefix + category + "/" + filename
|
||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||
_, err := backend.client.PutObject(
|
||||
context.Background(),
|
||||
backend.bucket,
|
||||
key,
|
||||
bytes.NewReader(data),
|
||||
int64(len(data)),
|
||||
opts,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("上传到对象存储失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByURL 删除本站管理的上传文件(非本站 URL 则忽略)
|
||||
func (s *UploadStore) DeleteByURL(rawURL string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
siblingURLs := s.resolveSiblingPublicURLs(rawURL)
|
||||
|
||||
// 始终尝试清理本地 /uploads/…(兼容切换到 S3 前的旧文件)
|
||||
s.deleteLocalByURL(rawURL)
|
||||
|
||||
_, publicBase, keyPrefix, backend := s.snapshot()
|
||||
if backend != nil {
|
||||
s.deleteS3ByURL(backend, publicBase, keyPrefix, rawURL)
|
||||
}
|
||||
|
||||
s.deleteMediaRecords(siblingURLs)
|
||||
}
|
||||
|
||||
func (s *UploadStore) deleteLocalByURL(rawURL string) {
|
||||
path := rawURL
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return
|
||||
}
|
||||
if i := strings.Index(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
const prefix = "/uploads/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return
|
||||
}
|
||||
rel := strings.TrimPrefix(path, prefix)
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
for _, candidate := range uploadSiblingRels(relSlash) {
|
||||
full := filepath.Join(s.UploadsRoot(), filepath.FromSlash(candidate))
|
||||
_ = os.Remove(full)
|
||||
if strings.HasPrefix(candidate, UploadCategoryPosts+"/") {
|
||||
thumbDir := filepath.Join(s.UploadsRoot(), ".thumbs")
|
||||
_ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".webp"))
|
||||
_ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".jpg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UploadStore) deleteS3ByURL(backend *s3Backend, publicBase, keyPrefix, rawURL string) {
|
||||
if backend == nil || publicBase == "" {
|
||||
return
|
||||
}
|
||||
rel, ok := relativeUnderPublicBase(rawURL, publicBase)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(rel, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return
|
||||
}
|
||||
for _, candidate := range uploadSiblingRels(parts[0] + "/" + parts[1]) {
|
||||
key := keyPrefix + candidate
|
||||
_ = backend.client.RemoveObject(context.Background(), backend.bucket, key, minio.RemoveObjectOptions{})
|
||||
}
|
||||
}
|
||||
|
||||
// uploadSiblingRels 返回同一主文件名下的自身与伴生扩展名路径(rel 使用 /)
|
||||
func uploadSiblingRels(rel string) []string {
|
||||
rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/"))
|
||||
ext := ""
|
||||
if i := strings.LastIndex(rel, "."); i >= 0 && i > strings.LastIndex(rel, "/") {
|
||||
ext = strings.ToLower(rel[i:])
|
||||
}
|
||||
stem := rel
|
||||
if ext != "" {
|
||||
stem = rel[:len(rel)-len(ext)]
|
||||
}
|
||||
if stem == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, 6)
|
||||
add := func(e string) {
|
||||
p := stem + e
|
||||
if seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
if ext != "" {
|
||||
add(ext)
|
||||
}
|
||||
for _, e := range siblingUploadExts(ext) {
|
||||
add(e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseS3Endpoint(raw string) (host string, secure bool, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false, errors.New("S3 ENDPOINT 不能为空")
|
||||
}
|
||||
secure = true
|
||||
if strings.Contains(raw, "://") {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("S3 ENDPOINT 无效: %w", err)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", false, errors.New("S3 ENDPOINT 无效")
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http":
|
||||
secure = false
|
||||
case "https":
|
||||
secure = true
|
||||
default:
|
||||
return "", false, fmt.Errorf("S3 ENDPOINT 不支持协议 %q", u.Scheme)
|
||||
}
|
||||
return u.Host, secure, nil
|
||||
}
|
||||
host = raw
|
||||
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
|
||||
secure = false
|
||||
}
|
||||
return host, secure, nil
|
||||
}
|
||||
|
||||
func relativeUnderPublicBase(rawURL, publicBase string) (string, bool) {
|
||||
publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/")
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if publicBase == "" || rawURL == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(rawURL, publicBase+"/") {
|
||||
rel := strings.TrimPrefix(rawURL, publicBase+"/")
|
||||
if i := strings.Index(rel, "?"); i >= 0 {
|
||||
rel = rel[:i]
|
||||
}
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return "", false
|
||||
}
|
||||
return rel, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
173
services/thumb.go
Normal file
173
services/thumb.go
Normal 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
248
services/unlock.go
Normal 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
18
services/upload.go
Normal 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
313
services/user.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user