feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -15,15 +15,15 @@ import (
)
var (
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天
)
// setAuthCookies 设置认证 cookie:
// - access token(HttpOnly,15min)
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
// - access token(HttpOnly,7天)
// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
// - CSRF token(JS 可读,7天,双提交校验)
// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验)
//
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
@@ -273,7 +273,8 @@ func (h *Handlers) Refresh(c *gin.Context) {
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false,
// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。
func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
_ = h.Auth.RevokeRefreshToken(refreshToken)

View File

@@ -0,0 +1,208 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// currentUserID 从 gin 上下文取登录用户 ID;RequireAuth 已保证存在
func currentUserID(c *gin.Context) (uint, bool) {
v, ok := c.Get("user")
if !ok {
return 0, false
}
claims, ok := v.(*service.UserClaims)
if !ok || claims.ID == 0 {
return 0, false
}
return claims.ID, true
}
// GET /api/friend-links 公开列表(仅 approved)
func (h *Handlers) GetFriendLinks(c *gin.Context) {
links, err := h.FriendLink.ListApproved()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"links": links})
}
// POST /api/friend-links/apply 登录用户提交申请
func (h *Handlers) ApplyFriendLink(c *gin.Context) {
uid, ok := currentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
fl, err := h.FriendLink.Apply(in, uid)
if err != nil {
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "提交申请失败"})
return
}
c.JSON(http.StatusOK, gin.H{"link": fl})
}
// GET /api/friend-links/mine 当前登录用户自己的申请记录(含待审/拒绝与拒绝理由)
func (h *Handlers) MyFriendLinks(c *gin.Context) {
uid, ok := currentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
links, err := h.FriendLink.ListMine(uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取申请记录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"links": links})
}
// PUT /api/friend-links/:id 申请人修改自己的申请;已通过/已驳回的修改后回退待审
func (h *Handlers) OwnerUpdateFriendLink(c *gin.Context) {
uid, ok := currentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.OwnerUpdate(uint(id), uid, in); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存修改失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/admin/friend-links 后台全量列表
func (h *Handlers) AdminListFriendLinks(c *gin.Context) {
links, err := h.FriendLink.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"links": links})
}
// POST /api/admin/friend-links 站长直接新建(status=approved)
func (h *Handlers) AdminCreateFriendLink(c *gin.Context) {
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
fl, err := h.FriendLink.AdminCreate(in)
if err != nil {
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"link": fl})
}
// PUT /api/admin/friend-links/:id 全量更新内容(不改 status)
func (h *Handlers) AdminUpdateFriendLink(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.AdminUpdate(uint(id), in); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// POST /api/admin/friend-links/:id/status 审批:body {status, reject_reason?}
func (h *Handlers) AdminSetFriendLinkStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var body struct {
Status string `json:"status"`
RejectReason string `json:"reject_reason"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.AdminSetStatus(uint(id), body.Status, body.RejectReason); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "审批参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新状态失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// DELETE /api/admin/friend-links/:id 软删除
func (h *Handlers) AdminDeleteFriendLink(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.AdminDelete(uint(id)); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -38,8 +38,10 @@ type Handlers struct {
HidePwd *service.HidePasswordCookie
Ads *service.AdService
Sidebar *service.SidebarService
FriendLink *service.FriendLinkService
Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
LibrarySvc *service.LibraryService
}
// resolvePublishStatus 决定新帖/新评的初始状态:

391
backend/handler/library.go Normal file
View File

@@ -0,0 +1,391 @@
package handler
import (
"errors"
"net/http"
"net/url"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ---------- 公开端 ----------
// LibraryList 已发布条目目录
func (h *Handlers) LibraryList(c *gin.Context) {
list, err := h.LibrarySvc.ListPublished()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
}
// LibraryDetail 公开条目详情(按 slug,仅已发布)
func (h *Handlers) LibraryDetail(c *gin.Context) {
d, err := h.LibrarySvc.GetPublishedBySlug(c.Param("slug"))
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取条目失败"})
return
}
c.JSON(http.StatusOK, gin.H{"doc": d})
}
// LibraryFileDownload 强制下载(attachment;成功后计数)
func (h *Handlers) LibraryFileDownload(c *gin.Context) {
f, ok := h.libraryPublicFile(c)
if !ok {
return
}
h.LibrarySvc.IncrDownload(f.ID)
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(f.Name))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox")
ct := f.MIME
if ct == "" {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
c.File(h.LibrarySvc.FilePath(f))
}
// libraryPublicFile 公开文件公共校验(存在 + 所属条目已发布)
func (h *Handlers) libraryPublicFile(c *gin.Context) (*model.LibraryFile, bool) {
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
return nil, false
}
f, err := h.LibrarySvc.GetPublicFile(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return nil, false
}
return f, true
}
// ---------- 管理端 ----------
// AdminListLibraryDocs 后台条目列表(含未发布)
func (h *Handlers) AdminListLibraryDocs(c *gin.Context) {
list, err := h.LibrarySvc.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
}
// AdminCreateLibraryDoc 新建条目
func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
var in service.LibraryInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
d, err := h.LibrarySvc.Create(&in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"doc": d})
}
// AdminUpdateLibraryDoc 编辑条目
func (h *Handlers) AdminUpdateLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in service.LibraryInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
d, err := h.LibrarySvc.Update(uint(id), &in)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"doc": d})
}
// AdminDeleteLibraryDoc 删除条目(软删)
func (h *Handlers) AdminDeleteLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
if err := h.LibrarySvc.Delete(uint(id)); err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminPurgeLibraryDoc 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件)
func (h *Handlers) AdminPurgeLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
if err := h.LibrarySvc.Purge(uint(id)); err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
if errors.Is(err, service.ErrLibraryNotDeleted) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminUploadLibraryFile 上传文件到条目
func (h *Handlers) AdminUploadLibraryFile(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
maxBytes, err := h.Setting.AttachmentMaxBytes()
if err != nil || maxBytes < 1 {
maxBytes = service.FileMaxBytes
}
overhead := int64(64 << 10) // multipart 边界开销
limit := maxBytes + overhead
if c.Request.ContentLength > limit {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件过大"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
return
}
if file.Size > maxBytes {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
src, err := file.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"})
return
}
defer src.Close()
f, err := h.LibrarySvc.AddFile(uint(docID), claims.ID, file.Filename, src)
if err != nil {
if errors.Is(err, service.ErrLibraryExtDenied) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"file": f})
}
// AdminDeleteLibraryFile 删除条目文件
func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
return
}
if err := h.LibrarySvc.DeleteFile(uint(id)); err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ---------- 管理端:章节 ----------
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)
func (h *Handlers) AdminCreateLibrarySection(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in service.SectionInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sec, err := h.LibrarySvc.CreateSection(uint(docID), &in)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"section": sec})
}
// AdminUpdateLibrarySection 编辑章节标题与正文
func (h *Handlers) AdminUpdateLibrarySection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
return
}
var in service.SectionInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sec, err := h.LibrarySvc.UpdateSection(uint(id), &in)
if err != nil {
if errors.Is(err, service.ErrSectionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"section": sec})
}
// AdminDeleteLibrarySection 删除章节(章级联删除其下小节)
func (h *Handlers) AdminDeleteLibrarySection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
return
}
if err := h.LibrarySvc.DeleteSection(uint(id)); err != nil {
if errors.Is(err, service.ErrSectionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminMoveLibrarySection 章节上移/下移(同父兄弟间交换顺序)
func (h *Handlers) AdminMoveLibrarySection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
return
}
var in struct {
Direction string `json:"direction"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.LibrarySvc.MoveSection(uint(id), in.Direction); err != nil {
if errors.Is(err, service.ErrSectionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminImportLibraryMarkdown 导入 md 并按标题拆章
func (h *Handlers) AdminImportLibraryMarkdown(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in struct {
Filename string `json:"filename"`
Content string `json:"content"`
SplitLevel string `json:"split_level"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sections, err := h.LibrarySvc.ImportMarkdown(uint(docID), in.Filename, in.Content, in.SplitLevel)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
}
// AdminImportLibraryMarkdownBatch 批量导入 md:多个文件按传入顺序依次拆章
func (h *Handlers) AdminImportLibraryMarkdownBatch(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in struct {
Files []service.ImportFileInput `json:"files"`
SplitLevel string `json:"split_level"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sections, err := h.LibrarySvc.ImportMarkdownBatch(uint(docID), in.Files, in.SplitLevel)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
}

View File

@@ -73,10 +73,14 @@ type updateSettingsRequest struct {
NecroReplyAfterHours *int `json:"necro_reply_after_hours"`
NecroReplyPenalty *int `json:"necro_reply_penalty"`
Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"`
Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"`
UrlStyle *string `json:"url_style"`
MobileTabs *[]string `json:"mobile_tabs"`
HeaderNav *[]service.HeaderNavItem `json:"header_nav"`
}
func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -87,9 +91,9 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"site_name": saved.SiteName,
"site_description": saved.SiteDescription,
"allow_register": saved.AllowRegister,
"allow_comments": saved.AllowComments,
"comments_require_login": saved.CommentsRequireLogin,
"allow_messages": saved.AllowMessages,
"allow_comments": saved.AllowComments,
"comments_require_login": saved.CommentsRequireLogin,
"allow_messages": saved.AllowMessages,
"post_cooldown_hours": saved.PostCooldownHours,
"code_block_auto_fold": saved.CodeBlockAutoFold,
"code_block_fold_lines": saved.CodeBlockFoldLines,
@@ -127,15 +131,19 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"points_reply_daily_cap": saved.PointsReplyDailyCap,
"points_recommend_reward": saved.PointsRecommendReward,
"post_edit_lock_hours": saved.PostEditLockHours,
"comment_edit_lock_hours": saved.CommentEditLockHours,
"necro_reply_after_hours": saved.NecroReplyAfterHours,
"necro_reply_penalty": saved.NecroReplyPenalty,
"post_edit_lock_hours": saved.PostEditLockHours,
"comment_edit_lock_hours": saved.CommentEditLockHours,
"necro_reply_after_hours": saved.NecroReplyAfterHours,
"necro_reply_penalty": saved.NecroReplyPenalty,
"levels": saved.Levels,
"levels": saved.Levels,
"levels_fx": saved.LevelsFx,
"url_style": saved.UrlStyle,
"mobile_tabs": saved.MobileTabs,
"header_nav": saved.HeaderNav,
}
}
@@ -159,7 +167,7 @@ func (req *updateSettingsRequest) hasAny() bool {
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
req.UrlStyle != nil
req.UrlStyle != nil || req.MobileTabs != nil || req.HeaderNav != nil
}
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
@@ -552,6 +560,26 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.MobileTabs != nil {
if err := h.Setting.SetMobileTabs(*req.MobileTabs); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "移动端底栏配置无效(至少保留一项,且取值须在允许列表内)"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.HeaderNav != nil {
if err := h.Setting.SetHeaderNav(*req.HeaderNav); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "顶部导航配置无效(最多 8 项,内置项须在允许列表内,自定义项名称 1–16 字、地址须为站内路径或 http(s))"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
saved, err := h.Setting.Public()
if err != nil {

View File

@@ -101,3 +101,25 @@ func (h *Handlers) AdminDeleteSitePage(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminPurgeSitePage 彻底删除单页(仅限已软删)
func (h *Handlers) AdminPurgeSitePage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"})
return
}
if err := h.SitePage.Purge(uint(id)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
if errors.Is(err, service.ErrSitePageNotDeleted) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -27,3 +27,20 @@ func (h *Handlers) TimelineFromGit(c *gin.Context) {
}
c.JSON(http.StatusOK, result)
}
// TimelineFromReleases 从 Git releases 页 URL 导入时间线条目(登录 + CSRF + 限流)
func (h *Handlers) TimelineFromReleases(c *gin.Context) {
var req struct {
URLs []string `json:"urls"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
result, err := h.Setting.ImportTimelineFromReleases(req.URLs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

View File

@@ -3,8 +3,11 @@ package handler
import (
"errors"
"io"
"mime"
"net/http"
"path"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
@@ -311,6 +314,75 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url})
}
// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
var req struct {
AttachmentID uint `json:"attachment_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// brandAliasSlots 固定地址路径段 → 品牌槽位
var brandAliasSlots = map[string]string{
"logo-light": service.BrandSlotLight,
"logo-dark": service.BrandSlotDark,
"favicon": service.BrandSlotFavicon,
}
// BrandImage 品牌图固定对外地址(GET /api/brand/logo-light|logo-dark|favicon):
// 友链等外部引用此地址,站点换图后按设置解析到当前图,地址永不变。
func (h *Handlers) BrandImage(c *gin.Context) {
slot, ok := brandAliasSlots[c.Param("slot")]
if !ok {
c.Status(http.StatusNotFound)
return
}
var url string
var err error
switch slot {
case service.BrandSlotLight:
url, err = h.Setting.LogoLightURL()
case service.BrandSlotDark:
url, err = h.Setting.LogoDarkURL()
default:
url, err = h.Setting.FaviconURL()
}
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
if url == "" {
c.Status(http.StatusNotFound)
return
}
f, err := h.Upload.OpenBrandFile(url)
if err != nil {
c.Status(http.StatusNotFound)
return
}
defer f.Close()
// 扩展名定 Content-Type(SVG 不能靠嗅探);短缓存让换图在友链侧尽快生效
if ct := mime.TypeByExtension(path.Ext(url)); ct != "" {
c.Header("Content-Type", ct)
}
c.Header("Cache-Control", "public, max-age=3600")
http.ServeContent(c.Writer, c.Request, path.Base(url), time.Time{}, f)
}
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
items, counts, err := h.Upload.AdminMediaLibrary()

View File

@@ -130,9 +130,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
}
// 访客与该用户的关注关系(关注按钮初始态)
userPayload["is_following"] = isFollowing
// 邮箱仅本人可见,打码展示(j***@163.com)
// 邮箱仅本人可见;仅本人访问时下发明文(用于账号信息展示与资料表单回填)
if viewerID == user.ID && user.Email != "" {
userPayload["email"] = maskEmail(user.Email)
userPayload["email"] = user.Email
}
// 徽章墙(失败不阻塞资料返回)
if h.Badge != nil {
@@ -158,21 +158,6 @@ func (h *Handlers) UserProfile(c *gin.Context) {
})
}
// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com)
func maskEmail(email string) string {
at := -1
for i, r := range email {
if r == '@' {
at = i
break
}
}
if at <= 0 {
return "***"
}
return email[:1] + "***" + email[at:]
}
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
func (h *Handlers) UserComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)

View File

@@ -62,8 +62,8 @@ func InitDB(dsn string) error {
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
&Badge{}, &UserBadge{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
&ImportRecord{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}

View File

@@ -522,6 +522,52 @@ type SitePage struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibraryDoc 书库条目(书籍/文档/纯 HTML 页展示;文件独立目录存储,仅经 API 消费)
type LibraryDoc struct {
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
Title string `gorm:"size:200;not null" json:"title"`
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
Published bool `gorm:"not null;index" json:"published"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibraryFile 书库文件(html/pdf/epub 等;存 data/library,不进公开静态目录)
type LibraryFile struct {
ID uint `gorm:"primaryKey" json:"id"`
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
UserID uint `gorm:"index;not null" json:"user_id"` // 上传管理员
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
Ext string `gorm:"size:16;not null;default:''" json:"ext"`
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"`
DownloadCount int `gorm:"not null;default:0" json:"download_count"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibrarySection 书库章节(两级树:ParentID 空=章,非空=节;正文 markdown)
type LibrarySection struct {
ID uint `gorm:"primaryKey" json:"id"`
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
ParentID *uint `gorm:"index" json:"parent_id"` // nil=章;非空=节,父必须是同条目的章
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text" json:"content"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
@@ -542,6 +588,24 @@ type SidebarWidget struct {
UpdatedAt time.Time `json:"updated_at"`
}
// FriendLink 友情链接;申请审批制,status=pending 经管理员 approve 后前台可见
type FriendLink struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
URL string `gorm:"size:512;not null" json:"url"`
Description string `gorm:"size:255" json:"description"`
LogoURL string `gorm:"size:512" json:"logo_url"`
Category string `gorm:"size:32;index" json:"category"`
ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected
ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil
RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
type SiteDailyStats struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`

View File

@@ -101,6 +101,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
postFileSvc.WithOperations(ops)
pointsSvc := service.NewPointsService(model.DB)
leaderboardSvc := service.NewLeaderboardService(model.DB)
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).WithSetting(settingSvc)
adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -118,6 +119,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
if err := postFileSvc.EnsureDir(); err != nil {
return nil, err
}
if err := librarySvc.EnsureDir(); err != nil {
return nil, err
}
limiter := service.DefaultRateLimiter()
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
@@ -155,8 +159,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB),
FriendLink: service.NewFriendLinkService(model.DB),
Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
LibrarySvc: librarySvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -180,6 +186,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// 品牌图固定地址(友链等外部引用):按站点设置解析到当前图,换图不变链
r.GET("/api/brand/:slot", h.BrandImage)
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
r.GET("/health", h.Health)
@@ -194,6 +203,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
pubAPI.GET("/overview", h.Overview)
pubAPI.GET("/sidebar", h.GetSidebar)
pubAPI.GET("/friend-links", h.GetFriendLinks)
pubAPI.GET("/posts", h.Posts)
pubAPI.GET("/posts/:id", h.PostDetail)
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
@@ -211,6 +221,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.GET("/leaderboard", h.Leaderboard)
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
// 书库(公开浏览/下载;文件经专用端点消费,不进 /uploads 静态目录)
pubAPI.GET("/library", h.LibraryList)
pubAPI.GET("/library/:slug", h.LibraryDetail)
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
pubAPI.GET("/site-state", h.SiteState)
pubAPI.GET("/media/:object", h.PublicObject)
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
@@ -234,6 +248,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.GET("/me/login-devices", h.MyLoginDevices)
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
api.PUT("/profile", h.UpdateProfile)
// 友情链接申请(登录用户;提交后进 pending 待审批)
api.POST("/friend-links/apply", h.ApplyFriendLink)
api.GET("/friend-links/mine", h.MyFriendLinks)
api.PUT("/friend-links/:id", h.OwnerUpdateFriendLink)
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost)
api.GET("/posts/:id/history", h.PostEditHistory)
@@ -281,6 +299,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
// 时间线:从 Git releases 页导入更新日志
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
@@ -367,6 +387,27 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
announceAPI.POST("/pages", h.AdminCreateSitePage)
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
announceAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
// 书库管理(管理员及以上,同公告/单页权限)
libraryAPI := staffAPI.Group("/library", authMW.RequirePerm(service.PermAnnouncements))
{
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
libraryAPI.POST("/docs", h.AdminCreateLibraryDoc)
libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc)
libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc)
libraryAPI.DELETE("/docs/:id/purge", h.AdminPurgeLibraryDoc)
libraryAPI.POST("/docs/:id/files", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadLibraryFile)
libraryAPI.DELETE("/files/:fid", h.AdminDeleteLibraryFile)
// 章节(两级树:章 → 节)
libraryAPI.POST("/docs/:id/sections", h.AdminCreateLibrarySection)
libraryAPI.PUT("/sections/:sid", h.AdminUpdateLibrarySection)
libraryAPI.DELETE("/sections/:sid", h.AdminDeleteLibrarySection)
libraryAPI.POST("/sections/:sid/move", h.AdminMoveLibrarySection)
libraryAPI.POST("/docs/:id/import-md", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdown)
libraryAPI.POST("/docs/:id/import-md-batch", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdownBatch)
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
}
// 广告位管理(管理员及以上)
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
@@ -400,6 +441,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
// 友情链接管理(超级管理员/站长):CRUD + 审批
friendLinkAPI := staffAPI.Group("/friend-links", authMW.RequirePerm(service.PermSettings))
{
friendLinkAPI.GET("", h.AdminListFriendLinks)
friendLinkAPI.POST("", h.AdminCreateFriendLink)
friendLinkAPI.PUT("/:id", h.AdminUpdateFriendLink)
friendLinkAPI.POST("/:id/status", h.AdminSetFriendLinkStatus)
friendLinkAPI.DELETE("/:id", h.AdminDeleteFriendLink)
}
// 站点设置(超级管理员/站长)
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))

View File

@@ -24,10 +24,15 @@ const (
// CSRFHeaderName 前端传递 CSRF token 的 header 名
CSRFHeaderName = "X-CSRF-Token"
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
AccessTokenTTL = 15 * time.Minute
// RefreshTokenTTL refresh token 有效期(长期)
RefreshTokenTTL = 7 * 24 * time.Hour
// AccessTokenTTL access token 有效期。安全性由每请求实时校验
// (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天:
// 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。
// (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链,
// 是手机端弹"登录已失效"的主因)
AccessTokenTTL = 7 * 24 * time.Hour
// RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期),
// 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录
RefreshTokenTTL = 30 * 24 * time.Hour
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
RefreshRotationGrace = 60 * time.Second

View File

@@ -24,6 +24,12 @@ const (
BrandSlotDark = "logo_dark"
BrandSlotFavicon = "favicon"
// 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。
// 实际文件仍存随机名,请求时按站点设置解析到当前图。
BrandAliasPathLight = "/api/brand/logo-light"
BrandAliasPathDark = "/api/brand/logo-dark"
BrandAliasPathFavicon = "/api/brand/favicon"
BrandMarkImageText = "image_text"
BrandMarkImage = "image"
BrandMarkText = "text"

View File

@@ -183,6 +183,22 @@ func (s *UploadService) BrandFileExists(url string) bool {
return err == nil && !info.IsDir()
}
// OpenBrandFile 打开品牌图文件供对外服务;未设置或文件缺失返回 os.ErrNotExist
func (s *UploadService) OpenBrandFile(url string) (*os.File, error) {
if BrandRelPath(url) == "" {
return nil, os.ErrNotExist
}
abs, ok := s.safeUploadPath(url)
if !ok {
return nil, os.ErrNotExist
}
f, err := os.Open(abs)
if err != nil {
return nil, os.ErrNotExist
}
return f, nil
}
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {

View File

@@ -1,6 +1,34 @@
package service
import "testing"
import (
"errors"
"os"
"testing"
)
func TestOpenBrandFile(t *testing.T) {
s, dir := newMediaLibraryService(t)
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
if _, err := s.OpenBrandFile(png); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing file want ErrNotExist, got %v", err)
}
writeMediaFile(t, dir, "brand/0123456789abcdef0123456789abcdef.png", []byte("png"))
f, err := s.OpenBrandFile(png)
if err != nil {
t.Fatal(err)
}
f.Close()
for _, bad := range []string{
"",
"/uploads/images/0123456789abcdef0123456789abcdef.png",
"/uploads/brand/../avatars/x.png",
"http://evil.test/x.png",
} {
if _, err := s.OpenBrandFile(bad); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("should reject %q, got %v", bad, err)
}
}
}
func TestNormalizeBrandURL(t *testing.T) {
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"

View 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
}

View File

@@ -0,0 +1,191 @@
package service
import (
"encoding/json"
"strings"
"unicode/utf8"
)
// 桌面端 Header 主导航(品牌名右侧的菜单)。
// 配置为有序数组,元素顺序即展示顺序;内置项只存 key(文案/图标/地址由前端注册表派生,
// 支持伪静态风格),自定义项存文案与地址。后续新增内置页面只需在前后端白名单各注册一项。
const (
// SettingKeyHeaderNav 缺行/非法=内置默认三项;显式空数组 [] 表示全部隐藏
SettingKeyHeaderNav = "header_nav"
HeaderNavItemBuiltin = "builtin"
HeaderNavItemCustom = "custom"
// MaxHeaderNavItems 菜单项总数上限(内置 + 自定义)
MaxHeaderNavItems = 8
// MaxHeaderNavLabelRunes 自定义菜单文案长度上限
MaxHeaderNavLabelRunes = 16
)
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"}
// HeaderNavItem 顶部导航菜单项:
//
// builtin:Key 取白名单,Label/URL 留空,地址与高亮由前端按 key 派生;
// custom:管理员自定义,Label 必填,URL 为站内路径或 http(s) 外链,NewTab 控制打开方式。
type HeaderNavItem struct {
Type string `json:"type"`
Key string `json:"key,omitempty"`
Label string `json:"label,omitempty"`
URL string `json:"url,omitempty"`
NewTab bool `json:"new_tab,omitempty"`
}
// DefaultHeaderNav 内置默认三项(缺行时回落)
func DefaultHeaderNav() []HeaderNavItem {
out := make([]HeaderNavItem, 0, len(HeaderNavBuiltinKeys))
for _, k := range HeaderNavBuiltinKeys {
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
}
return out
}
func headerNavBuiltinAllowed(k string) bool {
for _, valid := range HeaderNavBuiltinKeys {
if k == valid {
return true
}
}
return false
}
// headerNavItemIn 持久化读取用:NewTab 用指针区分「未指定(外链默认新开)」与显式 false
type headerNavItemIn struct {
Type string `json:"type"`
Key string `json:"key"`
Label string `json:"label"`
URL string `json:"url"`
NewTab *bool `json:"new_tab"`
}
// NormalizeHeaderNav 保存前严格校验:类型/内置 key 非法、自定义文案或地址非法直接报错;
// 内置项重复去重(首次出现为准,与 mobile_tabs 一致)
func NormalizeHeaderNav(raw []HeaderNavItem) ([]HeaderNavItem, error) {
if len(raw) > MaxHeaderNavItems {
return nil, ErrInvalidSiteSetting
}
out := make([]HeaderNavItem, 0, len(raw))
seenBuiltin := make(map[string]struct{}, len(raw))
for _, item := range raw {
switch strings.TrimSpace(item.Type) {
case HeaderNavItemBuiltin:
k := strings.TrimSpace(strings.ToLower(item.Key))
if !headerNavBuiltinAllowed(k) {
return nil, ErrInvalidSiteSetting
}
if _, dup := seenBuiltin[k]; dup {
continue
}
seenBuiltin[k] = struct{}{}
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
case HeaderNavItemCustom:
label := strings.TrimSpace(item.Label)
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
return nil, ErrInvalidSiteSetting
}
u, ok := NormalizeFooterURL(item.URL)
if !ok {
return nil, ErrInvalidSiteSetting
}
out = append(out, HeaderNavItem{
Type: HeaderNavItemCustom,
Label: label,
URL: u,
NewTab: item.NewTab,
})
default:
return nil, ErrInvalidSiteSetting
}
}
return out, nil
}
// parseHeaderNavJSON 读取时宽容:坏 JSON 回落默认;单条非法跳过;内置重复跳过;
// 显式空数组解析为空切片(全部隐藏),由调用方与「缺行」区分
func parseHeaderNavJSON(raw string) []HeaderNavItem {
var in []headerNavItemIn
if err := json.Unmarshal([]byte(raw), &in); err != nil {
return DefaultHeaderNav()
}
out := make([]HeaderNavItem, 0, len(in))
seenBuiltin := make(map[string]struct{}, len(in))
for _, item := range in {
switch strings.TrimSpace(item.Type) {
case HeaderNavItemBuiltin:
k := strings.TrimSpace(strings.ToLower(item.Key))
if !headerNavBuiltinAllowed(k) {
continue
}
if _, dup := seenBuiltin[k]; dup {
continue
}
seenBuiltin[k] = struct{}{}
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
case HeaderNavItemCustom:
label := strings.TrimSpace(item.Label)
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
continue
}
u, ok := NormalizeFooterURL(item.URL)
if !ok {
continue
}
newTab := false
if item.NewTab != nil {
newTab = *item.NewTab
} else if isExternalFooterURL(u) {
newTab = true
}
out = append(out, HeaderNavItem{Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: newTab})
}
if len(out) >= MaxHeaderNavItems {
break
}
}
return out
}
func headerNavEqual(a, b []HeaderNavItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// HeaderNav 读取顶部导航配置;缺行/非法回落默认三项,显式 [] 返回空切片
func (s *SettingService) HeaderNav() []HeaderNavItem {
v, found, err := s.getValue(SettingKeyHeaderNav)
if err != nil || !found || strings.TrimSpace(v) == "" {
return DefaultHeaderNav()
}
return parseHeaderNavJSON(v)
}
// SetHeaderNav 校验并保存顶部导航配置;与默认三项完全一致时删键保持「缺行=默认」语义,
// 空数组原样写入(表示全部隐藏)
func (s *SettingService) SetHeaderNav(items []HeaderNavItem) error {
out, err := NormalizeHeaderNav(items)
if err != nil {
return err
}
if headerNavEqual(out, DefaultHeaderNav()) {
return s.deleteKey(SettingKeyHeaderNav)
}
b, err := json.Marshal(out)
if err != nil {
return err
}
return s.putValue(SettingKeyHeaderNav, string(b))
}

View File

@@ -0,0 +1,164 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newHeaderNavTestService(t *testing.T) *SettingService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SiteSetting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSettingService(db)
}
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
s := newHeaderNavTestService(t)
got := s.HeaderNav()
if len(got) != 3 ||
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) {
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if len(pub.HeaderNav) != 3 {
t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav))
}
}
func TestSetHeaderNavRoundTrip(t *testing.T) {
s := newHeaderNavTestService(t)
want := []HeaderNavItem{
{Type: HeaderNavItemBuiltin, Key: "library"},
{Type: HeaderNavItemCustom, Label: "关于", URL: "/p/about", NewTab: false},
{Type: HeaderNavItemBuiltin, Key: "home"},
{Type: HeaderNavItemCustom, Label: "官网", URL: "https://example.com", NewTab: true},
}
if err := s.SetHeaderNav(want); err != nil {
t.Fatal(err)
}
got := s.HeaderNav()
if len(got) != len(want) {
t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("item %d = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestSetHeaderNavEmptyHidesAll(t *testing.T) {
s := newHeaderNavTestService(t)
if err := s.SetHeaderNav(nil); err != nil {
t.Fatal(err)
}
if got := s.HeaderNav(); len(got) != 0 {
t.Fatalf("explicit empty list should hide all menus, got %+v", got)
}
var rows int64
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
if rows != 1 {
t.Fatalf("empty list should be persisted, rows=%d", rows)
}
}
func TestSetHeaderNavDefaultDeletesKey(t *testing.T) {
s := newHeaderNavTestService(t)
if err := s.SetHeaderNav([]HeaderNavItem{{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}}); err != nil {
t.Fatal(err)
}
if err := s.SetHeaderNav(DefaultHeaderNav()); err != nil {
t.Fatal(err)
}
var rows int64
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
if rows != 0 {
t.Fatalf("default config should delete the row, rows=%d", rows)
}
}
func TestNormalizeHeaderNavRejectsInvalid(t *testing.T) {
longLabel := strings.Repeat("字", MaxHeaderNavLabelRunes+1)
cases := [][]HeaderNavItem{
{{Type: HeaderNavItemBuiltin, Key: "unknown"}},
{{Type: "weird", Key: "home"}},
{{Type: HeaderNavItemCustom, Label: "", URL: "/x"}},
{{Type: HeaderNavItemCustom, Label: longLabel, URL: "/x"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "javascript:alert(1)"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "relative"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "//evil.com/x"}},
}
for i, items := range cases {
if _, err := NormalizeHeaderNav(items); !errors.Is(err, ErrInvalidSiteSetting) {
t.Fatalf("case %d should fail, got %v", i, err)
}
}
tooMany := make([]HeaderNavItem, MaxHeaderNavItems+1)
for i := range tooMany {
tooMany[i] = HeaderNavItem{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}
}
if _, err := NormalizeHeaderNav(tooMany); !errors.Is(err, ErrInvalidSiteSetting) {
t.Fatalf("over-cap list should fail, got %v", err)
}
}
func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) {
got, err := NormalizeHeaderNav([]HeaderNavItem{
{Type: HeaderNavItemBuiltin, Key: "home"},
{Type: HeaderNavItemBuiltin, Key: " HOME "},
{Type: HeaderNavItemBuiltin, Key: "library"},
})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Key != "home" || got[1].Key != "library" {
t.Fatalf("builtin duplicates should be deduped, got %+v", got)
}
}
func TestParseHeaderNavJSONTolerant(t *testing.T) {
// 坏 JSON 回落默认
if got := parseHeaderNavJSON("{bad"); len(got) != 3 {
t.Fatalf("bad json should fall back to defaults, got %+v", got)
}
// 空数组 = 全部隐藏
if got := parseHeaderNavJSON("[]"); len(got) != 0 {
t.Fatalf("empty array should be respected, got %+v", got)
}
// 单条非法跳过;内置重复跳过;外链 new_tab 缺省为 true
got := parseHeaderNavJSON(`[
{"type":"builtin","key":"home"},
{"type":"builtin","key":"nope"},
{"type":"builtin","key":"home"},
{"type":"custom","label":"","url":"/x"},
{"type":"custom","label":"外链","url":"https://example.com"},
{"type":"custom","label":"站内","url":"/p/about","new_tab":true}
]`)
if len(got) != 3 {
t.Fatalf("want 3 valid items, got %+v", got)
}
if got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) {
t.Fatalf("item0 = %+v", got[0])
}
if got[1].Type != HeaderNavItemCustom || got[1].Label != "外链" || !got[1].NewTab {
t.Fatalf("external custom item should default new_tab=true, got %+v", got[1])
}
if got[2].Label != "站内" || !got[2].NewTab {
t.Fatalf("explicit new_tab=true should be kept, got %+v", got[2])
}
}

