Skip to content

Commit 6de7e46

Browse files
committed
feat: 支持合集 (ComicGroup) 关联目录作品 (ComicSeries)
1. 数据库新增 Migration Version 37 (ComicGroupSeries 表),建立合集与目录作品的关联关系。 2. 扩展 GetGroupByID 支持返回包含的 SeriesList(目录作品及其章节列表)与 Comics(散本列表),实现合集详情页按作品分块排版。 3. 新增 POST /api/groups/:id/series 与 DELETE /api/groups/:id/series/:seriesId 管理接口。 4. 保持主书架与合集专区逻辑解耦,通过了全部单元测试。
1 parent 37c7b48 commit 6de7e46

7 files changed

Lines changed: 260 additions & 24 deletions

File tree

internal/handler/group_crud_handler.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,56 @@ func (h *GroupHandler) RemoveComic(c *gin.Context) {
276276
c.JSON(http.StatusOK, gin.H{"success": true})
277277
}
278278

279+
// ============================================================
280+
// POST /api/groups/:id/series — 添加目录作品到分组
281+
// ============================================================
282+
283+
func (h *GroupHandler) AddSeries(c *gin.Context) {
284+
id, err := strconv.Atoi(c.Param("id"))
285+
if err != nil {
286+
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分组ID"})
287+
return
288+
}
289+
290+
var body struct {
291+
SeriesIDs []string `json:"seriesIds"`
292+
}
293+
if err := c.ShouldBindJSON(&body); err != nil || len(body.SeriesIDs) == 0 {
294+
c.JSON(http.StatusBadRequest, gin.H{"error": "目录作品ID列表不能为空"})
295+
return
296+
}
297+
298+
if err := store.AddSeriesToGroup(id, body.SeriesIDs); err != nil {
299+
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加目录作品到分组失败"})
300+
return
301+
}
302+
c.JSON(http.StatusOK, gin.H{"success": true})
303+
}
304+
305+
// ============================================================
306+
// DELETE /api/groups/:id/series/:seriesId — 从分组移除目录作品
307+
// ============================================================
308+
309+
func (h *GroupHandler) RemoveSeries(c *gin.Context) {
310+
id, err := strconv.Atoi(c.Param("id"))
311+
if err != nil {
312+
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分组ID"})
313+
return
314+
}
315+
316+
seriesID := c.Param("seriesId")
317+
if seriesID == "" {
318+
c.JSON(http.StatusBadRequest, gin.H{"error": "目录作品ID不能为空"})
319+
return
320+
}
321+
322+
if err := store.RemoveSeriesFromGroup(id, seriesID); err != nil {
323+
c.JSON(http.StatusInternalServerError, gin.H{"error": "从分组移除目录作品失败"})
324+
return
325+
}
326+
c.JSON(http.StatusOK, gin.H{"success": true})
327+
}
328+
279329
// ============================================================
280330
// PUT /api/groups/:id/reorder — 重新排序分组内漫画
281331
// ============================================================

internal/handler/routes_metadata.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package handler
1+
package handler
22

