diff --git a/.dockerignore b/.dockerignore index 01bd18c..77211c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,6 @@ deploy/nginx/conf.d/default.conf deploy/redis/redis.conf deploy/api/storage/ backend/booklib* -web/node_modules -web/dist -web/dev-dist +frontend/node_modules +frontend/dist +frontend/dev-dist diff --git a/.gitignore b/.gitignore index 47d61eb..fd343e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ .env .superpowers/ -backend/server -deploy/nginx/nginx.conf -deploy/nginx/conf.d/default.conf -deploy/redis/redis.conf -deploy/logs/ -deploy/api/storage/* -!deploy/api/storage/.gitkeep +tasks/ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..54be53f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +__debug_bin* +server +booklib* diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index dd5ca45..0cb4f40 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -7,4 +7,4 @@ FROM base AS dev ENV GOPROXY=https://goproxy.cn,direct RUN go install github.com/go-delve/delve/cmd/dlv@latest EXPOSE 8080 2345 -CMD ["sh", "-c", "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"] +CMD ["sh", "-c", "go mod download && dlv debug ./cmd/webui --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod index 4cddfa9..7f93948 100644 --- a/backend/Dockerfile.prod +++ b/backend/Dockerfile.prod @@ -3,7 +3,7 @@ WORKDIR /src COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ ./ -RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server +RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/webui FROM alpine:3.20 AS runner RUN adduser -D -H app diff --git a/backend/__debug_bin1866936080 b/backend/__debug_bin1866936080 deleted file mode 100755 index 5bed864..0000000 Binary files a/backend/__debug_bin1866936080 and /dev/null differ diff --git a/backend/__debug_bin2827366225 b/backend/__debug_bin2827366225 deleted file mode 100755 index 5bed864..0000000 Binary files a/backend/__debug_bin2827366225 and /dev/null differ diff --git a/backend/cmd/webui/api/router.go b/backend/cmd/webui/api/router.go new file mode 100644 index 0000000..22d4cf5 --- /dev/null +++ b/backend/cmd/webui/api/router.go @@ -0,0 +1,51 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "booklib/cmd/webui/handlers" + "booklib/internal/config" + "booklib/internal/redispkg" + "booklib/internal/scanner" + "booklib/internal/store" +) + +func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + h := handlers.New(cfg, st, rdb, sc) + r := gin.New() + if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil { + panic(e) + } + r.Use(gin.Recovery()) + g := r.Group("/api") + g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + g.POST("/auth/login", h.Login) + + p := g.Group("", h.AuthMw()) + p.GET("/auth/me", h.Me) + + users := p.Group("/users", h.AdminOnly()) + users.GET("", h.ListUsers) + users.POST("", h.CreateUser) + users.DELETE("/:id", h.DeleteUser) + + libs := p.Group("/libraries") + libs.GET("", h.ListLibraries) + libs.POST("", h.AdminOnly(), h.CreateLibrary) + libs.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary) + libs.POST("/:id/upload", h.AdminOnly(), h.Upload) + + p.GET("/books", h.ListBooks) + p.GET("/books/:id", h.GetBook) + p.DELETE("/books/:id", h.AdminOnly(), h.DeleteBook) + p.GET("/books/:id/cover", h.ServeCover) + p.GET("/books/:id/file", h.ServeFile) + p.GET("/books/:id/pages", h.PagesCount) + p.GET("/books/:id/pages/:n", h.Page) + p.PUT("/books/:id/progress", h.PutProgress) + p.GET("/progress", h.ListProgress) + return r +} diff --git a/backend/cmd/webui/api/router_test.go b/backend/cmd/webui/api/router_test.go new file mode 100644 index 0000000..26557cb --- /dev/null +++ b/backend/cmd/webui/api/router_test.go @@ -0,0 +1,26 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "booklib/internal/config" + "booklib/internal/redispkg" +) + +func testCfg() *config.Config { + return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200, + TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信 +} + +func TestHealthz(t *testing.T) { + r := NewRouter(testCfg(), nil, redispkg.New(""), nil) + req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("healthz = %d, want 200", w.Code) + } +} diff --git a/backend/internal/api/auth.go b/backend/cmd/webui/handlers/auth.go similarity index 81% rename from backend/internal/api/auth.go rename to backend/cmd/webui/handlers/auth.go index 4330d37..641c26c 100644 --- a/backend/internal/api/auth.go +++ b/backend/cmd/webui/handlers/auth.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "errors" @@ -14,17 +14,17 @@ import ( const loginWindow = time.Minute const loginMax = 5 -func (a *api) login(c *gin.Context) { +func (h *H) Login(c *gin.Context) { var req struct{ Username, Password string } if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" { err(c, http.StatusBadRequest, "bad_request", "username and password required") return } - if n := a.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax { + if n := h.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax { err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts") return } - u, qerr := a.st.GetUserByName(c, req.Username) + u, qerr := h.st.GetUserByName(c, req.Username) if qerr != nil { if !errors.Is(qerr, pgx.ErrNoRows) { dbErr(c, qerr) @@ -39,7 +39,7 @@ func (a *api) login(c *gin.Context) { err(c, http.StatusUnauthorized, "unauthorized", "bad credentials") return } - tok, serr := auth.Sign(a.cfg.JWTSecret, u.ID, u.Role) + tok, serr := auth.Sign(h.cfg.JWTSecret, u.ID, u.Role) if serr != nil { err(c, http.StatusInternalServerError, "internal", "sign") return @@ -47,8 +47,8 @@ func (a *api) login(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"token": tok}) } -func (a *api) me(c *gin.Context) { - u, qerr := a.st.GetUserByID(c, uid(c)) +func (h *H) Me(c *gin.Context) { + u, qerr := h.st.GetUserByID(c, uid(c)) if qerr != nil { err(c, http.StatusUnauthorized, "unauthorized", "no such user") return diff --git a/backend/internal/api/auth_test.go b/backend/cmd/webui/handlers/auth_test.go similarity index 92% rename from backend/internal/api/auth_test.go rename to backend/cmd/webui/handlers/auth_test.go index dbbd9bc..0090d0c 100644 --- a/backend/internal/api/auth_test.go +++ b/backend/cmd/webui/handlers/auth_test.go @@ -1,4 +1,4 @@ -package api +package handlers_test import ( "bytes" @@ -10,16 +10,24 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/redis/go-redis/v9" + "booklib/cmd/webui/api" "booklib/internal/auth" + "booklib/internal/config" "booklib/internal/db" "booklib/internal/redispkg" "booklib/internal/scanner" "booklib/internal/store" ) +func testCfg() *config.Config { + return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200, + TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信 +} + func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) { t.Helper() url := os.Getenv("DATABASE_URL") @@ -50,7 +58,7 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin cfg.CacheDir = t.TempDir() rdb := redispkg.New(os.Getenv("REDIS_URL")) sc := scanner.New(st, cfg, rdb) - r := NewRouter(cfg, st, rdb, sc) + r := api.NewRouter(cfg, st, rdb, sc) if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429 if opt, e := redis.ParseURL(u); e == nil { rc := redis.NewClient(opt) diff --git a/backend/internal/api/books.go b/backend/cmd/webui/handlers/books.go similarity index 77% rename from backend/internal/api/books.go rename to backend/cmd/webui/handlers/books.go index 00cc97a..1455bb1 100644 --- a/backend/internal/api/books.go +++ b/backend/cmd/webui/handlers/books.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "errors" @@ -17,8 +17,8 @@ import ( "booklib/internal/store" ) -func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) { - b, e := a.st.GetBook(c, id) +func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) { + b, e := h.st.GetBook(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such book") @@ -30,17 +30,17 @@ func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) { return b, true } -func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) { +func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) { id, e := strconv.ParseInt(c.Param("id"), 10, 64) if e != nil { err(c, http.StatusBadRequest, "bad_request", "bad id") return store.Book{}, false } - return a.getBookRow(c, id) + return h.getBookRow(c, id) } -func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) { - l, e := a.st.GetLibrary(c, id) +func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) { + l, e := h.st.GetLibrary(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such library") @@ -87,9 +87,9 @@ func bookJSON(b store.Book, percent float64, libraryName string) gin.H { return j } -func (a *api) listBooks(c *gin.Context) { +func (h *H) ListBooks(c *gin.Context) { libID, _ := strconv.ParseInt(c.Query("library"), 10, 64) - views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c)) + views, e := h.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c)) if e != nil { dbErr(c, e) return @@ -101,17 +101,17 @@ func (a *api) listBooks(c *gin.Context) { c.JSON(http.StatusOK, out) } -func (a *api) getBook(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) GetBook(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } - p, e := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent + p, e := h.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent if e != nil && !errors.Is(e, pgx.ErrNoRows) { dbErr(c, e) return } - lib, e := a.st.GetLibrary(c, b.LibraryID) + lib, e := h.st.GetLibrary(c, b.LibraryID) if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见 dbErr(c, e) return @@ -119,16 +119,16 @@ func (a *api) getBook(c *gin.Context) { c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name)) } -func (a *api) deleteBook(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) DeleteBook(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } - lib, ok := a.getLibRow(c, b.LibraryID) + lib, ok := h.getLibRow(c, b.LibraryID) if !ok { return } - root, ok := a.libRoot(c, lib) + root, ok := h.libRoot(c, lib) if !ok { return } @@ -142,9 +142,9 @@ func (a *api) deleteBook(c *gin.Context) { return } key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)) - os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key)) - os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key)) - if e := a.st.DeleteBook(c, b.ID); e != nil { + os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key)) + os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key)) + if e := h.st.DeleteBook(c, b.ID); e != nil { dbErr(c, e) return } diff --git a/backend/internal/api/books_test.go b/backend/cmd/webui/handlers/books_test.go similarity index 89% rename from backend/internal/api/books_test.go rename to backend/cmd/webui/handlers/books_test.go index 042dbf0..6710211 100644 --- a/backend/internal/api/books_test.go +++ b/backend/cmd/webui/handlers/books_test.go @@ -1,4 +1,4 @@ -package api +package handlers_test import ( "archive/zip" @@ -160,15 +160,3 @@ func TestDeleteUnsafePath403(t *testing.T) { t.Fatalf("row must survive: %v", e) } } - -func TestAbsBookPathTraversalRejected(t *testing.T) { - root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB - for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} { - if _, e := absBookPath(root, store.Book{Path: bad}); e == nil { - t.Fatalf("must reject %q", bad) - } - } - if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") { - t.Fatalf("must accept relative path: %q %v", p, e) - } -} diff --git a/backend/internal/api/content.go b/backend/cmd/webui/handlers/content.go similarity index 79% rename from backend/internal/api/content.go rename to backend/cmd/webui/handlers/content.go index ff26de0..29ec4f0 100644 --- a/backend/internal/api/content.go +++ b/backend/cmd/webui/handlers/content.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "fmt" @@ -17,25 +17,25 @@ import ( const defaultCover = `` -func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) { - lib, ok := a.getLibRow(c, b.LibraryID) +func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) { + lib, ok := h.getLibRow(c, b.LibraryID) if !ok { return "", false } - return a.libRoot(c, lib) + return h.libRoot(c, lib) } -func (a *api) immutable(c *gin.Context) { +func (h *H) immutable(c *gin.Context) { c.Header("Cache-Control", "public, max-age=31536000, immutable") } -func (a *api) serveCover(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) ServeCover(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } - a.immutable(c) - dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) + h.immutable(c) + dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) if entries, e := os.ReadDir(dir); e == nil { for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态 if !strings.Contains(en.Name(), ".tmp") { @@ -45,8 +45,8 @@ func (a *api) serveCover(c *gin.Context) { } } if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到) - if root, ok := a.bookRoot(c, b); ok { - if f, size, ok := a.openBook(c, b, root); ok { + if root, ok := h.bookRoot(c, b); ok { + if f, size, ok := h.openBook(c, b, root); ok { defer f.Close() var img []byte var ext string @@ -77,12 +77,12 @@ func (a *api) serveCover(c *gin.Context) { c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover)) } -func (a *api) serveFile(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) ServeFile(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } - root, ok := a.bookRoot(c, b) + root, ok := h.bookRoot(c, b) if !ok { return } @@ -96,7 +96,7 @@ func (a *api) serveFile(c *gin.Context) { http.ServeFile(c.Writer, c.Request, abs) } -func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) { +func (h *H) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) { abs, perr := absBookPath(root, b) if perr != nil { err(c, http.StatusForbidden, "forbidden", "unsafe path") @@ -116,13 +116,13 @@ func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int return f, st.Size(), true } -func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) { +func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) { hash := bookfile.Hash(b.FileSize, b.ModTS) key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash) - if v, ok := a.rdb.Get(c, key); ok && v != "" { + if v, ok := h.rdb.Get(c, key); ok && v != "" { return strings.Split(v, "\n"), nil } - f, size, ok := a.openBook(c, b, root) + f, size, ok := h.openBook(c, b, root) if !ok { return nil, os.ErrNotExist } @@ -132,13 +132,13 @@ func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, er return nil, e } if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页 - a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour) + h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour) } return idx, nil } -func (a *api) pagesCount(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) PagesCount(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } @@ -146,11 +146,11 @@ func (a *api) pagesCount(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "pages only for cbz") return } - root, ok := a.bookRoot(c, b) + root, ok := h.bookRoot(c, b) if !ok { return } - idx, e := a.pageIndex(c, b, root) + idx, e := h.pageIndex(c, b, root) if e != nil { if c.Writer.Written() { return // openBook 已写 403/404,不再叠加 422 @@ -161,8 +161,8 @@ func (a *api) pagesCount(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"count": len(idx)}) } -func (a *api) page(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) Page(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } @@ -175,11 +175,11 @@ func (a *api) page(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "bad page number") return } - root, ok := a.bookRoot(c, b) + root, ok := h.bookRoot(c, b) if !ok { return } - idx, e := a.pageIndex(c, b, root) + idx, e := h.pageIndex(c, b, root) if e != nil { if c.Writer.Written() { return // openBook 已写 403/404,不再叠加 422 @@ -192,10 +192,10 @@ func (a *api) page(c *gin.Context) { return } ext := strings.ToLower(filepath.Ext(idx[n])) - dir := bookfile.PagesDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) + dir := bookfile.PagesDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) dst := filepath.Join(dir, strconv.Itoa(n)+ext) if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子) - f, size, ok := a.openBook(c, b, root) + f, size, ok := h.openBook(c, b, root) if !ok { return } @@ -221,6 +221,6 @@ func (a *api) page(c *gin.Context) { return } } - a.immutable(c) + h.immutable(c) http.ServeFile(c.Writer, c.Request, dst) } diff --git a/backend/internal/api/content_test.go b/backend/cmd/webui/handlers/content_test.go similarity index 99% rename from backend/internal/api/content_test.go rename to backend/cmd/webui/handlers/content_test.go index f7c814e..c863495 100644 --- a/backend/internal/api/content_test.go +++ b/backend/cmd/webui/handlers/content_test.go @@ -1,4 +1,4 @@ -package api +package handlers_test import ( "context" diff --git a/backend/internal/api/api.go b/backend/cmd/webui/handlers/handlers.go similarity index 83% rename from backend/internal/api/api.go rename to backend/cmd/webui/handlers/handlers.go index b4ab45b..e5a0633 100644 --- a/backend/internal/api/api.go +++ b/backend/cmd/webui/handlers/handlers.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "errors" @@ -20,13 +20,17 @@ import ( "booklib/internal/store" ) -type api struct { +type H struct { cfg *config.Config st *store.Store rdb *redispkg.R sc *scanner.Scanner } +func New(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *H { + return &H{cfg: cfg, st: st, rdb: rdb, sc: sc} +} + func err(c *gin.Context, status int, code, msg string) { c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}}) } @@ -46,15 +50,15 @@ func dbErr(c *gin.Context, e error) { err(c, status, code, "db error") } -func (a *api) authMw() gin.HandlerFunc { +func (h *H) AuthMw() gin.HandlerFunc { return func(c *gin.Context) { - h := c.GetHeader("Authorization") - tok, ok := strings.CutPrefix(h, "Bearer ") + hdr := c.GetHeader("Authorization") + tok, ok := strings.CutPrefix(hdr, "Bearer ") if !ok { err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token") return } - cl, perr := auth.Parse(a.cfg.JWTSecret, tok) + cl, perr := auth.Parse(h.cfg.JWTSecret, tok) if perr != nil { err(c, http.StatusUnauthorized, "unauthorized", "invalid token") return @@ -65,7 +69,7 @@ func (a *api) authMw() gin.HandlerFunc { } } -func (a *api) adminOnly() gin.HandlerFunc { +func (h *H) AdminOnly() gin.HandlerFunc { return func(c *gin.Context) { if c.GetString("role") != "admin" { err(c, http.StatusForbidden, "forbidden", "admin only") diff --git a/backend/internal/api/router_test.go b/backend/cmd/webui/handlers/internal_test.go similarity index 57% rename from backend/internal/api/router_test.go rename to backend/cmd/webui/handlers/internal_test.go index f36db66..df911a4 100644 --- a/backend/internal/api/router_test.go +++ b/backend/cmd/webui/handlers/internal_test.go @@ -1,34 +1,30 @@ -package api +package handlers import ( "errors" "fmt" "net/http" "net/http/httptest" + "path/filepath" "syscall" "testing" - "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/puddle/v2" - "booklib/internal/config" - "booklib/internal/redispkg" + "booklib/internal/store" ) -func testCfg() *config.Config { - return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200, - TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信 -} - -func TestHealthz(t *testing.T) { - r := NewRouter(testCfg(), nil, redispkg.New(""), nil) - req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("healthz = %d, want 200", w.Code) +func TestAbsBookPathTraversalRejected(t *testing.T) { + root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB + for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} { + if _, e := absBookPath(root, store.Book{Path: bad}); e == nil { + t.Fatalf("must reject %q", bad) + } + } + if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") { + t.Fatalf("must accept relative path: %q %v", p, e) } } diff --git a/backend/internal/api/libraries.go b/backend/cmd/webui/handlers/libraries.go similarity index 83% rename from backend/internal/api/libraries.go rename to backend/cmd/webui/handlers/libraries.go index cd13683..5c09752 100644 --- a/backend/internal/api/libraries.go +++ b/backend/cmd/webui/handlers/libraries.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "context" @@ -19,9 +19,9 @@ import ( ) // resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验) -func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) { +func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) { root := filepath.Clean(lib.RootPath) - books := filepath.Clean(a.cfg.BooksDir) + books := filepath.Clean(h.cfg.BooksDir) if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) { err(c, http.StatusForbidden, "forbidden", "library root outside books dir") return "", false @@ -33,8 +33,8 @@ func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) { return root, true } -func (a *api) listLibraries(c *gin.Context) { - libs, e := a.st.ListLibraries(c) +func (h *H) ListLibraries(c *gin.Context) { + libs, e := h.st.ListLibraries(c) if e != nil { dbErr(c, e) return @@ -47,7 +47,7 @@ func (a *api) listLibraries(c *gin.Context) { c.JSON(http.StatusOK, out) } -func (a *api) createLibrary(c *gin.Context) { +func (h *H) CreateLibrary(c *gin.Context) { var req struct { Name string `json:"name"` RootPath string `json:"root_path"` @@ -60,7 +60,7 @@ func (a *api) createLibrary(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute") return } - id, e := a.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath)) + id, e := h.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath)) if e != nil { if isUnique(e) { err(c, http.StatusConflict, "exists", "root_path taken") @@ -72,13 +72,13 @@ func (a *api) createLibrary(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)}) } -func (a *api) getLibrary(c *gin.Context) (store.Library, bool) { +func (h *H) getLibrary(c *gin.Context) (store.Library, bool) { id, e := strconv.ParseInt(c.Param("id"), 10, 64) if e != nil { err(c, http.StatusBadRequest, "bad_request", "bad id") return store.Library{}, false } - lib, e := a.st.GetLibrary(c, id) + lib, e := h.st.GetLibrary(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such library") @@ -90,28 +90,28 @@ func (a *api) getLibrary(c *gin.Context) (store.Library, bool) { return lib, true } -func (a *api) scanLibrary(c *gin.Context) { - lib, ok := a.getLibrary(c) +func (h *H) ScanLibrary(c *gin.Context) { + lib, ok := h.getLibrary(c) if !ok { return } - if _, ok := a.libRoot(c, lib); !ok { + if _, ok := h.libRoot(c, lib); !ok { return } - go a.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID) + go h.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID) c.JSON(http.StatusAccepted, gin.H{"accepted": true}) } -func (a *api) upload(c *gin.Context) { - lib, ok := a.getLibrary(c) +func (h *H) Upload(c *gin.Context) { + lib, ok := h.getLibrary(c) if !ok { return } - root, ok := a.libRoot(c, lib) + root, ok := h.libRoot(c, lib) if !ok { return } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.cfg.UploadMaxMB<<20) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.cfg.UploadMaxMB<<20) fh, e := c.FormFile("file") if e != nil { err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required") @@ -122,7 +122,7 @@ func (a *api) upload(c *gin.Context) { err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md") return } - dst, e := a.uniquePath(root, name) + dst, e := h.uniquePath(root, name) if e != nil { err(c, http.StatusForbidden, "forbidden", e.Error()) return @@ -155,7 +155,7 @@ func (a *api) upload(c *gin.Context) { } // uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀 -func (a *api) uniquePath(root, name string) (string, error) { +func (h *H) uniquePath(root, name string) (string, error) { ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) for i := 0; ; i++ { diff --git a/backend/internal/api/libraries_test.go b/backend/cmd/webui/handlers/libraries_test.go similarity index 99% rename from backend/internal/api/libraries_test.go rename to backend/cmd/webui/handlers/libraries_test.go index 67ce27c..575e025 100644 --- a/backend/internal/api/libraries_test.go +++ b/backend/cmd/webui/handlers/libraries_test.go @@ -1,4 +1,4 @@ -package api +package handlers_test import ( "bytes" diff --git a/backend/internal/api/progress.go b/backend/cmd/webui/handlers/progress.go similarity index 82% rename from backend/internal/api/progress.go rename to backend/cmd/webui/handlers/progress.go index 2252876..6c130f4 100644 --- a/backend/internal/api/progress.go +++ b/backend/cmd/webui/handlers/progress.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "encoding/json" @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" ) -func (a *api) putProgress(c *gin.Context) { - b, ok := a.bookFromParam(c) +func (h *H) PutProgress(c *gin.Context) { + b, ok := h.bookFromParam(c) if !ok { return } @@ -32,15 +32,15 @@ func (a *api) putProgress(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "locator must be valid json") return } - if e := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil { + if e := h.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil { dbErr(c, e) return } c.Status(http.StatusNoContent) } -func (a *api) listProgress(c *gin.Context) { - rows, e := a.st.ListProgress(c, uid(c)) +func (h *H) ListProgress(c *gin.Context) { + rows, e := h.st.ListProgress(c, uid(c)) if e != nil { dbErr(c, e) return diff --git a/backend/internal/api/progress_test.go b/backend/cmd/webui/handlers/progress_test.go similarity index 99% rename from backend/internal/api/progress_test.go rename to backend/cmd/webui/handlers/progress_test.go index f473ede..1eb1c26 100644 --- a/backend/internal/api/progress_test.go +++ b/backend/cmd/webui/handlers/progress_test.go @@ -1,4 +1,4 @@ -package api +package handlers_test import ( "encoding/json" diff --git a/backend/internal/api/users.go b/backend/cmd/webui/handlers/users.go similarity index 82% rename from backend/internal/api/users.go rename to backend/cmd/webui/handlers/users.go index c589d57..821f7f5 100644 --- a/backend/internal/api/users.go +++ b/backend/cmd/webui/handlers/users.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "errors" @@ -18,8 +18,8 @@ func isUnique(e error) bool { return errors.As(e, &pgErr) && pgErr.Code == "23505" } -func (a *api) listUsers(c *gin.Context) { - users, e := a.st.ListUsers(c) +func (h *H) ListUsers(c *gin.Context) { + users, e := h.st.ListUsers(c) if e != nil { dbErr(c, e) return @@ -32,7 +32,7 @@ func (a *api) listUsers(c *gin.Context) { c.JSON(http.StatusOK, out) } -func (a *api) createUser(c *gin.Context) { +func (h *H) CreateUser(c *gin.Context) { var req struct{ Username, Password, Role string } if c.ShouldBindJSON(&req) != nil { err(c, http.StatusBadRequest, "bad_request", "json body required") @@ -46,12 +46,12 @@ func (a *api) createUser(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)") return } - h, e := auth.HashPassword(req.Password) + hp, e := auth.HashPassword(req.Password) if e != nil { err(c, http.StatusInternalServerError, "internal", "hash") return } - id, e := a.st.CreateUser(c, req.Username, h, req.Role) + id, e := h.st.CreateUser(c, req.Username, hp, req.Role) if e != nil { if isUnique(e) { err(c, http.StatusConflict, "exists", "username taken") @@ -63,7 +63,7 @@ func (a *api) createUser(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role}) } -func (a *api) deleteUser(c *gin.Context) { +func (h *H) DeleteUser(c *gin.Context) { id, e := strconv.ParseInt(c.Param("id"), 10, 64) if e != nil { err(c, http.StatusBadRequest, "bad_request", "bad id") @@ -73,7 +73,7 @@ func (a *api) deleteUser(c *gin.Context) { err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself") return } - target, e := a.st.GetUserByID(c, id) + target, e := h.st.GetUserByID(c, id) if e != nil { if errors.Is(e, pgx.ErrNoRows) { err(c, http.StatusNotFound, "not_found", "no such user") @@ -83,13 +83,13 @@ func (a *api) deleteUser(c *gin.Context) { return } if target.Role == "admin" { - n, _ := a.st.CountAdmins(c) // 防删光最后一个 admin + n, _ := h.st.CountAdmins(c) // 防删光最后一个 admin if n <= 1 { err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin") return } } - if e := a.st.DeleteUser(c, id); e != nil { + if e := h.st.DeleteUser(c, id); e != nil { dbErr(c, e) return } diff --git a/backend/internal/api/users_test.go b/backend/cmd/webui/handlers/users_test.go similarity index 99% rename from backend/internal/api/users_test.go rename to backend/cmd/webui/handlers/users_test.go index c15dafc..0c91098 100644 --- a/backend/internal/api/users_test.go +++ b/backend/cmd/webui/handlers/users_test.go @@ -1,4 +1,4 @@ -package api +package handlers_test import ( "encoding/json" diff --git a/backend/cmd/server/main.go b/backend/cmd/webui/main.go similarity index 98% rename from backend/cmd/server/main.go rename to backend/cmd/webui/main.go index 0548531..f487e59 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/webui/main.go @@ -9,7 +9,7 @@ import ( "syscall" "time" - "booklib/internal/api" + "booklib/cmd/webui/api" "booklib/internal/config" "booklib/internal/db" "booklib/internal/redispkg" diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go deleted file mode 100644 index 8a023a4..0000000 --- a/backend/internal/api/router.go +++ /dev/null @@ -1,50 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "booklib/internal/config" - "booklib/internal/redispkg" - "booklib/internal/scanner" - "booklib/internal/store" -) - -func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine { - gin.SetMode(gin.ReleaseMode) - a := &api{cfg: cfg, st: st, rdb: rdb, sc: sc} - r := gin.New() - if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil { - panic(e) - } - r.Use(gin.Recovery()) - g := r.Group("/api") - g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) - g.POST("/auth/login", a.login) - - p := g.Group("", a.authMw()) - p.GET("/auth/me", a.me) - - users := p.Group("/users", a.adminOnly()) - users.GET("", a.listUsers) - users.POST("", a.createUser) - users.DELETE("/:id", a.deleteUser) - - libs := p.Group("/libraries") - libs.GET("", a.listLibraries) - libs.POST("", a.adminOnly(), a.createLibrary) - libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary) - libs.POST("/:id/upload", a.adminOnly(), a.upload) - - p.GET("/books", a.listBooks) - p.GET("/books/:id", a.getBook) - p.DELETE("/books/:id", a.adminOnly(), a.deleteBook) - p.GET("/books/:id/cover", a.serveCover) - p.GET("/books/:id/file", a.serveFile) - p.GET("/books/:id/pages", a.pagesCount) - p.GET("/books/:id/pages/:n", a.page) - p.PUT("/books/:id/progress", a.putProgress) - p.GET("/progress", a.listProgress) - return r -} diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..596d5d1 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,7 @@ +.env +nginx/nginx.conf +nginx/conf.d/default.conf +redis/redis.conf +logs/ +api/storage/* +!api/storage/.gitkeep diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 9b0a9ae..0e6ee18 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -18,7 +18,7 @@ services: api: image: booklib/api:dev build: { context: .., dockerfile: backend/Dockerfile.dev, target: dev } - command: sh -c "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log" + command: sh -c "go mod download && dlv debug ./cmd/webui --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log" environment: DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable REDIS_URL: redis://redis:6379 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..276292b --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All non-WebUI user-visible changes. Newest version on top. + +## [Unreleased] + +### Changed + +- Repo structure conformed to `AGENTS.md`: `web/` renamed to `frontend/`; backend HTTP layer moved from `internal/api` to `cmd/webui/{api,handlers}` (`cmd/server` → `cmd/webui`); README/CHANGELOGs relocated under `docs/` (`README_zh.md` added as Chinese mirror); module-level `.gitignore`s added (`backend/`, `deploy/`); stray root `library/` removed (book files live in `deploy/api/storage/`); debug binaries untracked. +- Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`. diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md new file mode 100644 index 0000000..9395fee --- /dev/null +++ b/docs/CHANGELOG_web.md @@ -0,0 +1,7 @@ +# Changelog (WebUI) + +All WebUI user-visible changes. Newest version on top. + +## [Unreleased] + +_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..95e5f99 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,75 @@ +# Book & Comic Library + +Personal book/comic library: Go+Gin backend (scan/upload ingestion, multi-user JWT, reading progress, disk+Redis cache) + Docker Compose deployment. Design: `superpowers/specs/2026-09-04-book-comic-library-design.md`; deployment spec: `superpowers/specs/2026-09-07-docker-deploy-spec-design.md`. Chinese mirror: `README_zh.md`. + +## Deploy mode + +- release: `deploy/docker-compose.yml` — multi-stage `backend|frontend/Dockerfile.prod` (target runner) compiled artifacts, no source mounts; infra ports stay on the internal network. +- dev: `deploy/docker-compose.dev.yml` — all four services containerized, source mounted as `../backend:/app` and `../frontend:/app` (frontend with an anonymous `node_modules` volume), `target: dev` images; api runs `go mod download && dlv debug ./cmd/webui` (hot restart without rebuild, delve :2345); infra exposed to host PG 5432 / Redis 6379; healthcheck-gated startup. + +## Volume Mount + +- Shared named volumes: `booklib_postgres_data`, `booklib_redis_data` (identical names in both composes, so dev/release see the same data; only `down -v` clears them). +- Config/log bind mounts: `deploy/nginx/{nginx.conf,conf.d/default.conf}`, `deploy/redis/redis.conf` (`:ro`) and `deploy/logs/nginx` — all rendered by `deploy/prepare.sh` (templates are the `*.tpl` files beside each output); **wrong/missing config in the container = you forgot to rerun it**. +- File storage: `deploy/api/storage` → container `/data/books` (cache writes to `/data/books/cache`). + +## Run it (production shape) + +```bash +cp deploy/.env.example deploy/.env # set JWT_SECRET, ADMIN_USER, ADMIN_PASSWORD (>= 8 chars; seed skips and logs below 8) +deploy/prepare.sh +docker compose -f deploy/docker-compose.yml up -d --build +bash scripts/smoke.sh && bash scripts/smoke-web.sh +``` + +- web: `http://localhost:8080` (change via `WEB_PORT`); the API goes through the nginx `/api/` prefix reverse proxy to stateless api replicas (`--scale api=N`). +- Drop raw books into `deploy/api/storage/` (mounted at `/data/books`); the scanner ingests periodically (default 60s). +- nginx access/error logs: `deploy/logs/nginx/`. + +## Development + +```bash +deploy/prepare.sh +docker compose -f deploy/docker-compose.dev.yml up -d --build +``` + +- Frontend: http://localhost:5173 (vite, HMR works directly; `/api` proxied to the in-container api). +- After editing Go code: `docker compose -f deploy/docker-compose.dev.yml restart api` (recompiles the mounted source, no rebuild). +- Breakpoint debugging: delve headless at `localhost:2345` (VSCode launch: `{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345,"substitutePath":[{"from":"${workspaceFolder}/backend","to":"/app"}]}`; continue via dlv commands after hitting a breakpoint). +- Run tests against directly reachable infra: PG `localhost:5432` (lib/lib/lib), Redis `localhost:6379`: + +```bash +cd backend +export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable' +export REDIS_URL='redis://localhost:6379' +go vet ./... && gofmt -l . +go test -p 1 -count=1 ./... +``` + +`-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9). + +Frontend gate: `cd frontend && npm run check` (tsc + vitest + vite build). + +## Old-volume migration (one-off, upgrading from the previous deploy layout) + +```bash +# old PG data → new shared volume +docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/ +# the old cache volume is derived data (covers/unzipped pages), just drop it (auto-rebuilt) +docker volume rm book-comic-library_pgdata book-comic-library_cache +``` + +Book files: move the contents of the old host `./library/` into `deploy/api/storage/`. + +## Trusted proxies & rate limiting + +- nginx lives inside the compose network, so api's `ClientIP` only trusts `TRUSTED_PROXY_CIDRS` (comma-separated CIDRs, default `172.16.0.0/12`, the compose subnet). Spoofed external `X-Forwarded-For` can't bypass rate-limit buckets; change this env when the deploy network changes. +- Login is limited to 5 attempts/min/IP **counted per attempt, successful logins included** — brute force and normal high-frequency login share the budget. + +## Read this before changing the schema + +`db.Migrate` only runs the `CREATE TABLE IF NOT EXISTS` statements of `schema.sql` — column adds/changes **do not take effect** on existing databases. Before any column change, introduce a `schema_migrations` version table + ordered migrations, otherwise old deployments silently run on the old shape. + +## PWA + +Immutable assets (covers/CBZ pages/raw files) are SW cache-first; read content stays available offline; logging out clears the SW cache. diff --git a/README.md b/docs/README_zh.md similarity index 84% rename from README.md rename to docs/README_zh.md index f9105ec..902e02d 100644 --- a/README.md +++ b/docs/README_zh.md @@ -1,11 +1,11 @@ # Book & Comic Library -个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。 +个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。英文镜像:`README.md`。 ## Deploy mode -- release:`deploy/docker-compose.yml` — 多阶段 `backend|web/Dockerfile.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。 -- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../web:/app`(web 带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/server`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。 +- release:`deploy/docker-compose.yml` — 多阶段 `backend|frontend/Dockerfile.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。 +- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../frontend:/app`(web 服务带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/webui`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。 ## Volume Mount @@ -48,7 +48,7 @@ go test -p 1 -count=1 ./... `-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。 -前端门槛:`cd web && npm run check`(tsc + vitest + vite build)。 +前端门槛:`cd frontend && npm run check`(tsc + vitest + vite build)。 ## 旧卷迁移(一次性,升级自上一版部署) diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index fef499b..e2252f1 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -1,6 +1,6 @@ FROM node:22 AS dev WORKDIR /app -COPY web/package.json web/package-lock.json ./ +COPY frontend/package.json web/package-lock.json ./ RUN npm ci EXPOSE 5173 CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 2609882..c4a0a93 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -1,8 +1,8 @@ FROM node:22-alpine AS build WORKDIR /src -COPY web/package.json web/package-lock.json ./ +COPY frontend/package.json web/package-lock.json ./ RUN npm ci -COPY web/ ./ +COPY frontend/ ./ RUN npm run build FROM nginx:1.27-alpine AS runner