View File

@@ -14,7 +14,7 @@ const (
PeriodMonth = "month"
PeriodAll = "all"
MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分
MetricAll = "all" // 综合:总榜=累计积分,周/月=周期内获得积分
MetricPost = "post" // 发帖最多
MetricComment = "comment" // 回复最多
MetricLike = "like" // 获赞最多
@@ -112,6 +112,41 @@ func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRo
return out, nil
}
// earnedPoints 周期内累计获得积分:point_ledgers 正入账合计(排除 bounty_refund,
// 与 users.total_points 启动回填口径一致),仅统计未封禁用户。
// 签到、发帖/回复奖励、被推荐、悬赏采纳等均按实际入账分值计入贡献值
func (s *LeaderboardService) earnedPoints(from, to *time.Time) (map[uint]int64, error) {
clause := ""
var timeArgs []interface{}
if from != nil {
clause += " AND l.created_at >= ?"
timeArgs = append(timeArgs, *from)
}
if to != nil {
clause += " AND l.created_at < ?"
timeArgs = append(timeArgs, *to)
}
sql := "SELECT l.user_id AS user_id, SUM(l.delta) AS pts FROM point_ledgers l" +
" JOIN users AS u ON u.id = l.user_id" +
" WHERE l.delta > 0 AND l.reason <> ? AND u.banned = ? AND u.deleted_at IS NULL" + clause +
" GROUP BY l.user_id"
args := []interface{}{model.PointReasonBountyRefund, false}
args = append(args, timeArgs...)
var rows []struct {
UserID uint
Pts int64
}
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[uint]int64, len(rows))
for _, r := range rows {
out[r.UserID] = r.Pts
}
return out, nil
}
// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数,
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) {
@@ -167,7 +202,7 @@ func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, e
return stats, nil
}
// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween)
// scoreOf 细分指标 → 排名得分;「全部」维度走积分口径(见 boardBetween / earnedPoints)
func scoreOf(metric string, st statsRow) int64 {
switch metric {
case MetricPost:
@@ -178,9 +213,8 @@ func scoreOf(metric string, st statsRow) int64 {
return st.LikeCount
case MetricAnswer:
return st.AnswerCount
default:
return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount
}
return 0
}
// entryFromUser 由用户行构造榜单条目(不含统计)
@@ -214,7 +248,8 @@ func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *tim
}
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。
// 「全部」维度走积分口径(总榜=total_points,周/月=周期内获得积分,见 earnedPoints),
// 细分维度走统计聚合口径。
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
if metric == MetricAll && from == nil {
var users []model.User
@@ -245,9 +280,29 @@ func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric st
if err != nil {
return nil, err
}
entries := make([]LeaderboardEntry, 0, len(stats))
// 周/月·全部:贡献值=周期内累计获得积分,候选集并入仅签到/获奖无内容的用户
usePts := metric == MetricAll && from != nil
pts := map[uint]int64{}
if usePts {
if pts, err = s.earnedPoints(from, to); err != nil {
return nil, err
}
}
cand := make(map[uint]statsRow, len(stats)+len(pts))
for id, r := range stats {
cand[id] = r
}
for id := range pts {
if _, ok := cand[id]; !ok {
cand[id] = statsRow{}
}
}
entries := make([]LeaderboardEntry, 0, len(cand))
for id, r := range cand {
score := scoreOf(metric, r)
if usePts {
score = pts[id]
}
if score <= 0 {
continue
}

View File

@@ -0,0 +1,67 @@
package service
import (
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 贡献值口径:earnedPoints 只累计窗口内正入账积分(排除 bounty_refund),
// 支出、悬赏退回、封禁用户、窗口外入账均不计
func TestEarnedPointsWindowAndExclusions(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.PointLedger{}); err != nil {
t.Fatalf("migrate: %v", err)
}
now := time.Now()
weekAgo := now.AddDate(0, 0, -7)
users := []*model.User{
{Username: "earner", Password: "x"},
{Username: "refund_only", Password: "x"},
{Username: "banned", Password: "x", Banned: true},
{Username: "old_earner", Password: "x"},
}
for _, u := range users {
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user %s: %v", u.Username, err)
}
}
a, d := users[0].ID, users[3].ID
ledgers := []model.PointLedger{
{UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now},
{UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now},
{UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)},
{UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计
{UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计
{UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计
{UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外
}
if err := db.Create(&ledgers).Error; err != nil {
t.Fatalf("seed ledgers: %v", err)
}
s := NewLeaderboardService(db)
from := weekAgo
pts, err := s.earnedPoints(&from, nil)
if err != nil {
t.Fatalf("earnedPoints: %v", err)
}
if len(pts) != 1 || pts[a] != 10 {
t.Fatalf("周窗内应仅 earner 计 10 分,got %v", pts)
}
all, err := s.earnedPoints(nil, nil)
if err != nil {
t.Fatalf("earnedPoints(all): %v", err)
}
if len(all) != 2 || all[a] != 10 || all[d] != 5 {
t.Fatalf("全窗口:earner=10、old_earner=5,退回/封禁不计,got %v", all)
}
}

504
backend/service/library.go Normal file
View File

@@ -0,0 +1,504 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 书库文件扩展名白名单(小写、不含点);附件仅提供下载分发,
// 书籍正文走章节阅读,不做在线预览
var libraryAllowedExts = map[string]struct{}{
"html": {}, "htm": {}, "pdf": {}, "epub": {}, "mobi": {}, "azw3": {},
"zip": {}, "7z": {}, "rar": {}, "txt": {}, "md": {},
"doc": {}, "docx": {}, "ppt": {}, "pptx": {},
}
const (
MaxLibraryFilesPerDoc = 20
libraryDescriptionMax = 20000
)
var (
ErrLibraryNotFound = errors.New("书库条目不存在")
ErrLibraryExtDenied = errors.New("不支持该文档格式")
ErrLibraryTooMany = errors.New("文件数量超过上限")
ErrLibraryNotDeleted = errors.New("仅已删除的条目可彻底删除")
)
// LibraryExtAllowed 上传扩展名是否在书库白名单内
func LibraryExtAllowed(ext string) bool {
_, ok := libraryAllowedExts[strings.ToLower(strings.TrimPrefix(ext, "."))]
return ok
}
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
var libraryDocWriteFields = []string{
"Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto",
}
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
type LibraryDocDetail struct {
model.LibraryDoc
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
Files []model.LibraryFile `json:"files"`
Sections []model.LibrarySection `json:"sections"`
}
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
type LibraryDocListItem struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LibraryInput 创建/更新书库条目入参
type LibraryInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
Published *bool `json:"published"`
SortOrder *int `json:"sort_order"`
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
}
func (in *LibraryInput) normalize() error {
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
if in.Slug == "" {
return errors.New("slug 不能为空")
}
if len(in.Slug) > 64 {
return errors.New("slug 不能超过 64 字符")
}
if !sitePageSlugRe.MatchString(in.Slug) {
return errors.New("slug 仅允许小写字母、数字与连字符")
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return errors.New("标题不能为空")
}
if utf8.RuneCountInString(in.Title) > 200 {
return errors.New("标题不能超过 200 字")
}
in.Description = strings.TrimSpace(in.Description)
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
return errors.New("介绍不能超过 20000 字")
}
in.CoverURL = strings.TrimSpace(in.CoverURL)
if utf8.RuneCountInString(in.CoverURL) > 512 {
return errors.New("封面地址过长")
}
return nil
}
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
d.Slug = in.Slug
d.Title = in.Title
d.Description = in.Description
d.CoverURL = in.CoverURL
if in.Published != nil {
d.Published = *in.Published
}
if in.SortOrder != nil {
d.SortOrder = *in.SortOrder
}
if in.EntriesAuto != nil {
d.EntriesAuto = *in.EntriesAuto
}
}
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
type LibraryService struct {
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
setting *SettingService
}
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
return &LibraryService{db: db, dir: filepath.Join(dataDir, "library")}
}
// WithSetting 注入站点设置(文件大小上限复用附件设置)
func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
s.setting = setting
return s
}
func (s *LibraryService) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
func (s *LibraryService) FilePath(f *model.LibraryFile) string {
return filepath.Join(s.dir, f.StoredName)
}
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
// active=在用条目占用;deleted=已删除条目占用
func (s *LibraryService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
q := s.db.Unscoped().Model(&model.LibraryDoc{}).Where("slug = ?", slug).Select("deleted_at")
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var rows []struct {
DeletedAt gorm.DeletedAt
}
if err := q.Find(&rows).Error; err != nil {
return false, false, err
}
for _, r := range rows {
if r.DeletedAt.Valid {
deleted = true
} else {
active = true
}
}
return active, deleted, nil
}
// ---------- 条目:管理端 ----------
// ListAll 管理后台全部条目(含未发布与已软删,带文件;已删条目仅供查看与彻底删除)
func (s *LibraryService) ListAll() ([]LibraryDocDetail, error) {
var docs []model.LibraryDoc
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(200).Find(&docs).Error; err != nil {
return nil, err
}
return s.attachFiles(docs), nil
}
// Get 管理后台按 ID 取条目
func (s *LibraryService) Get(id uint) (*LibraryDocDetail, error) {
var d model.LibraryDoc
if err := s.db.First(&d, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}
func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail {
out := make([]LibraryDocDetail, 0, len(docs))
if len(docs) == 0 {
return out
}
ids := make([]uint, 0, len(docs))
for _, d := range docs {
ids = append(ids, d.ID)
}
var files []model.LibraryFile
if err := s.db.Where("doc_id IN ?", ids).
Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
files = nil
}
byDoc := make(map[uint][]model.LibraryFile, len(docs))
for _, f := range files {
byDoc[f.DocID] = append(byDoc[f.DocID], f)
}
var sections []model.LibrarySection
if err := s.db.Where("doc_id IN ?", ids).
Order("sort_order ASC, id ASC").Find(&sections).Error; err != nil {
sections = nil
}
secByDoc := make(map[uint][]model.LibrarySection, len(docs))
for _, sec := range sections {
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
}
for _, d := range docs {
fs := byDoc[d.ID]
if fs == nil {
fs = []model.LibraryFile{}
}
ss := secByDoc[d.ID]
if ss == nil {
ss = []model.LibrarySection{}
}
out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss})
}
return out
}
func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
if err := in.normalize(); err != nil {
return nil, err
}
active, deleted, err := s.slugTaken(in.Slug, 0)
if err != nil {
return nil, err
}
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
d := &model.LibraryDoc{Published: false, SortOrder: 0}
in.applyTo(d)
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return nil, err
}
return d, nil
}
func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, error) {
var d model.LibraryDoc
if err := s.db.First(&d, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
if err := in.normalize(); err != nil {
return nil, err
}
active, deleted, err := s.slugTaken(in.Slug, id)
if err != nil {
return nil, err
}
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
in.applyTo(&d)
// map 更新确保 false/空串写入(同 SitePage.Update)
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"description": d.Description,
"cover_url": d.CoverURL,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
}).Error; err != nil {
return nil, err
}
return &d, nil
}
func (s *LibraryService) Delete(id uint) error {
result := s.db.Delete(&model.LibraryDoc{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrLibraryNotFound
}
return nil
}
// Purge 彻底删除已软删的条目(章节/文件行与磁盘文件一并清除,slug 释放可复用)
func (s *LibraryService) Purge(id uint) error {
var d model.LibraryDoc
if err := s.db.Unscoped().First(&d, id).Error; err != nil {
return ErrLibraryNotFound
}
if !d.DeletedAt.Valid {
return ErrLibraryNotDeleted
}
// 先取出文件清单(含此前单独软删的文件行),事务成功后统一删磁盘
var files []model.LibraryFile
if err := s.db.Unscoped().Where("doc_id = ?", id).Find(&files).Error; err != nil {
return err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibraryFile{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&model.LibraryDoc{}, id).Error
})
if err != nil {
return err
}
// 事务外尽力删除磁盘文件(同 DeleteFile 惯例,失败不影响返回)
for i := range files {
_ = os.Remove(s.FilePath(&files[i]))
}
return nil
}
// ---------- 条目:公开端 ----------
// ListPublished 已发布条目目录(含文件概要,不含 description 全文)
func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
details, err := s.publishedDetails()
if err != nil {
return nil, err
}
out := make([]LibraryDocListItem, 0, len(details))
for _, d := range details {
out = append(out, LibraryDocListItem{
ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL,
SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
})
}
return out, nil
}
// GetPublishedBySlug 公开详情(仅已发布)
func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
var d model.LibraryDoc
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
return nil, ErrLibraryNotFound
}
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}
func (s *LibraryService) publishedDetails() ([]LibraryDocDetail, error) {
var docs []model.LibraryDoc
if err := s.db.Where("published = ?", true).
Order("sort_order ASC, id ASC").Limit(100).Find(&docs).Error; err != nil {
return nil, err
}
return s.attachFiles(docs), nil
}
// ---------- 文件 ----------
func (s *LibraryService) maxBytes() int64 {
if s.setting == nil {
return FileMaxBytes
}
n, err := s.setting.AttachmentMaxBytes()
if err != nil || n < 1 {
return FileMaxBytes
}
return n
}
// AddFile 上传文件到条目(落盘 data/library + 建行)
func (s *LibraryService) AddFile(docID, userID uint, originalName string, src io.Reader) (*model.LibraryFile, error) {
if src == nil {
return nil, errors.New("文件为空")
}
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
name := sanitizeFilename(originalName)
if name == "" {
name = "file"
}
ext := strings.TrimPrefix(ExtOfFilename(name), ".")
if !LibraryExtAllowed(ext) {
return nil, ErrLibraryExtDenied
}
var count int64
if err := s.db.Model(&model.LibraryFile{}).Where("doc_id = ?", docID).Count(&count).Error; err != nil {
return nil, err
}
if count >= MaxLibraryFilesPerDoc {
return nil, ErrLibraryTooMany
}
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
stored := hex.EncodeToString(raw) + "." + ext
full := filepath.Join(s.dir, stored)
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, err
}
maxB := s.maxBytes()
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(full)
return nil, copyErr
}
if written == 0 {
_ = os.Remove(full)
return nil, errors.New("文件为空")
}
if written > maxB {
_ = os.Remove(full)
mb := int(maxB >> 20)
if mb < 1 {
mb = 1
}
return nil, errors.New("文件过大:不能超过 " + strconv.Itoa(mb) + "MB")
}
file := &model.LibraryFile{
DocID: docID,
UserID: userID,
Name: name,
StoredName: stored,
Ext: ext,
MIME: MimeForAttachmentExt("." + ext),
Size: int(written),
}
if err := s.db.Create(file).Error; err != nil {
_ = os.Remove(full)
return nil, err
}
return file, nil
}
// DeleteFile 删除文件(软删行 + 磁盘尽力删除)
func (s *LibraryService) DeleteFile(id uint) error {
var f model.LibraryFile
if err := s.db.First(&f, id).Error; err != nil {
return ErrLibraryNotFound
}
if err := s.db.Delete(&f).Error; err != nil {
return err
}
_ = os.Remove(s.FilePath(&f))
return nil
}
// GetPublicFile 公开取文件(要求所属条目存在且已发布;软删条目的文件不可达)
func (s *LibraryService) GetPublicFile(id uint) (*model.LibraryFile, error) {
var f model.LibraryFile
if err := s.db.First(&f, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
if f.DocID == 0 {
return nil, ErrLibraryNotFound
}
var n int64
if err := s.db.Model(&model.LibraryDoc{}).
Where("id = ? AND published = ?", f.DocID, true).Count(&n).Error; err != nil {
return nil, err
}
if n == 0 {
return nil, ErrLibraryNotFound
}
return &f, nil
}
// IncrDownload 下载计数原子递增
func (s *LibraryService) IncrDownload(id uint) {
s.db.Model(&model.LibraryFile{}).Where("id = ?", id).
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
}

View File

@@ -0,0 +1,422 @@
package service
import (
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 章节约束:两级树(章 → 节),正文为 markdown
const (
MaxSectionsPerDoc = 200 // 单条目章+节总数上限
MaxSectionContent = 100_000 // 单章节正文字符上限
MaxImportBytes = 5 << 20 // 单个 md 文件字节上限(5MB)
MaxImportFiles = 50 // 单次批量导入文件数上限
MaxImportTotalBytes = 20 << 20 // 批量导入总字节上限(20MB)
)
var ErrSectionNotFound = errors.New("章节不存在")
// SectionInput 创建/更新章节入参(更新仅改标题与正文,父子关系由创建时决定、顺序用 move 调整)
type SectionInput struct {
ParentID *uint `json:"parent_id"`
Title string `json:"title"`
Content string `json:"content"`
}
func (in *SectionInput) normalize() error {
if in.ParentID != nil && *in.ParentID == 0 {
in.ParentID = nil
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return errors.New("章节标题不能为空")
}
if utf8.RuneCountInString(in.Title) > 200 {
return errors.New("章节标题不能超过 200 字")
}
if utf8.RuneCountInString(in.Content) > MaxSectionContent {
return errors.New("章节正文不能超过 100000 字")
}
return nil
}
func (s *LibraryService) section(id uint) (*model.LibrarySection, error) {
var sec model.LibrarySection
if err := s.db.First(&sec, id).Error; err != nil {
return nil, ErrSectionNotFound
}
return &sec, nil
}
func (s *LibraryService) sectionCount(docID uint) (int64, error) {
var n int64
if err := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID).Count(&n).Error; err != nil {
return 0, err
}
return n, nil
}
// sectionMaxSort 同级兄弟的当前最大 sort_order(无兄弟时 -1)
func (s *LibraryService) sectionMaxSort(docID uint, parentID *uint) (int, error) {
q := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID)
if parentID == nil {
q = q.Where("parent_id IS NULL")
} else {
q = q.Where("parent_id = ?", *parentID)
}
var maxSort int
if err := q.Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort).Error; err != nil {
return 0, err
}
return maxSort, nil
}
// ListSections 条目下全部章节(平铺,章在前节按组内序)
func (s *LibraryService) ListSections(docID uint) ([]model.LibrarySection, error) {
var secs []model.LibrarySection
if err := s.db.Where("doc_id = ?", docID).
Order("sort_order ASC, id ASC").Find(&secs).Error; err != nil {
return nil, err
}
return secs, nil
}
// validateParent 两级约束:父必须是同条目下的章(自身不能是节)
func (s *LibraryService) validateParent(docID uint, parentID *uint) error {
if parentID == nil {
return nil
}
parent, err := s.section(*parentID)
if err != nil {
return errors.New("父章节不存在")
}
if parent.DocID != docID {
return errors.New("父章节不属于该条目")
}
if parent.ParentID != nil {
return errors.New("目录最多两级:章下不能再建小节")
}
return nil
}
func (s *LibraryService) CreateSection(docID uint, in *SectionInput) (*model.LibrarySection, error) {
if err := in.normalize(); err != nil {
return nil, err
}
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
if err := s.validateParent(docID, in.ParentID); err != nil {
return nil, err
}
count, err := s.sectionCount(docID)
if err != nil {
return nil, err
}
if count >= MaxSectionsPerDoc {
return nil, errors.New("章节数量超过上限")
}
maxSort, err := s.sectionMaxSort(docID, in.ParentID)
if err != nil {
return nil, err
}
sec := &model.LibrarySection{
DocID: docID,
ParentID: in.ParentID,
Title: in.Title,
Content: in.Content,
SortOrder: maxSort + 1,
}
if err := s.db.Create(sec).Error; err != nil {
return nil, err
}
return sec, nil
}
// UpdateSection 更新标题与正文(父章节与顺序不变)
func (s *LibraryService) UpdateSection(id uint, in *SectionInput) (*model.LibrarySection, error) {
in.ParentID = nil // 忽略传入的 parent:父子关系不在此处变更
if err := in.normalize(); err != nil {
return nil, err
}
sec, err := s.section(id)
if err != nil {
return nil, err
}
if err := s.db.Model(sec).Updates(map[string]interface{}{
"title": in.Title,
"content": in.Content,
}).Error; err != nil {
return nil, err
}
return s.section(id)
}
// DeleteSection 删除章节;若为章则级联软删其下小节
func (s *LibraryService) DeleteSection(id uint) error {
sec, err := s.section(id)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
if sec.ParentID == nil {
if err := tx.Where("parent_id = ?", sec.ID).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
}
return tx.Delete(sec).Error
})
}
// MoveSection 与同父相邻兄弟交换 sort_order(边界为 no-op)
func (s *LibraryService) MoveSection(id uint, direction string) error {
if direction != "up" && direction != "down" {
return errors.New("无效的移动方向")
}
sec, err := s.section(id)
if err != nil {
return err
}
q := s.db.Where("doc_id = ?", sec.DocID)
if sec.ParentID == nil {
q = q.Where("parent_id IS NULL")
} else {
q = q.Where("parent_id = ?", *sec.ParentID)
}
var sibs []model.LibrarySection
if err := q.Order("sort_order ASC, id ASC").Find(&sibs).Error; err != nil {
return err
}
idx := -1
for i := range sibs {
if sibs[i].ID == sec.ID {
idx = i
break
}
}
if idx < 0 {
return ErrSectionNotFound
}
var other *model.LibrarySection
if direction == "up" && idx > 0 {
other = &sibs[idx-1]
}
if direction == "down" && idx < len(sibs)-1 {
other = &sibs[idx+1]
}
if other == nil {
return nil // 已在边界,无需移动
}
a, b := sec.SortOrder, other.SortOrder
if a == b {
b = a + 1 // 相同排序值时保证交换后顺序确实变化
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.LibrarySection{}).Where("id = ?", sec.ID).
UpdateColumn("sort_order", b).Error; err != nil {
return err
}
return tx.Model(&model.LibrarySection{}).Where("id = ?", other.ID).
UpdateColumn("sort_order", a).Error
})
}
// ---------- md 拆章导入 ----------
type mdChapterChunk struct {
Title string
Body string
}
// splitMarkdownChapters 按指定级别标题("# " 或 "## ")切块;
// 代码围栏内的 # 行不视为标题;首个标题之前的内容并入第一章正文。
func splitMarkdownChapters(content, level string) []mdChapterChunk {
prefix := "# "
if level == "h2" {
prefix = "## "
}
var chunks []mdChapterChunk
var pre strings.Builder
bodies := []strings.Builder{}
cur := -1
inCode := false
text := strings.ReplaceAll(content, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
for _, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "```") {
inCode = !inCode
}
if !inCode && strings.HasPrefix(t, prefix) {
title := strings.TrimSpace(strings.TrimPrefix(t, prefix))
title = strings.TrimSpace(strings.TrimRight(title, "#"))
chunks = append(chunks, mdChapterChunk{Title: title})
bodies = append(bodies, strings.Builder{})
cur = len(chunks) - 1
continue
}
if cur < 0 {
pre.WriteString(line)
pre.WriteString("\n")
} else {
bodies[cur].WriteString(line)
bodies[cur].WriteString("\n")
}
}
if len(chunks) == 0 {
return nil
}
out := make([]mdChapterChunk, len(chunks))
for i := range chunks {
body := bodies[i].String()
if i == 0 && pre.Len() > 0 {
body = pre.String() + body
}
out[i] = mdChapterChunk{Title: chunks[i].Title, Body: strings.TrimSpace(body)}
}
return out
}
// truncateRunes 按 rune 截断
func truncateRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n])
}
// ImportFileInput 批量导入的单个文件
type ImportFileInput struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
// chunksFromFile 把单个文件拆成章块:校验单文件大小,标题回退取文件名;
// splitLevel 为 none 或未命中标题时整篇一章。
func chunksFromFile(filename, content, splitLevel string) (string, []mdChapterChunk, error) {
if len(content) > MaxImportBytes {
return "", nil, errors.New("文件 " + sanitizeFilename(filename) + " 过大:单个文件不能超过 5MB")
}
base := sanitizeFilename(filename)
if i := strings.LastIndex(base, "."); i > 0 {
base = base[:i]
}
base = strings.TrimSpace(base)
if base == "" {
base = "未命名章节"
}
if utf8.RuneCountInString(base) > 200 {
base = truncateRunes(base, 200)
}
var chunks []mdChapterChunk
if splitLevel == "none" {
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
} else {
chunks = splitMarkdownChapters(content, splitLevel)
}
if len(chunks) == 0 {
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
}
return base, chunks, nil
}
// ImportMarkdownBatch 批量导入:多个 md 文件按传入顺序依次拆成顶级章(不生成节)。
// splitLevel 对每个文件生效;无命中标题的文件回退为单章(标题取文件名)。
// 任一文件失败整体回滚。单文件导入即长度为 1 的批量。
func (s *LibraryService) ImportMarkdownBatch(docID uint, files []ImportFileInput, splitLevel string) ([]model.LibrarySection, error) {
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
if splitLevel != "h1" && splitLevel != "h2" && splitLevel != "none" {
return nil, errors.New("无效的拆分方式")
}
if len(files) == 0 {
return nil, errors.New("没有可导入的文件")
}
if len(files) > MaxImportFiles {
return nil, errors.New("单次最多导入 " + strconv.Itoa(MaxImportFiles) + " 个文件")
}
total := 0
for _, f := range files {
total += len(f.Content)
}
if total > MaxImportTotalBytes {
return nil, errors.New("导入内容总量过大:不能超过 20MB")
}
// 逐文件拆块(保留文件边界,便于错误提示定位到文件)
type mdFilePlan struct {
Base string
Chunks []mdChapterChunk
}
plan := make([]mdFilePlan, 0, len(files))
totalChunks := 0
for _, f := range files {
base, chunks, err := chunksFromFile(f.Filename, f.Content, splitLevel)
if err != nil {
return nil, err
}
plan = append(plan, mdFilePlan{Base: base, Chunks: chunks})
totalChunks += len(chunks)
}
count, err := s.sectionCount(docID)
if err != nil {
return nil, err
}
if count+int64(totalChunks) > MaxSectionsPerDoc {
return nil, errors.New("导入后的章节数超过上限(现有 + 导入 ≤ 200)")
}
maxSort, err := s.sectionMaxSort(docID, nil)
if err != nil {
return nil, err
}
created := make([]model.LibrarySection, 0, totalChunks)
err = s.db.Transaction(func(tx *gorm.DB) error {
offset := 0
for _, fp := range plan {
for i, ch := range fp.Chunks {
title := strings.TrimSpace(ch.Title)
if title == "" {
title = fp.Base + "(续)"
}
if utf8.RuneCountInString(title) > 200 {
title = truncateRunes(title, 200)
}
if utf8.RuneCountInString(ch.Body) > MaxSectionContent {
return errors.New("「" + fp.Base + "」第 " + strconv.Itoa(i+1) + " 章正文超过 100000 字,请先拆分后再导入")
}
sec := &model.LibrarySection{
DocID: docID,
Title: title,
Content: ch.Body,
SortOrder: maxSort + 1 + offset + i,
}
if err := tx.Create(sec).Error; err != nil {
return err
}
created = append(created, *sec)
}
offset += len(fp.Chunks)
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// ImportMarkdown 单文件导入(批量为长度 1 的特例,保留旧端点兼容)
func (s *LibraryService) ImportMarkdown(docID uint, filename, content, splitLevel string) ([]model.LibrarySection, error) {
return s.ImportMarkdownBatch(docID, []ImportFileInput{{Filename: filename, Content: content}}, splitLevel)
}

View File

@@ -0,0 +1,506 @@
package service
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newLibraryTestService(t *testing.T) (*LibraryService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewLibraryService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func libPub(b bool) *bool { return &b }
func libInt(v int) *int { return &v }
func TestLibraryNormalize(t *testing.T) {
cases := []struct {
name string
mutate func(*LibraryInput)
wantErr string
}{
{"slug 为空", func(i *LibraryInput) { i.Slug = "" }, "slug 不能为空"},
{"slug 非法", func(i *LibraryInput) { i.Slug = "Bad_Slug" }, "slug 仅允许"},
{"slug 超长", func(i *LibraryInput) { i.Slug = strings.Repeat("a", 65) }, "slug 不能超过"},
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
{"合法", func(i *LibraryInput) {}, ""},
}
for _, tc := range cases {
in := &LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"}
tc.mutate(in)
err := in.normalize()
if tc.wantErr == "" {
if err != nil {
t.Fatalf("%s: 不应报错,got %v", tc.name, err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("%s: 期望包含 %q,got %v", tc.name, tc.wantErr, err)
}
}
}
func TestLibrarySlugUnique(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {
t.Fatalf("create a: %v", err)
}
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "B"}); err == nil {
t.Fatal("重复 slug 应报错")
}
}
func TestLibrarySlugReuseAfterSoftDelete(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, err := s.Create(&LibraryInput{Slug: "test", Title: "测试"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := s.Delete(doc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
_, err = s.Create(&LibraryInput{Slug: "test", Title: "重建"})
if err == nil || !strings.Contains(err.Error(), "已删除") {
t.Fatalf("软删后重建应提示已删除条目占用,got %v", err)
}
// 彻底删除后可重建
if err := s.Purge(doc.ID); err != nil {
t.Fatalf("purge: %v", err)
}
if _, err := s.Create(&LibraryInput{Slug: "test", Title: "重建"}); err != nil {
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
}
}
func TestLibraryPurgeRequiresDeleted(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "alive", Title: "在用"})
if err := s.Purge(doc.ID); err == nil || !errors.Is(err, ErrLibraryNotDeleted) {
t.Fatalf("未删条目 Purge 应拒绝,got %v", err)
}
}
func TestLibraryPurgeRemovesFilesAndSections(t *testing.T) {
s, dir := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "purge-me", Title: "待清"})
f, err := s.AddFile(doc.ID, 1, "x.txt", strings.NewReader("x"))
if err != nil {
t.Fatalf("add file: %v", err)
}
full := filepath.Join(dir, "library", f.StoredName)
if _, err := os.Stat(full); err != nil {
t.Fatalf("文件未落盘: %v", err)
}
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}); err != nil {
t.Fatalf("create section: %v", err)
}
if err := s.Delete(doc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if err := s.Purge(doc.ID); err != nil {
t.Fatalf("purge: %v", err)
}
var n int64
s.db.Unscoped().Model(&model.LibraryDoc{}).Count(&n)
if n != 0 {
t.Fatalf("条目行应已清除,got %d", n)
}
s.db.Unscoped().Model(&model.LibraryFile{}).Count(&n)
if n != 0 {
t.Fatalf("文件行应已清除,got %d", n)
}
s.db.Unscoped().Model(&model.LibrarySection{}).Count(&n)
if n != 0 {
t.Fatalf("章节行应已清除,got %d", n)
}
if _, err := os.Stat(full); !os.IsNotExist(err) {
t.Fatalf("磁盘文件应已删除,err=%v", err)
}
}
func TestLibraryExtWhitelists(t *testing.T) {
if !LibraryExtAllowed("epub") || !LibraryExtAllowed(".md") {
t.Fatal("白名单内扩展名应允许")
}
if LibraryExtAllowed("exe") || LibraryExtAllowed("php") || LibraryExtAllowed("") {
t.Fatal("白名单外扩展名应拒绝")
}
}
func TestLibraryAddFile(t *testing.T) {
s, dir := newLibraryTestService(t)
doc, err := s.Create(&LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"})
if err != nil {
t.Fatalf("create: %v", err)
}
// 白名单内:落盘 + 建行
f, err := s.AddFile(doc.ID, 1, "如何生活.pdf", strings.NewReader("PDF-DATA"))
if err != nil {
t.Fatalf("add pdf: %v", err)
}
if f.Ext != "pdf" || f.Size != 8 {
t.Fatalf("文件元数据错误: %+v", f)
}
if f.MIME != "application/pdf" {
t.Fatalf("pdf MIME 应为 application/pdf,got %s", f.MIME)
}
if _, err := os.Stat(filepath.Join(dir, "library", f.StoredName)); err != nil {
t.Fatalf("文件未落盘: %v", err)
}
// html 存储层 MIME 强制 octet-stream(下载安全)
h, err := s.AddFile(doc.ID, 1, "page.html", strings.NewReader("<h1>hi</h1>"))
if err != nil {
t.Fatalf("add html: %v", err)
}
if h.MIME != "application/octet-stream" {
t.Fatalf("html 下载 MIME 应强制 octet-stream,got %s", h.MIME)
}
// 白名单外拒绝
if _, err := s.AddFile(doc.ID, 1, "evil.exe", strings.NewReader("MZ")); err == nil {
t.Fatal("exe 应被拒绝")
}
if _, err := s.AddFile(doc.ID, 1, "noext", strings.NewReader("x")); err == nil {
t.Fatal("无扩展名应被拒绝")
}
// 超过每条目上限
for i := 0; i < MaxLibraryFilesPerDoc-2; i++ {
if _, err := s.AddFile(doc.ID, 1, "f.txt", strings.NewReader("x")); err != nil {
t.Fatalf("add #%d: %v", i, err)
}
}
if _, err := s.AddFile(doc.ID, 1, "over.txt", strings.NewReader("x")); err == nil {
t.Fatal("超出文件数上限应报错")
}
// 未发布条目的文件公开不可见
if _, err := s.GetPublicFile(f.ID); err == nil {
t.Fatal("未发布条目的文件不应公开可见")
}
}
func TestLibraryPublishedFilterAndOrder(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.Create(&LibraryInput{Slug: "draft-doc", Title: "草稿"}); err != nil {
t.Fatalf("create draft: %v", err)
}
published, _ := s.Create(&LibraryInput{
Slug: "live-doc", Title: "已发布", Published: libPub(true), SortOrder: libInt(1),
})
if _, err := s.AddFile(published.ID, 1, "b.txt", strings.NewReader("b")); err != nil {
t.Fatalf("add b: %v", err)
}
if _, err := s.AddFile(published.ID, 1, "a.txt", strings.NewReader("a")); err != nil {
t.Fatalf("add a: %v", err)
}
// 未发布不出现在公开目录
list, err := s.ListPublished()
if err != nil {
t.Fatalf("ListPublished: %v", err)
}
if len(list) != 1 || list[0].Slug != "live-doc" {
t.Fatalf("公开目录应只含 live-doc,got %+v", list)
}
if list[0].FileCount != 2 {
t.Fatalf("列表应带文件计数 2,got %d", list[0].FileCount)
}
if _, err := s.GetPublishedBySlug("draft-doc"); err == nil {
t.Fatal("未发布条目按 slug 不应可见")
}
// 软删后公开彻底不可见
if err := s.Delete(published.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := s.GetPublishedBySlug("live-doc"); err == nil {
t.Fatal("软删条目不应可见")
}
// 管理端仍可见(含未发布;软删条目带 Deleted 标记一并列出)
all, err := s.ListAll()
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(all) != 2 {
t.Fatalf("管理端应含 draft-doc 与软删的 live-doc,got %+v", all)
}
bySlug := map[string]LibraryDocDetail{}
for _, d := range all {
bySlug[d.Slug] = d
}
if d := bySlug["draft-doc"]; d.Deleted {
t.Fatalf("draft-doc 不应标记已删除: %+v", d)
}
if d := bySlug["live-doc"]; !d.Deleted {
t.Fatalf("live-doc 应标记已删除: %+v", d)
}
}
func TestLibraryFileOrdering(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "order", Title: "排序", Published: libPub(true)})
// b 先传(id 小),a 设 sort_order 靠前
b, _ := s.AddFile(doc.ID, 1, "b.txt", strings.NewReader("b"))
a, _ := s.AddFile(doc.ID, 1, "a.txt", strings.NewReader("a"))
s.db.Model(&model.LibraryFile{}).Where("id = ?", a.ID).Update("sort_order", -1)
_ = b
d, err := s.GetPublishedBySlug("order")
if err != nil {
t.Fatalf("detail: %v", err)
}
if len(d.Files) != 2 || d.Files[0].Name != "a.txt" {
t.Fatalf("文件应按 sort_order,id 排序: %+v", d.Files)
}
}
func TestLibraryIncrDownload(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "cnt", Title: "计数", Published: libPub(true)})
f, err := s.AddFile(doc.ID, 1, "x.pdf", strings.NewReader("x"))
if err != nil {
t.Fatalf("add: %v", err)
}
s.IncrDownload(f.ID)
s.IncrDownload(f.ID)
got, err := s.GetPublicFile(f.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.DownloadCount != 2 {
t.Fatalf("下载计数应为 2,got %d", got.DownloadCount)
}
}
func TestLibraryDeleteFile(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "del", Title: "删除"})
f, err := s.AddFile(doc.ID, 1, "gone.txt", strings.NewReader("gone"))
if err != nil {
t.Fatalf("add: %v", err)
}
if err := s.DeleteFile(f.ID); err != nil {
t.Fatalf("delete file: %v", err)
}
if err := s.DeleteFile(f.ID); err == nil {
t.Fatal("重复删除应报不存在")
}
}
// ---------- 章节(两级树) ----------
func TestLibrarySectionTwoLevelConstraint(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "book", Title: "书"})
ch, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"})
if err != nil {
t.Fatalf("create chapter: %v", err)
}
sec, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "第一节"})
if err != nil {
t.Fatalf("create section: %v", err)
}
if sec.ParentID == nil || *sec.ParentID != ch.ID {
t.Fatalf("节的父应为章 %d", ch.ID)
}
// 节下不能再挂节
if _, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &sec.ID, Title: "深层"}); err == nil {
t.Fatal("三级嵌套应被拒绝")
}
// 跨条目父章节
doc2, _ := s.Create(&LibraryInput{Slug: "book2", Title: "书2"})
if _, err := s.CreateSection(doc2.ID, &SectionInput{ParentID: &ch.ID, Title: "跨条目"}); err == nil {
t.Fatal("跨条目父章节应被拒绝")
}
}
func TestLibrarySectionMoveSwap(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "mv", Title: "排序"})
a, _ := s.CreateSection(doc.ID, &SectionInput{Title: "甲"})
b, _ := s.CreateSection(doc.ID, &SectionInput{Title: "乙"})
c, _ := s.CreateSection(doc.ID, &SectionInput{Title: "丙"})
if err := s.MoveSection(b.ID, "up"); err != nil {
t.Fatalf("move up: %v", err)
}
secs, _ := s.ListSections(doc.ID)
if secs[0].ID != b.ID || secs[1].ID != a.ID || secs[2].ID != c.ID {
t.Fatalf("上移后顺序应为 乙甲丙: %+v", titles(secs))
}
// 边界 no-op
if err := s.MoveSection(b.ID, "up"); err != nil {
t.Fatalf("边界上移: %v", err)
}
if err := s.MoveSection(b.ID, "bad"); err == nil {
t.Fatal("非法方向应报错")
}
}
func titles(secs []model.LibrarySection) []string {
out := make([]string, len(secs))
for i, sec := range secs {
out[i] = sec.Title
}
return out
}
func TestLibraryImportMarkdown(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "imp", Title: "导入"})
// h2 拆分:围栏内 ## 不拆;首标题前内容并入第一章
md := "## 前言\n\n引言正文。\n\n```\n## 不是标题\n```\n\n## 第二章\n\n内容二。"
secs, err := s.ImportMarkdown(doc.ID, "how.md", md, "h2")
if err != nil {
t.Fatalf("import: %v", err)
}
if len(secs) != 2 {
t.Fatalf("应拆出 2 章,got %d: %+v", len(secs), titles(secs))
}
if secs[0].Title != "前言" || !strings.Contains(secs[0].Content, "引言正文") {
t.Fatalf("第一章错误: %+v", secs[0])
}
if !strings.Contains(secs[0].Content, "不是标题") {
t.Fatal("围栏内的 ## 行应留在第一章正文,不应被拆分")
}
if secs[1].Title != "第二章" {
t.Fatalf("第二章标题错误: %+v", secs[1])
}
// 无命中标题 → 回退单章,标题取文件名
secs2, err := s.ImportMarkdown(doc.ID, "README.md", "只有正文,没有标题。", "h1")
if err != nil {
t.Fatalf("import fallback: %v", err)
}
if len(secs2) != 1 || secs2[0].Title != "README" {
t.Fatalf("应回退单章 README: %+v", titles(secs2))
}
// 非法 splitLevel
if _, err := s.ImportMarkdown(doc.ID, "x.md", "x", "h3"); err == nil {
t.Fatal("非法拆分方式应报错")
}
}
func TestLibraryImportMarkdownBatch(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "impb", Title: "批量导入"})
files := []ImportFileInput{
{Filename: "01-不想早死.md", Content: "## 甲\n\n内容甲。"},
{Filename: "02-不要慢性自杀.md", Content: "只有正文,没有标题。"},
{Filename: "03.md", Content: "# 丙\n\n内容丙。"},
}
secs, err := s.ImportMarkdownBatch(doc.ID, files, "h2")
if err != nil {
t.Fatalf("batch import: %v", err)
}
// 文件1 按 h2 拆出 1 章;文件2、文件3 未命中 ## → 回退单章(标题取文件名)
if len(secs) != 3 {
t.Fatalf("应创建 3 章,got %d: %+v", len(secs), titles(secs))
}
if secs[0].Title != "甲" {
t.Fatalf("文件1 应按 h2 拆出章: %+v", titles(secs))
}
if secs[1].Title != "02-不要慢性自杀" {
t.Fatalf("文件2 标题应取文件名: %+v", titles(secs))
}
if secs[2].Title != "03" {
t.Fatalf("文件3 未命中 ## 应回退单章: %+v", titles(secs))
}
for i, sec := range secs {
if sec.SortOrder != i {
t.Fatalf("sort_order 应按文件顺序连续: %+v", titles(secs))
}
}
// 文件数超限
many := make([]ImportFileInput, MaxImportFiles+1)
for i := range many {
many[i] = ImportFileInput{Filename: "f.md", Content: "x"}
}
if _, err := s.ImportMarkdownBatch(doc.ID, many, "none"); err == nil {
t.Fatal("文件数超过上限应报错")
}
// 空文件列表
if _, err := s.ImportMarkdownBatch(doc.ID, nil, "none"); err == nil {
t.Fatal("空文件列表应报错")
}
}
func TestLibraryDeleteSectionCascade(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "cas", Title: "级联"})
ch, _ := s.CreateSection(doc.ID, &SectionInput{Title: "章"})
sec, _ := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "节"})
if err := s.DeleteSection(ch.ID); err != nil {
t.Fatalf("delete chapter: %v", err)
}
// 章与节都被软删
if _, err := s.section(ch.ID); err == nil {
t.Fatal("章应已删除")
}
if _, err := s.section(sec.ID); err == nil {
t.Fatal("章删除时其下小节应级联删除")
}
}
func TestLibraryDetailCarriesSections(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "carry", Title: "携带", Published: libPub(true)})
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "章一", Content: "正文一"}); err != nil {
t.Fatalf("create: %v", err)
}
d, err := s.GetPublishedBySlug("carry")
if err != nil {
t.Fatalf("detail: %v", err)
}
if len(d.Sections) != 1 || d.Sections[0].Content != "正文一" {
t.Fatalf("公开详情应携带章节: %+v", d.Sections)
}
// 草稿条目不可见
doc2, _ := s.Create(&LibraryInput{Slug: "draft", Title: "草稿书"})
if _, err := s.CreateSection(doc2.ID, &SectionInput{Title: "草稿章"}); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := s.GetPublishedBySlug("draft"); err == nil {
t.Fatal("草稿条目不应出现在公开详情")
}
}

