Files
jiang13-forum/service/friend_link.go

474 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}