新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
306 lines
9.2 KiB
Go
306 lines
9.2 KiB
Go
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
|
||
}
|