From c3df5d9db17766e5aa364a557e4385c96b057998 Mon Sep 17 00:00:00 2001 From: bigcode Date: Wed, 4 Nov 2026 15:15:58 +0800 Subject: [PATCH] =?UTF-8?q?v0.15.0:=20Docker=20=E5=8C=96=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=EF=BC=88OVH-US-BAK=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile(oven/bun 多阶段:构建 dist + 全量 node_modules 运行时) - deploy/docker-compose.yml(postgres:18 + app,uploads 双挂载同宿主目录) - .dockerignore(uploads 经卷挂载不进镜像,避免 3GB 膨胀) - prod-server /uploads/* 运行时直读磁盘(媒体上传后无需重启即可访问,含路径穿越防护) - README Docker 部署章节 --- .dockerignore | 11 +++++++++++ Dockerfile | 20 ++++++++++++++++++++ README.md | 19 +++++++++++++++++++ deploy/docker-compose.yml | 37 +++++++++++++++++++++++++++++++++++++ package.json | 2 +- scripts/prod-server.ts | 17 ++++++++++++++++- 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..5a7157f56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Docker 构建上下文排除项(uploads 经 compose 卷挂载进容器,不进镜像) +node_modules +dist +.git +.env +backups +legacy +visual-audit +docs +tests +public/uploads diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d93733f45 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# 构建阶段:bun 安装依赖并产出 dist(vite build) +FROM oven/bun:1-slim AS build +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . +RUN bun run build + +# 运行阶段:产物 + 全量 node_modules(dist/server 对 react/@tanstack/seroval 等是裸导入) +FROM oven/bun:1-slim +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/public ./public +COPY --from=build /app/scripts ./scripts +COPY --from=build /app/package.json ./package.json +# public/uploads 由部署侧卷挂载提供(写入与 /uploads 访问同一宿主目录,见 deploy/docker-compose.yml) +EXPOSE 3312 +CMD ["bun", "scripts/prod-server.ts"] diff --git a/README.md b/README.md index 4b74de941..237d10e69 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,25 @@ bun test # 单元/集成测试 `/admin`(默认管理员见导入输出)。模块:Dashboard / 内容 / 产品 / 订单 / 询盘 / 媒体 / 语言 / SEO 重定向 / 筛选 / 用户 / 设置。 所有接口 `requireAdmin()` 权限校验,敏感操作写 `audit_logs`。 +## Docker 部署(v0.15.0) + +生产环境部署在 OVH(OVH-US-BAK)`/home/docker/dusuniot/`,域名 `dusuniot.ibug.qzz.io`(Cloudflare 橙云): + +``` +/home/docker/dusuniot/ +├── docker-compose.yml # 即仓库 deploy/docker-compose.yml +├── app/ # 仓库源码 rsync(.dockerignore 排除 node_modules/dist/uploads 等) +└── data/ + ├── pg/ # PostgreSQL 18 数据卷 + └── uploads/ # 媒体库(容器内双挂载:/app/public/uploads 写入 + /app/dist/client/uploads 服务) +``` + +- 应用容器 `dusuniot-app`(oven/bun 构建,`bun scripts/prod-server.ts`,PORT 3312),仅绑定 `127.0.0.1:3320`;数据库容器 `dusuniot-db-1`(postgres:18,不对外暴露)。 +- nginx 站点 `/etc/nginx/sites-available/dusuniot.ibug.qzz.io.conf`:80 + 443(Let's Encrypt,certbot webroot 续期自动 reload)→ `proxy_pass http://127.0.0.1:3320`。CF SSL 模式 Full/Full(strict)/Flexible 均兼容。 +- 数据迁移:本机 `pg_dump -Fc` → 服务器 `pg_restore`;媒体经 tar+zstd 流式同步到 `data/uploads`。 +- 更新发布:rsync 源码后 `cd /home/docker/dusuniot && docker compose up -d --build app`。 +- `scripts/prod-server.ts` 的 `/uploads/*` 运行时直读磁盘(媒体上传后无需重启即可访问,本地裸跑同样受益)。 + ## 目录结构 见方案文档第 6 节。`src/server/*` 按业务域拆分(auth/content→data/products→data/filters/orders→commerce/inquiries/translations/seo/jobs)。 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 000000000..256e32843 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,37 @@ +# dusuniot 生产部署编排(放在 /home/docker/dusuniot/ 下使用,源码 rsync 到 ./app) +# 用法:docker compose up -d --build +name: dusuniot +services: + # # 数据库服务 PostgreSQL 18(数据落 ./data/pg,不对外暴露端口) + db: + image: postgres:18 + restart: unless-stopped + environment: + POSTGRES_USER: dusuniot + POSTGRES_PASSWORD: dusuniot + POSTGRES_DB: dusuniot + volumes: + - ./data/pg:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dusuniot -d dusuniot"] + interval: 10s + timeout: 5s + retries: 12 + + # # 应用服务 TanStack Start + Bun(nginx 反代 127.0.0.1:3320) + app: + build: ./app + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://dusuniot:dusuniot@db:5432/dusuniot + PORT: "3312" + ports: + - "127.0.0.1:3320:3312" + volumes: + # 双挂载同一宿主目录:adminUploadMedia 写 public/uploads(cwd 相对), + # prod-server 的 /uploads 直读与启动静态表走 dist/client/uploads + - ./data/uploads:/app/public/uploads + - ./data/uploads:/app/dist/client/uploads diff --git a/package.json b/package.json index b9f4b6902..cdcb23012 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.14.0", + "version": "0.15.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/prod-server.ts b/scripts/prod-server.ts index 2812392f2..d56806d6b 100644 --- a/scripts/prod-server.ts +++ b/scripts/prod-server.ts @@ -9,11 +9,15 @@ // @ts-expect-error 构建产物无 d.ts import serverEntry from "../dist/server/server.js"; import { readdirSync } from "node:fs"; +import { resolve, sep } from "node:path"; /** # 成员变量 dist/client 静态文件表(pathname → 绝对路径,启动时递归扫描一次) **/ const CLIENT_DIR = new URL("../dist/client/", import.meta.url).pathname; const staticFiles = new Map(); +/** # 成员变量 uploads 根目录(媒体上传写入 public/uploads,运行时直读,启动静态表不含新增文件) **/ +const UPLOADS_ROOT = resolve(process.cwd(), "public", "uploads"); + /** # 函数 递归收集 dist/client 下全部静态文件 */ function walk(dir: string): void { for (const e of readdirSync(dir, { withFileTypes: true })) { @@ -28,9 +32,20 @@ walk(CLIENT_DIR); export default { /** # 成员变量 监听端口(PORT 环境变量可覆盖) **/ port: Number(process.env.PORT ?? 3312), - /** # 函数 请求入口:静态文件优先,其余交给 Start handler */ + /** # 函数 请求入口:/uploads 直读磁盘(新增文件无需重启)→ 启动静态表 → Start handler */ async fetch(req: Request): Promise { const url = new URL(req.url); + // /uploads/ 运行时直读磁盘:媒体库上传落在 public/uploads,静态表启动时已定格, + // 不直读会导致运行中上传的图片 404。resolve 后校验前缀,防路径穿越。 + if (url.pathname.startsWith("/uploads/")) { + const abs = resolve(UPLOADS_ROOT, "." + url.pathname.slice("/uploads".length)); + if (abs.startsWith(UPLOADS_ROOT + sep)) { + const uf = Bun.file(abs); + if (await uf.exists()) { + return new Response(uf, { headers: { "cache-control": "public, max-age=3600" } }); + } + } + } // 尾斜杠归一化:偶发的 ".jpg/" 形态请求(SPA 预取取回/上游转发)直接按静态 // 文件应答,避免交给 Start handler 后落入路由 catch-all 返回 404 let f = staticFiles.get(url.pathname);