diff --git a/.dockerignore b/.dockerignore index e55cc45..7e770bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,6 @@ library/ .superpowers/ backend/booklib* +web/node_modules +web/dist +web/dev-dist diff --git a/README.md b/README.md index 8f12811..fa9ddc9 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,18 @@ go test -p 1 -count=1 ./... ## 改 schema 前必读 `db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。 + +## 前端开发(web/) + +- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。 +- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380), + 然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \ + REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。 +- 门槛:`npm run check`(tsc + vitest + vite build)。 + +## 生产部署(含前端) + +- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`; + 打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。 +- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。 +- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。 diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web index 99b1847..2b65b38 100644 --- a/deploy/Dockerfile.web +++ b/deploy/Dockerfile.web @@ -1,3 +1,10 @@ +FROM node:22-alpine AS build +WORKDIR /src +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + FROM nginx:1.27-alpine COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY deploy/web-dist /usr/share/nginx/html +COPY --from=build /src/dist /usr/share/nginx/html diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 8f0dccf..106fd1c 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,11 +1,10 @@ server { listen 80; client_max_body_size 200m; - resolver 127.0.0.11 valid=10s; location /api/ { - set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本 - proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发 + # 静态主机名:启动时经 /etc/resolv.conf 解析(Docker 127.0.0.11 / podman aardvark 均兼容) + proxy_pass http://api:8080; # 无 URI 部分:保留 /api 前缀转发 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/deploy/web-dist/index.html b/deploy/web-dist/index.html deleted file mode 100644 index 011f16d..0000000 --- a/deploy/web-dist/index.html +++ /dev/null @@ -1 +0,0 @@ -booklib

backend up — frontend lands in Plan 2.

diff --git a/scripts/smoke-web.sh b/scripts/smoke-web.sh new file mode 100755 index 0000000..0250535 --- /dev/null +++ b/scripts/smoke-web.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# 全栈部署冒烟:经 nginx 验证 SPA 静态、/api 反代、登录、SW/manifest。 +# 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。 +set -euo pipefail +BASE=${BASE:-http://localhost:8080} +[ -f .env ] && set -a && . ./.env && set +a +: "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}" + +say(){ echo "smoke-web: $1"; } +die(){ echo "SMOKE-WEB FAIL: $1"; exit 1; } + +say "build web + api images" +docker compose build web api + +say "up --wait" +# docker compose 支持 --wait;podman-compose 不支持 → 回退 up -d + 轮询健康 +if docker compose up -d --wait postgres redis api web 2>/dev/null; then + : +else + say "no --wait support: up -d + poll healthz" + docker compose up -d postgres redis api web + for _ in $(seq 1 60); do + curl -fsS --max-time 3 "$BASE/api/healthz" >/dev/null 2>&1 && break + sleep 2 + done + curl -fsS --max-time 5 "$BASE/api/healthz" >/dev/null || die "api healthz 未就绪" +fi + +say "SPA index served by nginx" +INDEX=$(curl -fsS "$BASE/") +echo "$INDEX" | grep -q 'id="root"' || die "index 缺 #root" +ASSET=$(echo "$INDEX" | sed -nE 's#.*src="(/assets/[^"]+\.js)".*#\1#p' | head -1) +[ -n "$ASSET" ] || die "index 未引用 /assets/*.js" +curl -fsS -o /dev/null "$BASE$ASSET" || die "asset $ASSET 拉取失败" + +say "SPA deep link falls back to index" +curl -fsS "$BASE/admin/users" | grep -q 'id="root"' || die "try_files 回退失败" + +say "api via /api/ proxy" +curl -fsS "$BASE/api/healthz" >/dev/null || die "healthz via nginx" +TOK=$(curl -fsS "$BASE/api/auth/login" -H 'content-type: application/json' \ + -d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" \ + | sed -E 's/.*"token":"([^"]+)".*/\1/') +[ -n "$TOK" ] || die "nginx 反代登录失败" +curl -fsS "$BASE/api/auth/me" -H "authorization: Bearer $TOK" | grep -q "$ADMIN_USER" || die "me via nginx" + +say "pwa artifacts" +curl -fsS "$BASE/sw.js" >/dev/null || die "sw.js 缺失" +curl -fsS "$BASE/manifest.webmanifest" >/dev/null || die "manifest 缺失" + +say "ALL WEB SMOKE PASSED" diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index 45068a5..a2b56a4 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -47,6 +47,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setToken(null); setTok(null); qc.clear(); + if (typeof caches !== "undefined") { + void caches.delete("booklib-api"); + void caches.delete("booklib-immutable"); + } }, }), [meQ.data, meQ.isPending, meQ.isError, token, qc], diff --git a/web/src/main.tsx b/web/src/main.tsx index 7ffae04..0c0fc1f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import { queryClient } from "./lib/qc"; import "./index.css"; +import "./pwa"; createRoot(document.getElementById("root")!).render( diff --git a/web/src/pwa.ts b/web/src/pwa.ts new file mode 100644 index 0000000..dcac791 --- /dev/null +++ b/web/src/pwa.ts @@ -0,0 +1,4 @@ +import { registerSW } from "virtual:pwa-register"; + +// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗 +registerSW({ immediate: true }); diff --git a/web/tsconfig.json b/web/tsconfig.json index a087af1..5527844 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -12,7 +12,7 @@ "isolatedModules": true, "skipLibCheck": true, "noEmit": true, - "types": ["vite/client"] + "types": ["vite/client", "vite-plugin-pwa/client"] }, "include": ["src", "test"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index cc8dbfd..65d95d6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,50 @@ import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + VitePWA({ + registerType: "autoUpdate", + includeAssets: ["icon.svg"], + manifest: { + name: "BookLib 个人书库", + short_name: "BookLib", + description: "个人书库/漫画库在线阅读", + theme_color: "#09090b", + background_color: "#09090b", + display: "standalone", + icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }], + }, + workbox: { + globPatterns: ["**/*.{js,css,html,svg,woff2}"], + navigateFallback: "/index.html", + runtimeCaching: [ + { + // ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3) + urlPattern: ({ url }) => + url.pathname.startsWith("/api/books/") && + url.search.includes("v=") && + (/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)), + handler: "CacheFirst", + options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } }, + }, + { + // 普通 API 请求 network-first,离线可读最近一次列表/进度 + urlPattern: ({ url }) => url.pathname.startsWith("/api/"), + handler: "NetworkFirst", + options: { + cacheName: "booklib-api", + networkTimeoutSeconds: 5, + expiration: { maxAgeSeconds: 3600, maxEntries: 200 }, + }, + }, + ], + }, + }), + ], server: { proxy: { "/api": "http://localhost:8080" } }, });