feat(reader): legado 书源规则引擎 Go 移植 + 阅读 P0 后端

- internal/service/reader/rule/:逐方法移植 legado analyzeRule 包
  (AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/
  AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer),JS 规则留 P2 接入点
- 书源导入(JSON数组/对象/Base64/URL)、多源聚合搜索(legado 四档
  排序合并)、详情/目录/正文(nextContentUrl 翻页合并)
- 数据模型 4 表注册迁移;/api/reader/* 路由组(书源/搜索/书架/进度/调试)
- 单测 + httptest 全链路端到端测试
- docs/reader-ui-spec.md:legado UI 交互仿制规格(供 P1 前端使用)
This commit is contained in:
truewhile
2026-09-30 15:41:58 +08:00
parent c84f2604ed
commit b8e6418326
28 changed files with 5736 additions and 14 deletions
+281
View File
@@ -0,0 +1,281 @@
// Package handler — 阅读(legado 书源兼容)子系统路由。
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g := authed.Group("/reader")
// 书源管理
g.GET("/sources", readerListSourcesHandler(svc))
g.POST("/sources/import", readerImportSourcesHandler(svc))
g.PATCH("/sources/:id", readerUpdateSourceHandler(svc))
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
// 详情 / 目录 / 正文
g.GET("/book-info", readerBookInfoHandler(svc))
g.GET("/toc", readerTocHandler(svc))
g.GET("/content", readerContentHandler(svc))
// 书架
g.GET("/books", readerListBooksHandler(svc))
g.POST("/books", readerAddBookHandler(svc))
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
// 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
}
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sources, err := svc.Reader.ListSources(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sources": sources})
}
}
func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Text string `json:"text" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"imported": imported})
}
}
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Enabled *bool `json:"enabled"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return
}
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
}
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"books": books, "skipped": skipped})
}
}
func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Reader.GetBookInfo(
c.Request.Context(),
c.Query("source_id"), c.Query("book_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func readerTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapters, err := svc.Reader.GetToc(
c.Request.Context(),
c.Query("source_id"), c.Query("book_url"), c.Query("toc_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
func readerContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
content, err := svc.Reader.GetContent(
c.Request.Context(),
c.Query("source_id"), c.Query("book_url"), c.Query("chapter_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, content)
}
}
func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
books, err := svc.Reader.ListBooks(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"books": books})
}
}
func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
Name string `json:"name" binding:"required"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.AddBook(c.Request.Context(), userID, body.Origin, body.Name, body.Author, body.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.RemoveBook(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndex int `json:"chapter_index"`
Pos int `json:"pos"`
ChapterTitle string `json:"chapter_title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, body.Pos, body.ChapterTitle); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerListChaptersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapters, err := svc.Reader.ListChapters(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
func readerReplaceChaptersHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Chapters []reader.ChapterInput `json:"chapters"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SaveChapters(c.Request.Context(), c.Param("id"), body.Chapters); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerListReplaceRulesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
rules, err := svc.Reader.ListReplaceRules(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rules": rules})
}
}
+1
View File
@@ -30,4 +30,5 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
registerAuthedDLNAControlRoutes(authed, svc)
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
registerAuthedPlaybackExtraRoutes(authed, svc)
registerReaderRoutes(authed, svc)
}