feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user