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