feat(web): pwa offline caching, dockerized SPA, full-stack nginx smoke
This commit is contained in:
@@ -3,3 +3,6 @@
|
|||||||
library/
|
library/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
backend/booklib*
|
backend/booklib*
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
web/dev-dist
|
||||||
|
|||||||
@@ -36,3 +36,18 @@ go test -p 1 -count=1 ./...
|
|||||||
## 改 schema 前必读
|
## 改 schema 前必读
|
||||||
|
|
||||||
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
|
`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 空闲)。
|
||||||
|
|||||||
@@ -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
|
FROM nginx:1.27-alpine
|
||||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
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
@@ -1,11 +1,10 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
client_max_body_size 200m;
|
client_max_body_size 200m;
|
||||||
resolver 127.0.0.11 valid=10s;
|
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本
|
# 静态主机名:启动时经 /etc/resolv.conf 解析(Docker 127.0.0.11 / podman aardvark 均兼容)
|
||||||
proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发
|
proxy_pass http://api:8080; # 无 URI 部分:保留 /api 前缀转发
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<!doctype html><title>booklib</title><p>backend up — frontend lands in Plan 2.</p>
|
|
||||||
Executable
+51
@@ -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"
|
||||||
@@ -47,6 +47,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
setTok(null);
|
setTok(null);
|
||||||
qc.clear();
|
qc.clear();
|
||||||
|
if (typeof caches !== "undefined") {
|
||||||
|
void caches.delete("booklib-api");
|
||||||
|
void caches.delete("booklib-immutable");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[meQ.data, meQ.isPending, meQ.isError, token, qc],
|
[meQ.data, meQ.isPending, meQ.isError, token, qc],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { queryClient } from "./lib/qc";
|
import { queryClient } from "./lib/qc";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./pwa";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
|
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
|
||||||
|
registerSW({ immediate: true });
|
||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||||
},
|
},
|
||||||
"include": ["src", "test"]
|
"include": ["src", "test"]
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-2
@@ -1,8 +1,50 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
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" } },
|
server: { proxy: { "/api": "http://localhost:8080" } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user