v0.15.0: Docker 化部署(OVH-US-BAK)
- Dockerfile(oven/bun 多阶段:构建 dist + 全量 node_modules 运行时) - deploy/docker-compose.yml(postgres:18 + app,uploads 双挂载同宿主目录) - .dockerignore(uploads 经卷挂载不进镜像,避免 3GB 膨胀) - prod-server /uploads/* 运行时直读磁盘(媒体上传后无需重启即可访问,含路径穿越防护) - README Docker 部署章节
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Docker 构建上下文排除项(uploads 经 compose 卷挂载进容器,不进镜像)
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
backups
|
||||||
|
legacy
|
||||||
|
visual-audit
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
public/uploads
|
||||||
+20
@@ -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"]
|
||||||
@@ -59,6 +59,25 @@ bun test # 单元/集成测试
|
|||||||
`/admin`(默认管理员见导入输出)。模块:Dashboard / 内容 / 产品 / 订单 / 询盘 / 媒体 / 语言 / SEO 重定向 / 筛选 / 用户 / 设置。
|
`/admin`(默认管理员见导入输出)。模块:Dashboard / 内容 / 产品 / 订单 / 询盘 / 媒体 / 语言 / SEO 重定向 / 筛选 / 用户 / 设置。
|
||||||
所有接口 `requireAdmin()` 权限校验,敏感操作写 `audit_logs`。
|
所有接口 `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)。
|
见方案文档第 6 节。`src/server/*` 按业务域拆分(auth/content→data/products→data/filters/orders→commerce/inquiries/translations/seo/jobs)。
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dusuniot-rebuild",
|
"name": "dusuniot-rebuild",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+16
-1
@@ -9,11 +9,15 @@
|
|||||||
// @ts-expect-error 构建产物无 d.ts
|
// @ts-expect-error 构建产物无 d.ts
|
||||||
import serverEntry from "../dist/server/server.js";
|
import serverEntry from "../dist/server/server.js";
|
||||||
import { readdirSync } from "node:fs";
|
import { readdirSync } from "node:fs";
|
||||||
|
import { resolve, sep } from "node:path";
|
||||||
|
|
||||||
/** # 成员变量 dist/client 静态文件表(pathname → 绝对路径,启动时递归扫描一次) **/
|
/** # 成员变量 dist/client 静态文件表(pathname → 绝对路径,启动时递归扫描一次) **/
|
||||||
const CLIENT_DIR = new URL("../dist/client/", import.meta.url).pathname;
|
const CLIENT_DIR = new URL("../dist/client/", import.meta.url).pathname;
|
||||||
const staticFiles = new Map<string, string>();
|
const staticFiles = new Map<string, string>();
|
||||||
|
|
||||||
|
/** # 成员变量 uploads 根目录(媒体上传写入 public/uploads,运行时直读,启动静态表不含新增文件) **/
|
||||||
|
const UPLOADS_ROOT = resolve(process.cwd(), "public", "uploads");
|
||||||
|
|
||||||
/** # 函数 递归收集 dist/client 下全部静态文件 */
|
/** # 函数 递归收集 dist/client 下全部静态文件 */
|
||||||
function walk(dir: string): void {
|
function walk(dir: string): void {
|
||||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
@@ -28,9 +32,20 @@ walk(CLIENT_DIR);
|
|||||||
export default {
|
export default {
|
||||||
/** # 成员变量 监听端口(PORT 环境变量可覆盖) **/
|
/** # 成员变量 监听端口(PORT 环境变量可覆盖) **/
|
||||||
port: Number(process.env.PORT ?? 3312),
|
port: Number(process.env.PORT ?? 3312),
|
||||||
/** # 函数 请求入口:静态文件优先,其余交给 Start handler */
|
/** # 函数 请求入口:/uploads 直读磁盘(新增文件无需重启)→ 启动静态表 → Start handler */
|
||||||
async fetch(req: Request): Promise<Response> {
|
async fetch(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url);
|
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" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 尾斜杠归一化:偶发的 "<file>.jpg/" 形态请求(SPA 预取取回/上游转发)直接按静态
|
// 尾斜杠归一化:偶发的 "<file>.jpg/" 形态请求(SPA 预取取回/上游转发)直接按静态
|
||||||
// 文件应答,避免交给 Start handler 后落入路由 catch-all 返回 404
|
// 文件应答,避免交给 Start handler 后落入路由 catch-all 返回 404
|
||||||
let f = staticFiles.get(url.pathname);
|
let f = staticFiles.get(url.pathname);
|
||||||
|
|||||||
Reference in New Issue
Block a user