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 @@ -
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(