diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d656c74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules +**/node_modules +**/dist +.git +.gitignore +.env +.env.local +.env.* +!.env.production.example +prisma/dev.db +prisma/dev.db-journal +*.log +.DS_Store +# 原始素材(镜像只需要 packages/server/uploads/stamps 里的成品) +assets/ +# Docker 构建上下文不需要旧的数据目录 +data/ +uploads/ diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..7931afe --- /dev/null +++ b/.env.production.example @@ -0,0 +1,12 @@ +# 部署前复制为 .env 并修改以下值 +# cp .env.production.example .env + +# JWT 签名密钥(32+ 字符随机串) +JWT_SECRET=please-replace-with-a-strong-random-secret + +# 管理后台访问密钥(通过 X-Admin-Key 请求头传入) +ADMIN_API_KEY=please-replace-with-a-strong-random-admin-key + +# 对外访问的站点根 URL(用于生成 QR 码中的收集链接) +# 必须与你在 Nginx 上配置的域名一致 +SITE_URL=https://stamp.example.com diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..e9a7d31 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,181 @@ +# 部署指南 + +基于 Docker Compose 的单容器部署方案。容器内 Express 同时托管: + +- `/api/*` — API +- `/uploads/*` — 图章静态资源 +- 其余路径 — React SPA(`packages/web/dist`) + +外部通过宿主机 Nginx 反向代理到容器的 3000 端口(仅绑定 127.0.0.1,不直接暴露)。 + +--- + +## 1. 前置条件 + +- Linux 服务器(Docker ≥ 24、Docker Compose v2) +- 已解析到服务器的域名(例 `stamp.example.com`) +- 已签发的 SSL 证书(Let's Encrypt / acme.sh 等任选) + +--- + +## 2. 拉取代码并配置环境变量 + +```bash +git clone citywalk-stamp +cd citywalk-stamp + +cp .env.production.example .env +vim .env # 填入 JWT_SECRET / ADMIN_API_KEY / SITE_URL +``` + +必填项: + +| 变量 | 说明 | +|---|---| +| `JWT_SECRET` | 用户 JWT 签名密钥,建议 `openssl rand -hex 32` 生成 | +| `ADMIN_API_KEY` | 管理后台访问密钥,同上 | +| `SITE_URL` | 对外域名,例 `https://stamp.example.com`(影响 QR 码链接) | + +--- + +## 3. 启动容器 + +```bash +docker compose up -d --build +``` + +首次启动会自动完成: + +- Prisma migrate deploy(建表) +- 把内置的 16 枚图章素材复制到 `./uploads/stamps/` + +查看日志确认启动成功: + +```bash +docker compose logs -f app +# 看到 "Server running on http://localhost:3000" 即 OK +``` + +--- + +## 4. 首次导入图章数据 + +容器默认不自动 seed 数据库(避免覆盖后续运营数据)。首次部署需手动执行一次: + +```bash +docker compose exec app pnpm db:seed +``` + +> 再次执行会**清空并重建**所有图章,同时级联删除用户的收集记录。生产环境请勿随意重跑。 + +--- + +## 5. 配置 Nginx 反向代理 + +在宿主机上配置 Nginx: + +```nginx +server { + listen 80; + server_name stamp.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name stamp.example.com; + + ssl_certificate /etc/letsencrypt/live/stamp.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stamp.example.com/privkey.pem; + + # 单入口反代:容器里 Express 自己处理 /api、/uploads、SPA 路由 + location / { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 静态资源缓存(可选) + location ^~ /uploads/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public, max-age=604800"; + } + + # 上传体积(管理后台上传图章) + client_max_body_size 10m; +} +``` + +测试并 reload: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +访问 `https://stamp.example.com` 即可看到首页。 + +--- + +## 6. 数据持久化与备份 + +容器两个 bind mount: + +- `./data/` — SQLite 数据库(`prod.db`) +- `./uploads/` — 图章图片(含首次填充的 16 枚 + 后续管理后台上传的) + +备份: + +```bash +# 停机备份最稳妥(SQLite 文件锁) +docker compose stop app +tar czf backup-$(date +%F).tgz data/ uploads/ .env +docker compose start app +``` + +--- + +## 7. 常用运维命令 + +```bash +# 查看日志 +docker compose logs -f app + +# 进入容器 +docker compose exec app sh + +# 重启 +docker compose restart app + +# 升级(拉新代码后重建) +git pull +docker compose up -d --build + +# 查看当前图章数 +docker compose exec app sh -c 'echo "SELECT COUNT(*) FROM Stamp;" | sqlite3 /app/data/prod.db' \ + || docker compose exec app node -e " + import('@prisma/client').then(async ({PrismaClient}) => { + const p = new PrismaClient(); + console.log(await p.stamp.count()); + await p.\$disconnect(); + }); + " + +# 完全重置(会删除所有用户和收集数据,谨慎) +docker compose down +rm -rf data/ uploads/ +docker compose up -d --build +docker compose exec app pnpm db:seed +``` + +--- + +## 8. 故障排查 + +- **容器启动失败 `PrismaClientInitializationError`**:检查 `./data/` 目录权限、`DATABASE_URL` 值 +- **图章图片 404**:进入容器 `ls /app/packages/server/uploads/stamps` 看是否被 seed 进来;若空,手动执行 `cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/` +- **QR 码扫出来是 `localhost`**:`.env` 里 `SITE_URL` 没改对,修正后 `docker compose restart app` +- **Nginx 502**:确认容器跑着 `docker compose ps`,端口 `ss -tlnp | grep 3000` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3446ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM node:22-alpine + +WORKDIR /app + +# 国内构建环境加速(仅容器构建期生效,不影响本地/正式 registry) +# 1) Alpine apk 源换清华 +RUN sed -i 's|https\?://dl-cdn.alpinelinux.org|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories + +RUN corepack enable && corepack prepare pnpm@9 --activate \ + && apk add --no-cache openssl + +# 2) npm / pnpm registry 换淘宝镜像 +# 3) Prisma 下载引擎走淘宝镜像 +ENV PRISMA_ENGINES_MIRROR=https://registry.npmmirror.com/-/binary/prisma +RUN npm config set registry https://registry.npmmirror.com/ \ + && pnpm config set registry https://registry.npmmirror.com/ + +# Copy everything (.dockerignore filters out node_modules, dist, .git, etc.) +COPY . . + +# 强制项目级 registry(兜底:防止项目根 .npmrc 未覆盖) +RUN echo "registry=https://registry.npmmirror.com/" > /app/.npmrc + +# Install all deps (tsx is used at runtime via devDependencies) +RUN pnpm install --frozen-lockfile --registry=https://registry.npmmirror.com/ + +# Generate Prisma client (produces linux-musl binary per schema.prisma) +RUN pnpm exec prisma generate + +# Build web SPA; server runs directly from TS via tsx +RUN pnpm --filter @stamp/web build + +# Stash initial stamp assets so the uploads volume can be seeded on first run +RUN mkdir -p /app/stamps-seed \ + && cp packages/server/uploads/stamps/*.jpg /app/stamps-seed/ \ + && rm -rf packages/server/uploads/stamps + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENV NODE_ENV=production +EXPOSE 3000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b75199 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + app: + build: . + image: citywalk-stamp:latest + container_name: citywalk-stamp + restart: unless-stopped + # Bind to loopback only: the outside world must go through your host Nginx + # 宿主机 3001 -> 容器 3000(3000 已被宿主其它服务占用,例如 1Panel Gitea) + ports: + - "127.0.0.1:3001:3000" + volumes: + - ./data:/app/data + - ./uploads:/app/packages/server/uploads + env_file: + - .env + environment: + NODE_ENV: production + SERVER_PORT: 3000 + DATABASE_URL: file:/app/data/prod.db diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..0eb3406 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +cd /app + +mkdir -p /app/data +mkdir -p /app/packages/server/uploads/stamps + +# Seed stamp assets on first run (idempotent) +if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then + echo "→ Seeding stamp assets into uploads volume..." + cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/ 2>/dev/null || true +fi + +echo "→ Applying database migrations..." +pnpm exec prisma migrate deploy + +echo "→ Starting server on :${SERVER_PORT:-3000}..." +cd /app/packages/server +exec pnpm exec tsx src/index.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b2c6065..d38d049 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -31,6 +31,17 @@ app.use("/api/redemption", redemptionRoutes); // Admin routes app.use("/api/admin", adminRoutes); +// Serve built web frontend in production (single-container deployment) +if (process.env.NODE_ENV === "production") { + const webDist = path.join(__dirname, "../../web/dist"); + app.use(express.static(webDist)); + app.use((req, res, next) => { + if (req.method !== "GET" && req.method !== "HEAD") return next(); + if (req.path.startsWith("/api") || req.path.startsWith("/uploads")) return next(); + res.sendFile(path.join(webDist, "index.html")); + }); +} + app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6f1d75..d2d0351 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db {