33
import (
44
"github.com/gin-gonic/gin"
@@ -187,6 +187,8 @@ func registerMetadataRoutes(api *gin.RouterGroup) {
187187
groupWrite.DELETE("/:id", group.DeleteGroup)
188188
groupWrite.POST("/:id/comics", group.AddComics)
189189
groupWrite.DELETE("/:id/comics/:comicId", group.RemoveComic)
190+
groupWrite.POST("/:id/series", group.AddSeries)
191+
groupWrite.DELETE("/:id/series/:seriesId", group.RemoveSeries)
190192
groupWrite.PUT("/:id/reorder", group.ReorderComics)
191193
groupWrite.PUT("/:id/metadata", group.UpdateMetadata)
192194
groupWrite.POST("/:id/inherit-metadata", group.InheritMetadata)

internal/model/models.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,9 @@ type ComicGroupItem struct {
248248
ComicID string `json:"comicId"`
249249
SortIndex int `json:"sortIndex"`
250250
}
251+
252+
type ComicGroupSeriesItem struct {
253+
GroupID int `json:"groupId"`
254+
SeriesID string `json:"seriesId"`
255+
SortIndex int `json:"sortIndex"`
256+
}

internal/store/db_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ func setupTestDB(t *testing.T) {
2222
if err := InitDB(dbPath); err != nil {
2323
t.Fatalf("InitDB failed: %v", err)
2424
}
25+
if err := RunMigrations(); err != nil {
26+
t.Fatalf("RunMigrations failed: %v", err)
27+
}
2528
t.Cleanup(func() {
2629
CloseDB()
2730
os.Remove(dbPath)
@@ -656,3 +659,47 @@ func TestSortOrders(t *testing.T) {
656659
t.Errorf("Expected sort-2 first (sortOrder=1), got %s", result.Comics[0].ID)
657660
}
658661
}
662+
663+
func TestComicGroupSeriesCRUD(t *testing.T) {
664+
setupTestDB(t)
665+
666+
// Create dummy library and series
667+
if _, err := db.Exec(`INSERT INTO "Library" ("id", "name", "rootPath") VALUES ('lib-1', 'Lib 1', '/tmp')`); err != nil {
668+
t.Fatalf("Failed to insert library: %v", err)
669+
}
670+
if _, err := db.Exec(`INSERT INTO "ComicSeries" ("id", "libraryId", "rootRelativePath", "title") VALUES ('series-1', 'lib-1', 'dragonball_z', 'Dragon Ball Z')`); err != nil {
671+
t.Fatalf("Failed to insert series: %v", err)
672+
}
673+
674+
groupID, err := CreateGroup("Dragon Ball Universe")
675+
if err != nil {
676+
t.Fatalf("CreateGroup failed: %v", err)
677+
}
678+
679+
if err := AddSeriesToGroup(int(groupID), []string{"series-1"}); err != nil {
680+
t.Fatalf("AddSeriesToGroup failed: %v", err)
681+
}
682+
683+
detail, err := GetGroupByID(int(groupID))
684+
if err != nil {
685+
t.Fatalf("GetGroupByID failed: %v", err)
686+
}
687+
if len(detail.SeriesList) != 1 {
688+
t.Fatalf("Expected 1 series in group, got %d", len(detail.SeriesList))
689+
}
690+
if detail.SeriesList[0].SeriesID != "series-1" {
691+
t.Errorf("Expected series-1, got %s", detail.SeriesList[0].SeriesID)
692+
}
693+
694+
if err := RemoveSeriesFromGroup(int(groupID), "series-1"); err != nil {
695+
t.Fatalf("RemoveSeriesFromGroup failed: %v", err)
696+
}
697+
698+
detail2, err := GetGroupByID(int(groupID))
699+
if err != nil {
700+
t.Fatalf("GetGroupByID failed after remove: %v", err)
701+
}
702+
if detail2 != nil {
703+
t.Errorf("Expected group to be auto-deleted when empty, but still exists")
704+
}
705+
}

internal/store/group_crud.go

Lines changed: 132 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,34 @@ type ComicGroupWithCount struct {
3434

3535
// ComicGroupDetail 包含系列详情和所属漫画列表。
3636
type ComicGroupDetail struct {
37-
ID int `json:"id"`
38-
Name string `json:"name"`
39-
CoverURL string `json:"coverUrl"`
40-
SortOrder int `json:"sortOrder"`
41-
Author string `json:"author"`
42-
Description string `json:"description"`
43-
Tags string `json:"tags"`
44-
Year *int `json:"year"`
45-
Publisher string `json:"publisher"`
46-
Language string `json:"language"`
47-
Genre string `json:"genre"`
48-
Status string `json:"status"`
49-
CreatedAt string `json:"createdAt"`
50-
UpdatedAt string `json:"updatedAt"`
51-
ComicCount int `json:"comicCount"`
52-
Comics []GroupComicItem `json:"comics"`
37+
ID int `json:"id"`
38+
Name string `json:"name"`
39+
CoverURL string `json:"coverUrl"`
40+
SortOrder int `json:"sortOrder"`
41+
Author string `json:"author"`
42+
Description string `json:"description"`
43+
Tags string `json:"tags"`
44+
Year *int `json:"year"`
45+
Publisher string `json:"publisher"`
46+
Language string `json:"language"`
47+
Genre string `json:"genre"`
48+
Status string `json:"status"`
49+
CreatedAt string `json:"createdAt"`
50+
UpdatedAt string `json:"updatedAt"`
51+
ComicCount int `json:"comicCount"`
52+
SeriesList []GroupSeriesItem `json:"seriesList"`
53+
Comics []GroupComicItem `json:"comics"`
54+
}
55+
56+
// GroupSeriesItem 分组内的目录作品条目。
57+
type GroupSeriesItem struct {
58+
SeriesID string `json:"id"`
59+
Title string `json:"title"`
60+
RootRelativePath string `json:"rootRelativePath"`
61+
CoverComicID string `json:"coverComicId"`
62+
CoverURL string `json:"coverUrl"`
63+
SortIndex int `json:"sortIndex"`
64+
Comics []GroupComicItem `json:"comics"`
5365
}
5466

5567
// GroupComicItem 分组内的漫画条目。
@@ -338,9 +350,65 @@ func GetGroupByID(groupID int, contentType ...string) (*ComicGroupDetail, error)
338350
g.Comics = append(g.Comics, item)
339351
}
340352

341-
// 封面 URL:有自定义封面时返回本地缓存路径,无封面时使用第一本漫画缩略图
353+
// 获取分组内的目录作品 (ComicSeries)
354+
g.SeriesList = []GroupSeriesItem{}
355+
seriesRows, err := db.Query(`
356+
SELECT cs."id", cs."title", cs."rootRelativePath", cs."coverComicId", cgs."sortIndex"
357+
FROM "ComicGroupSeries" cgs
358+
JOIN "ComicSeries" cs ON cs."id" = cgs."seriesId"
359+
WHERE cgs."groupId" = ?
360+
ORDER BY cgs."sortIndex" ASC
361+
`, groupID)
362+
if err == nil {
363+
defer seriesRows.Close()
364+
for seriesRows.Next() {
365+
var sItem GroupSeriesItem
366+
if scanErr := seriesRows.Scan(&sItem.SeriesID, &sItem.Title, &sItem.RootRelativePath, &sItem.CoverComicID, &sItem.SortIndex); scanErr == nil {
367+
if sItem.CoverComicID != "" {
368+
sItem.CoverURL = BuildComicCoverURL(sItem.CoverComicID)
369+
}
370+
sItem.Comics = []GroupComicItem{}
371+
cRows, cErr := db.Query(`
372+
SELECT c."id", c."filename", c."title", c."pageCount", c."fileSize",
373+
c."lastReadPage", c."totalReadTime", c."readingStatus", c."lastReadAt",
374+
csi."sortIndex", COALESCE(c."type", '') as "type"
375+
FROM "ComicSeriesItem" csi
376+
JOIN "Comic" c ON c."id" = csi."comicId"
377+
WHERE csi."seriesId" = ?
378+
ORDER BY csi."sortIndex" ASC
379+
`, sItem.SeriesID)
380+
if cErr == nil {
381+
for cRows.Next() {
382+
var item GroupComicItem
383+
var lastReadAt sql.NullTime
384+
if cRows.Scan(
385+
&item.ComicID, &item.Filename, &item.Title, &item.PageCount, &item.FileSize,
386+
&item.LastReadPage, &item.TotalReadTime, &item.ReadingStatus, &lastReadAt,
387+
&item.SortIndex, &item.ComicType,
388+
) == nil {
389+
item.CoverURL = BuildComicCoverURL(item.ComicID)
390+
if lastReadAt.Valid {
391+
str := lastReadAt.Time.UTC().Format(time.RFC3339)
392+
item.LastReadAt = &str
393+
}
394+
sItem.Comics = append(sItem.Comics, item)
395+
}
396+
}
397+
cRows.Close()
398+
}
399+
if sItem.CoverURL == "" && len(sItem.Comics) > 0 {
400+
sItem.CoverURL = sItem.Comics[0].CoverURL
401+
}
402+
g.SeriesList = append(g.SeriesList, sItem)
403+
}
404+
}
405+
}
406+
407+
// 封面 URL:有自定义封面时返回本地缓存路径,无封面时按优先使用 Series 目录作品或第一本漫画缩略图
342408
if g.CoverURL != "" {
343409
g.CoverURL = BuildGroupCoverURL(g.ID)
410+
} else if len(g.SeriesList) > 0 && g.SeriesList[0].CoverURL != "" {
411+
g.CoverURL = g.SeriesList[0].CoverURL
344412
} else if len(g.Comics) > 0 {
345413
g.CoverURL = g.Comics[0].CoverURL
346414
}
@@ -605,18 +673,60 @@ func AddComicsToGroup(groupID int, comicIDs []string) error {
605673
return nil
606674
}
607675

608-
// RemoveComicFromGroup 从分组移除漫画。如果移除后分组为空,自动删除分组。
676+
// RemoveComicFromGroup 从分组移除漫画。如果移除后分组无任何数据,自动删除分组。
609677
func RemoveComicFromGroup(groupID int, comicID string) error {
610678
_, err := db.Exec(`DELETE FROM "ComicGroupItem" WHERE "groupId" = ? AND "comicId" = ?`, groupID, comicID)
611679
if err != nil {
612680
return err
613681
}
614682
db.Exec(`UPDATE "ComicGroup" SET "updatedAt" = ? WHERE "id" = ?`, time.Now().UTC(), groupID)
615683

616-
// 检查分组是否为空,为空则自动删除
617-
var count int
618-
err = db.QueryRow(`SELECT COUNT(*) FROM "ComicGroupItem" WHERE "groupId" = ?`, groupID).Scan(&count)
619-
if err == nil && count == 0 {
684+
// 检查分组是否为空(无漫画且无目录作品),为空则自动删除
685+
var countItems, countSeries int
686+
_ = db.QueryRow(`SELECT COUNT(*) FROM "ComicGroupItem" WHERE "groupId" = ?`, groupID).Scan(&countItems)
687+
_ = db.QueryRow(`SELECT COUNT(*) FROM "ComicGroupSeries" WHERE "groupId" = ?`, groupID).Scan(&countSeries)
688+
if countItems == 0 && countSeries == 0 {
689+
db.Exec(`DELETE FROM "ComicGroup" WHERE "id" = ?`, groupID)
690+
}
691+
692+
return nil
693+
}
694+
695+
// AddSeriesToGroup 将一个或多个目录作品 (ComicSeries) 添加到分组。
696+
func AddSeriesToGroup(groupID int, seriesIDs []string) error {
697+
if len(seriesIDs) == 0 {
698+
return nil
699+
}
700+
var maxIdx int
701+
db.QueryRow(`SELECT COALESCE(MAX("sortIndex"), -1) FROM "ComicGroupSeries" WHERE "groupId" = ?`, groupID).Scan(&maxIdx)
702+
703+
for i, seriesID := range seriesIDs {
704+
_, err := db.Exec(`
705+
INSERT INTO "ComicGroupSeries" ("groupId", "seriesId", "sortIndex")
706+
VALUES (?, ?, ?)
707+
ON CONFLICT("groupId", "seriesId") DO NOTHING
708+
`, groupID, seriesID, maxIdx+1+i)
709+
if err != nil {
710+
return err
711+
}
712+
}
713+
714+
db.Exec(`UPDATE "ComicGroup" SET "updatedAt" = ? WHERE "id" = ?`, time.Now().UTC(), groupID)
715+
return nil
716+
}
717+
718+
// RemoveSeriesFromGroup 从分组移除目录作品 (ComicSeries)。如果移除后分组无任何数据,自动删除分组。
719+
func RemoveSeriesFromGroup(groupID int, seriesID string) error {
720+
_, err := db.Exec(`DELETE FROM "ComicGroupSeries" WHERE "groupId" = ? AND "seriesId" = ?`, groupID, seriesID)
721+
if err != nil {
722+
return err
723+
}
724+
db.Exec(`UPDATE "ComicGroup" SET "updatedAt" = ? WHERE "id" = ?`, time.Now().UTC(), groupID)
725+
726+
var countItems, countSeries int
727+
_ = db.QueryRow(`SELECT COUNT(*) FROM "ComicGroupItem" WHERE "groupId" = ?`, groupID).Scan(&countItems)
728+
_ = db.QueryRow(`SELECT COUNT(*) FROM "ComicGroupSeries" WHERE "groupId" = ?`, groupID).Scan(&countSeries)
729+
if countItems == 0 && countSeries == 0 {
620730
db.Exec(`DELETE FROM "ComicGroup" WHERE "id" = ?`, groupID)
621731
}
622732

internal/store/library_reconcile_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func TestReconcileComicOwnershipPreservesRelations(t *testing.T) {
1515
t.Fatal(err)
1616
}
1717
for _, lib := range []*model.Library{
18-
{ID: "parent-lib", Name: "Parent", Type: "mixed", RootPath: "/books", Enabled: true},
18+
{ID: "parent-lib", Name: "Parent", Type: "comic", RootPath: "/books", Enabled: true},
1919
{ID: "novel-lib", Name: "Novels", Type: "novel", RootPath: "/books/novels", Enabled: true},
2020
} {
2121
if err := CreateLibrary(lib); err != nil {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package store
2+
3+
import "strings"
4+
5+
func init() {
6+
Migrations = append(Migrations, Migration{
7+
Version: 37,
8+
Description: "Add ComicGroupSeries relationship table for linking comic series to groups",
9+
SQL: strings.Join([]string{
10+
`CREATE TABLE IF NOT EXISTS "ComicGroupSeries" (
11+
"groupId" INTEGER NOT NULL,
12+
"seriesId" TEXT NOT NULL,
13+
"sortIndex" INTEGER NOT NULL DEFAULT 0,
14+
PRIMARY KEY ("groupId", "seriesId"),
15+
CONSTRAINT "ComicGroupSeries_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ComicGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
16+
CONSTRAINT "ComicGroupSeries_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "ComicSeries" ("id") ON DELETE CASCADE ON UPDATE CASCADE
17+
);`,
18+
`CREATE INDEX IF NOT EXISTS "ComicGroupSeries_groupId_idx" ON "ComicGroupSeries"("groupId", "sortIndex");`,
19+
}, "\n"),
20+
})
21+
}

0 commit comments

Comments
 (0)