diff --git a/backend/go.mod b/backend/go.mod index 4a6e168..6be78ba 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,24 +2,26 @@ module booklib go 1.26.0 +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/jackc/pgx/v5 v5.10.0 +) + require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.12.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -29,14 +31,13 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/redis/go-redis/v9 v9.22.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/backend/go.sum b/backend/go.sum index 85d8f02..418abff 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,11 +4,10 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= @@ -16,6 +15,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -26,8 +27,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= @@ -35,6 +36,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -50,13 +53,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -67,20 +69,24 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -90,4 +96,5 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..ce3228d --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,28 @@ +package db + +import ( + "context" + _ "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed schema.sql +var schema string + +func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, err + } + cfg.MaxConns = 10 + return pgxpool.NewWithConfig(ctx, cfg) +} + +func Migrate(ctx context.Context, p *pgxpool.Pool) error { + if _, err := p.Exec(ctx, schema); err != nil { + return fmt.Errorf("migrate: %w", err) + } + return nil +} diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql new file mode 100644 index 0000000..6fa08a2 --- /dev/null +++ b/backend/internal/db/schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin','member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS libraries ( + id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, root_path TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS books ( + id BIGSERIAL PRIMARY KEY, library_id BIGINT NOT NULL REFERENCES libraries(id), + path TEXT NOT NULL, title TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('cbz','pdf','epub','txt','md')), + file_size BIGINT NOT NULL, mod_ts BIGINT NOT NULL, page_count INT NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'ready' CHECK (state IN ('ready','error')), + error_msg TEXT NOT NULL DEFAULT '', added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (library_id, path)); +CREATE TABLE IF NOT EXISTS reading_progress ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id BIGINT NOT NULL, book_path TEXT NOT NULL, + locator JSONB NOT NULL DEFAULT '{}', percent DOUBLE PRECISION NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, library_id, book_path)); diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..054ba7b --- /dev/null +++ b/backend/internal/store/store.go @@ -0,0 +1,307 @@ +package store + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct{ P *pgxpool.Pool } + +func New(p *pgxpool.Pool) *Store { return &Store{P: p} } + +// ---------- types ---------- + +type User struct { + ID int64 + Username string + PasswordHash string + Role string + CreatedAt time.Time +} +type Library struct { + ID int64 + Name string + RootPath string + CreatedAt time.Time +} +type Book struct { + ID, LibraryID int64 + Path string + Title string + Format string + FileSize int64 + ModTS int64 + PageCount int // 0 表示未知(pdf/epub/txt) + State string + ErrMsg string + AddedAt time.Time +} +type BookMeta struct { + ID int64 + Size int64 + ModTS int64 + Format string +} +type BookView struct { + Book + LibraryName string + Percent float64 +} +type Progress struct { + LibraryID int64 + LibraryName string + BookPath string + Title string // 书已删时为空 + Locator []byte + Percent float64 + UpdatedAt time.Time +} + +// ---------- users ---------- + +const userCols = "id, username, password_hash, role, created_at" + +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.P.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n) + return n, err +} + +func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + "INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id", + username, hash, role).Scan(&id) + return id, err +} + +func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) { + return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username)) +} + +func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) { + return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id)) +} + +func (s *Store) ListUsers(ctx context.Context) ([]User, error) { + rows, err := s.P.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + _, err := s.P.Exec(ctx, "DELETE FROM users WHERE id=$1", id) + return err +} + +func scanUser(row pgx.Row) (User, error) { + var u User + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + return u, err +} + +// CountAdmins 供 Task 5 的"最后一个 admin 不可删"保护 +func (s *Store) CountAdmins(ctx context.Context) (int, error) { + var n int + err := s.P.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n) + return n, err +} + +// ---------- libraries ---------- + +func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + "INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id) + return id, err +} + +func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) { + rows, err := s.P.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var out []Library + for rows.Next() { + var l Library + if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + +func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) { + var l Library + err := s.P.QueryRow(ctx, + "SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id). + Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt) + return l, err +} + +// ---------- books ---------- + +const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at" + +func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + `INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + libID, path, title, format, size, modTS, pageCount).Scan(&id) + return id, err +} + +func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) { + var b Book + err := s.P.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan( + &b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format, + &b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt) + return b, err +} + +func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) { + rows, err := s.P.Query(ctx, + "SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]BookMeta{} + for rows.Next() { + var m BookMeta + var path string + if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil { + return nil, err + } + out[path] = m + } + return out, rows.Err() +} + +func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error { + _, err := s.P.Exec(ctx, + `UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`, + id, size, modTS, pageCount) + return err +} + +func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error { + _, err := s.P.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path) + return err +} + +func (s *Store) DeleteBook(ctx context.Context, id int64) error { + _, err := s.P.Exec(ctx, "DELETE FROM books WHERE id=$1", id) + return err +} + +func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error { + _, err := s.P.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg) + return err +} + +func (s *Store) ListBookIDs(ctx context.Context) ([]int64, error) { + rows, err := s.P.Query(ctx, "SELECT id FROM books") + if err != nil { + return nil, err + } + defer rows.Close() + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) { + rows, err := s.P.Query(ctx, + `SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts, + b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0) + FROM books b JOIN libraries l ON l.id = b.library_id + LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path + WHERE ($1 = 0 OR b.library_id = $1) + AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%') + AND ($3 = '' OR b.path LIKE $3 || '%') + ORDER BY l.name, b.path`, libID, q, prefix, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BookView + for rows.Next() { + var v BookView + err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format, + &v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt, + &v.LibraryName, &v.Percent) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// ---------- progress ---------- + +func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error { + _, err := s.P.Exec(ctx, + `INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at) + VALUES ($1,$2,$3,$4,$5,now()) + ON CONFLICT (user_id, library_id, book_path) + DO UPDATE SET locator=$4, percent=$5, updated_at=now()`, + userID, libID, bookPath, locator, percent) + return err +} + +func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) { + rows, err := s.P.Query(ctx, + `SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at + FROM reading_progress p JOIN libraries l ON l.id = p.library_id + LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path + WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Progress + for rows.Next() { + var pr Progress + if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title, + &pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil { + return nil, err + } + out = append(out, pr) + } + return out, rows.Err() +} + +func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) { + var pr Progress + err := s.P.QueryRow(ctx, + `SELECT library_id, book_path, locator, percent, updated_at + FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`, + userID, libID, bookPath). + Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt) + return pr, err +} diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go new file mode 100644 index 0000000..c5633e3 --- /dev/null +++ b/backend/internal/store/store_test.go @@ -0,0 +1,117 @@ +package store + +import ( + "context" + "os" + "testing" + + "booklib/internal/db" +) + +func setup(t *testing.T) *Store { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, err := db.Connect(ctx, url) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(ctx, p); err != nil { + t.Fatal(err) + } + s := New(p) + for _, q := range []string{ + "DELETE FROM reading_progress", "DELETE FROM books", "DELETE FROM libraries", "DELETE FROM users"} { + if _, err := p.Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + return s +} + +func TestBooksDiffLifecycle(t *testing.T) { + s := setup(t) + ctx := context.Background() + libID, err := s.CreateLibrary(ctx, "comics", "/data/books/comics") + if err != nil { + t.Fatal(err) + } + bid, err := s.InsertBook(ctx, libID, "series-a/01.cbz", "01", "cbz", 100, 1000, 24) + if err != nil { + t.Fatal(err) + } + meta, err := s.ListBookMeta(ctx, libID) + if err != nil { + t.Fatal(err) + } + if m := meta["series-a/01.cbz"]; m.ID != bid || m.Size != 100 { + t.Fatalf("bad meta %+v", m) + } + if err := s.UpdateBookFile(ctx, bid, 200, 2000, 25); err != nil { + t.Fatal(err) + } + b, _ := s.GetBook(ctx, bid) + if b.FileSize != 200 || b.PageCount != 25 { + t.Fatalf("update failed: %+v", b) + } + if err := s.SetBookState(ctx, bid, "error", "boom"); err != nil { + t.Fatal(err) + } + b, _ = s.GetBook(ctx, bid) + if b.State != "error" || b.ErrMsg != "boom" { + t.Fatal("state not set") + } + // changed + deleted 的 diff 输入验证 + meta, _ = s.ListBookMeta(ctx, libID) + if _, ok := meta["nope.cbz"]; ok { + t.Fatal("unexpected row") + } + if err := s.DeleteBookByPath(ctx, libID, "series-a/01.cbz"); err != nil { + t.Fatal(err) + } + if _, err := s.GetBook(ctx, bid); err == nil { + t.Fatal("book still exists") + } +} + +func TestProgressUpsertAndJoin(t *testing.T) { + s := setup(t) + ctx := context.Background() + uid, _ := s.CreateUser(ctx, "u1", "h", "member") + libID, _ := s.CreateLibrary(ctx, "l", "/data/books/l") + bid, err := s.InsertBook(ctx, libID, "x/a.cbz", "a", "cbz", 1, 1, 3) + if err != nil { + t.Fatal(err) + } + if err := s.UpsertProgress(ctx, uid, libID, "x/a.cbz", []byte(`{"page":2}`), 0.5); err != nil { + t.Fatal(err) + } + if err := s.UpsertProgress(ctx, uid, libID, "x/a.cbz", []byte(`{"page":3}`), 0.9); err != nil { + t.Fatal(err) // upsert 不报错 + } + p, err := s.GetProgress(ctx, uid, libID, "x/a.cbz") + // jsonb 规范输出: 冒号后有空格 + if err != nil || p.Percent != 0.9 || string(p.Locator) != `{"page": 3}` { + t.Fatalf("bad progress %+v %v", p, err) + } + list, _ := s.ListProgress(ctx, uid) + if len(list) != 1 { + t.Fatalf("want 1 got %d", len(list)) + } + // 删书行,进度保留(spec §4) + if err := s.DeleteBook(ctx, bid); err != nil { + t.Fatal(err) + } + if _, err := s.GetProgress(ctx, uid, libID, "x/a.cbz"); err != nil { + t.Fatal("progress lost with book") + } + // 列表联查带本人 percent + _, _ = s.InsertBook(ctx, libID, "x/a.cbz", "a", "cbz", 1, 1, 3) + views, _ := s.ListBooks(ctx, 0, "", "", uid) + if len(views) != 1 || views[0].Percent != 0.9 { + t.Fatalf("views %+v", views) + } +} diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..e19ac4b --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,9 @@ +services: + postgres: + image: postgres:16-alpine + environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib } + ports: ["5433:5432"] + healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 } + redis: + image: redis:7-alpine + ports: ["6380:6379"]