Files
jiang13-bbs/backend/service/friendlink.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

306 lines
9.2 KiB
Go
Raw Permalink 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"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 友情链接状态
const (
FriendLinkStatusPending = "pending"
FriendLinkStatusApproved = "approved"
FriendLinkStatusRejected = "rejected"
)
// 字段长度上限
const (
friendLinkMaxName = 64
friendLinkMaxURL = 512
friendLinkMaxDescription = 255
friendLinkMaxLogoURL = 512
friendLinkMaxCategory = 32
friendLinkMaxReciprocal = 512
friendLinkMaxReject = 255
)
var (
// ErrFriendLinkInvalid 参数校验失败
ErrFriendLinkInvalid = errors.New("友情链接参数无效")
// ErrFriendLinkNotFound 记录不存在
ErrFriendLinkNotFound = errors.New("友情链接不存在")
)
// FriendLinkInput 申请/新建/更新共用入参(指针区分「未传」与「显式清空」由调用方决定)
type FriendLinkInput struct {
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description"`
LogoURL string `json:"logo_url"`
Category string `json:"category"`
ReciprocalURL string `json:"reciprocal_url"`
}
// FriendLinkService 友情链接申请 + 后台 CRUD/审批
type FriendLinkService struct {
db *gorm.DB
}
func NewFriendLinkService(db *gorm.DB) *FriendLinkService {
return &FriendLinkService{db: db}
}
// validateInput 校验并归一化入参;返回归一化后的值
func validateFriendLinkInput(in FriendLinkInput) (FriendLinkInput, error) {
name := strings.TrimSpace(in.Name)
if name == "" || utf8.RuneCountInString(name) > friendLinkMaxName {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
rawURL := strings.TrimSpace(in.URL)
if rawURL == "" || len(rawURL) > friendLinkMaxURL {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
if u, err := url.Parse(rawURL); err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
desc := strings.TrimSpace(in.Description)
if utf8.RuneCountInString(desc) > friendLinkMaxDescription {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
logo := strings.TrimSpace(in.LogoURL)
if len(logo) > friendLinkMaxLogoURL {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
if logo != "" {
if u, err := url.Parse(logo); err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
}
category := strings.TrimSpace(in.Category)
if utf8.RuneCountInString(category) > friendLinkMaxCategory {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
reciprocal := strings.TrimSpace(in.ReciprocalURL)
if len(reciprocal) > friendLinkMaxReciprocal {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
if reciprocal != "" {
if u, err := url.Parse(reciprocal); err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
}
return FriendLinkInput{
Name: name, URL: rawURL, Description: desc, LogoURL: logo,
Category: category, ReciprocalURL: reciprocal,
}, nil
}
// ListApproved 前台公开列表:仅 approved,按 sort_order ASC, id ASC
func (s *FriendLinkService) ListApproved() ([]model.FriendLink, error) {
var list []model.FriendLink
err := s.db.Where("status = ?", FriendLinkStatusApproved).
Order("sort_order ASC, id ASC").Find(&list).Error
if list == nil {
list = []model.FriendLink{}
}
return list, err
}
// ListAll 后台全量列表(含 pending/rejected),按 status, sort_order, id
func (s *FriendLinkService) ListAll() ([]model.FriendLink, error) {
var list []model.FriendLink
err := s.db.Order("status ASC, sort_order ASC, id ASC").Find(&list).Error
if list == nil {
list = []model.FriendLink{}
}
return list, err
}
// ListMine 申请人本人提交的全部申请(含待审/拒绝),按提交时间倒序
func (s *FriendLinkService) ListMine(userID uint) ([]model.FriendLink, error) {
var list []model.FriendLink
err := s.db.Where("applicant_user_id = ?", userID).
Order("created_at DESC, id DESC").Find(&list).Error
if list == nil {
list = []model.FriendLink{}
}
return list, err
}
// OwnerUpdate 申请人修改自己的申请(名称/链接/回链/LOGO);非待审状态的记录修改后回退待审
func (s *FriendLinkService) OwnerUpdate(id, userID uint, in FriendLinkInput) error {
norm, err := validateFriendLinkInput(in)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
var fl model.FriendLink
if err := tx.First(&fl, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFriendLinkNotFound
}
return err
}
// 仅申请人本人可改;他人访问统一按不存在处理,不暴露记录存在性
if fl.ApplicantUserID == nil || *fl.ApplicantUserID != userID {
return ErrFriendLinkNotFound
}
updates := map[string]interface{}{
"name": norm.Name,
"url": norm.URL,
"logo_url": norm.LogoURL,
"reciprocal_url": norm.ReciprocalURL,
"updated_at": time.Now(),
}
if fl.Status != FriendLinkStatusPending {
updates["status"] = FriendLinkStatusPending
updates["reject_reason"] = ""
}
return tx.Model(&fl).Updates(updates).Error
})
}
// nextSortOrder 取当前 approved 记录中最大 sort_order + 1(新建/审批通过时使用)
func (s *FriendLinkService) nextSortOrder() (int, error) {
var maxOrder int
err := s.db.Model(&model.FriendLink{}).
Where("status = ?", FriendLinkStatusApproved).
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder)
if err != nil {
return 0, err
}
return maxOrder + 1, nil
}
// Apply 用户申请:status=pending
func (s *FriendLinkService) Apply(in FriendLinkInput, applicantUserID uint) (*model.FriendLink, error) {
norm, err := validateFriendLinkInput(in)
if err != nil {
return nil, err
}
uid := applicantUserID
now := time.Now()
fl := &model.FriendLink{
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
Status: FriendLinkStatusPending, SortOrder: 0,
ApplicantUserID: &uid, CreatedAt: now, UpdatedAt: now,
}
if err := s.db.Create(fl).Error; err != nil {
return nil, err
}
return fl, nil
}
// AdminCreate 站长直接新建:status=approved,自动分配 sort_order
func (s *FriendLinkService) AdminCreate(in FriendLinkInput) (*model.FriendLink, error) {
norm, err := validateFriendLinkInput(in)
if err != nil {
return nil, err
}
order, err := s.nextSortOrder()
if err != nil {
return nil, err
}
now := time.Now()
fl := &model.FriendLink{
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
Status: FriendLinkStatusApproved, SortOrder: order,
CreatedAt: now, UpdatedAt: now,
}
if err := s.db.Create(fl).Error; err != nil {
return nil, err
}
return fl, nil
}
// AdminUpdate 全量更新内容(不改 status/sort_order/applicant)
func (s *FriendLinkService) AdminUpdate(id uint, in FriendLinkInput) error {
norm, err := validateFriendLinkInput(in)
if err != nil {
return err
}
res := s.db.Model(&model.FriendLink{}).Where("id = ?", id).
Updates(map[string]interface{}{
"name": norm.Name,
"url": norm.URL,
"description": norm.Description,
"logo_url": norm.LogoURL,
"category": norm.Category,
"reciprocal_url": norm.ReciprocalURL,
"updated_at": time.Now(),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrFriendLinkNotFound
}
return nil
}
// AdminSetStatus 审批:approved 时自动补 sort_order;rejected 时写 reject_reason
func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) error {
status = strings.TrimSpace(status)
if status != FriendLinkStatusApproved && status != FriendLinkStatusRejected &&
status != FriendLinkStatusPending {
return ErrFriendLinkInvalid
}
if status == FriendLinkStatusRejected && utf8.RuneCountInString(rejectReason) > friendLinkMaxReject {
return ErrFriendLinkInvalid
}
return s.db.Transaction(func(tx *gorm.DB) error {
var fl model.FriendLink
if err := tx.First(&fl, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFriendLinkNotFound
}
return err
}
updates := map[string]interface{}{
"status": status,
"updated_at": time.Now(),
"reject_reason": "",
}
switch status {
case FriendLinkStatusApproved:
if fl.SortOrder == 0 {
var maxOrder int
if err := tx.Model(&model.FriendLink{}).
Where("status = ? AND id <> ?", FriendLinkStatusApproved, id).
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder); err != nil {
return err
}
updates["sort_order"] = maxOrder + 1
}
case FriendLinkStatusRejected:
updates["reject_reason"] = rejectReason
case FriendLinkStatusPending:
// 切回 pending 视为下线,不动 sort_order
}
return tx.Model(&fl).Updates(updates).Error
})
}
// AdminDelete 软删除
func (s *FriendLinkService) AdminDelete(id uint) error {
res := s.db.Delete(&model.FriendLink{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrFriendLinkNotFound
}
return nil
}