View File

@@ -66,7 +66,7 @@ const (
SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0
SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0
@@ -83,6 +83,10 @@ const (
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
SettingKeyUrlStyle = "url_style"
// SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys);
// 缺行/非法/为空视为全量默认
SettingKeyMobileTabs = "mobile_tabs"
)
const (
@@ -118,8 +122,19 @@ const (
// DefaultUrlStyle 标准动态路由风格(/post/123)
DefaultUrlStyle = "default"
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
MaxMobileTabs = 5
)
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认全量配置
func DefaultMobileTabs() []string {
return append([]string(nil), MobileTabKeys...)
}
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
var DefaultAttachmentExts = []string{
"pdf", "txt", "md", "csv", "json",
@@ -146,34 +161,34 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
type PublicSiteSettings struct {
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
PublishWithoutReview bool `json:"publish_without_review"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
CommentsRequireLogin bool `json:"comments_require_login"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
PublishWithoutReview bool `json:"publish_without_review"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
CommentsRequireLogin bool `json:"comments_require_login"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
// 内容锁定与旧帖回复(0=关闭;公开保持规则透明)
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
@@ -206,6 +221,12 @@ type PublicSiteSettings struct {
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
UrlStyle string `json:"url_style"`
// MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认
MobileTabs []string `json:"mobile_tabs"`
// HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项
HeaderNav []HeaderNavItem `json:"header_nav"`
}
// SettingService 站点级键值设置
@@ -310,6 +331,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
BrandLogoFit: BrandLogoFitContain,
FooterLinks: []FooterLink{},
UrlStyle: DefaultUrlStyle,
HeaderNav: DefaultHeaderNav(),
}
accent, err := s.AccentColor()
if err != nil {
@@ -513,6 +535,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
}
out.UrlStyle = urlStyle
out.MobileTabs = s.MobileTabs()
out.HeaderNav = s.HeaderNav()
if err := s.fillBrand(&out); err != nil {
return out, err
}
@@ -551,6 +576,84 @@ func (s *SettingService) SetUrlStyle(style string) error {
return ErrInvalidSiteSetting
}
// normalizeMobileTabs 校验底栏 tab 列表:去空白、去重、只留白名单内元素
func normalizeMobileTabs(tabs []string) ([]string, bool) {
allowed := make(map[string]struct{}, len(MobileTabKeys))
for _, k := range MobileTabKeys {
allowed[k] = struct{}{}
}
seen := make(map[string]struct{}, len(tabs))
out := make([]string, 0, len(tabs))
for _, t := range tabs {
k := strings.TrimSpace(strings.ToLower(t))
if k == "" {
continue
}
if _, ok := allowed[k]; !ok {
return nil, false
}
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
out = append(out, k)
if len(out) > MaxMobileTabs {
return nil, false
}
}
return out, true
}
// mobileTabsEqual 判断两个列表是否元素一致(忽略顺序)
func mobileTabsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
set := make(map[string]struct{}, len(b))
for _, k := range b {
set[k] = struct{}{}
}
for _, k := range a {
if _, ok := set[k]; !ok {
return false
}
}
return true
}
// MobileTabs 移动端底栏配置;缺行/为空/非法/解析失败回落默认全量
func (s *SettingService) MobileTabs() []string {
v, found, err := s.getValue(SettingKeyMobileTabs)
if err != nil || !found || strings.TrimSpace(v) == "" {
return DefaultMobileTabs()
}
var tabs []string
if err := json.Unmarshal([]byte(v), &tabs); err != nil {
return DefaultMobileTabs()
}
out, ok := normalizeMobileTabs(tabs)
if !ok || len(out) == 0 {
return DefaultMobileTabs()
}
return out
}
// SetMobileTabs 校验并保存底栏配置;与默认全量一致时删键保持「缺行=默认」语义
func (s *SettingService) SetMobileTabs(tabs []string) error {
out, ok := normalizeMobileTabs(tabs)
if !ok || len(out) == 0 {
return ErrInvalidSiteSetting
}
if mobileTabsEqual(out, MobileTabKeys) {
return s.deleteKey(SettingKeyMobileTabs)
}
b, err := json.Marshal(out)
if err != nil {
return err
}
return s.putValue(SettingKeyMobileTabs, string(b))
}
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
func (s *SettingService) AccentColor() (string, error) {
v, found, err := s.getValue(SettingKeyAccent)

View File

@@ -11,7 +11,10 @@ import (
"gorm.io/gorm"
)
var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
var (
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
)
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
var sitePageWriteFields = []string{
@@ -116,16 +119,27 @@ func (in *SitePageInput) applyTo(p *model.SitePage) {
}
}
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
// active=在用单页占用;deleted=已删除单页占用
func (s *SitePageService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
q := s.db.Unscoped().Model(&model.SitePage{}).Where("slug = ?", slug).Select("deleted_at")
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var n int64
if err := q.Count(&n).Error; err != nil {
return false, err
var rows []struct {
DeletedAt gorm.DeletedAt
}
return n > 0, nil
if err := q.Find(&rows).Error; err != nil {
return false, false, err
}
for _, r := range rows {
if r.DeletedAt.Valid {
deleted = true
} else {
active = true
}
}
return active, deleted, nil
}
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
@@ -179,11 +193,23 @@ func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, erro
return &p, nil
}
// ListAll 管理后台全部单页
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
var list []model.SitePage
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
return list, err
// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型)
type SitePageAdminItem struct {
model.SitePage
Deleted bool `json:"deleted"`
}
// ListAll 管理后台全部单页(含已软删,仅供查看与彻底删除)
func (s *SitePageService) ListAll() ([]SitePageAdminItem, error) {
var pages []model.SitePage
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(100).Find(&pages).Error; err != nil {
return nil, err
}
out := make([]SitePageAdminItem, 0, len(pages))
for _, p := range pages {
out = append(out, SitePageAdminItem{SitePage: p, Deleted: p.DeletedAt.Valid})
}
return out, nil
}
// Get 管理后台按 ID 取单页
@@ -200,13 +226,16 @@ func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
if err := in.normalize(nil); err != nil {
return nil, err
}
taken, err := s.slugTaken(in.Slug, 0)
active, deleted, err := s.slugTaken(in.Slug, 0)
if err != nil {
return nil, err
}
if taken {
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
}
p := &model.SitePage{
Published: false,
ShowInSidebar: false,
@@ -229,13 +258,16 @@ func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, e
if err := in.normalize(&p); err != nil {
return nil, err
}
taken, err := s.slugTaken(in.Slug, id)
active, deleted, err := s.slugTaken(in.Slug, id)
if err != nil {
return nil, err
}
if taken {
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
}
in.applyTo(&p)
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
@@ -264,3 +296,15 @@ func (s *SitePageService) Delete(id uint) error {
}
return nil
}
// Purge 彻底删除已软删的单页(slug 释放可复用)
func (s *SitePageService) Purge(id uint) error {
var p model.SitePage
if err := s.db.Unscoped().First(&p, id).Error; err != nil {
return gorm.ErrRecordNotFound
}
if !p.DeletedAt.Valid {
return ErrSitePageNotDeleted
}
return s.db.Unscoped().Delete(&p).Error
}

View File

@@ -0,0 +1,69 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newSitePageTestService(t *testing.T) *SitePageService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SitePage{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSitePageService(db)
}
func sitePageInput(slug, title string) *SitePageInput {
return &SitePageInput{Slug: slug, Title: title, Content: "正文内容"}
}
func TestSitePageSlugReuseAfterSoftDelete(t *testing.T) {
s := newSitePageTestService(t)
p, err := s.Create(sitePageInput("test", "测试"))
if err != nil {
t.Fatalf("create: %v", err)
}
if err := s.Delete(p.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
_, err = s.Create(sitePageInput("test", "重建"))
if err == nil || !strings.Contains(err.Error(), "已删除") {
t.Fatalf("软删后重建应提示已删除单页占用,got %v", err)
}
// 管理端列表含软删行且带 deleted 标记
all, err := s.ListAll()
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(all) != 1 || !all[0].Deleted || all[0].Slug != "test" {
t.Fatalf("ListAll 应含软删单页且 Deleted=true,got %+v", all)
}
// 彻底删除后可重建
if err := s.Purge(p.ID); err != nil {
t.Fatalf("purge: %v", err)
}
if _, err := s.Create(sitePageInput("test", "重建")); err != nil {
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
}
}
func TestSitePagePurgeRequiresDeleted(t *testing.T) {
s := newSitePageTestService(t)
p, _ := s.Create(sitePageInput("alive", "在用"))
if err := s.Purge(p.ID); err == nil || !errors.Is(err, ErrSitePageNotDeleted) {
t.Fatalf("未删单页 Purge 应拒绝,got %v", err)
}
}

View File

@@ -0,0 +1,371 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
)
const (
timelineReleaseMaxPages = 10
timelineReleaseMaxItems = 100
)
// timelineReleaseSource 内置 Release 适配(Gitea + GitHub)
type timelineReleaseSource struct {
id string
host string // 精确主机或 "*"(通配)
listPath string // releases 页正则
apiURL string // API 模板
query map[string]string
headers map[string]string
pagination string // link_header | query_page
dateField string // 优先取的字段:published_at / created_at
}
var timelineReleaseSources = []timelineReleaseSource{
{
id: "github_release",
host: "github.com",
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
apiURL: "https://api.github.com/repos/{owner}/{repo}/releases",
query: map[string]string{"per_page": "100", "page": "{page}"},
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json"},
pagination: "link_header",
dateField: "published_at",
},
{
id: "gitea_release",
host: "*",
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
apiURL: "https://{host}/api/v1/repos/{owner}/{repo}/releases",
query: map[string]string{"limit": "50", "page": "{page}"},
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/json"},
pagination: "query_page",
dateField: "created_at",
},
}
// matchReleaseSource 按 host + path 匹配内置 release 适配
func matchReleaseSource(host, path string) (*timelineReleaseSource, map[string]string, error) {
for i := range timelineReleaseSources {
src := &timelineReleaseSources[i]
if src.host != "*" && !strings.EqualFold(src.host, host) {
continue
}
if src.host == "*" && strings.EqualFold(host, "github.com") {
continue
}
re, err := regexp.Compile(src.listPath)
if err != nil {
continue
}
if m := re.FindStringSubmatch(path); m != nil {
return src, subexpMap(re, m), nil
}
}
return nil, nil, errors.New("地址不符或主机未配置")
}
// ImportTimelineFromReleases 按 releases 页 URL 拉取并解析发布记录
func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGitImportResult, error) {
cleanURLs := make([]string, 0, len(urls))
for _, u := range urls {
u = strings.TrimSpace(u)
if u != "" {
cleanURLs = append(cleanURLs, u)
}
}
if len(cleanURLs) == 0 {
return nil, errors.New("请提供至少一条 URL")
}
if len(cleanURLs) > 20 {
return nil, errors.New("一次最多 20 条 URL")
}
client := &http.Client{
Timeout: timelineGitHTTPTimeout,
Transport: &http.Transport{
DialContext: publicOnlyDial,
TLSHandshakeTimeout: timelineGitHTTPTimeout,
ForceAttemptHTTP2: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("重定向过多")
}
if err := assertSafeHTTPSURL(req.URL); err != nil {
return err
}
return nil
},
}
seenURL := map[string]bool{}
var items []TimelineGitItem
var failMsgs []string
truncated := false
for _, rawURL := range cleanURLs {
part, partTrunc, err := s.importOneReleaseURL(client, rawURL, seenURL, timelineReleaseMaxItems-len(items))
if err != nil {
failMsgs = append(failMsgs, fmt.Sprintf("%s:%s", truncateTimelineStr(rawURL, 80), err.Error()))
continue
}
items = append(items, part...)
if partTrunc {
truncated = true
}
if len(items) >= timelineReleaseMaxItems {
truncated = true
break
}
}
out := &TimelineGitImportResult{Items: items}
if truncated {
out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", timelineReleaseMaxItems)
}
if len(failMsgs) > 0 {
out.Error = strings.Join(failMsgs, ";")
}
if len(items) == 0 && out.Error == "" {
out.Error = "未能解析出 Release"
}
return out, nil
}
func (s *SettingService) importOneReleaseURL(
client *http.Client,
rawURL string,
seenURL map[string]bool,
remain int,
) ([]TimelineGitItem, bool, error) {
if remain <= 0 {
return nil, true, nil
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, false, errors.New("URL 无效")
}
if err := assertSafeHTTPSURL(u); err != nil {
return nil, false, err
}
host := strings.ToLower(u.Hostname())
path := u.EscapedPath()
if path == "" {
path = "/"
}
src, caps, err := matchReleaseSource(host, path)
if err != nil {
return nil, false, err
}
if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) {
return nil, false, errors.New("仓库名非法")
}
startPage := 1
if p := u.Query().Get("page"); p != "" {
if n, e := parseIntPage(p); e == nil && n >= 1 {
startPage = n
}
}
var out []TimelineGitItem
truncated := false
page := startPage
maxPages := timelineReleaseMaxPages
pagesDone := 0
nextURL := ""
for pagesDone < maxPages && len(out) < remain {
var apiURL string
if nextURL != "" {
apiURL = nextURL
nextURL = ""
} else {
apiURL = expandTemplate(src.apiURL, host, caps, page)
q := url.Values{}
for k, v := range src.query {
q.Set(k, expandTemplate(v, host, caps, page))
}
parsed, e := url.Parse(apiURL)
if e != nil {
return out, truncated, errors.New("API URL 无效")
}
if len(q) > 0 {
existing := parsed.Query()
for k, vs := range q {
existing.Set(k, vs[0])
}
parsed.RawQuery = existing.Encode()
}
apiURL = parsed.String()
}
parsedAPI, err := url.Parse(apiURL)
if err != nil {
return out, truncated, errors.New("API URL 无效")
}
if err := assertSafeHTTPSURL(parsedAPI); err != nil {
return out, truncated, err
}
headers := http.Header{}
for k, v := range src.headers {
if allowedAdapterHeaders[strings.ToLower(k)] {
headers.Set(k, v)
}
}
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", "jiang13-bbs")
}
body, linkNext, status, err := httpGetLimited(client, parsedAPI.String(), headers)
if err != nil {
return out, truncated, err
}
if status == 404 || status == 401 || status == 403 {
return out, truncated, errors.New("无法读取该仓库(私有、不存在或无权访问)")
}
if status == 429 {
return out, truncated, errors.New("远端限流,请稍后再试")
}
if status < 200 || status >= 300 {
return out, truncated, fmt.Errorf("远端返回 %d", status)
}
pageItems, err := parseReleaseListJSON(body, src, host)
if err != nil {
return out, truncated, err
}
if len(pageItems) == 0 {
break
}
for _, it := range pageItems {
if it.SourceURL != "" && seenURL[it.SourceURL] {
continue
}
if it.SourceURL != "" {
seenURL[it.SourceURL] = true
}
out = append(out, it)
if len(out) >= remain {
truncated = true
break
}
}
pagesDone++
if src.pagination == "link_header" && linkNext != "" {
nu, e := url.Parse(linkNext)
if e != nil || assertSafeHTTPSURL(nu) != nil {
break
}
nextURL = nu.String()
} else if src.pagination == "query_page" {
page++
} else {
break
}
}
if pagesDone >= maxPages {
truncated = true
}
return out, truncated, nil
}
func parseReleaseListJSON(body []byte, src *timelineReleaseSource, host string) ([]TimelineGitItem, error) {
var root any
if err := json.Unmarshal(body, &root); err != nil {
return nil, errors.New("响应非 JSON")
}
arr, ok := root.([]any)
if !ok {
return nil, errors.New("响应不是 Release 列表")
}
var out []TimelineGitItem
for _, el := range arr {
item, ok := mapReleaseObject(el, src, host)
if !ok {
continue
}
out = append(out, item)
}
return out, nil
}
func mapReleaseObject(el any, src *timelineReleaseSource, host string) (TimelineGitItem, bool) {
m, ok := el.(map[string]any)
if !ok {
return TimelineGitItem{}, false
}
// 跳过草稿
if draft, ok := m["draft"].(bool); ok && draft {
return TimelineGitItem{}, false
}
name := jsonStringField(m, "name")
tag := jsonStringField(m, "tag_name")
title := strings.TrimSpace(name)
if title == "" {
title = strings.TrimSpace(tag)
}
if title == "" {
return TimelineGitItem{}, false
}
body := jsonStringField(m, "body")
dateRaw := jsonStringField(m, src.dateField)
if dateRaw == "" {
// 兜底用 created_at
dateRaw = jsonStringField(m, "created_at")
}
srcURL := jsonStringField(m, "html_url")
title = sanitizeTimelinePlain(title, timelineTitleMax)
body = sanitizeTimelinePlain(body, timelineBodyMax)
title = neutralizeDirectivePlain(title)
body = neutralizeDirectivePlain(body)
date := parseCommitDate(dateRaw)
srcURL = sanitizeSourceURL(srcURL, host)
return TimelineGitItem{
Date: date,
Title: title,
Body: body,
SourceURL: srcURL,
}, true
}
// jsonStringField 从 map[string]any 取字符串字段,兼容 string/number
func jsonStringField(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case float64:
return fmt.Sprintf("%v", x)
case json.Number:
return x.String()
default:
return fmt.Sprintf("%v", x)
}
}
func parseIntPage(s string) (int, error) {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errors.New("page 非数字")
}
n = n*10 + int(r-'0')
if n > 1_000_000 {
return 0, errors.New("page 过大")
}
}
return n, nil
}

View File

@@ -585,6 +585,35 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
return s.SaveBackground(f)
}
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.Attachment, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return nil, errors.New("图片不存在或不属于你")
}
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
if e != nil {
return nil, e
}
defer r.Close()
return s.SaveImage(userID, r)
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
return nil, errors.New("无效的图片地址")
}
f, err := os.Open(abs)
if err != nil {
return nil, errors.New("读取图片失败")
}
defer f.Close()
return s.SaveImage(userID, f)
}
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
func (s *UploadService) BackgroundFileExists(url string) bool {
rel := BgRelPath(url)