feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
305
backend/service/friendlink.go
Normal file
305
backend/service/friendlink.go
Normal file
@@ -0,0 +1,305 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user