feat(web): pwa offline caching, dockerized SPA, full-stack nginx smoke

This commit is contained in:
2026-09-07 16:33:50 +08:00
parent 1bd7688ee6
commit 96e96fec51
11 changed files with 133 additions and 8 deletions
+3
View File
@@ -3,3 +3,6 @@
library/
.superpowers/
backend/booklib*
web/node_modules
web/dist
web/dev-dist
+15
View File
@@ -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 空闲)。
+8 -1
View File
@@ -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
+2 -3
View File
@@ -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;
-1
View File
@@ -1 +0,0 @@
<!doctype html><title>booklib</title><p>backend up — frontend lands in Plan 2.</p>
+51
View File
@@ -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"
+4
View File
@@ -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],
+1
View File
@@ -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(
<QueryClientProvider client={queryClient}>
+4
View File
@@ -0,0 +1,4 @@
import { registerSW } from "virtual:pwa-register";
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
registerSW({ immediate: true });
+1 -1
View File
@@ -12,7 +12,7 @@
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src", "test"]
}
+44 -2
View File
@@ -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" } },
});