diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f12811 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Book & Comic Library + +个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`。 + +## 跑起来(生产形态) + +```bash +cp .env.example .env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log) +docker compose up -d --build +./scripts/smoke.sh # 端到端验收(登录、建库、上传、扫描、进度、删除、immutable 头) +``` + +- web: `http://localhost:8080`,API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。 +- 原始书放在 `./library/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。 + +## 可信代理与限流 + +- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。 +- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。 + +## 开发 / 测试 + +```bash +docker compose -f deploy/docker-compose.dev.yml up -d # PG :5433, Redis :6380(避开本机默认端口) +cd backend +export DATABASE_URL='postgres://lib:lib@localhost:5433/lib?sslmode=disable' +export REDIS_URL='redis://localhost:6380' +go vet ./... && gofmt -l . +go test -p 1 -count=1 ./... +``` + +`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。 + +无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。 + +## 改 schema 前必读 + +`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。 diff --git a/backend/internal/seed/seed.go b/backend/internal/seed/seed.go index 1b4fdd0..168f2b0 100644 --- a/backend/internal/seed/seed.go +++ b/backend/internal/seed/seed.go @@ -15,6 +15,10 @@ func Admin(ctx context.Context, s *store.Store, user, pass string) error { if user == "" || pass == "" { return nil } + if len(pass) < 8 { // 与 API 建户口令下限一致 + log.Printf("seed admin skipped: ADMIN_PASSWORD must be >= 8 chars") + return nil + } n, err := s.CountUsers(ctx) if err != nil || n > 0 { return err diff --git a/backend/internal/seed/seed_test.go b/backend/internal/seed/seed_test.go index 9f7a854..c67cfe2 100644 --- a/backend/internal/seed/seed_test.go +++ b/backend/internal/seed/seed_test.go @@ -21,10 +21,16 @@ func TestSeedOnlyWhenEmpty(t *testing.T) { } s := store.New(p) p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") - if err := Admin(ctx, s, "admin", "pw12345"); err != nil { + if err := Admin(ctx, s, "shorty", "pw123"); err != nil { // <8 位 → 跳过 + log,不报错(空库时验证确实没建) + t.Fatal("short pw must no-op, got", err) + } + if _, err := s.GetUserByName(ctx, "shorty"); err == nil { + t.Fatal("short pw must not create user") + } + if err := Admin(ctx, s, "admin", "pw123456"); err != nil { t.Fatal(err) } - if err := Admin(ctx, s, "admin2", "pw12345"); err != nil { // 已有用户 → no-op + if err := Admin(ctx, s, "admin2", "pw123456"); err != nil { // 已有用户 → no-op t.Fatal(err) } n, _ := s.CountUsers(ctx) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index e8c0121..9989064 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -4,6 +4,7 @@ BASE=${BASE:-http://localhost:8080} API=$BASE/api J=(-H 'content-type: application/json') [ -f .env ] && set -a && . ./.env && set +a +WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT say(){ echo "smoke: $1"; } die(){ echo "SMOKE FAIL: $1"; exit 1; } @@ -26,10 +27,10 @@ code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/users" "${J[@]}" -H say "library + bad-ext upload rejected + good upload + scan" mkdir -p library/smoke-books LID=$(curl -fsS "$API/libraries" "${J[@]}" -H "$AUTH" -d '{"name":"smoke","root_path":"/data/books/smoke-books"}' | sed -E 's/.*"id":([0-9]+).*/\1/') -printf 'x' > library_upload_note.txt -curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@library_upload_note.txt;filename=virus.exe" && die "bad ext upload must fail" || true -printf 'hello smoke book' > library_upload_note.txt -curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@library_upload_note.txt;filename=note.txt" || die "upload failed" +printf 'x' > "$WORK/f" +curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@$WORK/f;filename=virus.exe" && die "bad ext upload must fail" || true +printf 'hello smoke book' > "$WORK/f" +curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@$WORK/f;filename=note.txt" || die "upload failed" curl -fsS -o /dev/null -X POST "$API/libraries/$LID/scan" -H "$AUTH" || die "scan trigger" found="" for _ in $(seq 30); do