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 }