feat(backend): reading progress upsert + listing, path-keyed survival
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/file", a.serveFile)
|
||||||
p.GET("/books/:id/pages", a.pagesCount)
|
p.GET("/books/:id/pages", a.pagesCount)
|
||||||
p.GET("/books/:id/pages/:n", a.page)
|
p.GET("/books/:id/pages/:n", a.page)
|
||||||
|
p.PUT("/books/:id/progress", a.putProgress)
|
||||||
|
p.GET("/progress", a.listProgress)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user