feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
36
service/aside_widgets_test.go
Normal file
36
service/aside_widgets_test.go
Normal 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
150
service/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
service/bounty_test.go
Normal file
175
service/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)
|
||||
}
|
||||
}
|
||||
473
service/friend_link.go
Normal file
473
service/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
service/friend_link_enrich.go
Normal file
93
service/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
service/friend_link_reciprocal.go
Normal file
269
service/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
service/friend_link_reciprocal_async.go
Normal file
62
service/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
service/friend_link_reciprocal_test.go
Normal file
46
service/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)
|
||||
}
|
||||
}
|
||||
150
service/lottery_post.go
Normal file
150
service/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{})
|
||||
}
|
||||
@@ -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
280
service/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{})
|
||||
}
|
||||
@@ -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
69
service/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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "/"
|
||||
}
|
||||
|
||||
@@ -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.go:SettingPermalinkEnabled / 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
176
service/site_page.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user