diff --git a/backend/internal/api/progress.go b/backend/internal/api/progress.go new file mode 100644 index 0000000..9aa7337 --- /dev/null +++ b/backend/internal/api/progress.go @@ -0,0 +1,57 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +func (a *api) putProgress(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + var req struct { + Locator json.RawMessage `json:"locator"` + Percent float64 `json:"percent"` + } + if e := c.ShouldBindJSON(&req); e != nil { + err(c, http.StatusBadRequest, "bad_request", "json body required") + return + } + if req.Percent < 0 || req.Percent > 1 { + err(c, http.StatusBadRequest, "bad_request", "percent must be in [0,1]") + return + } + if len(req.Locator) == 0 { + req.Locator = []byte("{}") + } + if !json.Valid(req.Locator) { + 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 { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + c.Status(http.StatusNoContent) +} + +func (a *api) listProgress(c *gin.Context) { + rows, e := a.st.ListProgress(c, uid(c)) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(rows)) + for _, p := range rows { + out = append(out, gin.H{ + "library_id": p.LibraryID, "library": p.LibraryName, "path": p.BookPath, + "title": p.Title, "locator": json.RawMessage(p.Locator), + "percent": p.Percent, "updated_at": p.UpdatedAt.Format(time.RFC3339), + }) + } + c.JSON(http.StatusOK, out) +} diff --git a/backend/internal/api/progress_test.go b/backend/internal/api/progress_test.go new file mode 100644 index 0000000..f473ede --- /dev/null +++ b/backend/internal/api/progress_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestProgressRoundTrip(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + do(h, "POST", "/api/users", atok, map[string]string{"username": "p1", "password": testPW, "role": "member"}) + mtok := loginAs(t, h, "p1", testPW) + + lib, root := newLibrary(t, st, h, atok, booksDir, "prog") + os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0o644) + scanNow(t, sc, lib) + w := do(h, "GET", "/api/books?q=a", atok, nil) // title="a"(文件名去 .txt) + var bs []map[string]any + json.Unmarshal(w.Body.Bytes(), &bs) + id := itoa(bs[0]["id"]) + + w = do(h, "PUT", "/api/books/"+id+"/progress", mtok, + map[string]any{"locator": map[string]int{"page": 12}, "percent": 0.42}) + if w.Code != 204 { + t.Fatalf("put %d %s", w.Code, w.Body) + } + w = do(h, "GET", "/api/progress", mtok, nil) + var rows []map[string]any + json.Unmarshal(w.Body.Bytes(), &rows) + if len(rows) != 1 || rows[0]["percent"].(float64) != 0.42 || rows[0]["title"] != "a" { + t.Fatalf("get %s", w.Body) + } + if rows[0]["locator"].(map[string]any)["page"].(float64) != 12 { + t.Fatalf("locator %s", w.Body) + } + // upsert 覆盖 + do(h, "PUT", "/api/books/"+id+"/progress", mtok, map[string]any{"locator": map[string]int{"page": 20}, "percent": 0.8}) + w = do(h, "GET", "/api/progress", mtok, nil) + json.Unmarshal(w.Body.Bytes(), &rows) + if len(rows) != 1 || rows[0]["percent"].(float64) != 0.8 { + t.Fatalf("upsert %s", w.Body) + } + // 别人的进度不可见 + w = do(h, "GET", "/api/progress", atok, nil) + json.Unmarshal(w.Body.Bytes(), &rows) + if len(rows) != 0 { + t.Fatalf("leak %s", w.Body) + } + // 校验 + w = do(h, "PUT", "/api/books/"+id+"/progress", mtok, map[string]any{"percent": 1.5}) + if w.Code != 400 { + t.Fatalf("bad percent want 400 got %d", w.Code) + } + w = do(h, "PUT", "/api/books/999999/progress", mtok, map[string]any{"percent": 0.5}) + if w.Code != 404 { + t.Fatalf("bad book want 404 got %d", w.Code) + } + // 列表 percent 内嵌(Task 10 预告的断言在此兑现) + w = do(h, "GET", "/api/books", mtok, nil) + json.Unmarshal(w.Body.Bytes(), &bs) + if bs[0]["percent"].(float64) != 0.8 { + t.Fatalf("list percent %+v", bs[0]) + } + // 删书,进度按 path 保留,重扫后 0.8 回来 + do(h, "DELETE", "/api/books/"+id, atok, nil) + os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0o644) + scanNow(t, sc, lib) + w = do(h, "GET", "/api/books?q=a", mtok, nil) + json.Unmarshal(w.Body.Bytes(), &bs) + if bs[0]["percent"].(float64) != 0.8 { + t.Fatalf("progress did not survive delete+rescan: %+v", bs[0]) + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 83e8841..58c26a6 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -41,5 +41,7 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner 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 }