Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7912b67ddc | |||
| 99c0f0319d | |||
| 3e98052c16 | |||
| ff2ab286fb | |||
| 7fbd6ba699 | |||
| 4f3f3a265c | |||
| e487bc2e92 | |||
| 3e0689fe98 | |||
| 0c3b3a710c | |||
| 34aee91a13 | |||
| 2f96a36275 | |||
| 7d8c79f61f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ out/
|
|||||||
.vercel/
|
.vercel/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-debug.log*
|
next-debug.log*
|
||||||
|
# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成)
|
||||||
|
frontend/public/fonts/
|
||||||
|
|
||||||
# ===== Go 构建产物 =====
|
# ===== Go 构建产物 =====
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseSiteURLFromIni(t *testing.T) {
|
func TestParseSiteURLFromIni(t *testing.T) {
|
||||||
work := t.TempDir()
|
work := t.TempDir()
|
||||||
body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
|
body := "[security]\nJWT_SECRET = " + testJWTSecret + "\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
|
||||||
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bo
|
|||||||
return "", true // 字段不存在,视为未上传
|
return "", true // 字段不存在,视为未上传
|
||||||
}
|
}
|
||||||
if fh.Size > service.LegacyZipMaxBytes {
|
if fh.Size > service.LegacyZipMaxBytes {
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label+"不能超过 128MB"})
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label + "不能超过 128MB"})
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
path, err := saveMultipartToTemp(fh, pattern)
|
path, err := saveMultipartToTemp(fh, pattern)
|
||||||
|
|||||||
@@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminGetUserPerms 读取账号级渠道权限(超管/站长,授权弹窗回显用)
|
||||||
|
func (h *Handlers) AdminGetUserPerms(c *gin.Context) {
|
||||||
|
id, ok := parseAdminUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perms, err := h.AdminUser.GetPermOverrides(middleware.CurrentActor(c), id)
|
||||||
|
if err != nil {
|
||||||
|
respondAdminUserError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"perm_overrides": perms})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetUserPerms 授予/撤销账号级渠道权限(超管/站长)
|
||||||
|
func (h *Handlers) AdminSetUserPerms(c *gin.Context) {
|
||||||
|
id, ok := parseAdminUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Perms []string `json:"perms"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.AdminUser.SetPermOverrides(middleware.CurrentActor(c), id, body.Perms)
|
||||||
|
if err != nil {
|
||||||
|
respondAdminUserError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||||
|
}
|
||||||
|
|
||||||
// AdminSetUserBan 封禁 / 解封用户
|
// AdminSetUserBan 封禁 / 解封用户
|
||||||
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
||||||
id, ok := parseAdminUserID(c)
|
id, ok := parseAdminUserID(c)
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
|
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天
|
||||||
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
|
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天
|
||||||
)
|
)
|
||||||
|
|
||||||
// setAuthCookies 设置认证 cookie:
|
// setAuthCookies 设置认证 cookie:
|
||||||
// - access token(HttpOnly,15min)
|
// - access token(HttpOnly,7天)
|
||||||
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
|
// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC
|
||||||
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
|
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
|
||||||
// - CSRF token(JS 可读,7天,双提交校验)
|
// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验)
|
||||||
//
|
//
|
||||||
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
|
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
|
||||||
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
|
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
|
||||||
@@ -242,15 +242,23 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
|
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
|
||||||
if errors.Is(err, service.ErrAccountBanned) {
|
if errors.Is(err, service.ErrAccountBanned) {
|
||||||
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
||||||
|
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
|
||||||
|
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
|
||||||
|
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 其他错误(数据库抖动等):服务暂不可用。保留 cookie 等下次刷新,
|
||||||
|
// 前端按瞬态失败处理(不弹"登录已失效"、不强制重登)
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
@@ -265,7 +273,8 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
|||||||
|
|
||||||
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
|
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
|
||||||
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
|
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
|
||||||
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
|
// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false,
|
||||||
|
// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。
|
||||||
func (h *Handlers) Logout(c *gin.Context) {
|
func (h *Handlers) Logout(c *gin.Context) {
|
||||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
||||||
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
||||||
@@ -373,6 +382,9 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin
|
|||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"board_ids": boardIDs,
|
"board_ids": boardIDs,
|
||||||
"can_manage_messages": user.CanManageMessages,
|
"can_manage_messages": user.CanManageMessages,
|
||||||
|
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
|
||||||
|
// 最终权限以后端 RequirePerm 校验为准
|
||||||
|
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
|
||||||
"level": user.Level,
|
"level": user.Level,
|
||||||
"total_points": user.TotalPoints,
|
"total_points": user.TotalPoints,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -42,6 +43,26 @@ func chatRoomID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
return uint(id), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用
|
||||||
|
func formatMuteDurationCN(minutes int) string {
|
||||||
|
switch {
|
||||||
|
case minutes >= 30*24*60:
|
||||||
|
return "30 天"
|
||||||
|
case minutes >= 7*24*60:
|
||||||
|
return "7 天"
|
||||||
|
case minutes >= 3*24*60:
|
||||||
|
return "3 天"
|
||||||
|
case minutes >= 24*60:
|
||||||
|
return "1 天"
|
||||||
|
case minutes >= 12*60:
|
||||||
|
return "12 小时"
|
||||||
|
case minutes >= 60:
|
||||||
|
return "1 小时"
|
||||||
|
default:
|
||||||
|
return strconv.Itoa(minutes) + " 分钟"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) chatOversee(userID uint) bool {
|
func (h *Handlers) chatOversee(userID uint) bool {
|
||||||
actor, err := h.Auth.LoadActor(userID)
|
actor, err := h.Auth.LoadActor(userID)
|
||||||
if err != nil || actor == nil {
|
if err != nil || actor == nil {
|
||||||
@@ -322,7 +343,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChatMemberMute 禁言/解禁成员
|
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
|
||||||
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
roomID, ok := chatRoomID(c)
|
roomID, ok := chatRoomID(c)
|
||||||
@@ -336,16 +357,57 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil {
|
duration := time.Duration(body.DurationMinutes) * time.Minute
|
||||||
|
targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID))
|
||||||
|
if err != nil {
|
||||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted})
|
// 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表
|
||||||
|
var mutedUntil *time.Time
|
||||||
|
if body.Muted && body.DurationMinutes > 0 {
|
||||||
|
t := time.Now().Add(duration)
|
||||||
|
mutedUntil = &t
|
||||||
|
}
|
||||||
|
// 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等)
|
||||||
|
var sysContent string
|
||||||
|
if body.Muted {
|
||||||
|
if body.DurationMinutes > 0 {
|
||||||
|
sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes)
|
||||||
|
} else {
|
||||||
|
sysContent = targetName + " 已被禁言 永久"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sysContent = targetName + " 的禁言已被解除"
|
||||||
|
}
|
||||||
|
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
|
||||||
|
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染);
|
||||||
|
// Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏
|
||||||
|
if sysMsg != nil && sysMsg.ID > 0 {
|
||||||
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||||
|
Type: realtime.EventChatMessage,
|
||||||
|
Data: sysMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||||
|
Type: realtime.EventChatMemberMuted,
|
||||||
|
Data: gin.H{
|
||||||
|
"room_id": roomID,
|
||||||
|
"user_id": uint(targetID),
|
||||||
|
"muted": body.Muted,
|
||||||
|
"muted_until": mutedUntil,
|
||||||
|
"duration_minutes": body.DurationMinutes,
|
||||||
|
"target_name": targetName,
|
||||||
|
"operator_id": claims.ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChatMemberRole 站长任命/撤销群管理员
|
// SetChatMemberRole 站长任命/撤销群管理员
|
||||||
@@ -619,7 +681,9 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, ne
|
|||||||
}
|
}
|
||||||
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
||||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
||||||
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
|
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览:
|
||||||
|
// 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage
|
||||||
|
// 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。
|
||||||
if h.Chat == nil {
|
if h.Chat == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,38 +82,6 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
|
|
||||||
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
|
|
||||||
func (h *Handlers) CommentLocation(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 待审/被拒帖子的评论不对公众开放
|
|
||||||
var viewerID uint
|
|
||||||
var loadActor func() *service.Actor
|
|
||||||
if claims := middleware.CurrentUser(c); claims != nil {
|
|
||||||
viewerID = claims.ID
|
|
||||||
loadActor = h.actorLoader(claims.ID)
|
|
||||||
}
|
|
||||||
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
floor := h.Comment.FloorNumber(uint(id), uint(cid))
|
|
||||||
if floor <= 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"floor": floor})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCommentRequest 评论请求
|
// CreateCommentRequest 评论请求
|
||||||
type CreateCommentRequest struct {
|
type CreateCommentRequest struct {
|
||||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||||
|
|||||||
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
|
HidePwd *service.HidePasswordCookie
|
||||||
Ads *service.AdService
|
Ads *service.AdService
|
||||||
Sidebar *service.SidebarService
|
Sidebar *service.SidebarService
|
||||||
|
FriendLink *service.FriendLinkService
|
||||||
Badge *service.BadgeService
|
Badge *service.BadgeService
|
||||||
LeaderboardSvc *service.LeaderboardService
|
LeaderboardSvc *service.LeaderboardService
|
||||||
|
LibrarySvc *service.LibraryService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
499
backend/handler/library.go
Normal file
499
backend/handler/library.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
var creatorID uint
|
||||||
|
if claims := middleware.CurrentUser(c); claims != nil {
|
||||||
|
creatorID = claims.ID
|
||||||
|
}
|
||||||
|
d, err := h.LibrarySvc.Create(&in, creatorID)
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理端:导出(迁移 / 备份) ----------
|
||||||
|
|
||||||
|
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
|
||||||
|
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
|
||||||
|
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
|
||||||
|
c.Header("Content-Type", "application/zip")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
if err := exp.WriteZip(c.Writer); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
|
||||||
|
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
h.writeLibraryExport(c, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
|
||||||
|
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
|
||||||
|
exp, err := h.LibrarySvc.BuildAllExport()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeLibraryExport(c, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
|
||||||
|
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
|
||||||
|
claims := middleware.CurrentUser(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const overhead = 64 << 10 // multipart 边界开销
|
||||||
|
limit := int64(service.LibraryImportMaxBytes) + 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)
|
||||||
|
|
||||||
|
fh, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fh.Size > service.LibraryImportMaxBytes {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := c.PostForm("mode")
|
||||||
|
if mode == "" {
|
||||||
|
mode = service.LibraryImportModeCreate
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrLibraryImportBadZip),
|
||||||
|
errors.Is(err, service.ErrLibraryImportFormat),
|
||||||
|
errors.Is(err, service.ErrLibraryImportVer),
|
||||||
|
errors.Is(err, service.ErrLibraryImportEmpty),
|
||||||
|
errors.Is(err, service.ErrLibraryImportMode):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理端:章节 ----------
|
||||||
|
|
||||||
|
// 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)})
|
||||||
|
}
|
||||||
@@ -5,9 +5,18 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// notificationView 通知列表项:附带关联评论的楼层相对锚点,
|
||||||
|
// 供前端拼 #comment-{floor}(主楼)或 #comment-{floor}-r{comment_id}(楼中楼)。
|
||||||
|
type notificationView struct {
|
||||||
|
*model.Notification
|
||||||
|
CommentFloor uint `json:"comment_floor"`
|
||||||
|
CommentIsRoot bool `json:"comment_is_root"`
|
||||||
|
}
|
||||||
|
|
||||||
// Notifications 获取当前用户的通知列表(分页)
|
// Notifications 获取当前用户的通知列表(分页)
|
||||||
func (h *Handlers) Notifications(c *gin.Context) {
|
func (h *Handlers) Notifications(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
@@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
commentIDs := make([]uint, 0, len(list))
|
||||||
|
for _, n := range list {
|
||||||
|
if n.CommentID > 0 {
|
||||||
|
commentIDs = append(commentIDs, n.CommentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anchors := h.Comment.AnchorsByCommentIDs(commentIDs)
|
||||||
|
views := make([]notificationView, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := notificationView{Notification: &list[i]}
|
||||||
|
if a, ok := anchors[list[i].CommentID]; ok {
|
||||||
|
v.CommentFloor = a.Floor
|
||||||
|
v.CommentIsRoot = a.IsRoot
|
||||||
|
}
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"notifications": nonNilSlice(list),
|
"notifications": nonNilSlice(views),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"size": size,
|
"size": size,
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ func (h *Handlers) PublicObject(c *gin.Context) {
|
|||||||
defer r.Close()
|
defer r.Close()
|
||||||
c.Header("Content-Type", m)
|
c.Header("Content-Type", m)
|
||||||
c.Header("X-Content-Type-Options", "nosniff")
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
c.Header("Cache-Control", "private, no-store")
|
// 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用)
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
c.Status(200)
|
c.Status(200)
|
||||||
_, _ = io.Copy(c.Writer, r)
|
_, _ = io.Copy(c.Writer, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
@@ -16,13 +17,20 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxSearchKeywordRunes 帖子搜索关键词长度上限(字符数,与前端搜索框 maxLength 一致)
|
||||||
|
const maxSearchKeywordRunes = 100
|
||||||
|
|
||||||
// Posts 获取帖子列表
|
// Posts 获取帖子列表
|
||||||
func (h *Handlers) Posts(c *gin.Context) {
|
func (h *Handlers) Posts(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||||
sort := c.DefaultQuery("sort", "latest")
|
sort := c.DefaultQuery("sort", "latest")
|
||||||
keyword := c.Query("keyword")
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
// 搜索词上限:前端输入框同限;超长词 ILIKE 必然无意义结果,静默截断
|
||||||
|
if runes := []rune(keyword); len(runes) > maxSearchKeywordRunes {
|
||||||
|
keyword = string(runes[:maxSearchKeywordRunes])
|
||||||
|
}
|
||||||
recommended := c.Query("recommended") == "true"
|
recommended := c.Query("recommended") == "true"
|
||||||
|
|
||||||
var viewerID uint
|
var viewerID uint
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ type updateSettingsRequest struct {
|
|||||||
LevelsFx *bool `json:"levels_fx"`
|
LevelsFx *bool `json:"levels_fx"`
|
||||||
|
|
||||||
UrlStyle *string `json:"url_style"`
|
UrlStyle *string `json:"url_style"`
|
||||||
|
|
||||||
|
MobileTabs *[]string `json:"mobile_tabs"`
|
||||||
|
|
||||||
|
HeaderNav *[]service.HeaderNavItem `json:"header_nav"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
@@ -136,6 +140,10 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"levels_fx": saved.LevelsFx,
|
"levels_fx": saved.LevelsFx,
|
||||||
|
|
||||||
"url_style": saved.UrlStyle,
|
"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.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
|
||||||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
|
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
|
||||||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
|
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
|
||||||
req.UrlStyle != nil
|
req.UrlStyle != nil || req.MobileTabs != nil || req.HeaderNav != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||||
@@ -552,6 +560,26 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
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()
|
saved, err := h.Setting.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -101,3 +101,25 @@ func (h *Handlers) AdminDeleteSitePage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
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)
|
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,10 +3,14 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -99,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
att, err := h.Upload.SaveImage(claims.ID, f)
|
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var maxErr *http.MaxBytesError
|
var maxErr *http.MaxBytesError
|
||||||
if errors.As(err, &maxErr) {
|
if errors.As(err, &maxErr) {
|
||||||
@@ -311,6 +315,84 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
|
||||||
|
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
|
||||||
|
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
|
||||||
|
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
|
||||||
|
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 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
|
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
|
||||||
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
|
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
|
||||||
items, counts, err := h.Upload.AdminMediaLibrary()
|
items, counts, err := h.Upload.AdminMediaLibrary()
|
||||||
@@ -332,6 +414,23 @@ func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
|
|||||||
c.Data(http.StatusOK, "image/webp", data)
|
c.Data(http.StatusOK, "image/webp", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
|
||||||
|
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
|
||||||
|
func (h *Handlers) ImageVariant(c *gin.Context) {
|
||||||
|
w, err := strconv.Atoi(c.Query("w"))
|
||||||
|
if err != nil || !service.IsVariantWidth(w) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, m, err := h.Upload.Variant(c.Query("u"), w)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.Data(http.StatusOK, m, data)
|
||||||
|
}
|
||||||
|
|
||||||
func brandTooLarge(slot string) string {
|
func brandTooLarge(slot string) string {
|
||||||
if slot == service.BrandSlotFavicon {
|
if slot == service.BrandSlotFavicon {
|
||||||
return "Favicon 不能超过 512KB"
|
return "Favicon 不能超过 512KB"
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 访客与该用户的关注关系(关注按钮初始态)
|
// 访客与该用户的关注关系(关注按钮初始态)
|
||||||
userPayload["is_following"] = isFollowing
|
userPayload["is_following"] = isFollowing
|
||||||
// 邮箱仅本人可见,打码展示(j***@163.com)
|
// 邮箱仅本人可见;仅本人访问时下发明文(用于账号信息展示与资料表单回填)
|
||||||
if viewerID == user.ID && user.Email != "" {
|
if viewerID == user.ID && user.Email != "" {
|
||||||
userPayload["email"] = maskEmail(user.Email)
|
userPayload["email"] = user.Email
|
||||||
}
|
}
|
||||||
// 徽章墙(失败不阻塞资料返回)
|
// 徽章墙(失败不阻塞资料返回)
|
||||||
if h.Badge != nil {
|
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 获取用户发表的评论列表(分页,含帖子标题)
|
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
|
||||||
func (h *Handlers) UserComments(c *gin.Context) {
|
func (h *Handlers) UserComments(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -85,7 +85,32 @@ func (m *AuthMiddleware) RequireStaff() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequirePerm 功能点鉴权,必须接在 RequireStaff 之后
|
// RequireActor 必须登录并加载实时 Actor(不要求管理角色),
|
||||||
|
// 供账号级渠道权限(非管理角色也可被单独授予)所在路由组使用;
|
||||||
|
// 功能点鉴权由后续 RequirePerm 完成
|
||||||
|
func (m *AuthMiddleware) RequireActor() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
claims, ok := m.parseToken(c)
|
||||||
|
if !ok {
|
||||||
|
if c.GetBool(AccountBannedKey) {
|
||||||
|
bannedJSON(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, err := m.auth.LoadActor(claims.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权限执行该操作"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("user", claims)
|
||||||
|
c.Set(ActorKey, actor)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePerm 功能点鉴权,必须接在 RequireStaff / RequireActor 之后
|
||||||
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
actor := CurrentActor(c)
|
actor := CurrentActor(c)
|
||||||
|
|||||||
19
backend/middleware/upload_cache.go
Normal file
19
backend/middleware/upload_cache.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadStaticCache 上传文件长缓存:/uploads 下文件名均为上传时随机生成、
|
||||||
|
// 写入后不覆盖(SaveAvatar/SaveImage 等均新建文件),URL 内容恒定,可安全
|
||||||
|
// immutable。PageSpeed「缓存生命周期」修复项。
|
||||||
|
func UploadStaticCache() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/model/board_seed_test.go
Normal file
42
backend/model/board_seed_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认板块只在空库播种:站长改名/删除板块后重启不得复活默认板块(0.2.1 事故回归)
|
||||||
|
func TestSeedDefaultBoardsOnlyWhenEmpty(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(&Board{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空库首启:播种 4 个默认板块
|
||||||
|
seedDefaultBoards(db)
|
||||||
|
var n int64
|
||||||
|
db.Model(&Board{}).Count(&n)
|
||||||
|
if n != 4 {
|
||||||
|
t.Fatalf("空库应播种 4 个默认板块,实际 %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 站长改名后重启:不再复活默认板块
|
||||||
|
if err := db.Model(&Board{}).Where("name = ?", "综合讨论").Update("name", "新综合").Error; err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
seedDefaultBoards(db)
|
||||||
|
db.Model(&Board{}).Count(&n)
|
||||||
|
if n != 4 {
|
||||||
|
t.Fatalf("改名后重启不应重复播种,实际 %d 个板块", n)
|
||||||
|
}
|
||||||
|
var revived int64
|
||||||
|
db.Model(&Board{}).Where("name = ?", "综合讨论").Count(&revived)
|
||||||
|
if revived != 0 {
|
||||||
|
t.Fatal("默认板块「综合讨论」被复活")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,8 @@ func InitDB(dsn string) error {
|
|||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||||
&Badge{}, &UserBadge{},
|
&Badge{}, &UserBadge{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
|
||||||
&ImportRecord{},
|
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,9 @@ func InitDB(dsn string) error {
|
|||||||
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
||||||
backfillUserTotalPoints(db)
|
backfillUserTotalPoints(db)
|
||||||
|
|
||||||
|
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
|
||||||
|
backfillLibraryCreator(db)
|
||||||
|
|
||||||
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
||||||
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
||||||
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
||||||
@@ -146,6 +149,7 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
|
|||||||
pairs := []struct{ table, constraint string }{
|
pairs := []struct{ table, constraint string }{
|
||||||
{"chat_rooms", "fk_chat_rooms_last_message"},
|
{"chat_rooms", "fk_chat_rooms_last_message"},
|
||||||
{"notifications", "fk_notifications_room"},
|
{"notifications", "fk_notifications_room"},
|
||||||
|
{"chat_messages", "fk_chat_messages_sender"},
|
||||||
}
|
}
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
|
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
|
||||||
@@ -368,6 +372,29 @@ func backfillUserTotalPoints(db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入)
|
||||||
|
// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行;
|
||||||
|
// 站长不存在(空库)时跳过,等下次启动自愈。
|
||||||
|
func backfillLibraryCreator(db *gorm.DB) {
|
||||||
|
var owner User
|
||||||
|
err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[model] 回填书库创建者前置查询失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Printf("[model] 回填书库创建者失败: %v", res.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
log.Printf("[model] 已回填书库条目创建者为站长 %d 行", res.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
||||||
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
||||||
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
||||||
@@ -382,20 +409,23 @@ func backfillBoardDefaults(db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultBoards 写入默认板块
|
// seedDefaultBoards 仅在板块表为空(全新站点首启)时写入默认板块。
|
||||||
|
// 曾按「名字是否存在」补种:站长改名板块后每次重启都会复活一套默认板块
|
||||||
|
// (真实事故:0.2.1 重启后默认 4 板块重现)。表里已有任何未删除板块即视为已初始化。
|
||||||
func seedDefaultBoards(db *gorm.DB) {
|
func seedDefaultBoards(db *gorm.DB) {
|
||||||
|
var count int64
|
||||||
|
db.Model(&Board{}).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
defaults := []Board{
|
defaults := []Board{
|
||||||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true},
|
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true},
|
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true},
|
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true},
|
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||||
}
|
}
|
||||||
for _, b := range defaults {
|
for i := range defaults {
|
||||||
var count int64
|
_ = db.Create(&defaults[i]).Error
|
||||||
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
|
|
||||||
if count == 0 {
|
|
||||||
_ = db.Create(&b).Error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type User struct {
|
|||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||||
|
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
|
||||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||||
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
||||||
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
||||||
@@ -522,6 +523,58 @@ type SitePage struct {
|
|||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
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)
|
||||||
|
CoverWidth int `gorm:"not null;default:0" json:"cover_width"` // 封面自然宽(0=未知,前端回退探测)
|
||||||
|
CoverHeight int `gorm:"not null;default:0" json:"cover_height"` // 封面自然高(0=未知,前端回退探测)
|
||||||
|
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"`
|
||||||
|
// Author 书籍作者(自由文本,可填多位,如「鲁迅 译 / 某某注」),仅展示用
|
||||||
|
Author string `gorm:"size:100;not null;default:''" json:"author"`
|
||||||
|
// CreatorID 条目创建者(站点用户;0=历史数据或导入时无对应用户),创建后不变
|
||||||
|
CreatorID uint `gorm:"index;not null;default:0" json:"creator_id"`
|
||||||
|
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)
|
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||||
type SiteSetting struct {
|
type SiteSetting struct {
|
||||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
@@ -542,6 +595,24 @@ type SidebarWidget struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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)
|
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
||||||
type SiteDailyStats struct {
|
type SiteDailyStats struct {
|
||||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||||
@@ -588,11 +659,19 @@ const (
|
|||||||
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用
|
||||||
|
const (
|
||||||
|
AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认)
|
||||||
|
AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用)
|
||||||
|
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
||||||
|
)
|
||||||
|
|
||||||
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
||||||
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
||||||
|
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
|
||||||
URL string `gorm:"size:512;not null" json:"url"`
|
URL string `gorm:"size:512;not null" json:"url"`
|
||||||
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
||||||
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
||||||
@@ -672,6 +751,7 @@ type ChatRoomMember struct {
|
|||||||
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
||||||
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
||||||
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
||||||
|
MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久
|
||||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -683,17 +763,18 @@ type ChatRoomMember struct {
|
|||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
|
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
|
||||||
SenderID uint `gorm:"index;not null" json:"sender_id"`
|
SenderID uint `gorm:"index" json:"sender_id"` // 0 表示系统消息(is_system=true)
|
||||||
Content string `gorm:"type:varchar(2000);not null" json:"content"`
|
Content string `gorm:"type:varchar(2000);not null" json:"content"`
|
||||||
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
|
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
|
||||||
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
||||||
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
||||||
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
||||||
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
|
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
|
||||||
|
IsSystem bool `gorm:"not null;default:false" json:"is_system"` // 系统提示(如禁言通知),sender_id=0
|
||||||
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
|
Sender User `gorm:"foreignKey:SenderID;constraint:-" json:"sender,omitempty"` // constraint:- 禁用 FK,支持 sender_id=0 的系统消息
|
||||||
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
||||||
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const (
|
|||||||
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
||||||
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
||||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||||
|
EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID}
|
||||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||||
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
postFileSvc.WithOperations(ops)
|
postFileSvc.WithOperations(ops)
|
||||||
pointsSvc := service.NewPointsService(model.DB)
|
pointsSvc := service.NewPointsService(model.DB)
|
||||||
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
||||||
|
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
|
||||||
|
WithSetting(settingSvc).
|
||||||
|
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
|
||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
@@ -118,11 +121,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
if err := postFileSvc.EnsureDir(); err != nil {
|
if err := postFileSvc.EnsureDir(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := librarySvc.EnsureDir(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
limiter := service.DefaultRateLimiter()
|
limiter := service.DefaultRateLimiter()
|
||||||
|
|
||||||
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
|
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
|
||||||
pipeCtx, pipeCancel := context.WithCancel(context.Background())
|
// 不用 WithCancel+defer:Setup 返回即 cancel 会让落库协程在启动阶段就退出,事件永远不落库
|
||||||
defer pipeCancel()
|
pipeCtx := context.Background()
|
||||||
go visitSvc.StartFlusher(pipeCtx)
|
go visitSvc.StartFlusher(pipeCtx)
|
||||||
go visitSvc.RunRetention(pipeCtx)
|
go visitSvc.RunRetention(pipeCtx)
|
||||||
|
|
||||||
@@ -155,8 +161,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||||
Ads: adSvc,
|
Ads: adSvc,
|
||||||
Sidebar: service.NewSidebarService(model.DB),
|
Sidebar: service.NewSidebarService(model.DB),
|
||||||
|
FriendLink: service.NewFriendLinkService(model.DB),
|
||||||
Badge: badgeSvc,
|
Badge: badgeSvc,
|
||||||
LeaderboardSvc: leaderboardSvc,
|
LeaderboardSvc: leaderboardSvc,
|
||||||
|
LibrarySvc: librarySvc,
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
notifSvc.OnNotifyNew = func(userID uint) {
|
||||||
@@ -177,9 +185,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
||||||
r.Use(middleware.VisitCapture(visitSvc))
|
r.Use(middleware.VisitCapture(visitSvc))
|
||||||
|
|
||||||
|
// 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable)
|
||||||
|
r.Use(middleware.UploadStaticCache())
|
||||||
|
|
||||||
// 上传文件静态服务(data/uploads → /uploads)
|
// 上传文件静态服务(data/uploads → /uploads)
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
|
||||||
|
// 品牌图固定地址(友链等外部引用):按站点设置解析到当前图,换图不变链
|
||||||
|
r.GET("/api/brand/:slot", h.BrandImage)
|
||||||
|
|
||||||
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
||||||
r.GET("/health", h.Health)
|
r.GET("/health", h.Health)
|
||||||
|
|
||||||
@@ -194,12 +208,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||||
pubAPI.GET("/overview", h.Overview)
|
pubAPI.GET("/overview", h.Overview)
|
||||||
pubAPI.GET("/sidebar", h.GetSidebar)
|
pubAPI.GET("/sidebar", h.GetSidebar)
|
||||||
|
pubAPI.GET("/friend-links", h.GetFriendLinks)
|
||||||
pubAPI.GET("/posts", h.Posts)
|
pubAPI.GET("/posts", h.Posts)
|
||||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
||||||
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
||||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||||
pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation)
|
|
||||||
pubAPI.GET("/users/:id", h.UserProfile)
|
pubAPI.GET("/users/:id", h.UserProfile)
|
||||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||||
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
||||||
@@ -211,7 +225,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/settings", h.PublicSettings)
|
pubAPI.GET("/settings", h.PublicSettings)
|
||||||
pubAPI.GET("/leaderboard", h.Leaderboard)
|
pubAPI.GET("/leaderboard", h.Leaderboard)
|
||||||
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
|
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("/site-state", h.SiteState)
|
||||||
|
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
|
||||||
|
pubAPI.GET("/img", h.ImageVariant)
|
||||||
pubAPI.GET("/media/:object", h.PublicObject)
|
pubAPI.GET("/media/:object", h.PublicObject)
|
||||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||||
@@ -234,6 +254,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.GET("/me/login-devices", h.MyLoginDevices)
|
api.GET("/me/login-devices", h.MyLoginDevices)
|
||||||
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
|
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
|
||||||
api.PUT("/profile", h.UpdateProfile)
|
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.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
|
||||||
api.PUT("/posts/:id", h.UpdatePost)
|
api.PUT("/posts/:id", h.UpdatePost)
|
||||||
api.GET("/posts/:id/history", h.PostEditHistory)
|
api.GET("/posts/:id/history", h.PostEditHistory)
|
||||||
@@ -281,6 +305,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
|
|
||||||
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
||||||
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
|
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
|
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
||||||
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
||||||
@@ -356,20 +382,52 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||||
// (见下方 msgAPI)
|
// (见下方 msgAPI)
|
||||||
|
|
||||||
// 站点公告文章管理(管理员及以上)
|
// 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入,
|
||||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
// 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上,
|
||||||
|
// 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权)
|
||||||
|
permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware())
|
||||||
|
|
||||||
|
// 站点公告文章管理(PermAnnouncements)
|
||||||
|
announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||||
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
||||||
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
||||||
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
||||||
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
||||||
announceAPI.GET("/pages", h.AdminListSitePages)
|
|
||||||
announceAPI.POST("/pages", h.AdminCreateSitePage)
|
|
||||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
|
||||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
|
||||||
|
|
||||||
// 广告位管理(管理员及以上)
|
// 站点单页管理(PermPages)
|
||||||
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
pagesAPI := permAPI.Group("", authMW.RequirePerm(service.PermPages))
|
||||||
|
pagesAPI.GET("/pages", h.AdminListSitePages)
|
||||||
|
pagesAPI.POST("/pages", h.AdminCreateSitePage)
|
||||||
|
pagesAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||||
|
pagesAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||||
|
pagesAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
|
||||||
|
|
||||||
|
// 书库管理(PermLibrary)
|
||||||
|
libraryAPI := permAPI.Group("/library", authMW.RequirePerm(service.PermLibrary))
|
||||||
|
{
|
||||||
|
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
|
||||||
|
libraryAPI.GET("/export", h.AdminExportAllLibrary)
|
||||||
|
libraryAPI.POST("/import", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibrary)
|
||||||
|
libraryAPI.GET("/docs/:id/export", h.AdminExportLibraryDoc)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广告位管理(PermAds)
|
||||||
|
adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds))
|
||||||
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
||||||
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
||||||
adsAPI.GET("/ads", h.AdminListAds)
|
adsAPI.GET("/ads", h.AdminListAds)
|
||||||
@@ -400,6 +458,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
||||||
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
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)
|
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||||
@@ -426,6 +494,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
usersAPI.GET("/users", h.AdminListUsers)
|
usersAPI.GET("/users", h.AdminListUsers)
|
||||||
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
||||||
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
||||||
|
usersAPI.GET("/users/:id/perms", h.AdminGetUserPerms)
|
||||||
|
usersAPI.PUT("/users/:id/perms", h.AdminSetUserPerms)
|
||||||
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
||||||
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
||||||
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -10,13 +12,110 @@ import (
|
|||||||
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
||||||
const (
|
const (
|
||||||
PermUsers = "users" // 用户与权限管理(超管/站长)
|
PermUsers = "users" // 用户与权限管理(超管/站长)
|
||||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
|
||||||
|
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
|
||||||
|
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
|
||||||
|
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
|
||||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||||
PermBoards = "boards" // 板块管理(仅站长)
|
PermBoards = "boards" // 板块管理(仅站长)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。
|
||||||
|
// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。
|
||||||
|
var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds}
|
||||||
|
|
||||||
|
// ValidPermOverride 权限码是否可被账号级授予
|
||||||
|
func ValidPermOverride(p string) bool {
|
||||||
|
for _, g := range GrantablePerms {
|
||||||
|
if p == g {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集
|
||||||
|
func NormalizePermOverrides(perms []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(perms))
|
||||||
|
for _, p := range perms {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" || seen[p] || !ValidPermOverride(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空)
|
||||||
|
func ParsePermOverrides(raw string) []string {
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return NormalizePermOverrides(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧)
|
||||||
|
func SerializePermOverrides(perms []string) string {
|
||||||
|
norm := NormalizePermOverrides(perms)
|
||||||
|
b, err := json.Marshal(norm)
|
||||||
|
if err != nil {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予)
|
||||||
|
func roleDefaultPerm(role model.Role, p string) bool {
|
||||||
|
switch p {
|
||||||
|
case PermBoards:
|
||||||
|
return role == model.RoleOwner
|
||||||
|
case PermUsers, PermSettings:
|
||||||
|
return role == model.RoleSuperAdmin || role == model.RoleOwner
|
||||||
|
case PermAnnouncements, PermPages, PermLibrary, PermAds:
|
||||||
|
return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin)
|
||||||
|
case PermModeration:
|
||||||
|
return model.IsStaff(role)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理),
|
||||||
|
// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。
|
||||||
|
func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string {
|
||||||
|
set := map[string]bool{}
|
||||||
|
all := append([]string{
|
||||||
|
PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds,
|
||||||
|
PermSettings, PermModeration, PermBoards,
|
||||||
|
}, GrantablePerms...)
|
||||||
|
for _, p := range all {
|
||||||
|
if roleDefaultPerm(role, p) {
|
||||||
|
set[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range ParsePermOverrides(permOverridesRaw) {
|
||||||
|
set[p] = true
|
||||||
|
}
|
||||||
|
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
|
||||||
|
set[PermMessages] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for p := range set {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||||
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
||||||
type Actor struct {
|
type Actor struct {
|
||||||
@@ -25,6 +124,7 @@ type Actor struct {
|
|||||||
Role model.Role
|
Role model.Role
|
||||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||||
|
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStaff 是否管理团队成员
|
// IsStaff 是否管理团队成员
|
||||||
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
|
|||||||
return a != nil && model.IsStaff(a.Role)
|
return a != nil && model.IsStaff(a.Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPerm 是否拥有某后台功能点
|
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
|
||||||
func (a *Actor) HasPerm(p string) bool {
|
func (a *Actor) HasPerm(p string) bool {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch p {
|
if roleDefaultPerm(a.Role, p) {
|
||||||
case PermBoards:
|
return true
|
||||||
return a.Role == model.RoleOwner
|
}
|
||||||
case PermUsers, PermSettings:
|
if p == PermMessages {
|
||||||
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
|
|
||||||
case PermAnnouncements:
|
|
||||||
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
|
|
||||||
case PermModeration:
|
|
||||||
return a.IsStaff()
|
|
||||||
case PermMessages:
|
|
||||||
return a.HasSiteMessagePerm()
|
return a.HasSiteMessagePerm()
|
||||||
}
|
}
|
||||||
|
for _, e := range a.ExtraPerms {
|
||||||
|
if e == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
|
|||||||
return a.HasPerm(PermUsers)
|
return a.HasPerm(PermUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadActor 读取用户实时角色与板块授权
|
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
|
||||||
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
||||||
var u model.User
|
var u model.User
|
||||||
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
|
if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
actor := &Actor{
|
actor := &Actor{
|
||||||
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
|
ExtraPerms: ParsePermOverrides(u.PermOverrides),
|
||||||
}
|
}
|
||||||
if u.Role == model.RoleBoardAdmin {
|
if u.Role == model.RoleBoardAdmin {
|
||||||
var ids []uint
|
var ids []uint
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ type AdminContentComment struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Floor uint `json:"floor"` // 所属楼层相对序号
|
||||||
|
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
|
||||||
Board model.Board `json:"board"`
|
Board model.Board `json:"board"`
|
||||||
User model.User `json:"user"`
|
User model.User `json:"user"`
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
items := make([]AdminContentComment, 0, len(rows))
|
items := make([]AdminContentComment, 0, len(rows))
|
||||||
|
commentIDs := make([]uint, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
commentIDs = append(commentIDs, r.ID)
|
||||||
|
}
|
||||||
|
anchors := CommentAnchors(s.db, commentIDs)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
it := AdminContentComment{
|
it := AdminContentComment{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
|||||||
Board: boards[r.BoardID],
|
Board: boards[r.BoardID],
|
||||||
User: users[r.UserID],
|
User: users[r.UserID],
|
||||||
}
|
}
|
||||||
|
if a, ok := anchors[r.ID]; ok {
|
||||||
|
it.Floor = a.Floor
|
||||||
|
it.IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
if r.DeletedAt.Valid {
|
if r.DeletedAt.Valid {
|
||||||
it.Deleted = true
|
it.Deleted = true
|
||||||
t := r.DeletedAt.Time
|
t := r.DeletedAt.Time
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type AdminUserItem struct {
|
|||||||
BoardIDs []uint `json:"board_ids"`
|
BoardIDs []uint `json:"board_ids"`
|
||||||
Banned bool `json:"banned"`
|
Banned bool `json:"banned"`
|
||||||
CanManageMessages bool `json:"can_manage_messages"`
|
CanManageMessages bool `json:"can_manage_messages"`
|
||||||
|
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
|
||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
CommentCount int64 `json:"comment_count"`
|
CommentCount int64 `json:"comment_count"`
|
||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
|||||||
BoardIDs: []uint{},
|
BoardIDs: []uint{},
|
||||||
Banned: u.Banned,
|
Banned: u.Banned,
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
|
PermOverrides: ParsePermOverrides(u.PermOverrides),
|
||||||
Points: u.Points,
|
Points: u.Points,
|
||||||
TotalPoints: u.TotalPoints,
|
TotalPoints: u.TotalPoints,
|
||||||
Level: u.Level,
|
Level: u.Level,
|
||||||
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导
|
||||||
|
if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" {
|
||||||
|
if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
|||||||
return s.getItem(s.db, targetID)
|
return s.getItem(s.db, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显)
|
||||||
|
func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) {
|
||||||
|
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||||
|
return nil, ErrCannotAssignRole
|
||||||
|
}
|
||||||
|
var u model.User
|
||||||
|
if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParsePermOverrides(u.PermOverrides), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。
|
||||||
|
// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带,
|
||||||
|
// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。
|
||||||
|
func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) {
|
||||||
|
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||||
|
return nil, ErrCannotAssignRole
|
||||||
|
}
|
||||||
|
if operator.ID == targetID {
|
||||||
|
return nil, ErrAdminSelfAction
|
||||||
|
}
|
||||||
|
norm := NormalizePermOverrides(perms)
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var u model.User
|
||||||
|
if err := tx.First(&u, targetID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Role == model.RoleOwner {
|
||||||
|
return ErrProtectedOwner
|
||||||
|
}
|
||||||
|
// 管理员及以上角色自带全部渠道权限,账号级授予保持为空
|
||||||
|
if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||||||
|
norm = []string{}
|
||||||
|
}
|
||||||
|
raw := SerializePermOverrides(norm)
|
||||||
|
if u.PermOverrides == raw {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&u).Update("perm_overrides", raw).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.getItem(s.db, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
||||||
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
||||||
if operator == nil || operator.Role != model.RoleOwner {
|
if operator == nil || operator.Role != model.RoleOwner {
|
||||||
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
Floor uint `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminAuditMessageItem 审计聊天消息行
|
// AdminAuditMessageItem 审计聊天消息行
|
||||||
@@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
|||||||
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
||||||
return nil, 0, page, err
|
return nil, 0, page, err
|
||||||
}
|
}
|
||||||
|
commentIDs := make([]uint, 0, len(comments))
|
||||||
|
for _, c := range comments {
|
||||||
|
commentIDs = append(commentIDs, c.ID)
|
||||||
|
}
|
||||||
|
anchors := CommentAnchors(s.db, commentIDs)
|
||||||
items := make([]AdminAuditCommentItem, 0, len(comments))
|
items := make([]AdminAuditCommentItem, 0, len(comments))
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
it := AdminAuditCommentItem{
|
it := AdminAuditCommentItem{
|
||||||
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
|||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
|
if a, ok := anchors[c.ID]; ok {
|
||||||
|
it.Floor = a.Floor
|
||||||
|
it.IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
if c.DeletedAt.Valid {
|
if c.DeletedAt.Valid {
|
||||||
it.Deleted = true
|
it.Deleted = true
|
||||||
t := c.DeletedAt.Time
|
t := c.DeletedAt.Time
|
||||||
|
|||||||
@@ -24,10 +24,15 @@ const (
|
|||||||
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
||||||
CSRFHeaderName = "X-CSRF-Token"
|
CSRFHeaderName = "X-CSRF-Token"
|
||||||
|
|
||||||
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
|
// AccessTokenTTL access token 有效期。安全性由每请求实时校验
|
||||||
AccessTokenTTL = 15 * time.Minute
|
// (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天:
|
||||||
// RefreshTokenTTL refresh token 有效期(长期)
|
// 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。
|
||||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
// (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链,
|
||||||
|
// 是手机端弹"登录已失效"的主因)
|
||||||
|
AccessTokenTTL = 7 * 24 * time.Hour
|
||||||
|
// RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期),
|
||||||
|
// 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录
|
||||||
|
RefreshTokenTTL = 30 * 24 * time.Hour
|
||||||
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
|
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
|
||||||
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
|
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
|
||||||
RefreshRotationGrace = 60 * time.Second
|
RefreshRotationGrace = 60 * time.Second
|
||||||
@@ -282,7 +287,9 @@ var (
|
|||||||
var (
|
var (
|
||||||
ErrRefreshInvalid = errors.New("refresh token 无效")
|
ErrRefreshInvalid = errors.New("refresh token 无效")
|
||||||
ErrRefreshExpired = errors.New("refresh token 已过期")
|
ErrRefreshExpired = errors.New("refresh token 已过期")
|
||||||
ErrRefreshReused = errors.New("refresh token 存在重用嫌疑")
|
// errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点
|
||||||
|
// (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效
|
||||||
|
errReplayChainDead = errors.New("refresh token 轮转链已无有效会话")
|
||||||
)
|
)
|
||||||
|
|
||||||
// hashRefreshToken refresh token 只以 SHA-256 形式落库
|
// hashRefreshToken refresh token 只以 SHA-256 形式落库
|
||||||
@@ -401,7 +408,8 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
|
|||||||
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
|
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
|
||||||
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
|
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
|
||||||
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
|
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
|
||||||
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
|
// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时
|
||||||
|
// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead)
|
||||||
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
|
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
|
||||||
var (
|
var (
|
||||||
accessToken, newRefresh string
|
accessToken, newRefresh string
|
||||||
@@ -412,6 +420,11 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
|||||||
var rt model.RefreshToken
|
var rt model.RefreshToken
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
|
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理,
|
||||||
|
// 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登)
|
||||||
|
return err
|
||||||
|
}
|
||||||
outErr = ErrRefreshInvalid
|
outErr = ErrRefreshInvalid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -441,17 +454,24 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP:
|
// 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失
|
||||||
// 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期
|
// (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时
|
||||||
// middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线)
|
// 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器
|
||||||
fam := sessionFamilyID(rt)
|
// jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把
|
||||||
if err := tx.Model(&model.RefreshToken{}).
|
// 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to
|
||||||
Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false).
|
// 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。
|
||||||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
|
at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now)
|
||||||
return err
|
if healErr == nil {
|
||||||
|
accessToken, newRefresh, outUser = at, plain, healedUser
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam)
|
if !errors.Is(healErr, errReplayChainDead) {
|
||||||
outErr = ErrRefreshReused
|
// 封禁等账号级错误透传,让前端给出对应告知
|
||||||
|
outErr = healErr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt))
|
||||||
|
outErr = ErrRefreshInvalid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +530,46 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
|||||||
return accessToken, newRefresh, outUser, nil
|
return accessToken, newRefresh, outUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点
|
||||||
|
// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对——
|
||||||
|
// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时
|
||||||
|
// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。
|
||||||
|
// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用
|
||||||
|
// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除
|
||||||
|
// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰)
|
||||||
|
// 之后链上无活头节点,自愈自然失效,不会被绕过。
|
||||||
|
func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) {
|
||||||
|
cur := *rt
|
||||||
|
// 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数
|
||||||
|
for i := 0; cur.Revoked && i < 128; i++ {
|
||||||
|
if cur.RotatedTo == 0 {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
var next model.RefreshToken
|
||||||
|
if err := tx.First(&next, cur.RotatedTo).Error; err != nil {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
user, err := s.loadActiveUser(tx, cur.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
plain, err := s.decryptToken(cur.TokenCipher)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
at, err := s.generateToken(user, sessionFamilyID(cur))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur))
|
||||||
|
return at, plain, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
||||||
func (s *AuthService) RevokeRefreshToken(token string) error {
|
func (s *AuthService) RevokeRefreshToken(token string) error {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ const (
|
|||||||
BrandSlotDark = "logo_dark"
|
BrandSlotDark = "logo_dark"
|
||||||
BrandSlotFavicon = "favicon"
|
BrandSlotFavicon = "favicon"
|
||||||
|
|
||||||
|
// 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。
|
||||||
|
// 实际文件仍存随机名,请求时按站点设置解析到当前图。
|
||||||
|
BrandAliasPathLight = "/api/brand/logo-light"
|
||||||
|
BrandAliasPathDark = "/api/brand/logo-dark"
|
||||||
|
BrandAliasPathFavicon = "/api/brand/favicon"
|
||||||
|
|
||||||
BrandMarkImageText = "image_text"
|
BrandMarkImageText = "image_text"
|
||||||
BrandMarkImage = "image"
|
BrandMarkImage = "image"
|
||||||
BrandMarkText = "text"
|
BrandMarkText = "text"
|
||||||
|
|||||||
@@ -183,6 +183,22 @@ func (s *UploadService) BrandFileExists(url string) bool {
|
|||||||
return err == nil && !info.IsDir()
|
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 旧品牌图不再被任一槽位引用时删除
|
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
|
||||||
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
|
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
|
||||||
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {
|
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
package service
|
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) {
|
func TestNormalizeBrandURL(t *testing.T) {
|
||||||
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 过期禁言自动解除:惰性清理,不依赖定时任务
|
||||||
|
if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) {
|
||||||
|
m.Muted = false
|
||||||
|
m.MutedUntil = nil
|
||||||
|
_ = tx.Model(&model.ChatRoomMember{}).
|
||||||
|
Where("room_id = ? AND user_id = ?", roomID, userID).
|
||||||
|
Select("muted", "muted_until").
|
||||||
|
Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
|
|||||||
return s.membership(s.db, roomID, userID)
|
return s.membership(s.db, roomID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
|
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示)
|
||||||
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||||
out := make(map[uint]int64)
|
out := make(map[uint]int64)
|
||||||
if userID == 0 || len(roomIDs) == 0 {
|
if userID == 0 || len(roomIDs) == 0 {
|
||||||
@@ -132,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
|||||||
s.db.Table("chat_messages AS m").
|
s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
|
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows)
|
Scan(&rows)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
@@ -758,44 +767,84 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
|||||||
return s.removeMember(roomID, targetID)
|
return s.removeMember(roomID, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
|
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
|
||||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
|
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
|
||||||
room, err := s.getRoom(roomID)
|
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
||||||
if err != nil {
|
// 失败返回 nil + error。
|
||||||
|
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
||||||
|
msg := &model.ChatMessage{
|
||||||
|
RoomID: roomID,
|
||||||
|
SenderID: 0,
|
||||||
|
Content: content,
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(msg).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||||
|
Update("updated_at", time.Now()).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。
|
||||||
|
// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。
|
||||||
|
// 返回目标用户昵称(供广播系统提示使用)。
|
||||||
|
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) {
|
||||||
|
room, err := s.getRoom(roomID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if room.RoomType == model.ChatRoomTypeDirect {
|
if room.RoomType == model.ChatRoomTypeDirect {
|
||||||
return ErrChatOwnerOnly
|
return "", ErrChatOwnerOnly
|
||||||
}
|
}
|
||||||
target, err := s.membership(s.db, roomID, targetID)
|
target, err := s.membership(s.db, roomID, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if target.Role == model.ChatRoleOwner {
|
if target.Role == model.ChatRoleOwner {
|
||||||
return ErrChatCannotMuteOwner
|
return "", ErrChatCannotMuteOwner
|
||||||
}
|
}
|
||||||
var targetUser model.User
|
var targetUser model.User
|
||||||
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
|
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
|
||||||
return ErrChatUserGone
|
return "", ErrChatUserGone
|
||||||
}
|
}
|
||||||
if targetUser.Role == model.RoleOwner {
|
if targetUser.Role == model.RoleOwner {
|
||||||
return ErrChatCannotMuteOwner
|
return "", ErrChatCannotMuteOwner
|
||||||
}
|
}
|
||||||
if !oversee {
|
if !oversee {
|
||||||
op, err := s.membership(s.db, roomID, operatorID)
|
op, err := s.membership(s.db, roomID, operatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||||
return ErrChatMuteDenied
|
return "", ErrChatMuteDenied
|
||||||
}
|
}
|
||||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
||||||
return ErrChatMuteDenied
|
return "", ErrChatMuteDenied
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.db.Model(&model.ChatRoomMember{}).
|
var mutedUntil *time.Time
|
||||||
|
if muted && duration > 0 {
|
||||||
|
t := time.Now().Add(duration)
|
||||||
|
mutedUntil = &t
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.ChatRoomMember{}).
|
||||||
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
||||||
Select("muted").Update("muted", muted).Error
|
Select("muted", "muted_until").
|
||||||
|
Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
targetName := targetUser.Nickname
|
||||||
|
if targetName == "" {
|
||||||
|
targetName = targetUser.Username
|
||||||
|
}
|
||||||
|
return targetName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
||||||
@@ -970,7 +1019,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
|
if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, uid := range mentionIDs {
|
for _, uid := range mentionIDs {
|
||||||
@@ -1059,7 +1108,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
|||||||
q = q.Where("id < ?", beforeID)
|
q = q.Where("id < ?", beforeID)
|
||||||
}
|
}
|
||||||
var msgs []model.ChatMessage
|
var msgs []model.ChatMessage
|
||||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
|
// Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致)
|
||||||
|
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
hasMore := len(msgs) > size
|
hasMore := len(msgs) > size
|
||||||
@@ -1088,7 +1138,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
var msg model.ChatMessage
|
var msg model.ChatMessage
|
||||||
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
|
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, false, ErrChatMsgNotFound
|
return nil, false, ErrChatMsgNotFound
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1147,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
if msg.RecalledAt != nil {
|
if msg.RecalledAt != nil {
|
||||||
return &msg, false, nil // 幂等
|
return &msg, false, nil // 幂等
|
||||||
}
|
}
|
||||||
|
// 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回)
|
||||||
|
if msg.Sender.Role == model.RoleOwner && !oversee {
|
||||||
|
return nil, false, ErrChatRecallDenied
|
||||||
|
}
|
||||||
if msg.SenderID == operatorID {
|
if msg.SenderID == operatorID {
|
||||||
if !oversee {
|
if !oversee {
|
||||||
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
||||||
@@ -1115,7 +1169,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
}
|
}
|
||||||
msg.RecalledAt = &now
|
msg.RecalledAt = &now
|
||||||
msg.RecalledBy = operatorID
|
msg.RecalledBy = operatorID
|
||||||
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
|
// 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息:
|
||||||
|
// 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。
|
||||||
|
var prevID uint
|
||||||
|
_ = s.db.Model(&model.ChatMessage{}).
|
||||||
|
Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false).
|
||||||
|
Order("id DESC").Limit(1).
|
||||||
|
Select("id").Row().Scan(&prevID)
|
||||||
|
_ = s.db.Model(&model.ChatRoom{}).
|
||||||
|
Where("id = ? AND last_message_id = ?", roomID, msg.ID).
|
||||||
|
Update("last_message_id", prevID).Error
|
||||||
|
_ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID)
|
||||||
return &msg, true, nil
|
return &msg, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,13 +1359,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
|||||||
Update("last_read_message_id", messageID).Error
|
Update("last_read_message_id", messageID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnreadSummary 当前用户所有群的未读明细与总数
|
// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入)
|
||||||
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
||||||
var rows []UnreadRoom
|
var rows []UnreadRoom
|
||||||
err := s.db.Table("chat_messages AS m").
|
err := s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
|
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
|
|||||||
return FloorNumber(s.db, postID, commentID)
|
return FloorNumber(s.db, postID, commentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommentAnchor 评论在所属帖子内的相对锚点:
|
||||||
|
// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼,
|
||||||
|
// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。
|
||||||
|
type CommentAnchor struct {
|
||||||
|
Floor uint `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1);
|
||||||
|
// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。
|
||||||
|
func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor {
|
||||||
|
out := make(map[uint]CommentAnchor, len(ids))
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径
|
||||||
|
var targets []model.Comment
|
||||||
|
if err := db.Unscoped().
|
||||||
|
Select("id", "post_id", "parent_id", "root_id", "created_at").
|
||||||
|
Where("id IN ?", ids).Find(&targets).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
rootIDSet := make(map[uint]struct{}, len(targets))
|
||||||
|
for _, c := range targets {
|
||||||
|
rid := c.ID
|
||||||
|
if c.ParentID != nil && c.RootID != nil {
|
||||||
|
rid = *c.RootID
|
||||||
|
}
|
||||||
|
rootIDSet[rid] = struct{}{}
|
||||||
|
}
|
||||||
|
rootIDs := make([]uint, 0, len(rootIDSet))
|
||||||
|
for rid := range rootIDSet {
|
||||||
|
rootIDs = append(rootIDs, rid)
|
||||||
|
}
|
||||||
|
// 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1
|
||||||
|
type aheadRow struct {
|
||||||
|
RootID uint
|
||||||
|
Ahead int64
|
||||||
|
}
|
||||||
|
var aheadRows []aheadRow
|
||||||
|
if err := db.Unscoped().Table("comments AS f").
|
||||||
|
Select(`f.id AS root_id,
|
||||||
|
(SELECT COUNT(*) FROM comments c
|
||||||
|
WHERE c.post_id = f.post_id AND c.parent_id IS NULL
|
||||||
|
AND (c.created_at < f.created_at
|
||||||
|
OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`).
|
||||||
|
Where("f.id IN ? AND f.parent_id IS NULL", rootIDs).
|
||||||
|
Scan(&aheadRows).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
floorByRoot := make(map[uint]uint, len(aheadRows))
|
||||||
|
for _, r := range aheadRows {
|
||||||
|
floorByRoot[r.RootID] = uint(r.Ahead) + 1
|
||||||
|
}
|
||||||
|
for _, c := range targets {
|
||||||
|
isRoot := c.ParentID == nil
|
||||||
|
rid := c.ID
|
||||||
|
if !isRoot && c.RootID != nil {
|
||||||
|
rid = *c.RootID
|
||||||
|
}
|
||||||
|
floor := floorByRoot[rid]
|
||||||
|
if floor == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnchorsByCommentIDs 见包级 CommentAnchors
|
||||||
|
func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor {
|
||||||
|
return CommentAnchors(s.db, ids)
|
||||||
|
}
|
||||||
|
|
||||||
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
||||||
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
||||||
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
||||||
@@ -442,6 +516,8 @@ type UserCommentItem struct {
|
|||||||
PostID uint `json:"post_id"`
|
PostID uint `json:"post_id"`
|
||||||
PostTitle string `json:"post_title"`
|
PostTitle string `json:"post_title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
|
||||||
|
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
|||||||
if items == nil {
|
if items == nil {
|
||||||
items = []UserCommentItem{}
|
items = []UserCommentItem{}
|
||||||
}
|
}
|
||||||
|
ids := make([]uint, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
anchors := s.AnchorsByCommentIDs(ids)
|
||||||
|
for i := range items {
|
||||||
|
if a, ok := anchors[items[i].ID]; ok {
|
||||||
|
items[i].Floor = a.Floor
|
||||||
|
items[i].IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
305
backend/service/friendlink.go
Normal file
305
backend/service/friendlink.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 友情链接状态
|
||||||
|
const (
|
||||||
|
FriendLinkStatusPending = "pending"
|
||||||
|
FriendLinkStatusApproved = "approved"
|
||||||
|
FriendLinkStatusRejected = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 字段长度上限
|
||||||
|
const (
|
||||||
|
friendLinkMaxName = 64
|
||||||
|
friendLinkMaxURL = 512
|
||||||
|
friendLinkMaxDescription = 255
|
||||||
|
friendLinkMaxLogoURL = 512
|
||||||
|
friendLinkMaxCategory = 32
|
||||||
|
friendLinkMaxReciprocal = 512
|
||||||
|
friendLinkMaxReject = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrFriendLinkInvalid 参数校验失败
|
||||||
|
ErrFriendLinkInvalid = errors.New("友情链接参数无效")
|
||||||
|
// ErrFriendLinkNotFound 记录不存在
|
||||||
|
ErrFriendLinkNotFound = errors.New("友情链接不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FriendLinkInput 申请/新建/更新共用入参(指针区分「未传」与「显式清空」由调用方决定)
|
||||||
|
type FriendLinkInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
ReciprocalURL string `json:"reciprocal_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FriendLinkService 友情链接申请 + 后台 CRUD/审批
|
||||||
|
type FriendLinkService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFriendLinkService(db *gorm.DB) *FriendLinkService {
|
||||||
|
return &FriendLinkService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateInput 校验并归一化入参;返回归一化后的值
|
||||||
|
func validateFriendLinkInput(in FriendLinkInput) (FriendLinkInput, error) {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" || utf8.RuneCountInString(name) > friendLinkMaxName {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
rawURL := strings.TrimSpace(in.URL)
|
||||||
|
if rawURL == "" || len(rawURL) > friendLinkMaxURL {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(rawURL); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||||
|
(u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
desc := strings.TrimSpace(in.Description)
|
||||||
|
if utf8.RuneCountInString(desc) > friendLinkMaxDescription {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
logo := strings.TrimSpace(in.LogoURL)
|
||||||
|
if len(logo) > friendLinkMaxLogoURL {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if logo != "" {
|
||||||
|
if u, err := url.Parse(logo); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||||
|
(u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
category := strings.TrimSpace(in.Category)
|
||||||
|
if utf8.RuneCountInString(category) > friendLinkMaxCategory {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
reciprocal := strings.TrimSpace(in.ReciprocalURL)
|
||||||
|
if len(reciprocal) > friendLinkMaxReciprocal {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if reciprocal != "" {
|
||||||
|
if u, err := url.Parse(reciprocal); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||||
|
(u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FriendLinkInput{
|
||||||
|
Name: name, URL: rawURL, Description: desc, LogoURL: logo,
|
||||||
|
Category: category, ReciprocalURL: reciprocal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListApproved 前台公开列表:仅 approved,按 sort_order ASC, id ASC
|
||||||
|
func (s *FriendLinkService) ListApproved() ([]model.FriendLink, error) {
|
||||||
|
var list []model.FriendLink
|
||||||
|
err := s.db.Where("status = ?", FriendLinkStatusApproved).
|
||||||
|
Order("sort_order ASC, id ASC").Find(&list).Error
|
||||||
|
if list == nil {
|
||||||
|
list = []model.FriendLink{}
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll 后台全量列表(含 pending/rejected),按 status, sort_order, id
|
||||||
|
func (s *FriendLinkService) ListAll() ([]model.FriendLink, error) {
|
||||||
|
var list []model.FriendLink
|
||||||
|
err := s.db.Order("status ASC, sort_order ASC, id ASC").Find(&list).Error
|
||||||
|
if list == nil {
|
||||||
|
list = []model.FriendLink{}
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine 申请人本人提交的全部申请(含待审/拒绝),按提交时间倒序
|
||||||
|
func (s *FriendLinkService) ListMine(userID uint) ([]model.FriendLink, error) {
|
||||||
|
var list []model.FriendLink
|
||||||
|
err := s.db.Where("applicant_user_id = ?", userID).
|
||||||
|
Order("created_at DESC, id DESC").Find(&list).Error
|
||||||
|
if list == nil {
|
||||||
|
list = []model.FriendLink{}
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerUpdate 申请人修改自己的申请(名称/链接/回链/LOGO);非待审状态的记录修改后回退待审
|
||||||
|
func (s *FriendLinkService) OwnerUpdate(id, userID uint, in FriendLinkInput) error {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var fl model.FriendLink
|
||||||
|
if err := tx.First(&fl, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 仅申请人本人可改;他人访问统一按不存在处理,不暴露记录存在性
|
||||||
|
if fl.ApplicantUserID == nil || *fl.ApplicantUserID != userID {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"name": norm.Name,
|
||||||
|
"url": norm.URL,
|
||||||
|
"logo_url": norm.LogoURL,
|
||||||
|
"reciprocal_url": norm.ReciprocalURL,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
if fl.Status != FriendLinkStatusPending {
|
||||||
|
updates["status"] = FriendLinkStatusPending
|
||||||
|
updates["reject_reason"] = ""
|
||||||
|
}
|
||||||
|
return tx.Model(&fl).Updates(updates).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSortOrder 取当前 approved 记录中最大 sort_order + 1(新建/审批通过时使用)
|
||||||
|
func (s *FriendLinkService) nextSortOrder() (int, error) {
|
||||||
|
var maxOrder int
|
||||||
|
err := s.db.Model(&model.FriendLink{}).
|
||||||
|
Where("status = ?", FriendLinkStatusApproved).
|
||||||
|
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return maxOrder + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply 用户申请:status=pending
|
||||||
|
func (s *FriendLinkService) Apply(in FriendLinkInput, applicantUserID uint) (*model.FriendLink, error) {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uid := applicantUserID
|
||||||
|
now := time.Now()
|
||||||
|
fl := &model.FriendLink{
|
||||||
|
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
|
||||||
|
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
|
||||||
|
Status: FriendLinkStatusPending, SortOrder: 0,
|
||||||
|
ApplicantUserID: &uid, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(fl).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreate 站长直接新建:status=approved,自动分配 sort_order
|
||||||
|
func (s *FriendLinkService) AdminCreate(in FriendLinkInput) (*model.FriendLink, error) {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
order, err := s.nextSortOrder()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fl := &model.FriendLink{
|
||||||
|
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
|
||||||
|
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
|
||||||
|
Status: FriendLinkStatusApproved, SortOrder: order,
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(fl).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdate 全量更新内容(不改 status/sort_order/applicant)
|
||||||
|
func (s *FriendLinkService) AdminUpdate(id uint, in FriendLinkInput) error {
|
||||||
|
norm, err := validateFriendLinkInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := s.db.Model(&model.FriendLink{}).Where("id = ?", id).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"name": norm.Name,
|
||||||
|
"url": norm.URL,
|
||||||
|
"description": norm.Description,
|
||||||
|
"logo_url": norm.LogoURL,
|
||||||
|
"category": norm.Category,
|
||||||
|
"reciprocal_url": norm.ReciprocalURL,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetStatus 审批:approved 时自动补 sort_order;rejected 时写 reject_reason
|
||||||
|
func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) error {
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
if status != FriendLinkStatusApproved && status != FriendLinkStatusRejected &&
|
||||||
|
status != FriendLinkStatusPending {
|
||||||
|
return ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
if status == FriendLinkStatusRejected && utf8.RuneCountInString(rejectReason) > friendLinkMaxReject {
|
||||||
|
return ErrFriendLinkInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var fl model.FriendLink
|
||||||
|
if err := tx.First(&fl, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
"reject_reason": "",
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case FriendLinkStatusApproved:
|
||||||
|
if fl.SortOrder == 0 {
|
||||||
|
var maxOrder int
|
||||||
|
if err := tx.Model(&model.FriendLink{}).
|
||||||
|
Where("status = ? AND id <> ?", FriendLinkStatusApproved, id).
|
||||||
|
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates["sort_order"] = maxOrder + 1
|
||||||
|
}
|
||||||
|
case FriendLinkStatusRejected:
|
||||||
|
updates["reject_reason"] = rejectReason
|
||||||
|
case FriendLinkStatusPending:
|
||||||
|
// 切回 pending 视为下线,不动 sort_order
|
||||||
|
}
|
||||||
|
return tx.Model(&fl).Updates(updates).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDelete 软删除
|
||||||
|
func (s *FriendLinkService) AdminDelete(id uint) error {
|
||||||
|
res := s.db.Delete(&model.FriendLink{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrFriendLinkNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
191
backend/service/header_nav.go
Normal file
191
backend/service/header_nav.go
Normal 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))
|
||||||
|
}
|
||||||
164
backend/service/header_nav_test.go
Normal file
164
backend/service/header_nav_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
276
backend/service/image_variants.go
Normal file
276
backend/service/image_variants.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
webpenc "github.com/gen2brain/webp"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
xwebp "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽>
|
||||||
|
// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 <img srcset> 消费。
|
||||||
|
// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。
|
||||||
|
// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。
|
||||||
|
// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。
|
||||||
|
|
||||||
|
// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致)
|
||||||
|
const VariantWebPQuality = 78
|
||||||
|
|
||||||
|
// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进)
|
||||||
|
var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920}
|
||||||
|
|
||||||
|
// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸)
|
||||||
|
func IsVariantWidth(w int) bool {
|
||||||
|
for _, v := range variantWidths {
|
||||||
|
if v == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁
|
||||||
|
var variantGenLocks sync.Map
|
||||||
|
|
||||||
|
// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型)
|
||||||
|
func extMIME(ext string) string {
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".ico":
|
||||||
|
return "image/x-icon"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCacheFile 变体缓存路径:.thumbs/v/<sha1(url)>.w<w>.webp
|
||||||
|
func (s *UploadService) variantCacheFile(url string, w int) string {
|
||||||
|
sum := sha1.Sum([]byte(url))
|
||||||
|
name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp"
|
||||||
|
return filepath.Join(s.dir, ".thumbs", "v", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在
|
||||||
|
func variantCacheFresh(cache string, modTime time.Time) bool {
|
||||||
|
ti, err := os.Stat(cache)
|
||||||
|
if err != nil || ti.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return modTime.IsZero() || !ti.ModTime().Before(modTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variant 返回源图指定宽度的变体字节与 MIME。
|
||||||
|
func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
if len(rawURL) == 0 || len(rawURL) > 512 {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
if !IsVariantWidth(width) {
|
||||||
|
return nil, "", errors.New("不支持的尺寸")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
srcData []byte // 远程源预读字节(本地源在锁内读取)
|
||||||
|
srcMIME string
|
||||||
|
srcPath string // 本地源绝对路径
|
||||||
|
modTime time.Time
|
||||||
|
remote bool
|
||||||
|
cache = s.variantCacheFile(rawURL, width)
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rawURL, "/uploads/"):
|
||||||
|
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/")))
|
||||||
|
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(rel)
|
||||||
|
catOK := false
|
||||||
|
for _, cat := range mediaLibraryCategories {
|
||||||
|
if cat.Dir == dir {
|
||||||
|
catOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !catOK {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
|
mime := extMIME(ext)
|
||||||
|
if mime == "" {
|
||||||
|
return nil, "", errors.New("非图片")
|
||||||
|
}
|
||||||
|
srcPath = filepath.Join(s.dir, rel)
|
||||||
|
info, err := os.Stat(srcPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil, "", errors.New("文件不存在")
|
||||||
|
}
|
||||||
|
// 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存)
|
||||||
|
if ext == ".gif" || ext == ".svg" || ext == ".ico" {
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
return data, mime, err
|
||||||
|
}
|
||||||
|
srcMIME = mime
|
||||||
|
modTime = info.ModTime()
|
||||||
|
|
||||||
|
case strings.HasPrefix(rawURL, "/api/media/"):
|
||||||
|
if s.ops == nil {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(rawURL, "/api/media/")
|
||||||
|
if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
remote = true
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, mime, e := s.ops.OpenObject(ctx, id, true)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", errors.New("文件暂不可用")
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
switch mime {
|
||||||
|
case "image/jpeg", "image/png", "image/webp":
|
||||||
|
case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon":
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
return data, mime, err
|
||||||
|
default:
|
||||||
|
return nil, "", errors.New("非图片")
|
||||||
|
}
|
||||||
|
srcData, e = io.ReadAll(r)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
srcMIME = mime
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速路径:缓存已新鲜直接返回(无锁)
|
||||||
|
if variantCacheFresh(cache, modTime) {
|
||||||
|
if data, err := os.ReadFile(cache); err == nil {
|
||||||
|
return data, "image/webp", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发生成去重:同 url+w 只生成一次
|
||||||
|
sum := sha1.Sum([]byte(rawURL))
|
||||||
|
key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width)
|
||||||
|
mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{})
|
||||||
|
lk := mu.(*sync.Mutex)
|
||||||
|
lk.Lock()
|
||||||
|
defer lk.Unlock()
|
||||||
|
|
||||||
|
// double-check:等锁期间可能已由他人生成
|
||||||
|
if variantCacheFresh(cache, modTime) {
|
||||||
|
if data, err := os.ReadFile(cache); err == nil {
|
||||||
|
return data, "image/webp", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !remote {
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
srcData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
out, m, err := variantFromBytes(srcData, srcMIME, width)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err // 如动图 WebP,调用方回退原图
|
||||||
|
}
|
||||||
|
// 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应
|
||||||
|
if m == "image/webp" {
|
||||||
|
_ = atomicWriteFile(cache, out)
|
||||||
|
}
|
||||||
|
return out, m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图);
|
||||||
|
// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。
|
||||||
|
func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) {
|
||||||
|
img, err := decodeImageBytes(srcData, srcMIME == "image/webp")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() <= width {
|
||||||
|
return srcData, srcMIME, nil
|
||||||
|
}
|
||||||
|
nh := max(1, b.Dy()*width/b.Dx())
|
||||||
|
dst := scaleImage(img, width, nh)
|
||||||
|
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return out, "image/webp", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 与 MediaThumb 共用的图片处理核心 ----------
|
||||||
|
|
||||||
|
// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器
|
||||||
|
func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) {
|
||||||
|
if isWebP {
|
||||||
|
return xwebp.Decode(bytes.NewReader(data))
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
return img, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleImage CatmullRom 高质量缩放到指定宽高
|
||||||
|
func scaleImage(img image.Image, nw, nh int) *image.RGBA {
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeLossyWebP 有损 WebP 编码
|
||||||
|
func encodeLossyWebP(img image.Image, quality int) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomicWriteFile 临时文件 + rename 原子落盘
|
||||||
|
func atomicWriteFile(path string, data []byte) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".partial"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
282
backend/service/image_variants_test.go
Normal file
282
backend/service/image_variants_test.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/config"
|
||||||
|
webpenc "github.com/gen2brain/webp"
|
||||||
|
xwebp "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// solidImg 生成渐变纯色测试图
|
||||||
|
func solidImg(w, h int) *image.RGBA {
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
|
||||||
|
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
|
||||||
|
t.Helper()
|
||||||
|
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
f, err := os.Create(abs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := encode(f); err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJPEG(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodePNG(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return png.Encode(w, img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeWebP(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeGIF(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return gif.Encode(w, img, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeVariant 解码返回的 WebP 变体
|
||||||
|
func decodeVariant(t *testing.T, data []byte) image.Image {
|
||||||
|
t.Helper()
|
||||||
|
img, err := xwebp.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode variant webp: %v", err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsVariantWidth(t *testing.T) {
|
||||||
|
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
|
||||||
|
t.Fatal("whitelist endpoints should be valid")
|
||||||
|
}
|
||||||
|
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
|
||||||
|
t.Fatal("non-whitelist width should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRejectsInvalidInput(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
w int
|
||||||
|
}{
|
||||||
|
{"非法尺寸", "/uploads/images/a.webp", 100},
|
||||||
|
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
|
||||||
|
{"非白名单目录", "/uploads/secret/a.webp", 96},
|
||||||
|
{"非图片扩展名", "/uploads/images/a.txt", 96},
|
||||||
|
{"外部地址", "https://evil.com/a.webp", 96},
|
||||||
|
{"空地址", "", 96},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
|
||||||
|
t.Fatal("want error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantMissingFile(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
|
||||||
|
t.Fatal("missing source should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantPassthroughSmallImage(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/small.webp"
|
||||||
|
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
|
||||||
|
|
||||||
|
data, m, err := s.Variant(url, 96)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != "image/webp" {
|
||||||
|
t.Fatalf("want webp mime, got %s", m)
|
||||||
|
}
|
||||||
|
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
|
||||||
|
if !bytes.Equal(data, src) {
|
||||||
|
t.Fatal("small image should be returned byte-for-byte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantResizesByWidthLandscape(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/wide.jpg"
|
||||||
|
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||||
|
|
||||||
|
data, m, err := s.Variant(url, 384)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != "image/webp" {
|
||||||
|
t.Fatalf("want webp, got %s", m)
|
||||||
|
}
|
||||||
|
b := decodeVariant(t, data).Bounds()
|
||||||
|
if b.Dx() != 384 || b.Dy() != 192 {
|
||||||
|
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantResizesByWidthPortrait(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/tall.png"
|
||||||
|
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
|
||||||
|
|
||||||
|
data, _, err := s.Variant(url, 256)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
b := decodeVariant(t, data).Bounds()
|
||||||
|
if b.Dx() != 256 || b.Dy() != 512 {
|
||||||
|
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
mime string
|
||||||
|
body []byte
|
||||||
|
}{
|
||||||
|
{"gif", "/uploads/images/a.gif", "image/gif", nil},
|
||||||
|
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
|
||||||
|
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.body != nil {
|
||||||
|
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
|
||||||
|
_, err := w.Write(tc.body)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
|
||||||
|
}
|
||||||
|
data, m, err := s.Variant(tc.url, 96)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != tc.mime {
|
||||||
|
t.Fatalf("want %s, got %s", tc.mime, m)
|
||||||
|
}
|
||||||
|
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
|
||||||
|
if !bytes.Equal(data, src) {
|
||||||
|
t.Fatalf("%s should pass through unchanged", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantCachedAndReused(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/cache.jpg"
|
||||||
|
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||||
|
|
||||||
|
first, m, err := s.Variant(url, 384)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != "image/webp" {
|
||||||
|
t.Fatalf("want webp, got %s", m)
|
||||||
|
}
|
||||||
|
cache := s.variantCacheFile(url, 384)
|
||||||
|
if info, err := os.Stat(cache); err != nil || info.IsDir() {
|
||||||
|
t.Fatalf("cache file should exist: %v", err)
|
||||||
|
}
|
||||||
|
second, _, err := s.Variant(url, 384)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second variant: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Fatal("cached variant should be byte-identical")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantCacheFreshness(t *testing.T) {
|
||||||
|
_, dir := newUploadTestService(t)
|
||||||
|
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 远程对象:modTime 零值,存在即新鲜
|
||||||
|
if !variantCacheFresh(cache, time.Time{}) {
|
||||||
|
t.Fatal("remote cache should be fresh when file exists")
|
||||||
|
}
|
||||||
|
// 本地源:mtime 新于缓存 → 不新鲜
|
||||||
|
future := time.Now().Add(1 * time.Hour)
|
||||||
|
if variantCacheFresh(cache, future) {
|
||||||
|
t.Fatal("cache should be stale vs newer source mtime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRemoteRejectsBadID(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
s.ops = NewOperations(s.db, &config.Config{})
|
||||||
|
|
||||||
|
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
|
||||||
|
t.Fatal("id with slash should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRemoteUnavailable(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
s.ops = NewOperations(s.db, &config.Config{})
|
||||||
|
|
||||||
|
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
|
||||||
|
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
|
||||||
|
t.Fatal("missing remote object should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRemoteWithoutOps(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
|
||||||
|
t.Fatal("remote url without ops wired should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ const (
|
|||||||
PeriodMonth = "month"
|
PeriodMonth = "month"
|
||||||
PeriodAll = "all"
|
PeriodAll = "all"
|
||||||
|
|
||||||
MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分
|
MetricAll = "all" // 综合:总榜=累计积分,周/月=周期内获得积分
|
||||||
MetricPost = "post" // 发帖最多
|
MetricPost = "post" // 发帖最多
|
||||||
MetricComment = "comment" // 回复最多
|
MetricComment = "comment" // 回复最多
|
||||||
MetricLike = "like" // 获赞最多
|
MetricLike = "like" // 获赞最多
|
||||||
@@ -112,6 +112,41 @@ func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRo
|
|||||||
return out, nil
|
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)的条数,
|
// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数,
|
||||||
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
|
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
|
||||||
func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) {
|
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
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween)
|
// scoreOf 细分指标 → 排名得分;「全部」维度走积分口径(见 boardBetween / earnedPoints)
|
||||||
func scoreOf(metric string, st statsRow) int64 {
|
func scoreOf(metric string, st statsRow) int64 {
|
||||||
switch metric {
|
switch metric {
|
||||||
case MetricPost:
|
case MetricPost:
|
||||||
@@ -178,9 +213,8 @@ func scoreOf(metric string, st statsRow) int64 {
|
|||||||
return st.LikeCount
|
return st.LikeCount
|
||||||
case MetricAnswer:
|
case MetricAnswer:
|
||||||
return st.AnswerCount
|
return st.AnswerCount
|
||||||
default:
|
|
||||||
return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount
|
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// entryFromUser 由用户行构造榜单条目(不含统计)
|
// entryFromUser 由用户行构造榜单条目(不含统计)
|
||||||
@@ -214,7 +248,8 @@ func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
|
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
|
||||||
// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。
|
// 「全部」维度走积分口径(总榜=total_points,周/月=周期内获得积分,见 earnedPoints),
|
||||||
|
// 细分维度走统计聚合口径。
|
||||||
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
|
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
|
||||||
if metric == MetricAll && from == nil {
|
if metric == MetricAll && from == nil {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
@@ -245,9 +280,29 @@ func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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)
|
score := scoreOf(metric, r)
|
||||||
|
if usePts {
|
||||||
|
score = pts[id]
|
||||||
|
}
|
||||||
if score <= 0 {
|
if score <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
67
backend/service/leaderboard_test.go
Normal file
67
backend/service/leaderboard_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
652
backend/service/library.go
Normal file
652
backend/service/library.go
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"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", "CoverWidth", "CoverHeight",
|
||||||
|
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
|
||||||
|
type LibraryCreator struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
|
||||||
|
type LibraryDocDetail struct {
|
||||||
|
model.LibraryDoc
|
||||||
|
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
|
||||||
|
Files []model.LibraryFile `json:"files"`
|
||||||
|
Sections []model.LibrarySection `json:"sections"`
|
||||||
|
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要)
|
||||||
|
type LibraryDocListItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
Files []model.LibraryFile `json:"files"`
|
||||||
|
Creator *LibraryCreator `json:"creator,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryInput 创建/更新书库条目入参
|
||||||
|
type LibraryInput struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"` // 书籍作者(自由文本,可空)
|
||||||
|
Description string `json:"description"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
|
||||||
|
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
|
||||||
|
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.Author = strings.TrimSpace(in.Author)
|
||||||
|
if utf8.RuneCountInString(in.Author) > 100 {
|
||||||
|
return errors.New("作者不能超过 100 字")
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Author = in.Author
|
||||||
|
d.Description = in.Description
|
||||||
|
d.CoverURL = in.CoverURL
|
||||||
|
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
|
||||||
|
if in.CoverURL == "" {
|
||||||
|
d.CoverWidth = 0
|
||||||
|
d.CoverHeight = 0
|
||||||
|
} else {
|
||||||
|
d.CoverWidth = in.CoverWidth
|
||||||
|
d.CoverHeight = in.CoverHeight
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
uploadsDir string // data/uploads:封面回填时解析本地封面图
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
|
||||||
|
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
|
||||||
|
s.uploadsDir = uploadsDir
|
||||||
|
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(§ions).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)
|
||||||
|
}
|
||||||
|
creatorByID := s.creatorMap(docs)
|
||||||
|
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,
|
||||||
|
Creator: creatorByID[d.CreatorID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
|
||||||
|
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
|
||||||
|
ids := make([]uint, 0, len(docs))
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, d := range docs {
|
||||||
|
if d.CreatorID > 0 && !seen[d.CreatorID] {
|
||||||
|
seen[d.CreatorID] = true
|
||||||
|
ids = append(ids, d.CreatorID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return map[uint]*LibraryCreator{}
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||||
|
return map[uint]*LibraryCreator{}
|
||||||
|
}
|
||||||
|
m := make(map[uint]*LibraryCreator, len(users))
|
||||||
|
for i := range users {
|
||||||
|
u := &users[i]
|
||||||
|
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*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}
|
||||||
|
if len(creatorID) > 0 {
|
||||||
|
d.CreatorID = creatorID[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);creator_id 创建后不变,不在此列
|
||||||
|
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
|
||||||
|
"slug": d.Slug,
|
||||||
|
"title": d.Title,
|
||||||
|
"author": d.Author,
|
||||||
|
"description": d.Description,
|
||||||
|
"cover_url": d.CoverURL,
|
||||||
|
"cover_width": d.CoverWidth,
|
||||||
|
"cover_height": d.CoverHeight,
|
||||||
|
"published": d.Published,
|
||||||
|
"sort_order": d.SortOrder,
|
||||||
|
"entries_auto": d.EntriesAuto,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
|
||||||
|
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
|
||||||
|
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
|
||||||
|
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
|
||||||
|
root := filepath.Clean(s.uploadsDir)
|
||||||
|
clean := filepath.Clean(abs)
|
||||||
|
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return clean, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
|
||||||
|
func measureCoverReader(r io.Reader) (w, h int, err error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
format, err := detectImageFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
|
||||||
|
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
|
||||||
|
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
|
||||||
|
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(d.CoverURL)
|
||||||
|
var reader io.ReadCloser
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(url, "/uploads/"):
|
||||||
|
abs, ok := s.resolveCoverPath(url)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader = f
|
||||||
|
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader = resp.Body
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, h, err := measureCoverReader(reader)
|
||||||
|
reader.Close()
|
||||||
|
if err != nil || w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.Model(d).Updates(map[string]interface{}{
|
||||||
|
"cover_width": w,
|
||||||
|
"cover_height": h,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.CoverWidth, d.CoverHeight = w, h
|
||||||
|
}
|
||||||
|
|
||||||
|
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, Author: d.Author, Description: d.Description,
|
||||||
|
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
|
||||||
|
Creator: d.Creator,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
|
||||||
|
s.ensureCoverDimensions(&d)
|
||||||
|
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"))
|
||||||
|
}
|
||||||
369
backend/service/library_export.go
Normal file
369
backend/service/library_export.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
|
||||||
|
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
|
||||||
|
//
|
||||||
|
// 单本 ZIP 布局:
|
||||||
|
//
|
||||||
|
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
|
||||||
|
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
|
||||||
|
// files/<stored-name> 附件原文件
|
||||||
|
//
|
||||||
|
// 全库 ZIP 布局:
|
||||||
|
//
|
||||||
|
// library.json 索引(每本书的目录位置)
|
||||||
|
// docs/<slug>/book.json 各书清单
|
||||||
|
// docs/<slug>/cover.<ext>
|
||||||
|
// docs/<slug>/files/<stored-name>
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
libraryExportFormat = "jiang13-library" // 全库包
|
||||||
|
libraryExportBookFormat = "jiang13-library-book" // 单本书包
|
||||||
|
libraryExportVersion = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致)
|
||||||
|
var libraryExportCoverExts = map[string]struct{}{
|
||||||
|
"jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportFile 附件清单条目
|
||||||
|
type libraryExportFile struct {
|
||||||
|
Name string `json:"name"` // 原始文件名
|
||||||
|
Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空)
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
DownloadCount int `json:"download_count"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key)
|
||||||
|
type libraryExportSection struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
ParentKey string `json:"parent_key,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportBook 单本书的完整清单
|
||||||
|
type libraryExportBook struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径
|
||||||
|
CoverWidth int `json:"cover_width"`
|
||||||
|
CoverHeight int `json:"cover_height"`
|
||||||
|
Published bool `json:"published"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
EntriesAuto bool `json:"entries_auto"`
|
||||||
|
|
||||||
|
Files []libraryExportFile `json:"files"`
|
||||||
|
Sections []libraryExportSection `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportIndexEntry 全库索引中的单本概要
|
||||||
|
type libraryExportIndexEntry struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Dir string `json:"dir"` // 相对 ZIP 根的目录
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
SectionCount int `json:"section_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引
|
||||||
|
type libraryExportManifest struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
FormatVersion int `json:"format_version"`
|
||||||
|
ExportedAt string `json:"exported_at"`
|
||||||
|
Doc *libraryExportBook `json:"doc,omitempty"`
|
||||||
|
Docs []libraryExportIndexEntry `json:"docs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// preparedFile 已打开、待流式拷入 ZIP 的附件
|
||||||
|
type preparedFile struct {
|
||||||
|
zipPath string
|
||||||
|
reader *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄)
|
||||||
|
type preparedBook struct {
|
||||||
|
meta libraryExportBook
|
||||||
|
dir string // ZIP 内目录前缀(单本包为空串 = 根目录)
|
||||||
|
coverPath string // ZIP 内封面路径(空 = 不内嵌)
|
||||||
|
coverReader *os.File
|
||||||
|
files []preparedFile // 仅磁盘存在的附件
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *preparedBook) closeReaders() {
|
||||||
|
if b.coverReader != nil {
|
||||||
|
_ = b.coverReader.Close()
|
||||||
|
}
|
||||||
|
for i := range b.files {
|
||||||
|
_ = b.files[i].reader.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryExport 已完成装载、可直接流式写出的导出包
|
||||||
|
type LibraryExport struct {
|
||||||
|
Filename string
|
||||||
|
full bool
|
||||||
|
exportedAt time.Time
|
||||||
|
books []preparedBook
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放导出过程中打开的全部文件句柄
|
||||||
|
func (e *LibraryExport) Close() {
|
||||||
|
for i := range e.books {
|
||||||
|
e.books[i].closeReaders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。
|
||||||
|
// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。
|
||||||
|
func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) {
|
||||||
|
d, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := s.prepareBook(d, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := d.Slug
|
||||||
|
if name == "" {
|
||||||
|
name = "book"
|
||||||
|
}
|
||||||
|
return &LibraryExport{
|
||||||
|
Filename: name + ".zip",
|
||||||
|
full: false,
|
||||||
|
exportedAt: time.Now(),
|
||||||
|
books: []preparedBook{*b},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序)
|
||||||
|
func (s *LibraryService) BuildAllExport() (*LibraryExport, error) {
|
||||||
|
var docs []model.LibraryDoc
|
||||||
|
if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
details := s.attachFiles(docs)
|
||||||
|
exp := &LibraryExport{
|
||||||
|
Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip",
|
||||||
|
full: true,
|
||||||
|
exportedAt: time.Now(),
|
||||||
|
books: make([]preparedBook, 0, len(details)),
|
||||||
|
}
|
||||||
|
for i := range details {
|
||||||
|
// slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底
|
||||||
|
dir := "docs/" + details[i].Slug + "/"
|
||||||
|
if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) {
|
||||||
|
exp.Close()
|
||||||
|
return nil, errors.New("条目 slug 含非法字符,无法导出")
|
||||||
|
}
|
||||||
|
b, err := s.prepareBook(&details[i], dir)
|
||||||
|
if err != nil {
|
||||||
|
exp.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exp.books = append(exp.books, *b)
|
||||||
|
}
|
||||||
|
return exp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀)
|
||||||
|
func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) {
|
||||||
|
b := &preparedBook{dir: dir}
|
||||||
|
b.meta = libraryExportBook{
|
||||||
|
ID: d.ID,
|
||||||
|
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
Slug: d.Slug,
|
||||||
|
Title: d.Title,
|
||||||
|
Author: d.Author,
|
||||||
|
Description: d.Description,
|
||||||
|
CoverURL: d.CoverURL,
|
||||||
|
CoverWidth: d.CoverWidth,
|
||||||
|
CoverHeight: d.CoverHeight,
|
||||||
|
Published: d.Published,
|
||||||
|
SortOrder: d.SortOrder,
|
||||||
|
EntriesAuto: d.EntriesAuto,
|
||||||
|
Files: make([]libraryExportFile, 0, len(d.Files)),
|
||||||
|
Sections: make([]libraryExportSection, 0, len(d.Sections)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序)
|
||||||
|
keyByID := make(map[uint]string, len(d.Sections))
|
||||||
|
for i, sec := range d.Sections {
|
||||||
|
keyByID[sec.ID] = "s" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
for _, sec := range d.Sections {
|
||||||
|
m := libraryExportSection{
|
||||||
|
Key: keyByID[sec.ID],
|
||||||
|
Title: sec.Title,
|
||||||
|
Content: sec.Content,
|
||||||
|
SortOrder: sec.SortOrder,
|
||||||
|
}
|
||||||
|
if sec.ParentID != nil {
|
||||||
|
m.ParentKey = keyByID[*sec.ParentID]
|
||||||
|
}
|
||||||
|
b.meta.Sections = append(b.meta.Sections, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径
|
||||||
|
for _, f := range d.Files {
|
||||||
|
m := libraryExportFile{
|
||||||
|
Name: f.Name,
|
||||||
|
Ext: f.Ext,
|
||||||
|
MIME: f.MIME,
|
||||||
|
Size: f.Size,
|
||||||
|
DownloadCount: f.DownloadCount,
|
||||||
|
SortOrder: f.SortOrder,
|
||||||
|
}
|
||||||
|
full := filepath.Join(s.dir, f.StoredName)
|
||||||
|
if r, err := os.Open(full); err == nil {
|
||||||
|
m.Stored = "files/" + f.StoredName
|
||||||
|
b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r})
|
||||||
|
} else {
|
||||||
|
m.Missing = true
|
||||||
|
}
|
||||||
|
b.meta.Files = append(b.meta.Files, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL
|
||||||
|
if rel, ok := s.resolveCoverPath(d.CoverURL); ok {
|
||||||
|
if ext := coverExtOf(d.CoverURL); ext != "" {
|
||||||
|
if r, err := os.Open(rel); err == nil {
|
||||||
|
b.coverReader = r
|
||||||
|
b.coverPath = dir + "cover." + ext
|
||||||
|
b.meta.CoverFile = "cover." + ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。
|
||||||
|
func (e *LibraryExport) WriteZip(w io.Writer) error {
|
||||||
|
defer e.Close()
|
||||||
|
zw := zip.NewWriter(w)
|
||||||
|
|
||||||
|
index := make([]libraryExportIndexEntry, 0, len(e.books))
|
||||||
|
for i := range e.books {
|
||||||
|
b := &e.books[i]
|
||||||
|
// 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写
|
||||||
|
if e.full {
|
||||||
|
if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.coverReader != nil {
|
||||||
|
if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j := range b.files {
|
||||||
|
if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = append(index, libraryExportIndexEntry{
|
||||||
|
Slug: b.meta.Slug,
|
||||||
|
Title: b.meta.Title,
|
||||||
|
Dir: b.dir,
|
||||||
|
FileCount: len(b.meta.Files),
|
||||||
|
SectionCount: len(b.meta.Sections),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内)
|
||||||
|
if e.full {
|
||||||
|
root := libraryExportManifest{
|
||||||
|
Format: libraryExportFormat,
|
||||||
|
FormatVersion: libraryExportVersion,
|
||||||
|
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||||
|
Docs: index,
|
||||||
|
}
|
||||||
|
if err := writeZipJSON(zw, "library.json", root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root := libraryExportManifest{
|
||||||
|
Format: libraryExportBookFormat,
|
||||||
|
FormatVersion: libraryExportVersion,
|
||||||
|
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||||
|
Doc: &e.books[0].meta,
|
||||||
|
}
|
||||||
|
if err := writeZipJSON(zw, "book.json", root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名
|
||||||
|
func coverExtOf(coverURL string) string {
|
||||||
|
u := strings.TrimSpace(coverURL)
|
||||||
|
if i := strings.IndexByte(u, '?'); i >= 0 {
|
||||||
|
u = u[:i]
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), "."))
|
||||||
|
if _, ok := libraryExportCoverExts[ext]; !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读)
|
||||||
|
func writeZipJSON(zw *zip.Writer, name string, v any) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||||
|
hdr.SetMode(0o644)
|
||||||
|
f, err := zw.CreateHeader(hdr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(buf.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU)
|
||||||
|
func writeZipFile(zw *zip.Writer, name string, r io.Reader) error {
|
||||||
|
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||||
|
hdr.SetMode(0o644)
|
||||||
|
f, err := zw.CreateHeader(hdr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
199
backend/service/library_export_test.go
Normal file
199
backend/service/library_export_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
|
||||||
|
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
|
||||||
|
t.Helper()
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.Name == name {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open zip entry %s: %v", name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read zip entry %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := exp.WriteZip(&buf); err != nil {
|
||||||
|
t.Fatalf("write zip: %v", err)
|
||||||
|
}
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read exported zip: %v", err)
|
||||||
|
}
|
||||||
|
return zr
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
|
||||||
|
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
|
||||||
|
t.Helper()
|
||||||
|
pub := true
|
||||||
|
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create doc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverDir := filepath.Join(uploads, "images")
|
||||||
|
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir uploads: %v", err)
|
||||||
|
}
|
||||||
|
coverBytes := encodeJPEGBytes(t, 120, 160)
|
||||||
|
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
|
||||||
|
t.Fatalf("write cover: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||||
|
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
|
||||||
|
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
|
||||||
|
Published: &pub,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update cover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create chapter: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateSection(doc.ID, &SectionInput{
|
||||||
|
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create section: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add file: %v", err)
|
||||||
|
}
|
||||||
|
return doc.ID, f.StoredName
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExportBook(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
uploads := t.TempDir()
|
||||||
|
s.WithUploadsDir(uploads)
|
||||||
|
docID, storedName := seedBookForExport(t, s, uploads)
|
||||||
|
|
||||||
|
exp, err := s.BuildBookExport(docID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build export: %v", err)
|
||||||
|
}
|
||||||
|
defer exp.Close()
|
||||||
|
if exp.Filename != "dao-jia.zip" {
|
||||||
|
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
|
||||||
|
}
|
||||||
|
zr := buildExportZip(t, exp)
|
||||||
|
|
||||||
|
raw, ok := readZipEntry(t, zr, "book.json")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("缺少 book.json")
|
||||||
|
}
|
||||||
|
var manifest libraryExportManifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
t.Fatalf("parse book.json: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
|
||||||
|
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
|
||||||
|
}
|
||||||
|
b := manifest.Doc
|
||||||
|
if b == nil {
|
||||||
|
t.Fatal("book.json 缺少 doc")
|
||||||
|
}
|
||||||
|
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
|
||||||
|
t.Fatalf("元信息导出不正确: %+v", b)
|
||||||
|
}
|
||||||
|
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
|
||||||
|
t.Fatalf("封面信息导出不正确: %+v", b)
|
||||||
|
}
|
||||||
|
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
|
||||||
|
t.Fatalf("章节树导出不正确: %+v", b.Sections)
|
||||||
|
}
|
||||||
|
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
|
||||||
|
t.Fatalf("附件清单导出不正确: %+v", b.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
|
||||||
|
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
|
||||||
|
}
|
||||||
|
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
|
||||||
|
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
|
||||||
|
}
|
||||||
|
// 单本包不应出现全库索引
|
||||||
|
if _, ok := readZipEntry(t, zr, "library.json"); ok {
|
||||||
|
t.Fatal("单本包不应包含 library.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExportAll(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
uploads := t.TempDir()
|
||||||
|
s.WithUploadsDir(uploads)
|
||||||
|
docID, storedName := seedBookForExport(t, s, uploads)
|
||||||
|
_ = docID
|
||||||
|
|
||||||
|
exp, err := s.BuildAllExport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build all export: %v", err)
|
||||||
|
}
|
||||||
|
defer exp.Close()
|
||||||
|
zr := buildExportZip(t, exp)
|
||||||
|
|
||||||
|
raw, ok := readZipEntry(t, zr, "library.json")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("缺少 library.json")
|
||||||
|
}
|
||||||
|
var manifest libraryExportManifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
t.Fatalf("parse library.json: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
|
||||||
|
t.Fatalf("全库索引异常: %+v", manifest)
|
||||||
|
}
|
||||||
|
idx := manifest.Docs[0]
|
||||||
|
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
|
||||||
|
t.Fatalf("索引条目异常: %+v", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("缺少 docs/dao-jia/book.json")
|
||||||
|
}
|
||||||
|
var book libraryExportBook
|
||||||
|
if err := json.Unmarshal(bookRaw, &book); err != nil {
|
||||||
|
t.Fatalf("parse nested book.json: %v", err)
|
||||||
|
}
|
||||||
|
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
|
||||||
|
t.Fatalf("嵌套书籍清单异常: %+v", book)
|
||||||
|
}
|
||||||
|
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
|
||||||
|
t.Fatal("缺少嵌套附件")
|
||||||
|
}
|
||||||
|
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
|
||||||
|
t.Fatal("缺少嵌套封面")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExportMissingDoc(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
|
||||||
|
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
631
backend/service/library_import.go
Normal file
631
backend/service/library_import.go
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
|
||||||
|
//
|
||||||
|
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
|
||||||
|
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
|
||||||
|
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
|
||||||
|
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
|
||||||
|
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
|
||||||
|
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
|
||||||
|
const LibraryImportMaxBytes = 512 << 20
|
||||||
|
|
||||||
|
const (
|
||||||
|
LibraryImportModeCreate = "create" // 冲突跳过
|
||||||
|
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
|
||||||
|
)
|
||||||
|
|
||||||
|
// 失败原因(前端据此区分冲突,可提示覆盖重导)
|
||||||
|
const (
|
||||||
|
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
|
||||||
|
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
|
||||||
|
libraryImportReasonInvalid = "invalid" // 清单数据非法
|
||||||
|
libraryImportReasonFailed = "failed" // 落盘/写库失败
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
|
||||||
|
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
|
||||||
|
ErrLibraryImportVer = errors.New("导出版本不受支持")
|
||||||
|
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
|
||||||
|
ErrLibraryImportMode = errors.New("无效的导入模式")
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryImportItem 成功导入的单本结果
|
||||||
|
type LibraryImportItem struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Action string `json:"action"` // created / overwritten
|
||||||
|
Sections int `json:"sections"`
|
||||||
|
Files int `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryImportFailure 单本书失败明细
|
||||||
|
type LibraryImportFailure struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryImportReport 导入报告
|
||||||
|
type LibraryImportReport struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Imported []LibraryImportItem `json:"imported"`
|
||||||
|
Failed []LibraryImportFailure `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
|
||||||
|
type bookImportPlan struct {
|
||||||
|
book libraryExportBook
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
|
||||||
|
type plannedSection struct {
|
||||||
|
key string
|
||||||
|
parentKey string
|
||||||
|
title string
|
||||||
|
content string
|
||||||
|
sort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
|
||||||
|
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
|
||||||
|
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
|
||||||
|
return nil, ErrLibraryImportMode
|
||||||
|
}
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrLibraryImportBadZip
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
entries := make(map[string]*zip.File, len(zr.File))
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
|
||||||
|
}
|
||||||
|
|
||||||
|
plans, err := parseImportPlans(entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return nil, ErrLibraryImportEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := &LibraryImportReport{
|
||||||
|
Mode: mode,
|
||||||
|
Total: len(plans),
|
||||||
|
Imported: []LibraryImportItem{},
|
||||||
|
Failed: []LibraryImportFailure{},
|
||||||
|
}
|
||||||
|
imp := &bookImporter{s: s, entries: entries, userID: userID}
|
||||||
|
for _, p := range plans {
|
||||||
|
item, fail := imp.run(p, mode)
|
||||||
|
if fail != nil {
|
||||||
|
rep.Failed = append(rep.Failed, *fail)
|
||||||
|
} else {
|
||||||
|
rep.Imported = append(rep.Imported, *item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
|
||||||
|
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
|
||||||
|
if rootRaw, ok := entries["library.json"]; ok {
|
||||||
|
var root libraryExportManifest
|
||||||
|
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
|
||||||
|
return nil, ErrLibraryImportVer
|
||||||
|
}
|
||||||
|
plans := make([]bookImportPlan, 0, len(root.Docs))
|
||||||
|
for _, d := range root.Docs {
|
||||||
|
dir := strings.ReplaceAll(d.Dir, "\\", "/")
|
||||||
|
if !safeImportDir(dir) {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
raw, ok := entries[path.Clean(dir)+"/book.json"]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
var b libraryExportBook
|
||||||
|
if err := readZipJSON(raw, &b); err != nil {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
plans = append(plans, bookImportPlan{book: b, dir: dir})
|
||||||
|
}
|
||||||
|
return plans, nil
|
||||||
|
}
|
||||||
|
if rootRaw, ok := entries["book.json"]; ok {
|
||||||
|
var root libraryExportManifest
|
||||||
|
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
|
||||||
|
return nil, ErrLibraryImportVer
|
||||||
|
}
|
||||||
|
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
|
||||||
|
}
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
|
||||||
|
func safeImportDir(dir string) bool {
|
||||||
|
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
|
||||||
|
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sitePageSlugRe.MatchString(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bookImporter 携带一次批量导入的共享上下文
|
||||||
|
type bookImporter struct {
|
||||||
|
s *LibraryService
|
||||||
|
entries map[string]*zip.File
|
||||||
|
userID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
|
||||||
|
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run 导入单本书;返回 item 或 failure(二者互斥)
|
||||||
|
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
|
||||||
|
b := p.book
|
||||||
|
|
||||||
|
in, err := buildImportInput(&b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
sections, err := buildImportSections(&b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
if err := imp.checkPlannedFiles(p); err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
coverEntry, err := imp.planCover(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var docID uint
|
||||||
|
action := "created"
|
||||||
|
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
|
||||||
|
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
|
||||||
|
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
|
||||||
|
switch {
|
||||||
|
case active && mode != LibraryImportModeOverwrite:
|
||||||
|
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
|
||||||
|
case active:
|
||||||
|
var existing model.LibraryDoc
|
||||||
|
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
|
||||||
|
}
|
||||||
|
docID = existing.ID
|
||||||
|
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
|
||||||
|
backfillCreator = existing.CreatorID == 0
|
||||||
|
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
|
||||||
|
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
|
||||||
|
oldCoverDisk = disk
|
||||||
|
}
|
||||||
|
action = "overwritten"
|
||||||
|
case deleted:
|
||||||
|
return nil, imp.fail(p, libraryImportReasonDeleted,
|
||||||
|
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
|
||||||
|
coverDisk := ""
|
||||||
|
if coverEntry != nil {
|
||||||
|
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
|
||||||
|
if ferr != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
|
||||||
|
}
|
||||||
|
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
|
||||||
|
}
|
||||||
|
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
|
||||||
|
|
||||||
|
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if action == "created" {
|
||||||
|
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
|
||||||
|
in.applyTo(d)
|
||||||
|
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
docID = d.ID
|
||||||
|
} else {
|
||||||
|
var d model.LibraryDoc
|
||||||
|
if err := tx.First(&d, docID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.applyTo(&d)
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"slug": d.Slug,
|
||||||
|
"title": d.Title,
|
||||||
|
"author": d.Author,
|
||||||
|
"description": d.Description,
|
||||||
|
"cover_url": d.CoverURL,
|
||||||
|
"cover_width": d.CoverWidth,
|
||||||
|
"cover_height": d.CoverHeight,
|
||||||
|
"published": d.Published,
|
||||||
|
"sort_order": d.SortOrder,
|
||||||
|
"entries_auto": d.EntriesAuto,
|
||||||
|
}
|
||||||
|
if backfillCreator {
|
||||||
|
updates["creator_id"] = imp.userID
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imp.createSections(tx, docID, sections)
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
|
if action == "created" && coverDisk != "" {
|
||||||
|
_ = os.Remove(coverDisk)
|
||||||
|
}
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
|
||||||
|
for i := range oldFiles {
|
||||||
|
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
|
||||||
|
}
|
||||||
|
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
|
||||||
|
_ = os.Remove(oldCoverDisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
|
||||||
|
added := 0
|
||||||
|
var newFiles []model.LibraryFile
|
||||||
|
for _, mf := range b.Files {
|
||||||
|
if mf.Missing || mf.Stored == "" {
|
||||||
|
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
|
||||||
|
}
|
||||||
|
f, fail := imp.addPlannedFile(p, docID, mf)
|
||||||
|
if fail != nil {
|
||||||
|
if action == "created" {
|
||||||
|
imp.rollbackCreated(docID, newFiles, coverDisk)
|
||||||
|
}
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
newFiles = append(newFiles, *f)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LibraryImportItem{
|
||||||
|
Slug: b.Slug,
|
||||||
|
Title: b.Title,
|
||||||
|
Action: action,
|
||||||
|
Sections: len(sections),
|
||||||
|
Files: added,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 清单解析与预检 ----------
|
||||||
|
|
||||||
|
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
|
||||||
|
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
|
||||||
|
in := &LibraryInput{
|
||||||
|
Slug: b.Slug,
|
||||||
|
Title: b.Title,
|
||||||
|
Author: b.Author,
|
||||||
|
Description: b.Description,
|
||||||
|
CoverURL: b.CoverURL,
|
||||||
|
CoverWidth: b.CoverWidth,
|
||||||
|
CoverHeight: b.CoverHeight,
|
||||||
|
Published: &pub,
|
||||||
|
SortOrder: &sortOrder,
|
||||||
|
EntriesAuto: &entriesAuto,
|
||||||
|
}
|
||||||
|
if err := in.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
|
||||||
|
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
|
||||||
|
if len(b.Sections) > MaxSectionsPerDoc {
|
||||||
|
return nil, errors.New("章节数量超过上限(最多 200)")
|
||||||
|
}
|
||||||
|
keySeen := map[string]bool{}
|
||||||
|
out := make([]plannedSection, 0, len(b.Sections))
|
||||||
|
|
||||||
|
chapterSort := map[string]int{}
|
||||||
|
order := 0
|
||||||
|
for _, sec := range b.Sections {
|
||||||
|
if sec.ParentKey != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(sec.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("存在标题为空的章节")
|
||||||
|
}
|
||||||
|
if err := validateSectionText(title, sec.Content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keySeen[sec.Key] {
|
||||||
|
return nil, errors.New("章节标识重复:" + sec.Key)
|
||||||
|
}
|
||||||
|
keySeen[sec.Key] = true
|
||||||
|
chapterSort[sec.Key] = order
|
||||||
|
out = append(out, plannedSection{
|
||||||
|
key: sec.Key, title: title, content: sec.Content, sort: order,
|
||||||
|
})
|
||||||
|
order++
|
||||||
|
}
|
||||||
|
|
||||||
|
childCount := map[string]int{}
|
||||||
|
for _, sec := range b.Sections {
|
||||||
|
if sec.ParentKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := chapterSort[sec.ParentKey]; !ok {
|
||||||
|
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(sec.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("存在标题为空的小节")
|
||||||
|
}
|
||||||
|
if err := validateSectionText(title, sec.Content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, plannedSection{
|
||||||
|
key: sec.Key,
|
||||||
|
parentKey: sec.ParentKey,
|
||||||
|
title: title,
|
||||||
|
content: sec.Content,
|
||||||
|
sort: childCount[sec.ParentKey],
|
||||||
|
})
|
||||||
|
childCount[sec.ParentKey]++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSectionText(title, content string) error {
|
||||||
|
if len([]rune(title)) > 200 {
|
||||||
|
return errors.New("章节标题不能超过 200 字:" + title)
|
||||||
|
}
|
||||||
|
if len([]rune(content)) > MaxSectionContent {
|
||||||
|
return errors.New("章节正文不能超过 100000 字:" + title)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
|
||||||
|
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
|
||||||
|
if len(p.book.Files) > MaxLibraryFilesPerDoc {
|
||||||
|
return errors.New("附件数量超过上限(最多 20 个)")
|
||||||
|
}
|
||||||
|
maxB := imp.s.maxBytes()
|
||||||
|
for _, f := range p.book.Files {
|
||||||
|
if f.Missing || f.Stored == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel, ok := cleanZipRel(f.Stored)
|
||||||
|
if !ok || !strings.HasPrefix(rel, "files/") {
|
||||||
|
return errors.New("附件路径非法:" + f.Stored)
|
||||||
|
}
|
||||||
|
name := sanitizeFilename(f.Name)
|
||||||
|
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
|
||||||
|
return errors.New("附件格式不受支持:" + f.Name)
|
||||||
|
}
|
||||||
|
zf, ok := imp.entries[p.dir+rel]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("备份包缺少附件文件:" + f.Name)
|
||||||
|
}
|
||||||
|
if zf.UncompressedSize64 > uint64(maxB) {
|
||||||
|
return errors.New("附件超过大小上限:" + f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
|
||||||
|
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
|
||||||
|
cf := strings.TrimSpace(p.book.CoverFile)
|
||||||
|
if cf == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rel, ok := cleanZipRel(cf)
|
||||||
|
if !ok || strings.Contains(rel, "/") {
|
||||||
|
return nil, errors.New("封面路径非法:" + cf)
|
||||||
|
}
|
||||||
|
zf, ok := imp.entries[p.dir+rel]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("备份包缺少封面文件")
|
||||||
|
}
|
||||||
|
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
|
||||||
|
return nil, errors.New("封面不能超过 5MB")
|
||||||
|
}
|
||||||
|
return zf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 落盘 ----------
|
||||||
|
|
||||||
|
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
|
||||||
|
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
|
||||||
|
keyToID := make(map[string]uint, len(sections))
|
||||||
|
for i := range sections {
|
||||||
|
ps := §ions[i]
|
||||||
|
if ps.parentKey != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
|
||||||
|
if err := tx.Create(sec).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyToID[ps.key] = sec.ID
|
||||||
|
}
|
||||||
|
for i := range sections {
|
||||||
|
ps := §ions[i]
|
||||||
|
if ps.parentKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentID, ok := keyToID[ps.parentKey]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("小节找不到所属章节")
|
||||||
|
}
|
||||||
|
sec := &model.LibrarySection{
|
||||||
|
DocID: docID, ParentID: &parentID,
|
||||||
|
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
|
||||||
|
}
|
||||||
|
if err := tx.Create(sec).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
|
||||||
|
func (imp *bookImporter) addPlannedFile(
|
||||||
|
p bookImportPlan, docID uint, mf libraryExportFile,
|
||||||
|
) (*model.LibraryFile, *LibraryImportFailure) {
|
||||||
|
rel, _ := cleanZipRel(mf.Stored)
|
||||||
|
zf, ok := imp.entries[p.dir+rel]
|
||||||
|
if !ok {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
|
||||||
|
}
|
||||||
|
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
|
||||||
|
UpdateColumns(map[string]interface{}{
|
||||||
|
"download_count": mf.DownloadCount,
|
||||||
|
"sort_order": mf.SortOrder,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
|
||||||
|
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
|
||||||
|
if imp.s.uploadsDir == "" {
|
||||||
|
return "", 0, 0, "", errors.New("未配置上传目录")
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > ImageMaxBytes {
|
||||||
|
return "", 0, 0, "", errors.New("封面不能超过 5MB")
|
||||||
|
}
|
||||||
|
format, err := detectImageFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||||
|
if err != nil || width < 1 || height < 1 {
|
||||||
|
return "", 0, 0, "", errors.New("无法解析封面图片")
|
||||||
|
}
|
||||||
|
if width > ImageMaxDim || height > ImageMaxDim {
|
||||||
|
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
|
||||||
|
}
|
||||||
|
nameBytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nameBytes); err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||||
|
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
|
||||||
|
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
full := filepath.Join(imagesDir, filename)
|
||||||
|
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
return "/uploads/images/" + filename, width, height, full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
|
||||||
|
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
|
||||||
|
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
|
||||||
|
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
|
||||||
|
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
|
||||||
|
for i := range newFiles {
|
||||||
|
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
|
||||||
|
}
|
||||||
|
if coverDisk != "" {
|
||||||
|
_ = os.Remove(coverDisk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ZIP 工具 ----------
|
||||||
|
|
||||||
|
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
|
||||||
|
func cleanZipRel(rel string) (string, bool) {
|
||||||
|
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
|
||||||
|
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(p, "/") {
|
||||||
|
if seg == ".." {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readZipJSON(zf *zip.File, v any) error {
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return json.NewDecoder(rc).Decode(v)
|
||||||
|
}
|
||||||
354
backend/service/library_import_test.go
Normal file
354
backend/service/library_import_test.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
|
||||||
|
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "export.zip")
|
||||||
|
f, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp zip: %v", err)
|
||||||
|
}
|
||||||
|
if err := exp.WriteZip(f); err != nil {
|
||||||
|
t.Fatalf("write export zip: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatalf("close temp zip: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
|
||||||
|
func writeZipFileMap(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
for name, content := range entries {
|
||||||
|
w, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(w, content); err != nil {
|
||||||
|
t.Fatalf("zip write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("zip close: %v", err)
|
||||||
|
}
|
||||||
|
p := filepath.Join(t.TempDir(), "in.zip")
|
||||||
|
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write temp zip: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
|
||||||
|
t.Helper()
|
||||||
|
if rep == nil {
|
||||||
|
t.Fatal("报告为空")
|
||||||
|
}
|
||||||
|
if rep.Total != total {
|
||||||
|
t.Fatalf("total = %d, want %d", rep.Total, total)
|
||||||
|
}
|
||||||
|
if len(rep.Failed) != 0 {
|
||||||
|
t.Fatalf("存在失败记录: %+v", rep.Failed)
|
||||||
|
}
|
||||||
|
if len(rep.Imported) != total {
|
||||||
|
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
|
||||||
|
func TestLibraryImportBookRoundTrip(t *testing.T) {
|
||||||
|
// ---- 源库 ----
|
||||||
|
src, _ := newLibraryTestService(t)
|
||||||
|
srcUploads := t.TempDir()
|
||||||
|
src.WithUploadsDir(srcUploads)
|
||||||
|
docID, storedName := seedBookForExport(t, src, srcUploads)
|
||||||
|
|
||||||
|
// 制造两次下载,验证计数随包保留
|
||||||
|
var srcFile model.LibraryFile
|
||||||
|
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
|
||||||
|
t.Fatalf("find source file: %v", err)
|
||||||
|
}
|
||||||
|
src.IncrDownload(srcFile.ID)
|
||||||
|
src.IncrDownload(srcFile.ID)
|
||||||
|
|
||||||
|
exp, err := src.BuildBookExport(docID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build export: %v", err)
|
||||||
|
}
|
||||||
|
zipPath := writeExportZipFile(t, exp)
|
||||||
|
exp.Close()
|
||||||
|
|
||||||
|
// ---- 目标库(空库)----
|
||||||
|
dst, _ := newLibraryTestService(t)
|
||||||
|
dstUploads := t.TempDir()
|
||||||
|
dst.WithUploadsDir(dstUploads)
|
||||||
|
|
||||||
|
// 1) create 导入
|
||||||
|
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
assertReportOK(t, rep, 1)
|
||||||
|
item := rep.Imported[0]
|
||||||
|
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
|
||||||
|
t.Fatalf("导入条目异常: %+v", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc model.LibraryDoc
|
||||||
|
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
|
||||||
|
t.Fatalf("find imported doc: %v", err)
|
||||||
|
}
|
||||||
|
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
|
||||||
|
t.Fatalf("元信息恢复不正确: %+v", doc)
|
||||||
|
}
|
||||||
|
if doc.Author != "老子" {
|
||||||
|
t.Fatalf("作者恢复不正确: %q", doc.Author)
|
||||||
|
}
|
||||||
|
if doc.CreatorID != 7 {
|
||||||
|
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
|
||||||
|
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
|
||||||
|
}
|
||||||
|
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
|
||||||
|
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
|
||||||
|
}
|
||||||
|
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
|
||||||
|
}
|
||||||
|
coverData, err := os.ReadFile(coverDisk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("封面未落盘: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
|
||||||
|
t.Fatal("封面内容不是 JPEG")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节树:章 + 小节父子关系
|
||||||
|
secs, err := dst.ListSections(doc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list sections: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs) != 2 {
|
||||||
|
t.Fatalf("章节数 = %d, want 2", len(secs))
|
||||||
|
}
|
||||||
|
var chapter, child *model.LibrarySection
|
||||||
|
for i := range secs {
|
||||||
|
if secs[i].ParentID == nil {
|
||||||
|
chapter = &secs[i]
|
||||||
|
} else {
|
||||||
|
child = &secs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chapter == nil || child == nil {
|
||||||
|
t.Fatalf("章节层级不正确: %+v", secs)
|
||||||
|
}
|
||||||
|
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
|
||||||
|
t.Fatalf("章信息不正确: %+v", chapter)
|
||||||
|
}
|
||||||
|
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
|
||||||
|
t.Fatalf("小节信息不正确: %+v", child)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件:重新随机落盘、内容与下载计数保留
|
||||||
|
var files []model.LibraryFile
|
||||||
|
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
|
||||||
|
t.Fatalf("list files: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("附件数 = %d, want 1", len(files))
|
||||||
|
}
|
||||||
|
nf := files[0]
|
||||||
|
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
|
||||||
|
t.Fatalf("附件恢复不正确: %+v", nf)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(dst.FilePath(&nf))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("附件未落盘: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "EPUB-FILE-BYTES" {
|
||||||
|
t.Fatalf("附件内容不正确: %q", data)
|
||||||
|
}
|
||||||
|
firstCoverURL := doc.CoverURL
|
||||||
|
|
||||||
|
// 2) 再次 create:冲突跳过
|
||||||
|
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-import: %v", err)
|
||||||
|
}
|
||||||
|
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
|
||||||
|
t.Fatalf("冲突报告异常: %+v", rep2)
|
||||||
|
}
|
||||||
|
if rep2.Failed[0].Reason != libraryImportReasonConflict {
|
||||||
|
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
|
||||||
|
}
|
||||||
|
var docCount int64
|
||||||
|
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||||
|
if docCount != 1 {
|
||||||
|
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
|
||||||
|
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add extra: %v", err)
|
||||||
|
}
|
||||||
|
extraPath := dst.FilePath(extra)
|
||||||
|
if _, err := os.Stat(extraPath); err != nil {
|
||||||
|
t.Fatalf("extra 未先落盘: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overwrite import: %v", err)
|
||||||
|
}
|
||||||
|
assertReportOK(t, rep3, 1)
|
||||||
|
if rep3.Imported[0].Action != "overwritten" {
|
||||||
|
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||||
|
if docCount != 1 {
|
||||||
|
t.Fatalf("覆盖后书库数量异常: %d", docCount)
|
||||||
|
}
|
||||||
|
var doc2 model.LibraryDoc
|
||||||
|
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
|
||||||
|
t.Fatalf("find overwritten doc: %v", err)
|
||||||
|
}
|
||||||
|
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
|
||||||
|
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
|
||||||
|
}
|
||||||
|
if doc2.Author != "老子" || doc2.CreatorID != 7 {
|
||||||
|
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
|
||||||
|
}
|
||||||
|
var files2 []model.LibraryFile
|
||||||
|
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
|
||||||
|
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
|
||||||
|
}
|
||||||
|
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
|
||||||
|
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
|
||||||
|
}
|
||||||
|
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
|
||||||
|
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
|
||||||
|
}
|
||||||
|
if doc2.CoverURL == firstCoverURL {
|
||||||
|
t.Fatal("覆盖后封面应重新落盘为新文件")
|
||||||
|
}
|
||||||
|
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
|
||||||
|
if _, err := os.ReadFile(newCoverDisk); err != nil {
|
||||||
|
t.Fatalf("新封面未落盘: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryImportAll 全库包往返
|
||||||
|
func TestLibraryImportAll(t *testing.T) {
|
||||||
|
src, _ := newLibraryTestService(t)
|
||||||
|
srcUploads := t.TempDir()
|
||||||
|
src.WithUploadsDir(srcUploads)
|
||||||
|
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
|
||||||
|
t.Fatalf("create second doc: %v", err)
|
||||||
|
}
|
||||||
|
seedBookForExport(t, src, srcUploads)
|
||||||
|
|
||||||
|
exp, err := src.BuildAllExport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build all export: %v", err)
|
||||||
|
}
|
||||||
|
zipPath := writeExportZipFile(t, exp)
|
||||||
|
exp.Close()
|
||||||
|
|
||||||
|
dst, _ := newLibraryTestService(t)
|
||||||
|
dst.WithUploadsDir(t.TempDir())
|
||||||
|
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import all: %v", err)
|
||||||
|
}
|
||||||
|
assertReportOK(t, rep, 2)
|
||||||
|
slugs := map[string]bool{}
|
||||||
|
for _, it := range rep.Imported {
|
||||||
|
slugs[it.Slug] = true
|
||||||
|
}
|
||||||
|
if !slugs["dao-jia"] || !slugs["second-book"] {
|
||||||
|
t.Fatalf("导入书集不正确: %+v", slugs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryImportRejectBadPackage 非法包整体拒绝
|
||||||
|
func TestLibraryImportRejectBadPackage(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
s.WithUploadsDir(t.TempDir())
|
||||||
|
|
||||||
|
badPath := filepath.Join(t.TempDir(), "bad.zip")
|
||||||
|
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
|
||||||
|
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
|
||||||
|
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
|
||||||
|
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
|
||||||
|
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanZipRel 路径穿越校验
|
||||||
|
func TestCleanZipRel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", false},
|
||||||
|
{".", false},
|
||||||
|
{"..", false},
|
||||||
|
{"../evil", false},
|
||||||
|
{"a/../../b", false},
|
||||||
|
{"/etc/passwd", false},
|
||||||
|
{`docs\..\..\x`, false},
|
||||||
|
{"files/a.epub", true},
|
||||||
|
{"docs/x/book.json", true},
|
||||||
|
{`docs\x/cover.jpg`, true},
|
||||||
|
{"a//b", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, ok := cleanZipRel(tc.in)
|
||||||
|
if ok != tc.want {
|
||||||
|
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
422
backend/service/library_section.go
Normal file
422
backend/service/library_section.go
Normal 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)
|
||||||
|
}
|
||||||
559
backend/service/library_test.go
Normal file
559
backend/service/library_test.go
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
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.User{}, &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) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"},
|
||||||
|
{"合法", 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 TestLibraryAuthorAndCreator(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"}
|
||||||
|
if err := s.db.Create(user).Error; err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := true
|
||||||
|
doc, err := s.Create(&LibraryInput{
|
||||||
|
Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub,
|
||||||
|
}, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if doc.Author != "鲁迅" || doc.CreatorID != user.ID {
|
||||||
|
t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListPublished()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Author != "鲁迅" {
|
||||||
|
t.Fatalf("列表作者异常: %+v", list)
|
||||||
|
}
|
||||||
|
if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" {
|
||||||
|
t.Fatalf("列表创建者摘要异常: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := s.GetPublishedBySlug("authored")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
if detail.Creator == nil || detail.Creator.Username != "curator" {
|
||||||
|
t.Fatalf("详情创建者摘要异常: %+v", detail.Creator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 作者可清空;更新不改变创建者归属
|
||||||
|
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||||
|
Slug: "authored", Title: "署名本", Author: "", Published: &pub,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
var again model.LibraryDoc
|
||||||
|
if err := s.db.First(&again, doc.ID).Error; err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if again.Author != "" || again.CreatorID != user.ID {
|
||||||
|
t.Fatalf("更新后作者/创建者异常: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("草稿条目不应出现在公开详情")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,10 +188,10 @@ func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
|
// TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是
|
||||||
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
|
// 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发
|
||||||
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
|
// 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。
|
||||||
func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
func TestRefreshReplayBeyondGraceSelfHeals(t *testing.T) {
|
||||||
db := newLoginSessionTestDB(t)
|
db := newLoginSessionTestDB(t)
|
||||||
s := NewAuthService(db, "test-secret")
|
s := NewAuthService(db, "test-secret")
|
||||||
user := newLoginSessionUser(t, db, "dave")
|
user := newLoginSessionUser(t, db, "dave")
|
||||||
@@ -204,7 +204,7 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse access 1: %v", err)
|
t.Fatalf("parse access 1: %v", err)
|
||||||
}
|
}
|
||||||
access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
|
access2, _, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login 2: %v", err)
|
t.Fatalf("login 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -213,8 +213,9 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
|||||||
t.Fatalf("parse access 2: %v", err)
|
t.Fatalf("parse access 2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 家族1 正常轮转一次:旧行吊销并链接到新行
|
// 家族1 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2
|
||||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
|
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("rotate 1: %v", err)
|
t.Fatalf("rotate 1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,21 +226,102 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
|||||||
t.Fatalf("backdate rotated_at: %v", err)
|
t.Fatalf("backdate rotated_at: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
|
// 宽限期外重放:自愈放行,返回头节点(当前代)token 对
|
||||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
|
at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
t.Fatalf("期望 ErrRefreshReused,实际 %v", err)
|
if err != nil {
|
||||||
|
t.Fatalf("宽限期外重放应自愈放行,实际 %v", err)
|
||||||
}
|
}
|
||||||
if s.familySessionActive(user.ID, claims1.FamilyID) {
|
if replayRefresh != refresh2 {
|
||||||
t.Fatal("重放所在家族应被整体吊销")
|
t.Fatal("自愈应返回轮转链头节点的 refresh token")
|
||||||
|
}
|
||||||
|
if replayUser == nil || replayUser.ID != user.ID {
|
||||||
|
t.Fatal("自愈应返回所属用户")
|
||||||
|
}
|
||||||
|
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
||||||
|
t.Fatalf("头节点 refresh 应保持有效: %v", err)
|
||||||
|
}
|
||||||
|
if !s.familySessionActive(user.ID, claims1.FamilyID) {
|
||||||
|
t.Fatal("重放所在家族应保持活跃,不被吊销")
|
||||||
}
|
}
|
||||||
if !s.familySessionActive(user.ID, claims2.FamilyID) {
|
if !s.familySessionActive(user.ID, claims2.FamilyID) {
|
||||||
t.Fatal("其他登录设备(家族)不应被牵连")
|
t.Fatal("其他登录设备(家族)不应被牵连")
|
||||||
}
|
}
|
||||||
if n := countActiveSessions(t, db, user.ID); n != 1 {
|
if n := countActiveSessions(t, db, user.ID); n != 2 {
|
||||||
t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n)
|
t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n)
|
||||||
}
|
}
|
||||||
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
if claims, err := s.ParseToken(at); err != nil {
|
||||||
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
|
t.Fatalf("自愈签发的 access token 应可解析: %v", err)
|
||||||
|
} else if _, err := s.ValidateClaims(claims); err != nil {
|
||||||
|
t.Fatalf("自愈签发的 access token 应有效: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshReplayMultiHopHealsToHead 轮转链已前进多代后重放最早一代:
|
||||||
|
// 应沿 rotated_to 链走到头节点,补发最新一代 token 对。
|
||||||
|
func TestRefreshReplayMultiHopHealsToHead(t *testing.T) {
|
||||||
|
db := newLoginSessionTestDB(t)
|
||||||
|
s := NewAuthService(db, "test-secret")
|
||||||
|
newLoginSessionUser(t, db, "frank")
|
||||||
|
|
||||||
|
_, refresh1, _, _, err := s.Login("frank", "password123", "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotate 1: %v", err)
|
||||||
|
}
|
||||||
|
_, refresh3, _, err := s.RotateRefreshToken(refresh2, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotate 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各代 rotated_at 均拨回宽限期之外,模拟浏览器长时间后才补投旧 token
|
||||||
|
if err := db.Model(&model.RefreshToken{}).
|
||||||
|
Where("token_hash IN ?", []string{hashRefreshToken(refresh1), hashRefreshToken(refresh2)}).
|
||||||
|
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatalf("backdate rotated_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("多级链重放应自愈到头节点,实际 %v", err)
|
||||||
|
}
|
||||||
|
if replayRefresh != refresh3 {
|
||||||
|
t.Fatal("自愈应返回最新一代(头节点)的 refresh token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshReplayDeadChainRejected 轮转链已无有效头节点(家族被整体吊销:
|
||||||
|
// 设备剔除/超限淘汰/改密/封禁)时,宽限期外重放属真实失效,应拒绝并返回
|
||||||
|
// ErrRefreshInvalid,且不得复活任何会话。
|
||||||
|
func TestRefreshReplayDeadChainRejected(t *testing.T) {
|
||||||
|
db := newLoginSessionTestDB(t)
|
||||||
|
s := NewAuthService(db, "test-secret")
|
||||||
|
user := newLoginSessionUser(t, db, "grace")
|
||||||
|
|
||||||
|
_, refresh1, _, _, err := s.Login("grace", "password123", "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟改密/封禁/剔除设备类处置:家族整体吊销
|
||||||
|
if err := s.RevokeAllUserRefreshTokens(user.ID); err != nil {
|
||||||
|
t.Fatalf("revoke all: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.RefreshToken{}).
|
||||||
|
Where("token_hash = ?", hashRefreshToken(refresh1)).
|
||||||
|
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatalf("backdate rotated_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshInvalid) {
|
||||||
|
t.Fatalf("死链重放应返回 ErrRefreshInvalid,实际 %v", err)
|
||||||
|
}
|
||||||
|
if n := countActiveSessions(t, db, user.ID); n != 0 {
|
||||||
|
t.Fatalf("死链自愈不得复活会话,活跃会话应仍为 0,实际 %d", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct {
|
|||||||
{"brand", "brand", "品牌资源"},
|
{"brand", "brand", "品牌资源"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
|
||||||
|
const (
|
||||||
|
mediaCategoryBook = "book"
|
||||||
|
mediaCategoryBookName = "书籍"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
|
||||||
|
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
|
||||||
|
// 其余按 kind 对应磁盘目录分类。
|
||||||
|
func mediaCategoryForAttachment(kind, source string) (key, name string) {
|
||||||
|
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
|
||||||
|
return mediaCategoryBook, mediaCategoryBookName
|
||||||
|
}
|
||||||
|
for _, cat := range mediaLibraryCategories {
|
||||||
|
if cat.Key == kind {
|
||||||
|
return cat.Key, cat.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kind, kind
|
||||||
|
}
|
||||||
|
|
||||||
// MediaLibraryItem 媒体库条目
|
// MediaLibraryItem 媒体库条目
|
||||||
type MediaLibraryItem struct {
|
type MediaLibraryItem struct {
|
||||||
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
||||||
@@ -53,6 +74,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
||||||
type attMeta struct {
|
type attMeta struct {
|
||||||
Kind string
|
Kind string
|
||||||
|
Source string
|
||||||
URL string
|
URL string
|
||||||
MIME string
|
MIME string
|
||||||
Size int
|
Size int
|
||||||
@@ -64,7 +86,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
}
|
}
|
||||||
var rows []attMeta
|
var rows []attMeta
|
||||||
if err := s.db.Table("attachments").
|
if err := s.db.Table("attachments").
|
||||||
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
||||||
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -72,30 +94,22 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
metaByURL := make(map[string]attMeta, len(rows))
|
metaByURL := make(map[string]attMeta, len(rows))
|
||||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
||||||
items := make([]MediaLibraryItem, 0, len(rows))
|
items := make([]MediaLibraryItem, 0, len(rows))
|
||||||
categoryName := func(key string) string {
|
|
||||||
for _, cat := range mediaLibraryCategories {
|
|
||||||
if cat.Key == key {
|
|
||||||
return cat.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
if !strings.HasPrefix(r.URL, "/api/media/") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||||
|
continue // 未知 kind 不进媒体库
|
||||||
|
}
|
||||||
uploader := r.Nickname
|
uploader := r.Nickname
|
||||||
if uploader == "" {
|
if uploader == "" {
|
||||||
uploader = r.Username
|
uploader = r.Username
|
||||||
}
|
}
|
||||||
cat := r.Kind
|
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||||
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
|
|
||||||
continue // 未知 kind 不进媒体库
|
|
||||||
}
|
|
||||||
ca := r.CreatedAt
|
ca := r.CreatedAt
|
||||||
items = append(items, MediaLibraryItem{
|
items = append(items, MediaLibraryItem{
|
||||||
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
|
URL: r.URL, Category: catKey, CategoryName: catName,
|
||||||
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
||||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||||
Uploader: uploader, UploadedAt: &ca,
|
Uploader: uploader, UploadedAt: &ca,
|
||||||
@@ -153,6 +167,11 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
if m.Width > 0 {
|
if m.Width > 0 {
|
||||||
item.Width, item.Height = m.Width, m.Height
|
item.Width, item.Height = m.Width, m.Height
|
||||||
}
|
}
|
||||||
|
// 书库封面 / 正文插图改归“书籍”分类
|
||||||
|
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
|
||||||
|
item.Category = mediaCategoryBook
|
||||||
|
item.CategoryName = mediaCategoryBookName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
||||||
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
||||||
@@ -243,6 +262,8 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
postByURL := map[string]postRef{}
|
postByURL := map[string]postRef{}
|
||||||
type commentRef struct {
|
type commentRef struct {
|
||||||
id, postID uint
|
id, postID uint
|
||||||
|
floor uint
|
||||||
|
isRoot bool
|
||||||
}
|
}
|
||||||
commentByURL := map[string]commentRef{}
|
commentByURL := map[string]commentRef{}
|
||||||
|
|
||||||
@@ -299,6 +320,19 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
|
||||||
|
commentIDs := make([]uint, 0, len(commentByURL))
|
||||||
|
for _, cm := range commentByURL {
|
||||||
|
commentIDs = append(commentIDs, cm.id)
|
||||||
|
}
|
||||||
|
commentAnchors := CommentAnchors(s.db, commentIDs)
|
||||||
|
for u, cm := range commentByURL {
|
||||||
|
if a, ok := commentAnchors[cm.id]; ok {
|
||||||
|
cm.floor, cm.isRoot = a.Floor, a.IsRoot
|
||||||
|
commentByURL[u] = cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 评论来源要带宿主帖子标题,一次性补齐
|
// 评论来源要带宿主帖子标题,一次性补齐
|
||||||
postTitle := func(id uint) string {
|
postTitle := func(id uint) string {
|
||||||
var p model.Post
|
var p model.Post
|
||||||
@@ -374,7 +408,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if cm, ok := commentByURL[u]; ok {
|
if cm, ok := commentByURL[u]; ok {
|
||||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
|
if cm.floor > 0 {
|
||||||
|
if cm.isRoot {
|
||||||
|
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
|
||||||
|
} else {
|
||||||
|
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
|
||||||
|
}
|
||||||
title := commentPostTitle[cm.postID]
|
title := commentPostTitle[cm.postID]
|
||||||
if title != "" {
|
if title != "" {
|
||||||
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
||||||
|
|||||||
@@ -250,8 +250,7 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("comment image missing")
|
t.Fatal("comment image missing")
|
||||||
}
|
}
|
||||||
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
|
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
|
||||||
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
|
|
||||||
t.Fatalf("comment source url wrong: %+v", cimg)
|
t.Fatalf("comment source url wrong: %+v", cimg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
||||||
@@ -259,6 +258,63 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
||||||
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
|
||||||
|
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
|
||||||
|
|
||||||
|
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
var alice model.User
|
||||||
|
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||||
|
t.Fatalf("load user: %v", err)
|
||||||
|
}
|
||||||
|
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
mkAtt := func(source, url string) {
|
||||||
|
if err := s.db.Create(&model.Attachment{
|
||||||
|
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
|
||||||
|
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
|
||||||
|
CreatedAt: created,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create attachment %s: %v", source, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
|
||||||
|
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
|
||||||
|
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
|
||||||
|
|
||||||
|
items, counts, err := s.AdminMediaLibrary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||||
|
}
|
||||||
|
byURL := map[string]MediaLibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
byURL[it.URL] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面(磁盘)与正文插图(远程)都归“书籍”
|
||||||
|
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
|
||||||
|
it, ok := byURL[u]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s missing", u)
|
||||||
|
}
|
||||||
|
if it.Category != "book" || it.CategoryName != "书籍" {
|
||||||
|
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通帖子插图仍归 image
|
||||||
|
if post := byURL["/api/media/post-image"]; post.Category != "image" {
|
||||||
|
t.Fatalf("post image should stay image category, got %q", post.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts["book"] != 2 || counts["image"] != 1 {
|
||||||
|
t.Fatalf("counts wrong: %v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMediaThumb(t *testing.T) {
|
func TestMediaThumb(t *testing.T) {
|
||||||
s, dir := newMediaLibraryService(t)
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
webpenc "github.com/gen2brain/webp"
|
|
||||||
"golang.org/x/image/draw"
|
|
||||||
xwebp "golang.org/x/image/webp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||||||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||||||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||||||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||||||
|
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
|
||||||
|
|
||||||
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
||||||
const MediaThumbMaxSide = 480
|
const MediaThumbMaxSide = 480
|
||||||
@@ -70,12 +62,7 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var img image.Image
|
img, err := decodeImageBytes(data, ext == ".webp")
|
||||||
if ext == ".webp" {
|
|
||||||
img, err = xwebp.Decode(bytes.NewReader(data))
|
|
||||||
} else {
|
|
||||||
img, _, err = image.Decode(bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err // 如动图 WebP,调用方回退原图
|
return nil, err // 如动图 WebP,调用方回退原图
|
||||||
}
|
}
|
||||||
@@ -94,21 +81,13 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
|||||||
nh = MediaThumbMaxSide
|
nh = MediaThumbMaxSide
|
||||||
nw = max(1, w*MediaThumbMaxSide/h)
|
nw = max(1, w*MediaThumbMaxSide/h)
|
||||||
}
|
}
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
dst := scaleImage(img, nw, nh)
|
||||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||||
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||||||
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
|
_ = atomicWriteFile(thumbPath, out)
|
||||||
tmp := thumbPath + ".partial"
|
return out, nil
|
||||||
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
|
|
||||||
if err := os.Rename(tmp, thumbPath); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
|||||||
})
|
})
|
||||||
return 0, e
|
return 0, e
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codeVerifyMaxAttempts = 5
|
codeVerifyMaxAttempts = 5
|
||||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
|||||||
type AcceptedAnswer struct {
|
type AcceptedAnswer struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Floor int `json:"floor"`
|
Floor int `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
|
|||||||
ans := &AcceptedAnswer{
|
ans := &AcceptedAnswer{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Floor: floor,
|
Floor: floor,
|
||||||
|
IsRoot: c.ParentID == nil,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ const (
|
|||||||
|
|
||||||
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
|
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
|
||||||
SettingKeyUrlStyle = "url_style"
|
SettingKeyUrlStyle = "url_style"
|
||||||
|
|
||||||
|
// SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys);
|
||||||
|
// 缺行/非法/为空视为全量默认
|
||||||
|
SettingKeyMobileTabs = "mobile_tabs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -118,8 +122,19 @@ const (
|
|||||||
|
|
||||||
// DefaultUrlStyle 标准动态路由风格(/post/123)
|
// DefaultUrlStyle 标准动态路由风格(/post/123)
|
||||||
DefaultUrlStyle = "default"
|
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 论坛向默认允许扩展名(含安装包/脚本/网页)
|
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||||
var DefaultAttachmentExts = []string{
|
var DefaultAttachmentExts = []string{
|
||||||
"pdf", "txt", "md", "csv", "json",
|
"pdf", "txt", "md", "csv", "json",
|
||||||
@@ -206,6 +221,12 @@ type PublicSiteSettings struct {
|
|||||||
|
|
||||||
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
|
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
|
||||||
UrlStyle string `json:"url_style"`
|
UrlStyle string `json:"url_style"`
|
||||||
|
|
||||||
|
// MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认
|
||||||
|
MobileTabs []string `json:"mobile_tabs"`
|
||||||
|
|
||||||
|
// HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项
|
||||||
|
HeaderNav []HeaderNavItem `json:"header_nav"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingService 站点级键值设置
|
// SettingService 站点级键值设置
|
||||||
@@ -310,6 +331,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
BrandLogoFit: BrandLogoFitContain,
|
BrandLogoFit: BrandLogoFitContain,
|
||||||
FooterLinks: []FooterLink{},
|
FooterLinks: []FooterLink{},
|
||||||
UrlStyle: DefaultUrlStyle,
|
UrlStyle: DefaultUrlStyle,
|
||||||
|
HeaderNav: DefaultHeaderNav(),
|
||||||
}
|
}
|
||||||
accent, err := s.AccentColor()
|
accent, err := s.AccentColor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -513,6 +535,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
}
|
}
|
||||||
out.UrlStyle = urlStyle
|
out.UrlStyle = urlStyle
|
||||||
|
|
||||||
|
out.MobileTabs = s.MobileTabs()
|
||||||
|
out.HeaderNav = s.HeaderNav()
|
||||||
|
|
||||||
if err := s.fillBrand(&out); err != nil {
|
if err := s.fillBrand(&out); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -551,6 +576,84 @@ func (s *SettingService) SetUrlStyle(style string) error {
|
|||||||
return ErrInvalidSiteSetting
|
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);未配置时返回空串,表示走前端默认色
|
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
|
||||||
func (s *SettingService) AccentColor() (string, error) {
|
func (s *SettingService) AccentColor() (string, error) {
|
||||||
v, found, err := s.getValue(SettingKeyAccent)
|
v, found, err := s.getValue(SettingKeyAccent)
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"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 当成单个非法字段名)
|
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
|
||||||
var sitePageWriteFields = []string{
|
var sitePageWriteFields = []string{
|
||||||
@@ -116,16 +119,27 @@ func (in *SitePageInput) applyTo(p *model.SitePage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
|
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
|
||||||
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
|
// 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 {
|
if excludeID > 0 {
|
||||||
q = q.Where("id <> ?", excludeID)
|
q = q.Where("id <> ?", excludeID)
|
||||||
}
|
}
|
||||||
var n int64
|
var rows []struct {
|
||||||
if err := q.Count(&n).Error; err != nil {
|
DeletedAt gorm.DeletedAt
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
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 首页右栏:已发布且勾选侧栏展示
|
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
|
||||||
@@ -179,11 +193,23 @@ func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, erro
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll 管理后台全部单页
|
// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型)
|
||||||
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
|
type SitePageAdminItem struct {
|
||||||
var list []model.SitePage
|
model.SitePage
|
||||||
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
|
Deleted bool `json:"deleted"`
|
||||||
return list, err
|
}
|
||||||
|
|
||||||
|
// 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 取单页
|
// Get 管理后台按 ID 取单页
|
||||||
@@ -200,13 +226,16 @@ func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
|
|||||||
if err := in.normalize(nil); err != nil {
|
if err := in.normalize(nil); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
taken, err := s.slugTaken(in.Slug, 0)
|
active, deleted, err := s.slugTaken(in.Slug, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if taken {
|
if active {
|
||||||
return nil, errors.New("slug 已被占用")
|
return nil, errors.New("slug 已被占用")
|
||||||
}
|
}
|
||||||
|
if deleted {
|
||||||
|
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||||||
|
}
|
||||||
p := &model.SitePage{
|
p := &model.SitePage{
|
||||||
Published: false,
|
Published: false,
|
||||||
ShowInSidebar: 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 {
|
if err := in.normalize(&p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
taken, err := s.slugTaken(in.Slug, id)
|
active, deleted, err := s.slugTaken(in.Slug, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if taken {
|
if active {
|
||||||
return nil, errors.New("slug 已被占用")
|
return nil, errors.New("slug 已被占用")
|
||||||
}
|
}
|
||||||
|
if deleted {
|
||||||
|
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||||||
|
}
|
||||||
in.applyTo(&p)
|
in.applyTo(&p)
|
||||||
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
||||||
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
69
backend/service/site_page_test.go
Normal file
69
backend/service/site_page_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
371
backend/service/timeline_release.go
Normal file
371
backend/service/timeline_release.go
Normal 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
|
||||||
|
}
|
||||||
@@ -238,7 +238,7 @@ func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
||||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil, errors.New("文件为空")
|
return nil, errors.New("文件为空")
|
||||||
}
|
}
|
||||||
@@ -354,6 +354,7 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
|||||||
att := &model.Attachment{
|
att := &model.Attachment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Kind: model.AttachmentKindImage,
|
Kind: model.AttachmentKindImage,
|
||||||
|
Source: normalizeImageSource(source),
|
||||||
URL: "/uploads/images/" + storeName,
|
URL: "/uploads/images/" + storeName,
|
||||||
MIME: storeMime,
|
MIME: storeMime,
|
||||||
Size: int(storeSize),
|
Size: int(storeSize),
|
||||||
@@ -585,6 +586,46 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
|||||||
return s.SaveBackground(f)
|
return s.SaveBackground(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
|
||||||
|
func normalizeImageSource(s string) string {
|
||||||
|
switch s {
|
||||||
|
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
|
||||||
|
return s
|
||||||
|
default:
|
||||||
|
return model.AttachmentSourcePost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
|
||||||
|
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
||||||
|
// source 决定副本的来源标记(如 library_cover)
|
||||||
|
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*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, source)
|
||||||
|
}
|
||||||
|
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, source)
|
||||||
|
}
|
||||||
|
|
||||||
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
||||||
func (s *UploadService) BackgroundFileExists(url string) bool {
|
func (s *UploadService) BackgroundFileExists(url string) bool {
|
||||||
rel := BgRelPath(url)
|
rel := BgRelPath(url)
|
||||||
@@ -638,6 +679,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
|
|||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
|
||||||
|
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||||
|
var att model.Attachment
|
||||||
|
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &att, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
||||||
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
|
|||||||
t.Fatalf("encode jpeg: %v", err)
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
|
|||||||
t.Fatalf("encode png: %v", err)
|
t.Fatalf("encode png: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestSaveImageKeepsGIFAsIs(t *testing.T) {
|
|||||||
t.Fatalf("encode gif: %v", err)
|
t.Fatalf("encode gif: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func TestSaveImageKeepsWebPAsIs(t *testing.T) {
|
|||||||
t.Fatalf("encode webp: %v", err)
|
t.Fatalf("encode webp: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,26 @@ func TestClassifyReferrer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeSearchTerm(t *testing.T) {
|
||||||
|
// 「新人报道」的 UTF-8 百分号编码前缀,模拟 512 字节截断
|
||||||
|
news := "%E6%96%B0%E4%BA%BA%E6%8A%A5%E9%81%93"
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"%E4%BD%A0%E5%A5%BD", "你好"}, // 完整编码
|
||||||
|
{"a+b", "a b"}, // + 按空格解码
|
||||||
|
{"plain", "plain"}, // 无编码
|
||||||
|
{"2222%E5%A6%82%E6%9E%9C", "2222如果"}, // 完整转义结尾
|
||||||
|
{news + "%E6%96", "新人报道"}, // 截断后结尾是完整转义但只是半截字符 → 去非法 UTF-8 尾巴
|
||||||
|
{news + "%E6%96%9", "新人报道"}, // 一位十六进制的残缺转义 → 剥尾重试 + 去半截字符
|
||||||
|
{news + "%", "新人报道"}, // 截断在孤立的 %
|
||||||
|
{news + "%E", "新人报道"}, // 截断在 1 位十六进制
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := decodeSearchTerm(c.raw); got != c.want {
|
||||||
|
t.Errorf("decodeSearchTerm(%q) = %q, want %q", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -90,7 +93,7 @@ type VisitSourcesKindDay struct {
|
|||||||
type VisitSources struct {
|
type VisitSources struct {
|
||||||
RangeDays int `json:"range_days"`
|
RangeDays int `json:"range_days"`
|
||||||
Kinds []VisitNamedCount `json:"kinds"`
|
Kinds []VisitNamedCount `json:"kinds"`
|
||||||
SearchHost []VisitNamedCount `json:"search_hosts"`
|
SearchTerms []VisitNamedCount `json:"search_terms"` // 站内搜索词 Top N(/?q= 结果页浏览)
|
||||||
External []VisitNamedCount `json:"external"`
|
External []VisitNamedCount `json:"external"`
|
||||||
Trend []VisitSourcesKindDay `json:"trend"`
|
Trend []VisitSourcesKindDay `json:"trend"`
|
||||||
}
|
}
|
||||||
@@ -336,8 +339,7 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
|||||||
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
if out.SearchTerms, err = s.siteSearchTerms(start, 10); err != nil {
|
||||||
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
||||||
@@ -350,6 +352,64 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// siteSearchTerms 站内搜索词:聚合搜索结果页(/?q=…,path 记录完整 query)的浏览量。
|
||||||
|
// 词以 URL 编码存于 path,按编码串分组后解码展示(站内 encodeURIComponent 编码确定性强)。
|
||||||
|
func (s *VisitStatsService) siteSearchTerms(start time.Time, limit int) ([]VisitNamedCount, error) {
|
||||||
|
const expr = "substring(path from '[?&]q=([^&]*)')"
|
||||||
|
var rows []struct {
|
||||||
|
Key string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.VisitEvent{}).
|
||||||
|
Select(expr+" AS key, COUNT(*) AS count").
|
||||||
|
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||||
|
Where(expr + " IS NOT NULL AND " + expr + " <> ''").
|
||||||
|
Group("key").Order("count DESC").Limit(limit).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]VisitNamedCount, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
term := decodeSearchTerm(r.Key)
|
||||||
|
if runes := []rune(term); len(runes) > 30 {
|
||||||
|
term = string(runes[:30]) + "…"
|
||||||
|
}
|
||||||
|
out = append(out, VisitNamedCount{Key: term, Label: term, Count: r.Count})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSearchTerm 解码 path 里 q 参数值(URL 编码)。path 512 字节截断可能:
|
||||||
|
// 1) 把 %XX 转义拦腰切断(孤 % / 一位十六进制)→ 整串解码失败,剥掉残缺尾部重试;
|
||||||
|
// 2) 恰好切在多字节字符的转义序列中间 → 解码「成功」但尾部是半截字符,去掉非法 UTF-8 尾巴。
|
||||||
|
func decodeSearchTerm(raw string) string {
|
||||||
|
term := strings.ReplaceAll(raw, "+", " ")
|
||||||
|
dec, err := url.QueryUnescape(term)
|
||||||
|
if err != nil {
|
||||||
|
// 残缺转义(如结尾孤 % 或一位十六进制):剥掉最后一个 % 起的尾部重试
|
||||||
|
if i := strings.LastIndex(term, "%"); i > 0 {
|
||||||
|
dec, err = url.QueryUnescape(term[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
return validUTF8Prefix(dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validUTF8Prefix 返回 s 的最长合法 UTF-8 前缀(截断产生的半截多字节字符会被去掉;
|
||||||
|
// 真实的 U+FFFD 字符合法保留,与非法字节可区分)。
|
||||||
|
func validUTF8Prefix(s string) string {
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
if r == utf8.RuneError && size == 1 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// SourcesTrend 来源按日明细(kind 分列行)
|
// SourcesTrend 来源按日明细(kind 分列行)
|
||||||
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
|
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
|
||||||
days := parseRangeDays(rangeQ)
|
days := parseRangeDays(rangeQ)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { pageGlyph } from "@/lib/pageGlyph";
|
import { pageGlyph } from "@/lib/pageGlyph";
|
||||||
import {
|
import {
|
||||||
@@ -94,19 +94,13 @@ export default async function AboutIndexPage() {
|
|||||||
const announcements = overview?.announcements ?? [];
|
const announcements = overview?.announcements ?? [];
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||||
|
|
||||||
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
|
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
|
||||||
const right = <SiteDocAnnoNav items={announcements} />;
|
const right = <SiteDocAnnoNav items={announcements} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteDocShell
|
<SiteDocShell left={left} right={right}>
|
||||||
left={left}
|
|
||||||
right={right}
|
|
||||||
mobileExtras={
|
|
||||||
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
<header
|
<header
|
||||||
className="px-5 sm:px-6 pt-5 pb-4"
|
className="px-5 sm:px-6 pt-5 pb-4"
|
||||||
|
|||||||
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Loader2, RefreshCw, ShieldAlert } from "lucide-react";
|
||||||
|
import { apiMe, apiRefresh } from "@/lib/api";
|
||||||
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台布局降级自愈:SSR 侧 /api/me 未识别出用户(access 过期轮转竞态、后端
|
||||||
|
* 瞬断等)时布局只能渲染无壳的降级卡片。本组件挂载后走客户端链路复判——
|
||||||
|
* apiMe 内部会在 401 时经 fetchWithRefresh 自动续期后重试:
|
||||||
|
* - 复判出用户 → router.refresh() 让服务端重出完整后台壳(顶栏/抽屉回归);
|
||||||
|
* - 仍是游客且凭据已被清理 → 会话确实失效,带回归地址去登录页;
|
||||||
|
* - 仍是游客但凭据还在(refresh 瞬态失败/后端暂不可用)→ 转入 offline 态,
|
||||||
|
* 有限次自动重试(间隔 5s),期间随时可手动重试/重新登录。
|
||||||
|
* refresh 成功但服务端仍识别失败时最多重试 3 次,超出转入手动重试,避免循环刷新。
|
||||||
|
*/
|
||||||
|
const MAX_AUTO_REFRESH = 3;
|
||||||
|
// offline 态自动重试次数(间隔 5s,共 50s):后端短暂抖动/重启场景无需用户手动干预
|
||||||
|
const MAX_AUTO_RETRY = 10;
|
||||||
|
const AUTO_RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
export default function AdminSessionRecover() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, setState] = useState<"recovering" | "offline">("recovering");
|
||||||
|
const autoRefreshLeft = useRef(MAX_AUTO_REFRESH);
|
||||||
|
const autoRetryLeft = useRef(MAX_AUTO_RETRY);
|
||||||
|
|
||||||
|
const recover = useCallback(() => {
|
||||||
|
setState("recovering");
|
||||||
|
apiMe()
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.user) {
|
||||||
|
if (autoRefreshLeft.current > 0) {
|
||||||
|
autoRefreshLeft.current -= 1;
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// apiMe 内部的续期复判以 sessionSeen 为门(本页从未识别出用户时为 false,
|
||||||
|
// 不会触发),这里显式补一轮 refresh:access 竞态/瞬断多数在此恢复
|
||||||
|
const back = encodeURIComponent(location.pathname + location.search);
|
||||||
|
if (hasAuthCookieHint()) {
|
||||||
|
const refreshed = await apiRefresh();
|
||||||
|
if (refreshed.ok) {
|
||||||
|
const again = await apiMe();
|
||||||
|
if (again.user) {
|
||||||
|
if (autoRefreshLeft.current > 0) {
|
||||||
|
autoRefreshLeft.current -= 1;
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshed.transient) {
|
||||||
|
// 后端暂不可用:无法证明登录态失效,留在原地可手动重试
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// refresh 明确失效(后端同时下发清 cookie):会话确实终结,回跳登录
|
||||||
|
router.replace(`/login?redirect=${back}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 无任何凭据痕迹:直接回跳登录
|
||||||
|
router.replace(`/login?redirect=${back}`);
|
||||||
|
})
|
||||||
|
.catch(() => setState("offline"));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recover();
|
||||||
|
}, [recover]);
|
||||||
|
|
||||||
|
// offline 态有限次自动重试:后端短暂抖动/重启期间留在本页轮询恢复,
|
||||||
|
// 次数用尽后仅手动重试可再触发,避免无限轮询;手动重试会重置次数。
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "offline") return;
|
||||||
|
if (autoRetryLeft.current <= 0) return;
|
||||||
|
autoRetryLeft.current -= 1;
|
||||||
|
const t = window.setTimeout(recover, AUTO_RETRY_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [state, recover]);
|
||||||
|
|
||||||
|
const loginHref =
|
||||||
|
state === "offline" && typeof window !== "undefined"
|
||||||
|
? `/login?redirect=${encodeURIComponent(location.pathname + location.search)}`
|
||||||
|
: "/login";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md w-full panel p-8 text-center">
|
||||||
|
<span
|
||||||
|
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||||
|
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={26} />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||||
|
正在恢复登录状态
|
||||||
|
</h1>
|
||||||
|
{state === "recovering" ? (
|
||||||
|
<p className="meta mt-2 text-[13px] inline-flex items-center justify-center gap-2">
|
||||||
|
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||||
|
正在确认会话,稍候将自动进入后台…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="meta mt-2 text-[13px]">暂时无法确认登录状态,请稍后重试。</p>
|
||||||
|
<div className="flex items-center justify-center gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
autoRefreshLeft.current = MAX_AUTO_REFRESH;
|
||||||
|
autoRetryLeft.current = MAX_AUTO_RETRY;
|
||||||
|
recover();
|
||||||
|
}}
|
||||||
|
className="btn btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} aria-hidden /> 重试
|
||||||
|
</button>
|
||||||
|
<Link href={loginHref} className="btn btn-line">
|
||||||
|
重新登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-4">
|
||||||
|
<Link href="/" className="meta text-[13px]">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
|
|||||||
import { AdText } from "@/components/AdText";
|
import { AdText } from "@/components/AdText";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
||||||
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
||||||
@@ -882,8 +883,14 @@ function AdCard({
|
|||||||
{/* 预览缩略 */}
|
{/* 预览缩略 */}
|
||||||
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
||||||
{ad.kind === "image" && ad.image_url ? (
|
{ad.kind === "image" && ad.image_url ? (
|
||||||
|
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
|
<img
|
||||||
|
src={isOwnImage(ad.image_url) ? variantURL(ad.image_url, pickVariantWidth(88)) : ad.image_url}
|
||||||
|
srcSet={isOwnImage(ad.image_url) ? densitySrcSet(ad.image_url, 88) : undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
) : ad.kind === "text" && ad.title ? (
|
) : ad.kind === "text" && ad.title ? (
|
||||||
<div className="flex h-full w-full items-center justify-center p-1.5">
|
<div className="flex h-full w-full items-center justify-center p-1.5">
|
||||||
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
|
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
|
||||||
@@ -990,8 +997,14 @@ function PaymentEditor({
|
|||||||
{/* 二维码:点击即上传 */}
|
{/* 二维码:点击即上传 */}
|
||||||
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
||||||
{payment.qr_url ? (
|
{payment.qr_url ? (
|
||||||
|
// 96×96 收款码位:本站图走 96/160 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
|
<img
|
||||||
|
src={isOwnImage(payment.qr_url) ? variantURL(payment.qr_url, pickVariantWidth(96)) : payment.qr_url}
|
||||||
|
srcSet={isOwnImage(payment.qr_url) ? densitySrcSet(payment.qr_url, 96) : undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
@@ -1084,8 +1097,14 @@ function SponsorEditor({
|
|||||||
{/* Logo:点击即上传 */}
|
{/* Logo:点击即上传 */}
|
||||||
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
||||||
{item.logo_url ? (
|
{item.logo_url ? (
|
||||||
|
// 120×56 Logo 位:本站图走 160/256 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
|
<img
|
||||||
|
src={isOwnImage(item.logo_url) ? variantURL(item.logo_url, pickVariantWidth(120)) : item.logo_url}
|
||||||
|
srcSet={isOwnImage(item.logo_url) ? densitySrcSet(item.logo_url, 120) : undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<Upload size={15} />
|
<Upload size={15} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type SponsorsConfig,
|
type SponsorsConfig,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminAdsPage() {
|
export default async function AdminAdsPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ADS)) {
|
||||||
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,34 @@ function kindBadge(kind: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 日期筛选:复用搜索框壳层样式,与工具条同高同视觉 */
|
||||||
|
function DateField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="admin-field-shell admin-search-shell w-full sm:w-44">
|
||||||
|
<span className="shrink-0 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="admin-field-input"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DetailsInner() {
|
function DetailsInner() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
|
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
|
||||||
@@ -86,6 +114,17 @@ function DetailsInner() {
|
|||||||
|
|
||||||
const resetPage = () => setPage(1);
|
const resetPage = () => setPage(1);
|
||||||
|
|
||||||
|
const hasFilter = Boolean(kind || path || ip || bot || start || end);
|
||||||
|
const resetAll = () => {
|
||||||
|
setKind("");
|
||||||
|
setPath("");
|
||||||
|
setIp("");
|
||||||
|
setBot("");
|
||||||
|
setStart("");
|
||||||
|
setEnd("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="meta text-[12.5px]">
|
<p className="meta text-[12.5px]">
|
||||||
@@ -106,48 +145,38 @@ function DetailsInner() {
|
|||||||
ariaLabel="按路径筛选"
|
ariaLabel="按路径筛选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-44">
|
<div className="w-full sm:w-48">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={ip}
|
value={ip}
|
||||||
onChange={setIp}
|
onChange={setIp}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setIp(""); resetPage(); }}
|
onClear={() => { setIp(""); resetPage(); }}
|
||||||
placeholder="按 IP 筛选,回车应用"
|
placeholder="按 IP 精确筛选"
|
||||||
ariaLabel="按 IP 筛选"
|
ariaLabel="按 IP 筛选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-40">
|
<div className="w-full sm:w-44">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={bot}
|
value={bot}
|
||||||
onChange={setBot}
|
onChange={setBot}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setBot(""); resetPage(); }}
|
onClear={() => { setBot(""); resetPage(); }}
|
||||||
placeholder="按爬虫名筛选"
|
placeholder="按爬虫名精确筛选"
|
||||||
ariaLabel="按爬虫名筛选"
|
ariaLabel="按爬虫名筛选"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
<DateField label="从" value={start} onChange={(v) => { setStart(v); resetPage(); }} ariaLabel="开始日期" />
|
||||||
从
|
<DateField label="至" value={end} onChange={(v) => { setEnd(v); resetPage(); }} ariaLabel="结束日期" />
|
||||||
<input
|
{hasFilter ? (
|
||||||
type="date"
|
<button
|
||||||
value={start}
|
type="button"
|
||||||
onChange={(e) => { setStart(e.target.value); resetPage(); }}
|
onClick={resetAll}
|
||||||
className="admin-field-input"
|
className="admin-field-shell admin-search-shell w-full sm:w-auto justify-center text-[13px] cursor-pointer transition-colors hover:bg-[var(--panel-2)]"
|
||||||
style={{ width: 150 }}
|
style={{ color: "var(--ink-2)" }}
|
||||||
aria-label="开始日期"
|
>
|
||||||
/>
|
重置筛选
|
||||||
</label>
|
</button>
|
||||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
) : null}
|
||||||
至
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={end}
|
|
||||||
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
|
|
||||||
className="admin-field-input"
|
|
||||||
style={{ width: 150 }}
|
|
||||||
aria-label="结束日期"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
||||||
import { apiStatsSources, type VisitSources } from "@/lib/api";
|
import { apiStatsSources, type VisitSources } from "@/lib/api";
|
||||||
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
|
import { RangeSegmented, StatsState, TableEmpty, formatInt, refKindLabel } from "../ui";
|
||||||
|
|
||||||
/** 来源按日表:固定展示四类主要来源列 */
|
/** 来源按日表:固定展示四类主要来源列 */
|
||||||
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
|
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
|
||||||
@@ -49,11 +49,25 @@ export default function SourcesPage() {
|
|||||||
</AdminChartCard>
|
</AdminChartCard>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
|
<AdminChartCard title="站内搜索词 Top 10" hint="搜索结果页(/?q=)浏览量 Top 10">
|
||||||
{data.search_hosts.length ? (
|
{data.search_terms.length ? (
|
||||||
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
|
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{data.search_terms.map((t, i) => (
|
||||||
|
<li key={`${i}-${t.key}`} className="py-2 flex items-center gap-3 text-[13px]">
|
||||||
|
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={t.key}>
|
||||||
|
{t.key}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{formatInt(t.count)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<TableEmpty text="暂无搜索引擎来源" />
|
<TableEmpty text="暂无站内搜索" />
|
||||||
)}
|
)}
|
||||||
</AdminChartCard>
|
</AdminChartCard>
|
||||||
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../../Forbidden";
|
import Forbidden from "../../../Forbidden";
|
||||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../Forbidden";
|
import Forbidden from "../../Forbidden";
|
||||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminAnnouncementNewPage() {
|
export default async function AdminAnnouncementNewPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import AnnouncementAdmin from "./AnnouncementAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
|
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "站点公告管理",
|
title: "站点公告管理",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
|
// 公告管理:管理员及以上角色自带;超管/站长亦可授予任意账号
|
||||||
export default async function AdminAnnouncementsPage() {
|
export default async function AdminAnnouncementsPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||||
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SURFACE_LABEL: Record<BgSurface, string> = {
|
const SURFACE_LABEL: Record<BgSurface, string> = {
|
||||||
site: "前台背景",
|
site: "前台背景",
|
||||||
@@ -101,7 +102,9 @@ export default function BgMediaPicker({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={item.url}
|
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||||||
|
sizes="(max-width:520px) 23vw, 96px"
|
||||||
alt={`${SURFACE_LABEL[surface]}候选图`}
|
alt={`${SURFACE_LABEL[surface]}候选图`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canModerateAuthor } from "@/lib/roles";
|
import { canModerateAuthor } from "@/lib/roles";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { postPath } from "@/lib/urlStyle";
|
import { postPath, commentAnchor } from "@/lib/urlStyle";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
@@ -692,7 +692,7 @@ export default function ContentAdmin({
|
|||||||
c.post_title
|
c.post_title
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
|
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
496
frontend/app/admin/friend-links/FriendLinksAdmin.tsx
Normal file
496
frontend/app/admin/friend-links/FriendLinksAdmin.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminCreateFriendLink,
|
||||||
|
apiAdminDeleteFriendLink,
|
||||||
|
apiAdminListFriendLinks,
|
||||||
|
apiAdminSetFriendLinkStatus,
|
||||||
|
apiAdminUpdateFriendLink,
|
||||||
|
type FriendLink,
|
||||||
|
type FriendLinkAdminInput,
|
||||||
|
type FriendLinkStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialLinks: FriendLink[];
|
||||||
|
initError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_CLASS = "w-full rounded-lg px-3 py-2 text-[14px] outline-none transition-colors";
|
||||||
|
const FIELD_STYLE = {
|
||||||
|
background: "var(--panel-2)",
|
||||||
|
color: "var(--ink)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: { key: FriendLinkStatus; label: string }[] = [
|
||||||
|
{ key: "pending", label: "待审批" },
|
||||||
|
{ key: "approved", label: "已通过" },
|
||||||
|
{ key: "rejected", label: "已拒绝" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
logo_url: string;
|
||||||
|
category: string;
|
||||||
|
reciprocal_url: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
description: "",
|
||||||
|
logo_url: "",
|
||||||
|
category: "",
|
||||||
|
reciprocal_url: "",
|
||||||
|
sort_order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
|
||||||
|
const [links, setLinks] = useState<FriendLink[]>(initialLinks);
|
||||||
|
const [tab, setTab] = useState<FriendLinkStatus>("pending");
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState(initError);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = { pending: 0, approved: 0, rejected: 0 } as Record<FriendLinkStatus, number>;
|
||||||
|
for (const l of links) c[l.status] = (c[l.status] || 0) + 1;
|
||||||
|
return c;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => links.filter((l) => l.status === tab).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id),
|
||||||
|
[links, tab]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiAdminListFriendLinks();
|
||||||
|
setLinks(res.links ?? []);
|
||||||
|
setError("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "加载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (l: FriendLink) => {
|
||||||
|
setEditingId(l.id);
|
||||||
|
setForm({
|
||||||
|
name: l.name,
|
||||||
|
url: l.url,
|
||||||
|
description: l.description || "",
|
||||||
|
logo_url: l.logo_url || "",
|
||||||
|
category: l.category || "",
|
||||||
|
reciprocal_url: l.reciprocal_url || "",
|
||||||
|
sort_order: l.sort_order,
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (k: keyof FormState, v: string | number) =>
|
||||||
|
setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const body: FriendLinkAdminInput = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
logo_url: form.logo_url.trim() || undefined,
|
||||||
|
category: form.category.trim() || undefined,
|
||||||
|
reciprocal_url: form.reciprocal_url.trim() || undefined,
|
||||||
|
sort_order: form.sort_order,
|
||||||
|
};
|
||||||
|
if (editingId == null) {
|
||||||
|
await apiAdminCreateFriendLink(body);
|
||||||
|
} else {
|
||||||
|
await apiAdminUpdateFriendLink(editingId, body);
|
||||||
|
}
|
||||||
|
closeEditor();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = async (id: number, status: FriendLinkStatus) => {
|
||||||
|
let rejectReason = "";
|
||||||
|
if (status === "rejected") {
|
||||||
|
const r = window.prompt("请输入拒绝理由(可选)", "");
|
||||||
|
if (r === null) return;
|
||||||
|
rejectReason = r;
|
||||||
|
}
|
||||||
|
setBusyId(id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiAdminSetFriendLinkStatus(id, status, rejectReason || undefined);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "操作失败");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
if (!window.confirm("确定删除该友情链接?此操作不可撤销。")) return;
|
||||||
|
setBusyId(id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteFriendLink(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsPage>
|
||||||
|
<AdminPageHeader
|
||||||
|
icon={Link2}
|
||||||
|
title="友情链接管理"
|
||||||
|
description="审批用户申请的友情链接,或直接新建/编辑已收录条目"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||||
|
<Plus size={14} /> 新建
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="alert-error mb-4">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="panel overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 px-4 pt-3 border-b" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const active = tab === t.key;
|
||||||
|
const n = counts[t.key] || 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className="px-3 py-2 text-[13.5px] font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||||
|
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
|
marginBottom: "-1px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
<span className="ml-1.5 text-[11.5px] opacity-70">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<AdminEmpty
|
||||||
|
icon={<Link2 size={20} />}
|
||||||
|
title={tab === "pending" ? "暂无待审批申请" : tab === "approved" ? "暂无已收录友链" : "暂无被拒绝的申请"}
|
||||||
|
description={tab === "pending" ? "用户提交的申请会出现在这里" : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{filtered.map((l) => (
|
||||||
|
<li
|
||||||
|
key={l.id}
|
||||||
|
className="px-5 py-4 flex items-start gap-3"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{l.logo_url ? (
|
||||||
|
// 36px 位:本站图走 48/96 变体;外链保持原样
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(36)) : l.logo_url}
|
||||||
|
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 36) : undefined}
|
||||||
|
alt=""
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="rounded-lg object-contain shrink-0"
|
||||||
|
style={{ width: 36, height: 36, background: "var(--panel-2)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Link2 size={16} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<a
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-semibold text-[14.5px] hover:text-[var(--accent)]"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</a>
|
||||||
|
{l.category ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
{l.category}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="meta text-[11.5px]">#{l.id} · 顺序 {l.sort_order}</span>
|
||||||
|
</div>
|
||||||
|
{l.description ? (
|
||||||
|
<p className="meta mt-1 text-[12.5px] line-clamp-2">{l.description}</p>
|
||||||
|
) : null}
|
||||||
|
{l.reciprocal_url ? (
|
||||||
|
<p className="meta mt-1 text-[11.5px]">
|
||||||
|
回链:
|
||||||
|
<a href={l.reciprocal_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-[var(--accent)]">
|
||||||
|
{l.reciprocal_url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{l.status === "rejected" && l.reject_reason ? (
|
||||||
|
<p className="mt-1 text-[12px]" style={{ color: "var(--danger)" }}>拒绝理由:{l.reject_reason}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{busyId === l.id ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" style={{ color: "var(--ink-3)" }} />
|
||||||
|
) : null}
|
||||||
|
{l.status === "pending" ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "approved")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="通过"
|
||||||
|
>
|
||||||
|
<Check size={13} /> 通过
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "rejected")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="拒绝"
|
||||||
|
>
|
||||||
|
<X size={13} /> 拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{l.status === "rejected" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "approved")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="重新通过"
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> 恢复
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{l.status === "approved" ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => openEdit(l)}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil size={13} /> 编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setStatus(l.id, "pending")}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="下线"
|
||||||
|
>
|
||||||
|
下线
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{l.status === "pending" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => openEdit(l)}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => remove(l.id)}
|
||||||
|
disabled={busyId === l.id}
|
||||||
|
title="删除"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||||
|
onClick={closeEditor}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="panel w-full max-w-lg p-5 sm:p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-[17px] font-bold mb-4" style={{ color: "var(--ink)" }}>
|
||||||
|
{editingId == null ? "新建友情链接" : "编辑友情链接"}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">名称 *</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">地址 *</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
maxLength={512}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => set("url", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">简介</span>
|
||||||
|
<input
|
||||||
|
maxLength={255}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set("description", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">Logo URL</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
maxLength={512}
|
||||||
|
value={form.logo_url}
|
||||||
|
onChange={(e) => set("logo_url", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">分类</span>
|
||||||
|
<input
|
||||||
|
maxLength={32}
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => set("category", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">回链地址</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
maxLength={512}
|
||||||
|
value={form.reciprocal_url}
|
||||||
|
onChange={(e) => set("reciprocal_url", e.target.value)}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta block mb-1">顺序</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.sort_order}
|
||||||
|
onChange={(e) => set("sort_order", Number(e.target.value))}
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
style={FIELD_STYLE}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={closeEditor} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
|
||||||
|
{saving ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||||
|
{saving ? "保存中…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AdminSettingsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/app/admin/friend-links/page.tsx
Normal file
36
frontend/app/admin/friend-links/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import FriendLinksAdmin from "./FriendLinksAdmin";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { fetchAdminFriendLinks, type FriendLink } from "@/lib/api";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "友情链接管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminFriendLinksPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||||
|
return <Forbidden text="友情链接管理仅超级管理员及以上角色可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let links: FriendLink[] = [];
|
||||||
|
let initError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetchAdminFriendLinks(cookie || undefined);
|
||||||
|
links = res.links ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
initError = e instanceof Error ? e.message : "加载失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||||
|
<FriendLinksAdmin initialLinks={links} initError={initError} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
|
import AdminSessionRecover from "./AdminSessionRecover";
|
||||||
|
|
||||||
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -13,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||||
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
// 允许:管理角色 / 消息管理/群管权限 / 任一渠道权限(公告/单页/书库/广告)。
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const cookie = authCookieHeader(cookieStore);
|
const cookie = authCookieHeader(cookieStore);
|
||||||
@@ -22,9 +24,12 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
const allowed =
|
const allowed =
|
||||||
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
!!me.user &&
|
||||||
|
(isStaff(me.user.role) || canAccessAdminMessages(me.user) || hasAnyChannelPerm(me.user));
|
||||||
|
|
||||||
if (!allowed || !me.user) {
|
if (!allowed || !me.user) {
|
||||||
|
// 已登录但无后台权限:保留提示卡片
|
||||||
|
if (me.user) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-admin-viewport
|
data-admin-viewport
|
||||||
@@ -51,6 +56,32 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSR 侧未识别出用户(/api/me 瞬断、access 过期轮转竞态等)时,原实现直接
|
||||||
|
// 落到无壳的权限卡片——手机端表现为「首次进入后台整块导航 header 消失」,
|
||||||
|
// 无法切换管理板块,再进一次才恢复。这里按凭据状态分流入修复:
|
||||||
|
// ① 完全无凭据:直达登录页并回跳目标地址,不再给无导航的死胡同卡片;
|
||||||
|
// ② 仍带凭据:客户端经 fetchWithRefresh 自动续期后 router.refresh 自愈。
|
||||||
|
const hasAuthHint = !!(
|
||||||
|
cookieStore.get(TOKEN_COOKIE)?.value ||
|
||||||
|
cookieStore.get(REFRESH_COOKIE)?.value ||
|
||||||
|
cookieStore.get(CSRF_COOKIE)?.value
|
||||||
|
);
|
||||||
|
if (!hasAuthHint) {
|
||||||
|
const pathname = (await headers()).get("x-pathname") || "/admin";
|
||||||
|
redirect(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-admin-viewport
|
||||||
|
id="main"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="min-h-screen flex items-center justify-center px-4 outline-none"
|
||||||
|
>
|
||||||
|
<AdminSessionRecover />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const user = me.user;
|
const user = me.user;
|
||||||
|
|
||||||
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
||||||
|
|||||||
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal file
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { FileText, Loader2, Upload, X } from "lucide-react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import type { LibraryImportMdInput, LibrarySectionItem } from "@/lib/api";
|
||||||
|
import { parseEpub } from "./parseEpub";
|
||||||
|
|
||||||
|
const MAX_IMPORT_BYTES = 5 * 1024 * 1024; // 与后端 MaxImportBytes 一致(单文件)
|
||||||
|
const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 与后端 MaxImportTotalBytes 一致
|
||||||
|
const MAX_FILES = 50; // 与后端 MaxImportFiles 一致
|
||||||
|
const MAX_EPUB_BYTES = 50 * 1024 * 1024; // epub 原始文件上限(二进制,图片占大头,转出文本通常很小)
|
||||||
|
const MAX_CHAPTER_CHARS = 100_000; // 与后端 MaxSectionContent 一致(epub 每目录项即一章)
|
||||||
|
|
||||||
|
type SplitLevel = "h1" | "h2" | "none";
|
||||||
|
|
||||||
|
const SPLIT_OPTIONS: { value: SplitLevel; label: string; hint: string }[] = [
|
||||||
|
{ value: "h1", label: "按一级标题(#)", hint: "每个 # 标题拆为一章" },
|
||||||
|
{ value: "h2", label: "按二级标题(##)", hint: "每个 ## 标题拆为一章" },
|
||||||
|
{ value: "none", label: "不拆分", hint: "每个文件整篇一章" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PendingFile {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 splitMarkdownChapters 逻辑一致:跳过代码围栏,按所选级别标题取章标题列表 */
|
||||||
|
function splitPreview(content: string, level: SplitLevel): string[] {
|
||||||
|
if (level === "none") return [];
|
||||||
|
const prefix = level === "h2" ? "## " : "# ";
|
||||||
|
const titles: string[] = [];
|
||||||
|
let inCode = false;
|
||||||
|
let count = 0;
|
||||||
|
for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t.startsWith("```")) inCode = !inCode;
|
||||||
|
if (!inCode && t.startsWith(prefix) && t.length > prefix.length) {
|
||||||
|
const title = t.slice(prefix.length).replace(/#+\s*$/, "").trim();
|
||||||
|
titles.push(title || `第 ${++count} 章`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个文件预计拆出的章数(未命中标题回退 1 章,标题取文件名) */
|
||||||
|
function splitCount(text: string, level: SplitLevel): number {
|
||||||
|
if (level === "none") return 1;
|
||||||
|
const n = splitPreview(text, level).length;
|
||||||
|
return n > 0 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportMdDialog({
|
||||||
|
onImport,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
/** 执行导入,返回新建章节(父组件负责刷新与选中第一章);失败时抛错 */
|
||||||
|
onImport: (input: LibraryImportMdInput) => Promise<LibrarySectionItem[]>;
|
||||||
|
/** 关闭弹窗;导入成功时带出新建章节,父组件据此选中第一章 */
|
||||||
|
onClose: (created?: LibrarySectionItem[]) => void;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [files, setFiles] = useState<PendingFile[]>([]);
|
||||||
|
const [pasted, setPasted] = useState("");
|
||||||
|
const [splitLevel, setSplitLevel] = useState<SplitLevel>("h2");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
// epub 模式:列表已被 epub 解析结果整体替换,按目录顺序每项一章(不参与拆分)
|
||||||
|
const [fromEpub, setFromEpub] = useState(false);
|
||||||
|
const [epubBusy, setEpubBusy] = useState(false);
|
||||||
|
|
||||||
|
// 已选文件优先;未选文件时用粘贴内容(视为一个伪文件)
|
||||||
|
const sourceFiles = useMemo<PendingFile[]>(
|
||||||
|
() =>
|
||||||
|
files.length > 0
|
||||||
|
? files
|
||||||
|
: pasted.trim()
|
||||||
|
? [{ name: "未命名导入.md", text: pasted }]
|
||||||
|
: [],
|
||||||
|
[files, pasted]
|
||||||
|
);
|
||||||
|
|
||||||
|
const perFileCounts = useMemo(
|
||||||
|
() => sourceFiles.map((f) => ({ name: f.name, count: splitCount(f.text, splitLevel) })),
|
||||||
|
[sourceFiles, splitLevel]
|
||||||
|
);
|
||||||
|
const totalChapters = perFileCounts.reduce((n, f) => n + f.count, 0);
|
||||||
|
const single = sourceFiles.length === 1 ? sourceFiles[0] : null;
|
||||||
|
const singleTitles = useMemo(
|
||||||
|
() => (single && splitLevel !== "none" ? splitPreview(single.text, splitLevel) : []),
|
||||||
|
[single, splitLevel]
|
||||||
|
);
|
||||||
|
const willFallback =
|
||||||
|
single != null && splitLevel !== "none" && singleTitles.length === 0 && single.text.trim().length > 0;
|
||||||
|
|
||||||
|
const oversizeFile = sourceFiles.find((f) => f.text.length > MAX_IMPORT_BYTES);
|
||||||
|
const overFiles = sourceFiles.length > MAX_FILES;
|
||||||
|
const overTotal = sourceFiles.reduce((n, f) => n + f.text.length, 0) > MAX_TOTAL_BYTES;
|
||||||
|
// epub 每个目录项即一章,无法再拆;超限章直接阻止导入(与后端校验一致)
|
||||||
|
const oversizeChapters = fromEpub
|
||||||
|
? sourceFiles.filter((f) => f.text.length > MAX_CHAPTER_CHARS)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
async function pickFiles(list: FileList | null) {
|
||||||
|
if (!list || list.length === 0) return;
|
||||||
|
setError("");
|
||||||
|
const msgs: string[] = [];
|
||||||
|
const fileList = Array.from(list);
|
||||||
|
const epubFiles = fileList.filter((f) => f.name.toLowerCase().endsWith(".epub"));
|
||||||
|
const mdFiles = fileList.filter((f) => !f.name.toLowerCase().endsWith(".epub"));
|
||||||
|
|
||||||
|
// epub:解析为章节伪文件并整体替换(同一批次不与 md 混选)
|
||||||
|
if (epubFiles.length > 0) {
|
||||||
|
setEpubBusy(true);
|
||||||
|
try {
|
||||||
|
const chapters: PendingFile[] = [];
|
||||||
|
for (const f of epubFiles) {
|
||||||
|
if (f.size > MAX_EPUB_BYTES) {
|
||||||
|
msgs.push(`超过 50MB 已跳过:${f.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = await parseEpub(f);
|
||||||
|
for (const c of parsed) {
|
||||||
|
chapters.push({
|
||||||
|
name: `${String(chapters.length + 1).padStart(3, "0")}-${c.title || "未命名"}.md`,
|
||||||
|
text: c.markdown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chapters.length > 0) {
|
||||||
|
if (chapters.length > MAX_FILES) {
|
||||||
|
msgs.push(`epub 解析出 ${chapters.length} 章,超过单次上限,已截取前 ${MAX_FILES} 章`);
|
||||||
|
}
|
||||||
|
setFiles(chapters.slice(0, MAX_FILES));
|
||||||
|
setSplitLevel("none");
|
||||||
|
setFromEpub(true);
|
||||||
|
} else if (!msgs.length) {
|
||||||
|
msgs.push("未能从 epub 中解析出章节");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgs.push(e instanceof Error ? e.message : "epub 解析失败");
|
||||||
|
} finally {
|
||||||
|
setEpubBusy(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// md/txt:沿用原合并逻辑;当前若是 epub 章节列表则先清空
|
||||||
|
const picked: PendingFile[] = [];
|
||||||
|
const tooBig: string[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const f of mdFiles) {
|
||||||
|
if (f.size > MAX_IMPORT_BYTES) {
|
||||||
|
tooBig.push(f.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
picked.push({ name: f.name, text: await f.text() });
|
||||||
|
} catch {
|
||||||
|
failed.push(f.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let truncated = false;
|
||||||
|
setFiles((prev) => {
|
||||||
|
const merged = [...(fromEpub ? [] : prev), ...picked];
|
||||||
|
// 文件名自然排序,保证 01、02…23 顺序
|
||||||
|
merged.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true }));
|
||||||
|
truncated = merged.length > MAX_FILES;
|
||||||
|
return merged.slice(0, MAX_FILES);
|
||||||
|
});
|
||||||
|
if (fromEpub && picked.length > 0) {
|
||||||
|
setFromEpub(false);
|
||||||
|
msgs.push("已清空 epub 章节,改用所选 Markdown 文件");
|
||||||
|
}
|
||||||
|
if (tooBig.length) msgs.push(`超过 5MB 已跳过:${tooBig.join("、")}`);
|
||||||
|
if (failed.length) msgs.push(`无法读取:${failed.join("、")}`);
|
||||||
|
if (truncated) msgs.push(`单次最多 ${MAX_FILES} 个文件,已截取前 ${MAX_FILES} 个`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgs.length) setError(msgs.join(";"));
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(idx: number) {
|
||||||
|
setFiles((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (sourceFiles.length === 0) {
|
||||||
|
setError("请先选择文件或粘贴 Markdown 内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oversizeFile) {
|
||||||
|
setError(`单个文件不能超过 5MB:${oversizeFile.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overFiles) {
|
||||||
|
setError(`单次最多导入 ${MAX_FILES} 个文件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (overTotal) {
|
||||||
|
setError("内容总量过大:不能超过 20MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oversizeChapters.length > 0) {
|
||||||
|
setError(
|
||||||
|
`以下章节正文超过 100000 字,请先拆分后再导入:${oversizeChapters.map((f) => f.name).join("、")}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await onImport({
|
||||||
|
files: sourceFiles.map((f) => ({ filename: f.name, content: f.text })),
|
||||||
|
split_level: splitLevel,
|
||||||
|
});
|
||||||
|
toast(
|
||||||
|
sourceFiles.length > 1
|
||||||
|
? `已导入 ${sourceFiles.length} 个文件共 ${created.length} 章`
|
||||||
|
: `已导入 ${created.length} 章`,
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
onClose(created);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "导入失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = Boolean(oversizeFile || overFiles || overTotal || oversizeChapters.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="导入章节"
|
||||||
|
description="Markdown 按所选方式拆章;epub 按目录顺序导入,每个目录项一章"
|
||||||
|
onClose={onClose}
|
||||||
|
busy={busy}
|
||||||
|
maxWidthClass="max-w-xl"
|
||||||
|
bodyClassName="max-h-[60vh] overflow-y-auto j13-rail-scroll"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-line" onClick={() => onClose()} disabled={busy}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || epubBusy || sourceFiles.length === 0 || blocked}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
aria-busy={busy || epubBusy || undefined}
|
||||||
|
>
|
||||||
|
{busy || epubBusy ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Upload size={14} aria-hidden />
|
||||||
|
)}
|
||||||
|
{busy || epubBusy ? (epubBusy ? "解析中…" : "导入中…") : "导入"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="admin-doc-label">
|
||||||
|
来源
|
||||||
|
{sourceFiles.length > 1 ? `(${sourceFiles.length} 个文件,按文件名顺序导入)` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy || epubBusy}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileText size={13} aria-hidden /> 选择 .md / .txt / .epub 文件(可多选)
|
||||||
|
</button>
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-doc-textbtn"
|
||||||
|
disabled={busy || epubBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setFiles([]);
|
||||||
|
setFromEpub(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".md,.markdown,.txt,.epub,text/markdown,text/plain,application/epub+zip"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => void pickFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
className="rounded-xl max-h-48 overflow-y-auto j13-rail-scroll"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={`${f.name}-${i}`}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5"
|
||||||
|
style={{ borderBottom: i < files.length - 1 ? "1px solid var(--line)" : undefined }}
|
||||||
|
>
|
||||||
|
<span className="text-[12.5px] truncate min-w-0 flex-1" style={{ color: "var(--ink)" }}>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11px] shrink-0 tabular-nums">{f.text.length} 字符</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-5 h-5 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
aria-label={`移除 ${f.name}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => removeAt(i)}
|
||||||
|
>
|
||||||
|
<X size={12} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<label className="admin-doc-label" htmlFor="import-md-text">
|
||||||
|
或直接粘贴 Markdown
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="import-md-text"
|
||||||
|
className="admin-field-input"
|
||||||
|
rows={6}
|
||||||
|
value={pasted}
|
||||||
|
onChange={(e) => setPasted(e.target.value)}
|
||||||
|
placeholder="# 第一章 …"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="admin-doc-hint">{pasted.length} 字符</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fromEpub ? (
|
||||||
|
<p className="admin-doc-hint">epub 按目录顺序导入,每个目录项一章,不参与拆分。</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">拆分方式(对每个文件生效)</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{SPLIT_OPTIONS.map((opt) => (
|
||||||
|
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="import-split"
|
||||||
|
checked={splitLevel === opt.value}
|
||||||
|
onChange={() => setSplitLevel(opt.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11.5px]">{opt.hint}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">拆分预览</p>
|
||||||
|
{sourceFiles.length === 0 ? (
|
||||||
|
<p className="meta text-[12.5px]">等待内容…</p>
|
||||||
|
) : sourceFiles.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<ul
|
||||||
|
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
{perFileCounts.map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={`${f.name}-${i}`}
|
||||||
|
className="flex items-center justify-between gap-2 text-[12.5px] py-0.5"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
<span className="meta text-[11.5px] shrink-0 tabular-nums">{f.count} 章</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="admin-doc-hint">
|
||||||
|
共 {sourceFiles.length} 个文件 · 预计 {totalChapters} 章
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : willFallback ? (
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
未检测到所选级别的标题,将整篇导入为一章(标题取文件名)。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
{(splitLevel === "none" ? [single!.name] : singleTitles).map((t, i) => (
|
||||||
|
<li key={i} className="text-[12.5px] py-0.5 truncate" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{i + 1}. {t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overTotal ? <p className="meta text-[12.5px]">内容总量超过 20MB,无法导入。</p> : null}
|
||||||
|
{oversizeChapters.length > 0 ? (
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
以下章节正文超过 100000 字,无法导入:{oversizeChapters.map((f) => f.name).join("、")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p className="admin-settings-row-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
1241
frontend/app/admin/library/LibraryAdmin.tsx
Normal file
1241
frontend/app/admin/library/LibraryAdmin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
150
frontend/app/admin/library/LibraryCoverPicker.tsx
Normal file
150
frontend/app/admin/library/LibraryCoverPicker.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Images, Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
|
/** 来源标签映射 */
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
library_cover: "书籍封面",
|
||||||
|
library_content: "书籍内容",
|
||||||
|
post: "帖子插图",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceLabel(source?: string): string {
|
||||||
|
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
|
||||||
|
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
|
||||||
|
const groups = new Map<string, MediaAttachment[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.source || "post";
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
// 按优先级排序
|
||||||
|
const order = ["library_cover", "library_content", "post"];
|
||||||
|
return Array.from(groups.entries()).sort(
|
||||||
|
([a], [b]) => order.indexOf(a) - order.indexOf(b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
|
||||||
|
export default function LibraryCoverPicker({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (item: MediaAttachment) => void;
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<MediaAttachment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
void apiMyMedia()
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error);
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems((res.attachments || []).filter((item) => item.kind === "image"));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError("加载媒体库失败");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const groups = groupBySource(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="从图库选择封面"
|
||||||
|
description="已选作封面的图片会单独分组,再次选择直接复用。"
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidthClass="max-w-2xl"
|
||||||
|
dismissOnOverlay
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||||||
|
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="meta text-[13px] py-6 text-center">
|
||||||
|
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
|
||||||
|
{groups.map(([source, groupItems]) => (
|
||||||
|
<section key={source}>
|
||||||
|
<h4
|
||||||
|
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
{sourceLabel(source)}
|
||||||
|
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(item);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="relative group aspect-square rounded-xl overflow-hidden"
|
||||||
|
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||||||
|
data-tip={
|
||||||
|
item.source === "library_cover"
|
||||||
|
? "已作封面,点击直接复用"
|
||||||
|
: "复制一份用作封面"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||||||
|
sizes="(max-width:520px) 23vw, 96px"
|
||||||
|
alt="图库候选封面"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
<Images size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
506
frontend/app/admin/library/LibrarySectionsPanel.tsx
Normal file
506
frontend/app/admin/library/LibrarySectionsPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CornerDownRight,
|
||||||
|
ListTree,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminCreateLibrarySection,
|
||||||
|
apiAdminDeleteLibrarySection,
|
||||||
|
apiAdminImportLibraryMarkdownBatch,
|
||||||
|
apiAdminMoveLibrarySection,
|
||||||
|
apiAdminUpdateLibrarySection,
|
||||||
|
type LibraryDoc,
|
||||||
|
type LibraryImportMdInput,
|
||||||
|
type LibrarySectionItem,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
|
import { AdminEmpty } from "@/components/admin";
|
||||||
|
import ImportMdDialog from "./ImportMdDialog";
|
||||||
|
|
||||||
|
const MAX_TITLE = 200;
|
||||||
|
const MAX_CONTENT = 100_000; // 与后端 MaxSectionContent 保持一致
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT = JSON.stringify({ title: "", content: "" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「章节与正文」面板:左章节树(章 → 节 两级),右章节编辑器。
|
||||||
|
* 数据从 doc.sections 派生(后端 detail 已带全量含 content);
|
||||||
|
* 所有变更后调 onChanged() 让父组件刷新列表,doc.sections 随之更新。
|
||||||
|
*/
|
||||||
|
export default function LibrarySectionsPanel({
|
||||||
|
doc,
|
||||||
|
onChanged,
|
||||||
|
onDirtyChange,
|
||||||
|
}: {
|
||||||
|
doc: LibraryDoc;
|
||||||
|
/** 数据变更后由父组件刷新(doc.sections 随之更新) */
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
/** dirty 状态上报,供父组件在关闭编辑器 / 切换条目前确认 */
|
||||||
|
onDirtyChange: (dirty: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const sections = doc.sections ?? [];
|
||||||
|
|
||||||
|
// 平铺 → 章分组(顺序沿用后端 sort_order)
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const gs: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
|
||||||
|
for (const s of sections) {
|
||||||
|
if (s.parent_id == null) gs.push({ chapter: s, subs: [] });
|
||||||
|
else gs[gs.length - 1]?.subs.push(s);
|
||||||
|
}
|
||||||
|
return gs;
|
||||||
|
}, [sections]);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LibrarySectionItem | null>(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
// 新建 / 导入后要选中的章节 id:等 doc.sections 里出现再应用,避免被旧数据清掉
|
||||||
|
const [pendingSelectId, setPendingSelectId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => sections.find((s) => s.id === selectedId) ?? null,
|
||||||
|
[sections, selectedId]
|
||||||
|
);
|
||||||
|
const dirty = JSON.stringify({ title, content }) !== snapshot;
|
||||||
|
|
||||||
|
// dirty 上报给父组件;卸载时归零,避免残留的 dirty 影响父组件的关闭确认
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange(dirty);
|
||||||
|
return () => onDirtyChange(false);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
// 选中项被删除后清空选中
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId != null && !sections.some((s) => s.id === selectedId)) {
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
}, [sections, selectedId]);
|
||||||
|
|
||||||
|
// 应用待选章节(新建 / 导入完成后)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingSelectId == null) return;
|
||||||
|
const s = sections.find((x) => x.id === pendingSelectId);
|
||||||
|
if (!s) return;
|
||||||
|
setPendingSelectId(null);
|
||||||
|
setSelectedId(s.id);
|
||||||
|
setTitle(s.title);
|
||||||
|
setContent(s.content);
|
||||||
|
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
|
||||||
|
setError("");
|
||||||
|
}, [sections, pendingSelectId]);
|
||||||
|
|
||||||
|
function loadSection(s: LibrarySectionItem) {
|
||||||
|
setSelectedId(s.id);
|
||||||
|
setTitle(s.title);
|
||||||
|
setContent(s.content);
|
||||||
|
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dirty 时先弹确认,再执行动作(切换章节 / 打开导入 / 新增章节) */
|
||||||
|
function guardNav(action: () => void) {
|
||||||
|
if (dirty && !busy) setPendingNav(() => action);
|
||||||
|
else action();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSection(s: LibrarySectionItem) {
|
||||||
|
if (s.id === selectedId) return;
|
||||||
|
guardNav(() => loadSection(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSection(parent: LibrarySectionItem | null) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { section } = await apiAdminCreateLibrarySection(doc.id, {
|
||||||
|
parent_id: parent?.id,
|
||||||
|
title: parent ? "新节" : "新章",
|
||||||
|
});
|
||||||
|
await onChanged();
|
||||||
|
setPendingSelectId(section.id);
|
||||||
|
toast(parent ? "小节已添加" : "章已添加", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "添加章节失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (selectedId == null) return;
|
||||||
|
const t = title.trim();
|
||||||
|
if (!t) {
|
||||||
|
setError("章节标题不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { section } = await apiAdminUpdateLibrarySection(selectedId, {
|
||||||
|
title: t,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
await onChanged();
|
||||||
|
// 以服务端返回为准(后端会 trim 标题),保证 dirty 归零
|
||||||
|
setTitle(section.title);
|
||||||
|
setContent(section.content);
|
||||||
|
setSnapshot(JSON.stringify({ title: section.title, content: section.content }));
|
||||||
|
toast("章节已保存", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "保存章节失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(s: LibrarySectionItem, direction: "up" | "down") {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await apiAdminMoveLibrarySection(s.id, direction);
|
||||||
|
await onChanged(); // 选中 id 不变,原地刷新顺序
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "移动章节失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSection() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const target = deleteTarget;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteLibrarySection(target.id);
|
||||||
|
toast(target.parent_id == null ? "章已删除" : "小节已删除", "ok");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await onChanged();
|
||||||
|
if (target.id === selectedId) {
|
||||||
|
setSelectedId(null);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setSnapshot(EMPTY_SNAPSHOT);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "删除章节失败");
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(input: LibraryImportMdInput) {
|
||||||
|
const res = await apiAdminImportLibraryMarkdownBatch(doc.id, input);
|
||||||
|
await onChanged();
|
||||||
|
return res.sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImport(created?: LibrarySectionItem[]) {
|
||||||
|
setImportOpen(false);
|
||||||
|
if (created && created.length > 0) setPendingSelectId(created[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该章节在同级兄弟中的位置(供上移/下移禁用判断) */
|
||||||
|
function siblingPos(s: LibrarySectionItem): { first: boolean; last: boolean } {
|
||||||
|
if (s.parent_id == null) {
|
||||||
|
const i = groups.findIndex((g) => g.chapter.id === s.id);
|
||||||
|
return { first: i <= 0, last: i < 0 || i >= groups.length - 1 };
|
||||||
|
}
|
||||||
|
const g = groups.find((x) => x.subs.some((y) => y.id === s.id));
|
||||||
|
const subs = g?.subs ?? [];
|
||||||
|
const i = subs.findIndex((y) => y.id === s.id);
|
||||||
|
return { first: i <= 0, last: i < 0 || i >= subs.length - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowAction({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-6 h-6 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(s: LibrarySectionItem, depth: 0 | 1, isFirst: boolean, isLast: boolean) {
|
||||||
|
const active = s.id === selectedId;
|
||||||
|
const pos = siblingPos(s);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center gap-0.5 pr-1.5"
|
||||||
|
style={{
|
||||||
|
paddingLeft: depth === 0 ? 4 : 22,
|
||||||
|
background: active ? "var(--accent-soft)" : undefined,
|
||||||
|
boxShadow: active ? "inset 2px 0 0 var(--accent)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectSection(s)}
|
||||||
|
aria-current={active ? "true" : undefined}
|
||||||
|
className="min-w-0 flex-1 text-left px-2 py-2 flex items-center gap-1 rounded-md transition-colors hover:bg-[var(--panel-2)]"
|
||||||
|
title={s.title}
|
||||||
|
>
|
||||||
|
{depth === 1 ? (
|
||||||
|
<CornerDownRight
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className="truncate text-[13px]"
|
||||||
|
style={{
|
||||||
|
color: active ? "var(--accent)" : "var(--ink)",
|
||||||
|
fontWeight: depth === 0 ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.title || "未命名章节"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<RowAction label="上移" disabled={pos.first || busy} onClick={() => void move(s, "up")}>
|
||||||
|
<ChevronUp size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
<RowAction label="下移" disabled={pos.last || busy} onClick={() => void move(s, "down")}>
|
||||||
|
<ChevronDown size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
{depth === 0 ? (
|
||||||
|
<RowAction
|
||||||
|
label="添加小节"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void addSection(s)}
|
||||||
|
>
|
||||||
|
<CornerDownRight size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
) : null}
|
||||||
|
<RowAction label="删除" onClick={() => setDeleteTarget(s)}>
|
||||||
|
<Trash2 size={13} aria-hidden />
|
||||||
|
</RowAction>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
{/* 左:章节树 */}
|
||||||
|
<aside
|
||||||
|
className="w-52 sm:w-60 shrink-0 flex flex-col min-h-0"
|
||||||
|
style={{ borderRight: "1px solid var(--line)" }}
|
||||||
|
aria-label="章节树"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-2.5 py-2.5 flex-wrap"
|
||||||
|
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => guardNav(() => void addSection(null))}
|
||||||
|
>
|
||||||
|
<Plus size={13} aria-hidden /> 添加章
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => guardNav(() => setImportOpen(true))}
|
||||||
|
>
|
||||||
|
<Upload size={13} aria-hidden /> 导入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll py-1.5">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="meta text-[12.5px] px-3 py-3 leading-relaxed">
|
||||||
|
还没有章节。点上方「添加章」手动编写,或「导入」批量导入 .md / .epub(epub 按目录顺序拆章)。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
groups.map((g, gi) => (
|
||||||
|
<div key={g.chapter.id}>
|
||||||
|
{row(g.chapter, 0, gi === 0, gi === groups.length - 1)}
|
||||||
|
{g.subs.map((sub, si) => row(sub, 1, si === 0, si === g.subs.length - 1))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-3 py-2 meta text-[11.5px] tabular-nums"
|
||||||
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{sections.length} 章节
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右:章节编辑器 */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll px-4 sm:px-5 py-4 flex flex-col gap-4">
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<label className="admin-doc-label" htmlFor="lib-section-title">
|
||||||
|
章节标题
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lib-section-title"
|
||||||
|
className="admin-field-input"
|
||||||
|
maxLength={MAX_TITLE}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setError("");
|
||||||
|
setTitle(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="例如:第一章 …"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* flex-1 撑满滚动区剩余高度:编辑框最大化、提示行紧贴底部操作条上方 */}
|
||||||
|
<div className="admin-doc-md flex flex-col flex-1 min-h-0">
|
||||||
|
<label className="admin-doc-label" htmlFor="lib-section-content">
|
||||||
|
正文(Markdown)
|
||||||
|
</label>
|
||||||
|
<MarkdownEditor
|
||||||
|
id="lib-section-content"
|
||||||
|
value={content}
|
||||||
|
onChange={(v) => {
|
||||||
|
setError("");
|
||||||
|
setContent(v.slice(0, MAX_CONTENT));
|
||||||
|
}}
|
||||||
|
maxChars={MAX_CONTENT}
|
||||||
|
allowHide={false}
|
||||||
|
entriesAuto={doc.entries_auto ?? false}
|
||||||
|
placeholder="本章正文,支持 Markdown…"
|
||||||
|
rows={16}
|
||||||
|
ariaLabel="章节正文"
|
||||||
|
uploadSource="library_content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作条:不随正文滚动 */}
|
||||||
|
<footer
|
||||||
|
className="shrink-0 flex items-center gap-2 px-3.5 sm:px-4 py-3"
|
||||||
|
style={{ borderTop: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||||
|
>
|
||||||
|
<div className="mr-auto flex items-center gap-3 min-w-0">
|
||||||
|
{error ? (
|
||||||
|
<p className="admin-settings-row-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : dirty ? (
|
||||||
|
<span className="meta text-[12px]">有未保存的修改</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={busy || deleteBusy}
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} aria-hidden /> 删除章节
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
title={dirty ? undefined : "没有需要保存的修改"}
|
||||||
|
onClick={() => void save()}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 size={13} className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Save size={13} aria-hidden />
|
||||||
|
)}
|
||||||
|
{busy ? "保存中…" : "保存章节"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||||
|
<AdminEmpty
|
||||||
|
icon={<ListTree size={22} />}
|
||||||
|
title="未选择章节"
|
||||||
|
description="从左侧选择章节编辑,或添加章 / 导入 Markdown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 未保存切换章节确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!pendingNav}
|
||||||
|
title="放弃未保存的修改?"
|
||||||
|
message="当前章节有未保存的修改,继续将丢失这些改动。"
|
||||||
|
danger
|
||||||
|
confirmLabel="放弃修改"
|
||||||
|
cancelLabel="继续编辑"
|
||||||
|
onCancel={() => setPendingNav(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
const action = pendingNav;
|
||||||
|
setPendingNav(null);
|
||||||
|
action?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除章节确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title={deleteTarget?.parent_id == null ? "删除章" : "删除小节"}
|
||||||
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? `确定删除「${deleteTarget.title}」吗?${
|
||||||
|
deleteTarget.parent_id == null ? "其下所有小节将一并删除," : ""
|
||||||
|
}且不可恢复。`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
danger
|
||||||
|
busy={deleteBusy}
|
||||||
|
confirmLabel="删除"
|
||||||
|
onCancel={() => !deleteBusy && setDeleteTarget(null)}
|
||||||
|
onConfirm={() => void removeSection()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 导入 Markdown 拆章 */}
|
||||||
|
{importOpen ? <ImportMdDialog onImport={handleImport} onClose={closeImport} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/app/admin/library/page.tsx
Normal file
36
frontend/app/admin/library/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import LibraryAdmin from "./LibraryAdmin";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "书库管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminLibraryPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
|
||||||
|
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initial: LibraryDoc[] = [];
|
||||||
|
let initError = "";
|
||||||
|
try {
|
||||||
|
const res = await fetchAdminLibraryDocs(cookie || undefined);
|
||||||
|
initial = res.docs ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
initError = e instanceof Error ? e.message : "加载书库失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载书库列表…</p>}>
|
||||||
|
<LibraryAdmin initial={initial} initError={initError} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/app/admin/library/parseEpub.ts
Normal file
91
frontend/app/admin/library/parseEpub.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* epub 导入解析(浏览器端):epub 本质是 zip + XHTML。
|
||||||
|
* 流程:JSZip 解压 → META-INF/container.xml 找 OPF → 按 spine 顺序读 XHTML
|
||||||
|
* → 清理(去 script/style、剔除内部相对路径图片)→ turndown 转 Markdown。
|
||||||
|
* 产物直接作为伪 .md 文件走现有批量导入接口,后端零改动。
|
||||||
|
* 两个依赖均在本模块内动态 import,避免打进主包。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EpubChapter {
|
||||||
|
title: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以 OPF 所在目录为基准解析 zip 内相对路径(处理 ./ ../ 与 URL 编码) */
|
||||||
|
function resolveZipPath(base: string, href: string): string {
|
||||||
|
const parts = `${base}${href}`.split("/");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!p || p === ".") continue;
|
||||||
|
if (p === "..") out.pop();
|
||||||
|
else out.push(p);
|
||||||
|
}
|
||||||
|
return out.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理标题里不适合做文件名的字符 */
|
||||||
|
function sanitizeTitle(t: string): string {
|
||||||
|
return t.replace(/[\\/:*?"<>|\n\r\t]+/g, " ").trim().slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseEpub(file: File): Promise<EpubChapter[]> {
|
||||||
|
const { default: JSZip } = await import("jszip");
|
||||||
|
const { default: TurndownService } = await import("turndown");
|
||||||
|
const zip = await JSZip.loadAsync(await file.arrayBuffer()).catch(() => {
|
||||||
|
throw new Error("无法读取 epub:不是有效的 zip 文件");
|
||||||
|
});
|
||||||
|
|
||||||
|
// container.xml → OPF 路径
|
||||||
|
const containerEntry = zip.file("META-INF/container.xml");
|
||||||
|
if (!containerEntry) throw new Error("无法读取 epub:缺少 META-INF/container.xml");
|
||||||
|
const containerDoc = new DOMParser().parseFromString(await containerEntry.async("text"), "application/xml");
|
||||||
|
const rootfilePath = containerDoc.querySelector("rootfile")?.getAttribute("full-path");
|
||||||
|
if (!rootfilePath) throw new Error("无法读取 epub:container.xml 未声明 OPF 文件");
|
||||||
|
|
||||||
|
// OPF → manifest(id → href/mediaType)+ spine(阅读顺序)
|
||||||
|
const opfEntry = zip.file(rootfilePath) ?? zip.file(decodeURIComponent(rootfilePath));
|
||||||
|
if (!opfEntry) throw new Error(`无法读取 epub:缺少 OPF 文件 ${rootfilePath}`);
|
||||||
|
const opfDoc = new DOMParser().parseFromString(await opfEntry.async("text"), "application/xml");
|
||||||
|
const opfDir = rootfilePath.includes("/") ? rootfilePath.slice(0, rootfilePath.lastIndexOf("/") + 1) : "";
|
||||||
|
|
||||||
|
const manifest = new Map<string, { href: string; mediaType: string }>();
|
||||||
|
opfDoc.querySelectorAll("manifest > item").forEach((item) => {
|
||||||
|
const id = item.getAttribute("id");
|
||||||
|
const href = item.getAttribute("href");
|
||||||
|
if (id && href) manifest.set(id, { href, mediaType: item.getAttribute("media-type") ?? "" });
|
||||||
|
});
|
||||||
|
const spineIds = Array.from(opfDoc.querySelectorAll("spine > itemref"))
|
||||||
|
.map((el) => el.getAttribute("idref"))
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
|
const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
||||||
|
const chapters: EpubChapter[] = [];
|
||||||
|
|
||||||
|
for (const id of spineIds) {
|
||||||
|
const item = manifest.get(id);
|
||||||
|
// spine 里也会有图片/样式等资源项,只处理 HTML 文档
|
||||||
|
if (!item || !/xhtml|html/i.test(item.mediaType)) continue;
|
||||||
|
const path = resolveZipPath(opfDir, item.href);
|
||||||
|
const entry = zip.file(path) ?? zip.file(decodeURIComponent(path));
|
||||||
|
if (!entry) continue;
|
||||||
|
const doc = new DOMParser().parseFromString(await entry.async("text"), "text/html");
|
||||||
|
doc.querySelectorAll("script, style").forEach((el) => el.remove());
|
||||||
|
// 内部相对路径图片导入后无法解析,剔除;外链 http(s) 图片保留
|
||||||
|
doc.querySelectorAll("img").forEach((img) => {
|
||||||
|
if (!/^https?:\/\//i.test(img.getAttribute("src") ?? "")) img.remove();
|
||||||
|
});
|
||||||
|
const body = doc.body;
|
||||||
|
if (!body) continue;
|
||||||
|
const markdown = td
|
||||||
|
.turndown(body.innerHTML)
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
if (!markdown) continue; // 封面页等纯图片页
|
||||||
|
const heading = Array.from(body.querySelectorAll("h1, h2, h3, h4, h5, h6")).find((h) => h.textContent?.trim());
|
||||||
|
const title = sanitizeTitle(heading?.textContent ?? doc.title ?? "") || `第 ${chapters.length + 1} 章`;
|
||||||
|
chapters.push({ title, markdown });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapters.length === 0) throw new Error("未能从 epub 中解析出任何章节");
|
||||||
|
return chapters;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||||
import type { MediaLibraryItem } from "@/lib/api";
|
import type { MediaLibraryItem } from "@/lib/api";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
function formatBytes(n: number): string {
|
||||||
if (n < 1024) return `${n} B`;
|
if (n < 1024) return `${n} B`;
|
||||||
@@ -24,10 +25,11 @@ function formatDims(item: MediaLibraryItem): string {
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
|
||||||
function thumbSrc(item: MediaLibraryItem): string {
|
function thumbSrc(item: MediaLibraryItem): string {
|
||||||
if (item.url.startsWith("/uploads/")) {
|
// 网格缩略走统一变体端点(256 档);非本站图原样返回
|
||||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
if (isOwnImage(item.url)) {
|
||||||
|
return variantURL(item.url, 256);
|
||||||
}
|
}
|
||||||
return item.url;
|
return item.url;
|
||||||
}
|
}
|
||||||
@@ -143,6 +145,8 @@ export default function MediaLibrary({
|
|||||||
<span className="block relative aspect-square overflow-hidden">
|
<span className="block relative aspect-square overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={thumbSrc(item)}
|
src={thumbSrc(item)}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [160, 256, 384, 512]) : undefined}
|
||||||
|
sizes="(min-width:1280px) 16vw, (min-width:1024px) 25vw, (min-width:640px) 33vw, 50vw"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
|
import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
|
||||||
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
|
import { apiAdminDeleteSitePage, apiAdminPurgeSitePage, type SitePage } from "@/lib/api";
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import { sitePagePublicPath } from "@/lib/sitePagePath";
|
import { sitePagePublicPath } from "@/lib/sitePagePath";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
@@ -61,8 +61,14 @@ export default function PagesAdmin({
|
|||||||
const [menuUp, setMenuUp] = useState(false);
|
const [menuUp, setMenuUp] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
|
const publishedCount = useMemo(
|
||||||
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
|
() => list.filter((p) => p.published && !p.deleted).length,
|
||||||
|
[list]
|
||||||
|
);
|
||||||
|
const draftCount = useMemo(
|
||||||
|
() => list.filter((p) => !p.published && !p.deleted).length,
|
||||||
|
[list]
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (statusFilter === "published") return list.filter((p) => p.published);
|
if (statusFilter === "published") return list.filter((p) => p.published);
|
||||||
@@ -93,9 +99,16 @@ export default function PagesAdmin({
|
|||||||
async function remove(p: SitePage) {
|
async function remove(p: SitePage) {
|
||||||
setDeletingId(p.id);
|
setDeletingId(p.id);
|
||||||
try {
|
try {
|
||||||
await apiAdminDeleteSitePage(p.id);
|
if (p.deleted) {
|
||||||
|
// 已软删的页面:彻底删除(不可恢复,释放 slug)
|
||||||
|
await apiAdminPurgeSitePage(p.id);
|
||||||
setList((l) => l.filter((x) => x.id !== p.id));
|
setList((l) => l.filter((x) => x.id !== p.id));
|
||||||
toast("页面已删除", "ok");
|
toast("页面已彻底删除,地址可重新使用", "ok");
|
||||||
|
} else {
|
||||||
|
await apiAdminDeleteSitePage(p.id);
|
||||||
|
setList((l) => l.map((x) => (x.id === p.id ? { ...x, deleted: true } : x)));
|
||||||
|
toast("页面已删除,可在列表中彻底删除以释放地址", "ok");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "删除失败");
|
toast(e instanceof Error ? e.message : "删除失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -170,20 +183,30 @@ export default function PagesAdmin({
|
|||||||
const updated = p.updated_at || "";
|
const updated = p.updated_at || "";
|
||||||
const path = sitePagePublicPath(p.slug, urlStyle);
|
const path = sitePagePublicPath(p.slug, urlStyle);
|
||||||
return (
|
return (
|
||||||
<tr key={p.id}>
|
<tr key={p.id} style={p.deleted ? { opacity: 0.65 } : undefined}>
|
||||||
<td data-label="页面">
|
<td data-label="页面">
|
||||||
|
{p.deleted ? (
|
||||||
|
<span className="admin-doc-title" title={p.title}>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
|
<Link href={editHref(p.id, statusFilter)} className="admin-doc-title" title={p.title}>
|
||||||
{p.title}
|
{p.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<p className="admin-doc-path">
|
<p className="admin-doc-path">
|
||||||
<code>{path}</code>
|
<code>{path}</code>
|
||||||
</p>
|
</p>
|
||||||
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
|
{p.excerpt ? <p className="admin-doc-excerpt">{p.excerpt}</p> : null}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="状态">
|
<td data-label="状态">
|
||||||
|
{p.deleted ? (
|
||||||
|
<AdminStatusChip tone="danger">已删除</AdminStatusChip>
|
||||||
|
) : (
|
||||||
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
|
||||||
{p.published ? "已发布" : "草稿"}
|
{p.published ? "已发布" : "草稿"}
|
||||||
</AdminStatusChip>
|
</AdminStatusChip>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="展示位置">{placeLabel(p)}</td>
|
<td data-label="展示位置">{placeLabel(p)}</td>
|
||||||
<td data-label="排序" className="tabular-nums">
|
<td data-label="排序" className="tabular-nums">
|
||||||
@@ -200,10 +223,12 @@ export default function PagesAdmin({
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="操作">
|
<td data-label="操作">
|
||||||
<div className="admin-doc-row-actions">
|
<div className="admin-doc-row-actions">
|
||||||
|
{!p.deleted ? (
|
||||||
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
|
<Link href={editHref(p.id, statusFilter)} className="admin-doc-textbtn">
|
||||||
编辑
|
编辑
|
||||||
</Link>
|
</Link>
|
||||||
{p.published ? (
|
) : null}
|
||||||
|
{p.published && !p.deleted ? (
|
||||||
<a
|
<a
|
||||||
href={path}
|
href={path}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -248,7 +273,7 @@ export default function PagesAdmin({
|
|||||||
setDeleteTarget(p);
|
setDeleteTarget(p);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
删除
|
{p.deleted ? "彻底删除" : "删除"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -277,15 +302,17 @@ export default function PagesAdmin({
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="删除单页"
|
title={deleteTarget?.deleted ? "彻底删除单页" : "删除单页"}
|
||||||
message={
|
message={
|
||||||
deleteTarget
|
deleteTarget
|
||||||
? `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,后台也不能恢复。`
|
? deleteTarget.deleted
|
||||||
|
? `确定彻底删除「${deleteTarget.title}」吗?删除后不可恢复,地址将释放可重新使用。`
|
||||||
|
: `确定删除「${deleteTarget.title}」吗?当前为${deleteTarget.published ? "已发布" : "草稿"}。删除后前台不再显示,可在列表中彻底删除以释放地址。`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
danger
|
danger
|
||||||
busy={deletingId != null}
|
busy={deletingId != null}
|
||||||
confirmLabel="删除"
|
confirmLabel={deleteTarget?.deleted ? "彻底删除" : "删除"}
|
||||||
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (deleteTarget) void remove(deleteTarget);
|
if (deleteTarget) void remove(deleteTarget);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../../Forbidden";
|
import Forbidden from "../../../Forbidden";
|
||||||
import PageEditLoader from "./PageEditLoader";
|
import PageEditLoader from "./PageEditLoader";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function AdminPageEditPage({ params }: PageProps) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../Forbidden";
|
import Forbidden from "../../Forbidden";
|
||||||
import PageComposeClient from "../PageComposeClient";
|
import PageComposeClient from "../PageComposeClient";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminPageNewPage() {
|
export default async function AdminPageNewPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import PagesAdmin from "./PagesAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminPagesPage() {
|
export default async function AdminPagesPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
|||||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||||
import BrandSeoPanel from "./BrandSeoPanel";
|
import BrandSeoPanel from "./BrandSeoPanel";
|
||||||
import FooterLinksPanel from "./FooterLinksPanel";
|
import FooterLinksPanel from "./FooterLinksPanel";
|
||||||
|
import HeaderNavPanel from "./HeaderNavPanel";
|
||||||
|
import MobileTabBarPanel from "./MobileTabBarPanel";
|
||||||
import UrlStylePanel from "./UrlStylePanel";
|
import UrlStylePanel from "./UrlStylePanel";
|
||||||
import type { PublicSettings } from "@/lib/api";
|
import type { PublicSettings } from "@/lib/api";
|
||||||
|
|
||||||
@@ -15,19 +17,25 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
|
|||||||
const [brandDirty, setBrandDirty] = useState(false);
|
const [brandDirty, setBrandDirty] = useState(false);
|
||||||
const [seoDirty, setSeoDirty] = useState(false);
|
const [seoDirty, setSeoDirty] = useState(false);
|
||||||
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
|
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
|
||||||
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty;
|
const [mobileTabsDirty, setMobileTabsDirty] = useState(false);
|
||||||
|
const [headerNavDirty, setHeaderNavDirty] = useState(false);
|
||||||
|
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty || mobileTabsDirty || headerNavDirty;
|
||||||
const leave = useUnsavedGuard(dirty);
|
const leave = useUnsavedGuard(dirty);
|
||||||
|
|
||||||
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
|
||||||
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
|
||||||
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
|
||||||
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
|
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
|
||||||
|
const onMobileTabsDirty = useCallback((d: boolean) => setMobileTabsDirty(d), []);
|
||||||
|
const onHeaderNavDirty = useCallback((d: boolean) => setHeaderNavDirty(d), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-settings-stack">
|
<div className="admin-settings-stack">
|
||||||
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
|
||||||
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
|
||||||
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
|
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
|
||||||
|
<HeaderNavPanel initial={live} onSaved={setLive} onDirtyChange={onHeaderNavDirty} />
|
||||||
|
<MobileTabBarPanel initial={live} onSaved={setLive} onDirtyChange={onMobileTabsDirty} />
|
||||||
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={leave.leaveOpen}
|
open={leave.leaveOpen}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { Images, Loader2 } from "lucide-react";
|
import { Images, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api";
|
||||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
import { normalizeBrandURL, type BrandSlot } from "@/lib/brand";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||||
logo_light: "浅色 Logo",
|
logo_light: "浅色 Logo",
|
||||||
@@ -12,6 +13,13 @@ const SLOT_LABEL: Record<BrandSlot, string> = {
|
|||||||
favicon: "Favicon",
|
favicon: "Favicon",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
|
||||||
|
export type BrandPickItem = {
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
attachmentId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function BrandMediaPicker({
|
export default function BrandMediaPicker({
|
||||||
open,
|
open,
|
||||||
slot,
|
slot,
|
||||||
@@ -21,9 +29,9 @@ export default function BrandMediaPicker({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
slot: BrandSlot;
|
slot: BrandSlot;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (item: MediaAttachment) => void;
|
onPick: (item: BrandPickItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
const [items, setItems] = useState<BrandPickItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -32,16 +40,29 @@ export default function BrandMediaPicker({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
void apiMyMedia()
|
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
|
||||||
.then((res) => {
|
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
|
||||||
|
.then(([mediaRes, libRes]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.error) {
|
if (mediaRes.error) {
|
||||||
setError(res.error);
|
setError(mediaRes.error);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const all = res.attachments || [];
|
const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({
|
||||||
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
|
key: `att-${a.id}`,
|
||||||
|
url: a.url,
|
||||||
|
attachmentId: a.id,
|
||||||
|
}));
|
||||||
|
const seen = new Set(out.map((i) => i.url));
|
||||||
|
for (const it of libRes?.items || []) {
|
||||||
|
if (it.category !== "brand") continue;
|
||||||
|
if (!normalizeBrandURL(slot, it.url)) continue;
|
||||||
|
if (seen.has(it.url)) continue;
|
||||||
|
seen.add(it.url);
|
||||||
|
out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null });
|
||||||
|
}
|
||||||
|
setItems(out);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setError("加载媒体库失败");
|
if (!cancelled) setError("加载媒体库失败");
|
||||||
@@ -58,7 +79,7 @@ export default function BrandMediaPicker({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||||
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidthClass="max-w-lg"
|
maxWidthClass="max-w-lg"
|
||||||
dismissOnOverlay
|
dismissOnOverlay
|
||||||
@@ -72,12 +93,12 @@ export default function BrandMediaPicker({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
<p className="meta text-[13px] py-6 text-center">媒体库还没有图片,先上传一张吧。</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPick(item);
|
onPick(item);
|
||||||
@@ -88,7 +109,14 @@ export default function BrandMediaPicker({
|
|||||||
data-tip="用作草稿预览,点保存后才生效"
|
data-tip="用作草稿预览,点保存后才生效"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
|
<img
|
||||||
|
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||||||
|
sizes="(max-width:520px) 23vw, 96px"
|
||||||
|
alt={`${SLOT_LABEL[slot]}候选图`}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ImagePlus, Images, X } from "lucide-react";
|
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingsActions,
|
AdminSettingsActions,
|
||||||
AdminSettingsGroup,
|
AdminSettingsGroup,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
import BrandCropModal from "@/components/BrandCropModal";
|
||||||
import BrandLockup from "@/components/BrandLockup";
|
import BrandLockup from "@/components/BrandLockup";
|
||||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
BRAND_LOGO_FITS,
|
BRAND_LOGO_FITS,
|
||||||
BRAND_LOGO_SIZES,
|
BRAND_LOGO_SIZES,
|
||||||
brandAccept,
|
brandAccept,
|
||||||
|
brandAliasPath,
|
||||||
brandFileOk,
|
brandFileOk,
|
||||||
|
normalizeBrandURL,
|
||||||
type BrandLogoFit,
|
type BrandLogoFit,
|
||||||
type BrandLogoSize,
|
type BrandLogoSize,
|
||||||
type BrandMark,
|
type BrandMark,
|
||||||
type BrandSlot,
|
type BrandSlot,
|
||||||
} from "@/lib/brand";
|
} from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
import BrandMediaPicker from "./BrandMediaPicker";
|
import BrandMediaPicker from "./BrandMediaPicker";
|
||||||
|
|
||||||
type SlotDraft = {
|
type SlotDraft = {
|
||||||
@@ -75,6 +79,10 @@ export default function BrandSeoPanel({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||||
|
const [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
|
||||||
|
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
|
||||||
|
const [cropSeedUrl, setCropSeedUrl] = useState("");
|
||||||
|
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||||
const blobs = useRef<string[]>([]);
|
const blobs = useRef<string[]>([]);
|
||||||
const saveGen = useRef(0);
|
const saveGen = useRef(0);
|
||||||
|
|
||||||
@@ -120,6 +128,18 @@ export default function BrandSeoPanel({
|
|||||||
return draft.committed;
|
return draft.committed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCrop = (slot: BrandSlot, file: File | null, url: string) => {
|
||||||
|
setCropSlot(slot);
|
||||||
|
setCropSeedFile(file);
|
||||||
|
setCropSeedUrl(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCrop = () => {
|
||||||
|
setCropSlot(null);
|
||||||
|
setCropSeedFile(null);
|
||||||
|
setCropSeedUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
const discard = () => {
|
const discard = () => {
|
||||||
[light, dark, favicon].forEach((d) => {
|
[light, dark, favicon].forEach((d) => {
|
||||||
if (d.blob) URL.revokeObjectURL(d.blob);
|
if (d.blob) URL.revokeObjectURL(d.blob);
|
||||||
@@ -203,6 +223,21 @@ export default function BrandSeoPanel({
|
|||||||
const slotDraft = (id: BrandSlot) =>
|
const slotDraft = (id: BrandSlot) =>
|
||||||
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||||||
|
|
||||||
|
// 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用)
|
||||||
|
const savedOf = (slot: BrandSlot) =>
|
||||||
|
slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light;
|
||||||
|
|
||||||
|
const copyAlias = async (slot: BrandSlot) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`);
|
||||||
|
setCopiedSlot(slot);
|
||||||
|
toast("已复制固定地址", "ok");
|
||||||
|
window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500);
|
||||||
|
} catch {
|
||||||
|
toast("复制失败,请手动复制");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||||||
const draft = slotDraft(slot);
|
const draft = slotDraft(slot);
|
||||||
const preview = previewOf(draft);
|
const preview = previewOf(draft);
|
||||||
@@ -216,14 +251,31 @@ export default function BrandSeoPanel({
|
|||||||
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
||||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
|
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={preview} alt="" />
|
<img
|
||||||
|
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(384)) : preview}
|
||||||
|
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : undefined}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span>未设置</span>
|
<span>未设置</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-brand-asset-meta">
|
<div className="admin-brand-asset-meta">
|
||||||
<span className="admin-brand-asset-label">{label}</span>
|
<span className="admin-brand-asset-label">{label}</span>
|
||||||
|
{savedOf(slot) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-brand-alias"
|
||||||
|
title="点击复制固定地址(换图不变链,友链引用请用此地址)"
|
||||||
|
onClick={() => void copyAlias(slot)}
|
||||||
|
>
|
||||||
|
<Link2 size={12} aria-hidden />
|
||||||
|
<span className="admin-brand-alias-url">{brandAliasPath(slot)}</span>
|
||||||
|
{copiedSlot === slot ? <Check size={12} aria-hidden /> : null}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<div className="admin-brand-asset-actions">
|
<div className="admin-brand-asset-actions">
|
||||||
<label className="btn btn-line btn-sm cursor-pointer">
|
<label className="btn btn-line btn-sm cursor-pointer">
|
||||||
<ImagePlus size={14} /> 上传
|
<ImagePlus size={14} /> 上传
|
||||||
@@ -242,7 +294,14 @@ export default function BrandSeoPanel({
|
|||||||
toast(err);
|
toast(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// SVG / ICO 无需裁剪,直接走原流程
|
||||||
|
const isVector = file.name.toLowerCase().endsWith(".svg");
|
||||||
|
const isIco = file.name.toLowerCase().endsWith(".ico");
|
||||||
|
if (isVector || isIco) {
|
||||||
replaceFile(slot, file);
|
replaceFile(slot, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCrop(slot, file, "");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -383,11 +442,50 @@ export default function BrandSeoPanel({
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
onPick={(item) => {
|
onPick={(item) => {
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const [prev, set] = drafts[picker];
|
const slot = picker;
|
||||||
|
setPicker(null);
|
||||||
|
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
|
||||||
|
const brandURL = normalizeBrandURL(slot, item.url);
|
||||||
|
if (brandURL) {
|
||||||
|
const [prev, set] = drafts[slot];
|
||||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = item.url.toLowerCase();
|
||||||
|
const isVector = lower.endsWith(".svg");
|
||||||
|
const isIco = lower.endsWith(".ico");
|
||||||
|
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
|
||||||
|
if ((isVector || isIco) && item.attachmentId != null) {
|
||||||
|
const [prev, set] = drafts[slot];
|
||||||
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
|
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCrop(slot, null, item.url);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{cropSlot ? (
|
||||||
|
<BrandCropModal
|
||||||
|
open
|
||||||
|
slot={cropSlot}
|
||||||
|
seedFile={cropSeedFile}
|
||||||
|
seedUrl={cropSeedUrl}
|
||||||
|
onClose={closeCrop}
|
||||||
|
onSeedConsumed={() => {
|
||||||
|
setCropSeedFile(null);
|
||||||
|
setCropSeedUrl("");
|
||||||
|
}}
|
||||||
|
onCropped={(url) => {
|
||||||
|
const [, set] = drafts[cropSlot];
|
||||||
|
set((prev) => {
|
||||||
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
|
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||||
|
});
|
||||||
|
closeCrop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
322
frontend/app/admin/settings/HeaderNavPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
HEADER_NAV_BUILTIN_KEYS,
|
||||||
|
HEADER_NAV_BUILTIN_LABELS,
|
||||||
|
HEADER_NAV_LIMITS,
|
||||||
|
isHeaderNavExternal,
|
||||||
|
type HeaderNavBuiltinKey,
|
||||||
|
type HeaderNavItem,
|
||||||
|
} from "@/lib/headerNav";
|
||||||
|
import { footerURLAllowed } from "@/lib/brand";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
||||||
|
home: <Home size={14} />,
|
||||||
|
leaderboard: <Trophy size={14} />,
|
||||||
|
library: <BookOpen size={14} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
|
||||||
|
const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
|
||||||
|
home: "站点首页 /",
|
||||||
|
leaderboard: "排行榜页(地址随伪静态风格变化)",
|
||||||
|
library: "书库页(地址随伪静态风格变化)",
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
|
||||||
|
|
||||||
|
function clone(items: HeaderNavItem[]): HeaderNavItem[] {
|
||||||
|
return items.map((item) => ({ ...item }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
|
||||||
|
const present = new Set(
|
||||||
|
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
home: present.has("home"),
|
||||||
|
leaderboard: present.has("leaderboard"),
|
||||||
|
library: present.has("library"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整行列表:已启用项按配置顺序在前,未启用的内置项按默认顺序追加在后 */
|
||||||
|
function rowsOf(items: HeaderNavItem[]): HeaderNavItem[] {
|
||||||
|
const present = new Set(
|
||||||
|
items.filter((it): it is Extract<HeaderNavItem, { type: "builtin" }> => it.type === "builtin").map((it) => it.key),
|
||||||
|
);
|
||||||
|
const hidden = HEADER_NAV_BUILTIN_KEYS.filter((key) => !present.has(key)).map((key) => ({
|
||||||
|
type: "builtin" as const,
|
||||||
|
key,
|
||||||
|
}));
|
||||||
|
return [...clone(items), ...hidden];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面端 Header 品牌名右侧的主导航:排序、开关显隐内置项、增删自定义链接,整体替换保存。
|
||||||
|
* 与移动端底栏面板同款交互:操作按钮居右,内置项开关隐藏(隐藏项保留排序位置)。
|
||||||
|
*/
|
||||||
|
export default function HeaderNavPanel({
|
||||||
|
initial,
|
||||||
|
onSaved,
|
||||||
|
onDirtyChange,
|
||||||
|
}: {
|
||||||
|
initial: PublicSettings;
|
||||||
|
onSaved?: (next: PublicSettings) => void;
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<HeaderNavItem[]>(() => rowsOf(initial.header_nav));
|
||||||
|
const [on, setOn] = useState<BuiltinSwitches>(() => switchesOf(initial.header_nav));
|
||||||
|
const [savedPayload, setSavedPayload] = useState<HeaderNavItem[]>(() => clone(initial.header_nav));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
// 新增自定义行挂载后自动聚焦名称输入框
|
||||||
|
const [focusIdx, setFocusIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 提交负载 = 自定义链接 + 开关打开的内置项,按行顺序
|
||||||
|
const payload: HeaderNavItem[] = rows.filter((item) => item.type === "custom" || on[item.key]);
|
||||||
|
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
|
||||||
|
const atCap = payload.length >= HEADER_NAV_LIMITS.items;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(dirty);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusIdx !== null) setFocusIdx(null);
|
||||||
|
}, [focusIdx]);
|
||||||
|
|
||||||
|
const move = (i: number, dir: -1 | 1) => {
|
||||||
|
setError("");
|
||||||
|
setRows((prev) => {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= prev.length) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
const [row] = next.splice(i, 1);
|
||||||
|
next.splice(j, 0, row);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (i: number) => {
|
||||||
|
setError("");
|
||||||
|
setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustom = () => {
|
||||||
|
if (atCap) return;
|
||||||
|
setError("");
|
||||||
|
setFocusIdx(rows.length);
|
||||||
|
setRows((prev) => [...prev, { type: "custom", label: "", url: "", new_tab: false }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustom = (i: number, patch: Partial<Extract<HeaderNavItem, { type: "custom" }>>) => {
|
||||||
|
setError("");
|
||||||
|
setRows((prev) => prev.map((item, idx) => (idx === i && item.type === "custom" ? { ...item, ...patch } : item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
setRows(rowsOf(savedPayload));
|
||||||
|
setOn(switchesOf(savedPayload));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
for (const item of payload) {
|
||||||
|
if (item.type !== "custom") continue;
|
||||||
|
if (!item.label.trim()) {
|
||||||
|
setError("自定义菜单名称不能为空");
|
||||||
|
toast("自定义菜单名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!footerURLAllowed(item.url)) {
|
||||||
|
setError(`地址无效:${item.url || "(空)"}`);
|
||||||
|
toast(`地址无效:${item.url || "(空)"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body: HeaderNavItem[] = payload.map((item) =>
|
||||||
|
item.type === "custom"
|
||||||
|
? { type: "custom", label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab }
|
||||||
|
: { type: "builtin", key: item.key },
|
||||||
|
);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ header_nav: body });
|
||||||
|
setSavedPayload(clone(res.header_nav));
|
||||||
|
setRows(rowsOf(res.header_nav));
|
||||||
|
setOn(switchesOf(res.header_nav));
|
||||||
|
onSaved?.(res);
|
||||||
|
toast("已保存,顶部导航已更新", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setError(msg);
|
||||||
|
toast(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsGroup
|
||||||
|
id="settings-header-nav"
|
||||||
|
title="顶部导航菜单"
|
||||||
|
description="桌面端页眉品牌名右侧的菜单,顺序即前台展示顺序;移动端导航由底部菜单承担。全部隐藏后前台不显示导航。"
|
||||||
|
footer={
|
||||||
|
<AdminSettingsActions
|
||||||
|
dirty={dirty}
|
||||||
|
saving={saving}
|
||||||
|
saveLabel="保存"
|
||||||
|
error={error}
|
||||||
|
onSave={() => void save()}
|
||||||
|
onDiscard={discard}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((item, i) => {
|
||||||
|
const enabled = item.type === "custom" || on[item.key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.type === "builtin" ? `builtin-${item.key}` : `custom-${i}`}
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded-xl px-2 py-2"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
background: "var(--panel)",
|
||||||
|
opacity: enabled ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.type === "builtin" ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded-full shrink-0"
|
||||||
|
style={{ color: "var(--ink-2)", background: "var(--panel-2)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{BUILTIN_ICONS[item.key]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13.5px] font-semibold shrink-0" style={{ color: "var(--ink)" }}>
|
||||||
|
{HEADER_NAV_BUILTIN_LABELS[item.key]}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[12px] truncate">{BUILTIN_HINTS[item.key]}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-field-shell w-32 shrink-0">
|
||||||
|
<input
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ padding: "7px 10px" }}
|
||||||
|
maxLength={HEADER_NAV_LIMITS.label}
|
||||||
|
value={item.label}
|
||||||
|
onChange={(e) => updateCustom(i, { label: e.target.value })}
|
||||||
|
placeholder="菜单名称"
|
||||||
|
aria-label={`第 ${i + 1} 个自定义菜单名称`}
|
||||||
|
autoFocus={focusIdx === i}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-shell min-w-[10rem] flex-1">
|
||||||
|
<input
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ padding: "7px 10px" }}
|
||||||
|
maxLength={HEADER_NAV_LIMITS.url}
|
||||||
|
value={item.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
const wasExt = isHeaderNavExternal(item.url);
|
||||||
|
const nowExt = isHeaderNavExternal(v);
|
||||||
|
updateCustom(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||||||
|
}}
|
||||||
|
placeholder="https://… 或 /p/about"
|
||||||
|
aria-label={`第 ${i + 1} 个自定义菜单地址`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 w-[8.75rem]">
|
||||||
|
<div className="admin-footer-open-toggle" role="group" aria-label={`第 ${i + 1} 个菜单打开方式`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
|
||||||
|
aria-pressed={!item.new_tab}
|
||||||
|
onClick={() => updateCustom(i, { new_tab: false })}
|
||||||
|
>
|
||||||
|
本页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
|
||||||
|
aria-pressed={item.new_tab}
|
||||||
|
onClick={() => updateCustom(i, { new_tab: true })}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} aria-hidden />
|
||||||
|
新标签
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="上移"
|
||||||
|
disabled={i === 0 || saving}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label="下移"
|
||||||
|
disabled={i === rows.length - 1 || saving}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
{item.type === "builtin" ? (
|
||||||
|
<AdminSwitch
|
||||||
|
checked={on[item.key]}
|
||||||
|
disabled={saving || (!on[item.key] && atCap)}
|
||||||
|
onChange={() => {
|
||||||
|
setError("");
|
||||||
|
setOn((prev) => ({ ...prev, [item.key]: !prev[item.key] }));
|
||||||
|
}}
|
||||||
|
label={`顶部导航显示${HEADER_NAV_BUILTIN_LABELS[item.key]}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn is-danger"
|
||||||
|
aria-label="移除"
|
||||||
|
onClick={() => removeAt(i)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" disabled={atCap} onClick={addCustom}>
|
||||||
|
<Plus size={14} /> 新增自定义链接
|
||||||
|
<span className="meta text-[12px] ml-1">
|
||||||
|
{payload.length}/{HEADER_NAV_LIMITS.items}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||||
|
<li>内置菜单(首页/排行榜/书库)用开关控制显隐,地址由系统按伪静态风格自动生成;隐藏项保留排序位置。</li>
|
||||||
|
<li>自定义链接支持站内路径(如 /p/about、/announcements)或 http(s) 外链;名称最多 16 字。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
174
frontend/app/admin/settings/MobileTabBarPanel.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import { MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
/** 底栏入口说明 */
|
||||||
|
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
|
||||||
|
home: { label: "首页", hint: "站点首页" },
|
||||||
|
library: { label: "书库", hint: "书库列表页入口" },
|
||||||
|
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
|
||||||
|
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
|
||||||
|
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabSwitches = Record<MobileTabKey, boolean>;
|
||||||
|
|
||||||
|
function switchesOf(tabs: MobileTabKey[]): TabSwitches {
|
||||||
|
return {
|
||||||
|
home: tabs.includes("home"),
|
||||||
|
library: tabs.includes("library"),
|
||||||
|
compose: tabs.includes("compose"),
|
||||||
|
messages: tabs.includes("messages"),
|
||||||
|
me: tabs.includes("me"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整顺序:已启用项按配置顺序在前,未启用项按默认顺序追加在后 */
|
||||||
|
function orderOf(tabs: MobileTabKey[]): MobileTabKey[] {
|
||||||
|
const enabled = normalizeMobileTabs(tabs);
|
||||||
|
const rest = MOBILE_TABS.filter((key) => !enabled.includes(key));
|
||||||
|
return [...enabled, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移动端底栏:开关 + 顺序调整,保存启用的 tab 子集(数组顺序即前台展示顺序) */
|
||||||
|
export default function MobileTabBarPanel({
|
||||||
|
initial,
|
||||||
|
onSaved,
|
||||||
|
onDirtyChange,
|
||||||
|
}: {
|
||||||
|
initial: PublicSettings;
|
||||||
|
onSaved?: (next: PublicSettings) => void;
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [order, setOrder] = useState<MobileTabKey[]>(() => orderOf(initial.mobile_tabs));
|
||||||
|
const [on, setOn] = useState<TabSwitches>(() => switchesOf(normalizeMobileTabs(initial.mobile_tabs)));
|
||||||
|
const [savedPayload, setSavedPayload] = useState<MobileTabKey[]>(() => normalizeMobileTabs(initial.mobile_tabs));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const payload = order.filter((key) => on[key]);
|
||||||
|
const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(dirty);
|
||||||
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
const move = (i: number, dir: -1 | 1) => {
|
||||||
|
setError("");
|
||||||
|
setOrder((prev) => {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= prev.length) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
const [row] = next.splice(i, 1);
|
||||||
|
next.splice(j, 0, row);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
setOrder(orderOf(savedPayload));
|
||||||
|
setOn(switchesOf(savedPayload));
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
if (payload.length === 0) {
|
||||||
|
setError("至少保留一个入口");
|
||||||
|
toast("至少保留一个入口");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ mobile_tabs: [...payload] });
|
||||||
|
const nextSaved = normalizeMobileTabs(res.mobile_tabs);
|
||||||
|
setSavedPayload(nextSaved);
|
||||||
|
setOrder(orderOf(nextSaved));
|
||||||
|
setOn(switchesOf(nextSaved));
|
||||||
|
onSaved?.(res);
|
||||||
|
toast("已保存,移动端底栏已更新", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "保存失败";
|
||||||
|
setError(msg);
|
||||||
|
toast(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsGroup
|
||||||
|
id="settings-mobile-tabbar"
|
||||||
|
title="移动端底栏"
|
||||||
|
description="控制移动端底部导航显示哪些入口及顺序;底栏只在各入口自己的首页显示。"
|
||||||
|
footer={
|
||||||
|
<AdminSettingsActions
|
||||||
|
dirty={dirty}
|
||||||
|
saving={saving}
|
||||||
|
saveLabel="保存"
|
||||||
|
error={error}
|
||||||
|
saveDisabled={payload.length === 0}
|
||||||
|
onSave={() => void save()}
|
||||||
|
onDiscard={discard}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{order.map((key, i) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2 rounded-xl px-2 py-2"
|
||||||
|
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
{TAB_META[key].label}
|
||||||
|
</div>
|
||||||
|
<div className="meta text-[12px]">{TAB_META[key].hint}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label={`上移${TAB_META[key].label}`}
|
||||||
|
disabled={i === 0 || saving}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-icon-btn"
|
||||||
|
aria-label={`下移${TAB_META[key].label}`}
|
||||||
|
disabled={i === order.length - 1 || saving}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={on[key]}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={() => {
|
||||||
|
setError("");
|
||||||
|
setOn((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}}
|
||||||
|
label={`底栏显示${TAB_META[key].label}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
|
||||||
|
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
|
||||||
|
<li>至少保留一个入口;全部关闭将无法保存。</li>
|
||||||
|
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { announcementPath, leaderboardPath, pagePath, postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
|
import {
|
||||||
|
announcementPath,
|
||||||
|
leaderboardPath,
|
||||||
|
libraryDocPath,
|
||||||
|
libraryPath,
|
||||||
|
pagePath,
|
||||||
|
postPath,
|
||||||
|
userPath,
|
||||||
|
type UrlStyle,
|
||||||
|
} from "@/lib/urlStyle";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
|
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
|
||||||
@@ -20,6 +29,8 @@ function samplePaths(style: UrlStyle) {
|
|||||||
{ kind: "公告", path: announcementPath(style, 1) },
|
{ kind: "公告", path: announcementPath(style, 1) },
|
||||||
{ kind: "自建页", path: pagePath(style, "about") },
|
{ kind: "自建页", path: pagePath(style, "about") },
|
||||||
{ kind: "排行榜", path: leaderboardPath(style) },
|
{ kind: "排行榜", path: leaderboardPath(style) },
|
||||||
|
{ kind: "书库", path: libraryPath(style) },
|
||||||
|
{ kind: "书籍", path: libraryDocPath(style, "slug") },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +128,7 @@ export default function UrlStylePanel({
|
|||||||
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
|
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
|
||||||
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
|
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
|
||||||
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
|
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
|
||||||
|
<li>书库列表与书籍详情参与伪静态(两种伪静态风格同模板);阅读页保持标准路径不参与。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
|
|||||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
||||||
|
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
|
||||||
|
|
||||||
type Side = "left" | "right";
|
type Side = "left" | "right";
|
||||||
type SectionId = SidebarScope | "widgets";
|
type SectionId = SidebarScope | "widgets";
|
||||||
@@ -102,6 +104,9 @@ const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[
|
|||||||
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
||||||
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
|
||||||
|
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
|
||||||
|
|
||||||
export default function SidebarAdmin({
|
export default function SidebarAdmin({
|
||||||
initialConfig,
|
initialConfig,
|
||||||
initialWidgets,
|
initialWidgets,
|
||||||
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
|
|||||||
const [draftName, setDraftName] = useState("");
|
const [draftName, setDraftName] = useState("");
|
||||||
const [mixed, setMixed] = useState("");
|
const [mixed, setMixed] = useState("");
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
|
||||||
|
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
|
||||||
|
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
|
||||||
|
const pendingCodeRef = useRef<WidgetDraftCode | null>(null);
|
||||||
|
|
||||||
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
||||||
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
||||||
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setDraftName("");
|
setDraftName("");
|
||||||
setMixed("");
|
setMixed("");
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
|
|||||||
setEditingId(w.id);
|
setEditingId(w.id);
|
||||||
setDraftName(w.name);
|
setDraftName(w.name);
|
||||||
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitWidget = async () => {
|
const doSubmitWidget = async (code: WidgetDraftCode) => {
|
||||||
if (!draftOk) return;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
|
||||||
if (editingId == null) {
|
if (editingId == null) {
|
||||||
await apiAdminCreateSidebarWidget(code);
|
await apiAdminCreateSidebarWidget(code);
|
||||||
toast("工具已创建", "ok");
|
toast("工具已创建", "ok");
|
||||||
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
|
|||||||
toast("工具已保存", "ok");
|
toast("工具已保存", "ok");
|
||||||
}
|
}
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||||
@@ -228,6 +244,34 @@ export default function SidebarAdmin({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存
|
||||||
|
const submitWidget = () => {
|
||||||
|
if (!draftOk || busy) return;
|
||||||
|
const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||||
|
const issues = lintWidgetCode(code);
|
||||||
|
setLintIssues(issues.length ? issues : null);
|
||||||
|
if (issues.some((i) => i.level === "error")) return;
|
||||||
|
const warns = issues.filter((i) => i.level === "warn");
|
||||||
|
if (warns.length > 0) {
|
||||||
|
pendingCodeRef.current = code;
|
||||||
|
setWarnConfirmOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void doSubmitWidget(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmWarnSubmit = () => {
|
||||||
|
const code = pendingCodeRef.current;
|
||||||
|
pendingCodeRef.current = null;
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
if (code) void doSubmitWidget(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelWarnSubmit = () => {
|
||||||
|
pendingCodeRef.current = null;
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
|
|||||||
<SectionHead
|
<SectionHead
|
||||||
icon={<Code2 size={16} />}
|
icon={<Code2 size={16} />}
|
||||||
title="自定义工具"
|
title="自定义工具"
|
||||||
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
|
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
|
||||||
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
|
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
|
||||||
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
|
|||||||
<textarea
|
<textarea
|
||||||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||||
value={mixed}
|
value={mixed}
|
||||||
onChange={(e) => setMixed(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
setMixed(e.target.value);
|
||||||
|
setLintIssues(null);
|
||||||
|
}}
|
||||||
|
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
|
||||||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
|
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
|
||||||
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||||
|
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
|
||||||
|
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
|
||||||
|
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
|
||||||
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||||||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||||
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<p className="meta text-[11.5px]">
|
<p className="meta text-[11.5px]">
|
||||||
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 仅 warn 级问题时的二次确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={warnConfirmOpen}
|
||||||
|
title="代码存在风险提醒"
|
||||||
|
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
|
||||||
|
confirmLabel="仍要保存"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={confirmWarnSubmit}
|
||||||
|
onCancel={cancelWarnSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 删除工具确认 */}
|
{/* 删除工具确认 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
@@ -550,6 +612,75 @@ export default function SidebarAdmin({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 工具代码静态扫描结果面板 ─── */
|
||||||
|
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
|
||||||
|
js: "JS",
|
||||||
|
css: "CSS",
|
||||||
|
html: "HTML",
|
||||||
|
};
|
||||||
|
|
||||||
|
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
|
||||||
|
const errors = issues.filter((i) => i.level === "error");
|
||||||
|
const warns = issues.filter((i) => i.level === "warn");
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-2.5"
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
|
||||||
|
background: "var(--danger-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
|
||||||
|
<AlertTriangle size={13} aria-hidden />
|
||||||
|
{errors.length} 个错误,修复后才能保存
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{errors.map((issue, idx) => (
|
||||||
|
<LintIssueRow key={`e-${idx}`} issue={issue} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warns.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-2.5"
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
|
||||||
|
background: "var(--gold-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
|
||||||
|
<Info size={13} aria-hidden />
|
||||||
|
{warns.length} 个提醒,确认风险后仍可保存
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{warns.map((issue, idx) => (
|
||||||
|
<LintIssueRow key={`w-${idx}`} issue={issue} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
|
||||||
|
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
|
||||||
|
return (
|
||||||
|
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
|
||||||
|
<span
|
||||||
|
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
|
||||||
|
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
|
||||||
|
>
|
||||||
|
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── 分区卡片头 ─── */
|
/* ─── 分区卡片头 ─── */
|
||||||
function SectionHead({
|
function SectionHead({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { formatDate, formatRelative } from "@/lib/format";
|
|||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
import { postPath, userPath } from "@/lib/urlStyle";
|
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
|
||||||
import {
|
import {
|
||||||
AdminEmpty,
|
AdminEmpty,
|
||||||
AdminPager,
|
AdminPager,
|
||||||
@@ -356,7 +356,7 @@ export default function UserAuditClient({
|
|||||||
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
|
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
|
||||||
className="text-[11.5px] hover:underline"
|
className="text-[11.5px] hover:underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
AD_IMAGE_WIDTH,
|
AD_IMAGE_WIDTH,
|
||||||
AD_TEXT_COL_WIDTH,
|
AD_TEXT_COL_WIDTH,
|
||||||
} from "@/lib/ads";
|
} from "@/lib/ads";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
type Kind = "image" | "text";
|
type Kind = "image" | "text";
|
||||||
|
|
||||||
@@ -183,7 +184,8 @@ export default function AdsBuyClient() {
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={selectedPay.qr_url}
|
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(160)) : selectedPay.qr_url}
|
||||||
|
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 160) : undefined}
|
||||||
alt="收款码"
|
alt="收款码"
|
||||||
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
||||||
style={{ borderColor: "var(--line)" }}
|
style={{ borderColor: "var(--line)" }}
|
||||||
@@ -381,7 +383,8 @@ export default function AdsBuyClient() {
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={selectedPay.qr_url}
|
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(144)) : selectedPay.qr_url}
|
||||||
|
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 144) : undefined}
|
||||||
alt={`${selectedPay.name}收款码`}
|
alt={`${selectedPay.name}收款码`}
|
||||||
className="h-36 w-36 object-contain"
|
className="h-36 w-36 object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone, Pin } from "lucide-react";
|
||||||
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
import PostToc from "@/components/PostToc";
|
import PostToc from "@/components/PostToc";
|
||||||
@@ -59,7 +59,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
||||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||||
const announcements = overview?.announcements ?? [];
|
const announcements = overview?.announcements ?? [];
|
||||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const toc = extractToc(a.content);
|
const toc = extractToc(a.content);
|
||||||
@@ -105,17 +105,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
<>
|
<>
|
||||||
<JsonLd data={jsonLd} />
|
<JsonLd data={jsonLd} />
|
||||||
|
|
||||||
<SiteDocShell
|
<SiteDocShell left={left} right={right}>
|
||||||
left={left}
|
|
||||||
right={right}
|
|
||||||
mobileExtras={
|
|
||||||
<>
|
|
||||||
{toc.length > 0 ? <PostToc items={toc} /> : null}
|
|
||||||
<SiteDocAnnoNav items={announcements} currentId={annId} />
|
|
||||||
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<article className="panel p-6 sm:p-8">
|
<article className="panel p-6 sm:p-8">
|
||||||
<SiteDocMasthead
|
<SiteDocMasthead
|
||||||
kicker="站点公告"
|
kicker="站点公告"
|
||||||
@@ -126,7 +116,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
showTimeOfDay
|
showTimeOfDay
|
||||||
badges={
|
badges={
|
||||||
<>
|
<>
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? (
|
||||||
|
<span className="badge-pin" title="置顶" aria-label="置顶">
|
||||||
|
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -144,7 +138,8 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
|
{/* 手机端 #main 无底部内边距,补 mb 与 footer 拉开距离;桌面端由 lg:pb 提供,归零 */}
|
||||||
|
<div className="mt-5 mb-6 lg:mb-0 flex items-center gap-2 text-[12.5px] meta">
|
||||||
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
<Megaphone size={14} style={{ color: "var(--accent)" }} />
|
||||||
<span>由站点管理团队发布</span>
|
<span>由站点管理团队发布</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone, Pin } from "lucide-react";
|
||||||
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
|
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import {
|
import {
|
||||||
@@ -57,7 +57,11 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
|
|||||||
>
|
>
|
||||||
置顶公告
|
置顶公告
|
||||||
</span>
|
</span>
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? (
|
||||||
|
<span className="badge-pin" title="置顶" aria-label="置顶">
|
||||||
|
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||||
{formatDate(a.created_at)}
|
{formatDate(a.created_at)}
|
||||||
@@ -87,7 +91,11 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
|
|||||||
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
|
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? (
|
||||||
|
<span className="badge-pin" title="置顶" aria-label="置顶">
|
||||||
|
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||||
{formatDate(a.created_at)}
|
{formatDate(a.created_at)}
|
||||||
@@ -133,7 +141,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||||
|
|
||||||
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
|
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
|
||||||
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
|
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
|
||||||
@@ -145,13 +153,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteDocShell
|
<SiteDocShell left={left} right={right}>
|
||||||
left={left}
|
|
||||||
right={right}
|
|
||||||
mobileExtras={
|
|
||||||
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
<header
|
<header
|
||||||
className="px-5 sm:px-6 pt-5 pb-4"
|
className="px-5 sm:px-6 pt-5 pb-4"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user