feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View File

@@ -0,0 +1,36 @@
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) != 3 {
t.Fatalf("want 3 widgets, got %d", len(out))
}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments}
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 {
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},
}
bools := asideBoolsFromWidgets(widgets)
if !bools.tagCloud || !bools.recentComments || bools.friendLinks {
t.Fatalf("unexpected bools: %+v", bools)
}
}

150
service/bounty.go Normal file
View File

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

175
service/bounty_test.go Normal file
View File

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

473
service/friend_link.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

150
service/lottery_post.go Normal file
View File

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

View File

@@ -15,9 +15,12 @@ const (
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 伪静态(固定链接)配置
@@ -74,6 +77,32 @@ 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
@@ -105,6 +134,56 @@ func (p PermalinkConfig) MatchPostPath(path string) PermalinkMatch {
}
}
// 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)

280
service/poll.go Normal file
View File

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

View File

@@ -23,11 +23,21 @@ 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 时仅返回该用户的帖子
@@ -479,6 +489,13 @@ func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool,
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
@@ -646,6 +663,11 @@ func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
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
}

69
service/post_special.go Normal file
View File

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

View File

@@ -23,8 +23,8 @@ func NewRateLimiter(settings *ForumSettingsService) *RateLimiter {
// Allow 检查 action+key 是否允许操作
func (r *RateLimiter) Allow(action, key string) bool {
limit := r.settings.RateLimitFor(action)
window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
limit := r.limitFor(action)
window := r.windowFor(action)
if limit <= 0 {
return true
}
@@ -50,12 +50,26 @@ func (r *RateLimiter) Allow(action, key string) bool {
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()
window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
cutoff := time.Now().Add(-window * 2)
// 使用最大窗口清理,覆盖友链 1 小时窗口
cutoff := time.Now().Add(-time.Hour * 2)
for k, times := range r.records {
var valid []time.Time
for _, t := range times {

View File

@@ -99,8 +99,16 @@ func DisplayName(u *model.User) string {
return strings.TrimSpace(u.Username)
}
// QueryBoardHome 板块首页相对路径
func QueryBoardHome(boardID uint) string {
// 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 "/"
}

View File

@@ -13,7 +13,7 @@ import (
// 论坛设置键名
const (
SettingPostEditWindowHours = "post_edit_window_hours"
SettingPostEditWindowHours = "post_edit_window_hours"
SettingCommentEditWindowMinutes = "comment_edit_window_minutes"
SettingRateLimitPost = "rate_limit_post"
@@ -39,6 +39,11 @@ const (
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
SettingAsideShowTagCloud = "aside_show_tag_cloud"
SettingAsideShowRecentComments = "aside_show_recent_comments"
SettingAsideShowFriendLinks = "aside_show_friend_links"
SettingAsideWidgets = "aside_widgets"
SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt
@@ -73,17 +78,18 @@ const (
SettingStorageForcePathStyle = "storage_force_path_style"
SettingStorageImageDelivery = "storage_image_delivery"
SettingSiteName = "site_name"
SettingSiteSlogan = "site_slogan"
SettingSiteDescription = "site_description"
SettingSiteKeywords = "site_keywords"
SettingSiteLogoMark = "site_logo_mark"
SettingSiteLogo = "site_logo"
SettingSiteFavicon = "site_favicon"
SettingSiteOGImage = "site_og_image"
SettingSiteICPBeian = "site_icp_beian"
SettingSiteICPBeianURL = "site_icp_beian_url"
SettingSiteFriendLinks = "site_friend_links"
SettingSiteName = "site_name"
SettingSiteSlogan = "site_slogan"
SettingSiteDescription = "site_description"
SettingSiteKeywords = "site_keywords"
SettingSiteLogoMark = "site_logo_mark"
SettingSiteLogo = "site_logo"
SettingSiteFavicon = "site_favicon"
SettingSiteOGImage = "site_og_image"
SettingSiteICPBeian = "site_icp_beian"
SettingSiteICPBeianURL = "site_icp_beian_url"
SettingSiteFriendLinks = "site_friend_links"
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100
@@ -118,10 +124,35 @@ type ForumLimits struct {
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
// AsideWidget 右侧栏可选组件
type AsideWidget struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
const (
AsideWidgetTagCloud = "tag_cloud"
AsideWidgetRecentComments = "recent_comments"
AsideWidgetFriendLinks = "friend_links"
)
var asideWidgetDefaultOrder = []string{
AsideWidgetTagCloud,
AsideWidgetRecentComments,
AsideWidgetFriendLinks,
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
@@ -140,15 +171,22 @@ type ForumLimitsPublic struct {
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
type settingDef struct {
key string
key string
defaultVal string
min int
max int // 0 表示不限制上限
min int
max int // 0 表示不限制上限
}
var forumSettingDefs = []settingDef{
@@ -180,6 +218,17 @@ var forumSettingDefs = []settingDef{
{SettingOpenContentLinksInNewTab, "1", 0, 1},
}
var feedSettingDefaults = map[string]string{
SettingFeedListStyle: "title",
}
var asideSettingDefaults = map[string]string{
SettingAsideShowTagCloud: "0",
SettingAsideShowRecentComments: "0",
SettingAsideShowFriendLinks: "1",
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
}
var mailSettingDefaults = map[string]string{
SettingSMTPEnabled: "0",
SettingSMTPHost: "",
@@ -219,6 +268,10 @@ var storageSettingDefaults = map[string]string{
SettingStorageImageDelivery: ImageDeliveryWebP,
}
var friendLinkSettingDefaults = map[string]string{
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
}
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
@@ -245,10 +298,11 @@ const (
defaultICPBeianURL = "https://beian.miit.gov.cn/"
)
// FriendLink 页脚友情链接
// FriendLink 友情链接
type FriendLink struct {
Name string `json:"name"`
URL string `json:"url"`
Logo string `json:"logo,omitempty"`
}
// SiteBranding 站点品牌配置名称、Logo、Favicon、页脚等
@@ -264,6 +318,7 @@ type SiteBranding struct {
ICPBeian string `json:"icp_beian"`
ICPBeianURL string `json:"icp_beian_url"`
FriendLinks []FriendLink `json:"friend_links"`
SiteURL string `json:"site_url,omitempty" gorm:"-"` // 公开站点根 URL仅 API 填充
}
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
@@ -343,6 +398,20 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
for key, val := range feedSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range asideSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range mailSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
@@ -378,6 +447,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
for key, val := range friendLinkSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
}
func (s *ForumSettingsService) getString(key, fallback string) string {
@@ -426,6 +502,8 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
func (s *ForumSettingsService) Limits() ForumLimits {
permalink := s.Permalink()
widgets := s.AsideWidgets()
bools := asideBoolsFromWidgets(widgets)
return ForumLimits{
PostEditWindowHours: s.PostEditWindowHours(),
CommentEditWindowMinutes: s.CommentEditWindowMinutes(),
@@ -454,6 +532,13 @@ func (s *ForumSettingsService) Limits() ForumLimits {
OpenPostsInNewTab: s.OpenPostsInNewTab(),
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
AsideShowTagCloud: bools.tagCloud,
AsideShowRecentComments: bools.recentComments,
AsideShowFriendLinks: bools.friendLinks,
AsideWidgets: widgets,
FeedListStyle: s.FeedListStyle(),
PermalinkEnabled: permalink.Enabled,
PermalinkExt: permalink.Ext,
}
@@ -478,6 +563,13 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
AsideShowTagCloud: limits.AsideShowTagCloud,
AsideShowRecentComments: limits.AsideShowRecentComments,
AsideShowFriendLinks: limits.AsideShowFriendLinks,
AsideWidgets: limits.AsideWidgets,
FeedListStyle: limits.FeedListStyle,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
}
@@ -487,21 +579,21 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,
SettingCommentEditWindowMinutes: in.CommentEditWindowMinutes,
SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister,
SettingRateLimitLogin: in.RateLimitLogin,
SettingRateLimitWindow: in.RateLimitWindowSec,
SettingPostTitleMax: in.PostTitleMax,
SettingPostTagsMax: in.PostTagsMax,
SettingPostContentMax: in.PostContentMax,
SettingCommentMax: in.CommentMax,
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
SettingSignatureMax: in.SignatureMax,
SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister,
SettingRateLimitLogin: in.RateLimitLogin,
SettingRateLimitWindow: in.RateLimitWindowSec,
SettingPostTitleMax: in.PostTitleMax,
SettingPostTagsMax: in.PostTagsMax,
SettingPostContentMax: in.PostContentMax,
SettingCommentMax: in.CommentMax,
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
SettingSignatureMax: in.SignatureMax,
}
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting
@@ -511,9 +603,17 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
return err
}
}
widgets := NormalizeAsideWidgets(in.AsideWidgets)
if len(widgets) == 0 {
widgets = asideWidgetsFromBools(in.AsideShowTagCloud, in.AsideShowRecentComments, in.AsideShowFriendLinks)
}
bools := asideBoolsFromWidgets(widgets)
boolUpdates := map[string]bool{
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks,
SettingPermalinkEnabled: in.PermalinkEnabled,
}
for key, on := range boolUpdates {
@@ -525,6 +625,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
return err
}
}
widgetsJSON, err := json.Marshal(widgets)
if err != nil {
return err
}
if err := s.setString(SettingAsideWidgets, string(widgetsJSON)); err != nil {
return err
}
ext, ok := NormalizePermalinkExt(in.PermalinkExt)
if !ok {
return ErrInvalidSetting
@@ -532,6 +639,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
if err := s.setString(SettingPermalinkExt, ext); err != nil {
return err
}
style, ok := NormalizeFeedListStyle(in.FeedListStyle)
if !ok {
return ErrInvalidSetting
}
if err := s.setString(SettingFeedListStyle, style); err != nil {
return err
}
return nil
}
@@ -584,6 +698,126 @@ func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
}
// FriendLinkReciprocalCheckEnabled 是否启用友链回链检测;缺省为关闭
func (s *ForumSettingsService) FriendLinkReciprocalCheckEnabled() bool {
return s.getString(SettingFriendLinkReciprocalCheck, "0") == "1"
}
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingFriendLinkReciprocalCheck, v)
}
type asideWidgetBools struct {
tagCloud bool
recentComments bool
friendLinks bool
}
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
return []AsideWidget{
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
{ID: AsideWidgetRecentComments, Enabled: recentComments},
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
}
}
func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
out := asideWidgetBools{}
for _, w := range widgets {
switch w.ID {
case AsideWidgetTagCloud:
out.tagCloud = w.Enabled
case AsideWidgetRecentComments:
out.recentComments = w.Enabled
case AsideWidgetFriendLinks:
out.friendLinks = w.Enabled
}
}
return out
}
func isValidAsideWidgetID(id string) bool {
switch id {
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetFriendLinks:
return true
default:
return false
}
}
// NormalizeAsideWidgets 校验并补全右侧栏组件列表(顺序保留,缺失项按默认顺序追加)
func NormalizeAsideWidgets(in []AsideWidget) []AsideWidget {
seen := make(map[string]bool, len(asideWidgetDefaultOrder))
out := make([]AsideWidget, 0, len(asideWidgetDefaultOrder))
for _, w := range in {
id := strings.TrimSpace(w.ID)
if !isValidAsideWidgetID(id) || seen[id] {
continue
}
seen[id] = true
out = append(out, AsideWidget{ID: id, Enabled: w.Enabled})
}
for _, id := range asideWidgetDefaultOrder {
if seen[id] {
continue
}
out = append(out, AsideWidget{ID: id, Enabled: false})
}
return out
}
func (s *ForumSettingsService) AsideWidgets() []AsideWidget {
raw := strings.TrimSpace(s.getString(SettingAsideWidgets, ""))
if raw != "" {
var widgets []AsideWidget
if err := json.Unmarshal([]byte(raw), &widgets); err == nil {
normalized := NormalizeAsideWidgets(widgets)
if len(normalized) > 0 {
return normalized
}
}
}
return asideWidgetsFromBools(s.AsideShowTagCloud(), s.AsideShowRecentComments(), s.AsideShowFriendLinks())
}
func (s *ForumSettingsService) AsideShowTagCloud() bool {
return s.getString(SettingAsideShowTagCloud, "0") == "1"
}
func (s *ForumSettingsService) AsideShowRecentComments() bool {
return s.getString(SettingAsideShowRecentComments, "0") == "1"
}
func (s *ForumSettingsService) AsideShowFriendLinks() bool {
return s.getString(SettingAsideShowFriendLinks, "1") == "1"
}
// NormalizeFeedListStyle 校验首页列表样式
func NormalizeFeedListStyle(v string) (string, bool) {
switch strings.TrimSpace(strings.ToLower(v)) {
case "title", "":
return "title", true
case "excerpt":
return "excerpt", true
case "thumbnail":
return "thumbnail", true
default:
return "", false
}
}
func (s *ForumSettingsService) FeedListStyle() string {
v, ok := NormalizeFeedListStyle(s.getString(SettingFeedListStyle, feedSettingDefaults[SettingFeedListStyle]))
if !ok {
return "title"
}
return v
}
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
@@ -903,6 +1137,11 @@ func (s *ForumSettingsService) SiteBranding() SiteBranding {
mark = string(runes[0])
}
links := parseFriendLinksJSON(s.getString(SettingSiteFriendLinks, "[]"))
links = EnrichFriendLinksLogos(links)
if err := s.maybePersistEnrichedFriendLinks(links); err != nil {
// 回填失败不阻断读取
_ = err
}
return SiteBranding{
Name: name,
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
@@ -1111,7 +1350,7 @@ func normalizeFriendLinks(in []FriendLink) ([]FriendLink, error) {
if scheme != "http" && scheme != "https" {
return nil, ErrInvalidSetting
}
out = append(out, FriendLink{Name: name, URL: href})
out = append(out, FriendLink{Name: name, URL: href, Logo: normalizeFriendLinkLogoOptional(item.Logo)})
}
if out == nil {
out = []FriendLink{}

176
service/site_page.go Normal file
View File

@@ -0,0 +1,176 @@
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(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
}
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
}
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(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
}