feat: add combined frontend and API Docker deployment

This commit is contained in:
陈煜 committed 2026-10-05 16:25:06 +08:00
1 parent 0b2588e09c
commit a5f1236d44
12 files changed
+429 -2

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
.git
.codex
.agents
**/node_modules
.pnpm-store
**/dist
**/public
**/.env
**/.env.*
**/*.log
**/coverage
**/*.db
**/*.sqlite*
+27
View File
@@ -0,0 +1,27 @@
# Copy to .env.production in the project root. Never commit real credentials.
# Replace the domain, database connection and initial admin password before startup.
NODE_ENV=production
DATABASE_URL="mysql://USER:URL_ENCODED_PASSWORD@MYSQL_HOST:3306/worthpath"
ADMIN_USERNAME=admin
ADMIN_PASSWORD=REPLACE_WITH_A_STRONG_INITIAL_PASSWORD
API_HOST=0.0.0.0
PORT=3100
API_ALLOWED_HOSTS=worthpath.example.com
WEB_ORIGIN=https://worthpath.example.com
MCP_PUBLIC_URL=https://worthpath.example.com/mcp
MCP_WEB_URL=https://worthpath.example.com
MCP_ALLOWED_HOSTS=worthpath.example.com
MCP_ALLOWED_ORIGINS=https://worthpath.example.com
MCP_ALLOW_LOOPBACK_HOSTS=false
NETWORK_ALLOW_HTTP=false
NETWORK_ALLOW_HTTP_REDIRECTS=false
NETWORK_ALLOW_WILDCARD_ORIGINS=false
NETWORK_REQUIRE_SECURE_COOKIE=true
NETWORK_HSTS=true
NETWORK_UPGRADE_INSECURE_REQUESTS=true
NETWORK_RATE_LIMIT_ENABLED=true
COOKIE_SECURE=true
COOKIE_SAME_SITE=strict
+2
View File
@@ -4,6 +4,8 @@ coverage/
.env
.env.*
!.env.example
!.env.production.example
apps/api/public/
*.log
*.db
*.sqlite*
+34
View File
@@ -0,0 +1,34 @@
FROM node:24-bookworm-slim AS base
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@11.19.0
WORKDIR /app
FROM base AS build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY apps/api/prisma apps/api/prisma
RUN pnpm install --frozen-lockfile
COPY apps ./apps
RUN pnpm db:generate && pnpm build \
&& cp -r apps/web/dist apps/api/public
FROM base AS runtime
ENV NODE_ENV=production
# Preserve pnpm's workspace links and Prisma CLI for explicit release migrations.
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json /app/pnpm-workspace.yaml ./
COPY --from=build --chown=node:node /app/apps/api/package.json ./apps/api/package.json
COPY --from=build --chown=node:node /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist
COPY --from=build --chown=node:node /app/apps/api/public ./apps/api/public
COPY --from=build --chown=node:node /app/apps/api/prisma ./apps/api/prisma
COPY --from=build --chown=node:node /app/apps/api/assets ./apps/api/assets
COPY --from=build --chown=node:node /app/apps/api/scripts/database.cjs /app/apps/api/scripts/seed-icons.cjs ./apps/api/scripts/
WORKDIR /app/apps/api
USER node
EXPOSE 3100
CMD ["node", "dist/main.js"]
+2
View File
@@ -1,5 +1,7 @@
# WorthPath
Docker 前后端合并容器部署见 [Docker 部署说明](docker.md)。
个人资产负债与净资产管理。NestJS + React/Vite + TypeScript + MySQL,支持手机和电脑。Node.js 22.12+、pnpm 11、MySQL 8+。
```powershell
+1 -1
View File
@@ -5,7 +5,7 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts test/web.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
+2
View File
@@ -26,6 +26,7 @@ import { AgentOperations } from './mcp/operations';
import { AgentFiles } from './mcp/files';
import { AgentTransport } from './mcp/transport';
import { AgentManagementController } from './mcp/management';
import { installWeb } from './web';
@Catch()
class SafeErrors implements ExceptionFilter {
catch(error: unknown, host: ArgumentsHost) {
@@ -132,6 +133,7 @@ async function bootstrap() {
app.get(AgentTransport).install(app.getHttpAdapter().getInstance());
app.useGlobalFilters(new SafeErrors());
setupOpenApi(app);
installWeb(app.getHttpAdapter().getInstance());
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT || 3100), network.apiHost);
console.log('WorthPath API ready');
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { Express, static as serveStatic } from 'express';
/** Serve only the current Vite assets and page entries; leave service routes to Nest/OAuth. */
export function installWeb(app: Express, directory = join(__dirname, '../public')) {
const index = join(directory, 'index.html');
if (!existsSync(index)) return false;
app.use('/assets', serveStatic(join(directory, 'assets'), { index: false, redirect: false }));
const pages = new Set(['/', '/agent/authorize', '/agent/operation']);
app.use((req, res, next) => {
if (!['GET', 'HEAD'].includes(req.method) || !pages.has(req.path)) return next();
res.setHeader('Cache-Control', 'no-store');
res.sendFile(index, (error) => {
if (error) next(error);
});
});
return true;
}
+80
View File
@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AddressInfo } from 'node:net';
import { installWeb } from '../src/web';
test('combined web serves pages/assets without swallowing API, MCP, OAuth or missing assets', async () => {
const dir = await mkdtemp(join(tmpdir(), 'worthpath-web-'));
const app = express();
let server: ReturnType<typeof app.listen> | undefined;
try {
assert.equal(installWeb(app, join(dir, 'missing')), false);
await mkdir(join(dir, 'assets'));
await writeFile(join(dir, 'index.html'), '<html>WorthPath test page</html>');
await writeFile(join(dir, 'assets', 'app.js'), 'window.worthpath = true;');
assert.equal(installWeb(app, dir), true);
// Register downstream service routes to detect accidental web interception.
for (const path of [
'/api/probe',
'/mcp',
'/authorize',
'/token',
'/register',
'/revoke',
'/.well-known/oauth-authorization-server',
]) {
app.all(path, (_req, res) => res.status(401).json({ service: path }));
}
app.use((_req, res) => res.status(404).json({ missing: true }));
server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server!.once('listening', resolve));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
for (const path of [
'/',
'/?agent_operation=test',
'/agent/authorize?agent_authorization=test',
'/agent/operation',
]) {
const result = await fetch(base + path);
assert.equal(result.status, 200);
assert.match(result.headers.get('content-type') || '', /text\/html/);
assert.equal(result.headers.get('cache-control'), 'no-store');
assert.match(await result.text(), /WorthPath test page/);
}
const head = await fetch(base + '/', { method: 'HEAD' });
assert.equal(head.status, 200);
assert.equal(await head.text(), '');
assert.match(await (await fetch(base + '/assets/app.js')).text(), /window.worthpath/);
for (const path of ['/assets/missing.js', '/api/unknown', '/unknown']) {
assert.equal((await fetch(base + path)).status, 404);
}
assert.equal((await fetch(base + '/', { method: 'POST' })).status, 404);
for (const path of [
'/api/probe',
'/mcp',
'/authorize',
'/token',
'/register',
'/revoke',
'/.well-known/oauth-authorization-server',
]) {
for (const method of ['GET', 'POST']) {
const result = await fetch(base + path, { method });
assert.equal(result.status, 401);
assert.deepEqual(await result.json(), { service: path });
}
}
} finally {
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server!.close((error) => (error ? reject(error) : resolve())),
);
}
await rm(dir, { recursive: true, force: true });
}
});
+18
View File
@@ -0,0 +1,18 @@
services:
app:
image: worthpath:${WORTHPATH_IMAGE_TAG:-local}
build:
context: .
dockerfile: Dockerfile
env_file:
- .env.production
ports:
- '127.0.0.1:3100:3100'
restart: unless-stopped
init: true
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
+228
View File
@@ -0,0 +1,228 @@
# WorthPath Docker 部署
## 部署结构
前后端合并为一个应用容器:构建时编译 React/Vite 和 NestJS,将网页产物复制到 `apps/api/public`;运行时只启动 `node dist/main.js`。同一端口提供网页、`/api`、MCP 和 OAuth,不启动 Vite 开发服务。
```text
浏览器 / MCP 客户端
↓ HTTPS
服务器 Caddy / Nginx
↓ http://127.0.0.1:3100
WorthPath 应用容器
↓
已有 MySQL 数据库
```
本仓库的 `compose.yaml` 只管理应用,不创建数据库。需要已有 MySQL 8+、已创建的 `worthpath` 数据库及可用的数据库账号。Compose 使用命名镜像,可通过 `WORTHPATH_IMAGE_TAG` 区分版本。运行镜像保留 Prisma CLI 和相关依赖,以便单独执行迁移,不包含后端源码、测试或前端开发目录。
## Windows 本机安装 Docker 与构建镜像
本机开发/构建使用 Docker Desktop 的 WSL 2 后端,服务器运行使用 Docker Engine。你可以选择直接在 Linux 服务器构建,本机安装 Docker 不是服务器部署的前置条件。
先打开任务管理器 → 性能 → CPU,确认“虚拟化”已启用。如果未启用,需要在 BIOS/UEFI 打开硬件虚拟化。
以管理员身份打开 PowerShell,安装 WSL(不额外安装 Ubuntu):
```powershell
wsl --install --no-distribution
```
按提示重启电脑,然后执行:
```powershell
wsl --update
wsl --version
```
Docker Desktop 要求 WSL 2.1.5 或更新版本。参考 [Microsoft WSL 命令说明](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) 和 [Docker Desktop Windows 安装说明](https://docs.docker.com/desktop/setup/install/windows-install/)。
从上述 Docker 官方页面下载符合电脑架构的安装包(x64 电脑选择 x86_64),运行安装程序并选择 WSL 2 后端。安装完成后打开 Docker Desktop,完成其首次启动步骤,等待引擎启动,使用 Linux containers。
重新打开 PowerShell,验证客户端、引擎和 Compose:
```powershell
docker version
docker compose version
docker info --format '{{.OSType}}'
docker run --rm hello-world
```
`docker version` 应同时显示 Client 和 Server,操作系统类型应为 `linux`,hello-world 应正常输出成功说明。只有客户端信息或“无法连接引擎”时,先检查 Docker Desktop 是否已启动。不要在 Windows containers 模式构建本项目。
然后在项目目录只构建镜像,不需要数据库配置,也不会执行数据库迁移:
```powershell
cd E:\WorthPath
docker build -t worthpath:local .
```
如果 Docker 能运行但构建下载失败,检查错误对应的镜像仓库/npm/Debian 源网络和 Docker Desktop 代理配置,避免修改业务数据库配置来解决下载问题。
本机与服务器架构不同时,例如本机 x64、服务器 ARM64,需要按服务器架构构建镜像;上述默认命令按当前 Docker 引擎架构构建。服务器直接构建可以减少架构不匹配的问题。
## 当前验证记录
2026-10-05:已通过前后端 TypeScript 检查、前后端生产构建,以及网页托管/网络/Host 的 6 项回归测试。检查了真实环境文件的 Git 忽略规则。测试不连接业务数据库。
当前本机未找到 Docker 命令,WSL 状态检查提示需要安装;因此尚未完成 Linux 镜像构建、Compose 配置验证、容器内迁移或真实 HTTPS/MCP 联通验证。安装完成后先运行上面的环境验证和 `docker build`,再按后续步骤部署到服务器。
## 1. 准备服务器
建议使用 Linux 服务器,安装 Docker Engine 和 Docker Compose 插件,准备可用域名及 HTTPS 反向代理。以下部署命令在服务器的项目根目录执行,使用 Linux shell。
```bash
docker --version
docker compose version
```
服务器构建镜像需要访问镜像仓库、npm 包仓库和 Debian 软件源。镜像中固定 pnpm 11.19.0,并使用锁文件安装依赖;Node 基础镜像为 `node:24-bookworm-slim`,需要严格复现时另行固定镜像 digest。
## 2. 配置生产环境
首次部署复制模板;已有配置时不要再次覆盖:
```bash
cp -n .env.production.example .env.production
chmod 600 .env.production
```
编辑根目录 `.env.production`,至少替换:
| 配置 | 填写内容 |
| ---------------------------------------- | ----------------------------------- |
| `DATABASE_URL` | 服务器实际可访问的 MySQL 连接地址 |
| `ADMIN_PASSWORD` | 首次初始化管理员使用的强密码 |
| `API_ALLOWED_HOSTS`、`MCP_ALLOWED_HOSTS` | 真实域名;非默认端口需要包含端口 |
| `WEB_ORIGIN`、`MCP_ALLOWED_ORIGINS` | 网站完整 HTTPS 来源,通常无结尾斜杠 |
| `MCP_PUBLIC_URL` | `https://你的域名/mcp` |
| `MCP_WEB_URL` | `https://你的域名` |
模板的生产安全开关已经关闭 HTTP 和通配来源,并开启 Secure Cookie。不要直接沿用 `apps/api/.env` 的开放开发配置。根目录 `.env.production` 由 Compose 注入容器,镜像不携带真实 `.env`;本地 `apps/api/.env` 不会被修改。
数据库密码中的特殊字符需 URL 编码。容器的 `localhost` 指容器自身,不能用它连接宿主机 MySQL:
- 独立数据库服务器:填写实际可达地址,允许应用服务器访问。
- 同一 Docker 网络的 MySQL:填写数据库服务名,例如 `db`,并将应用加入该网络。
- Linux 宿主机 MySQL:可在 Compose 的 `app` 下加 `extra_hosts: ["host.docker.internal:host-gateway"]`,连接地址填 `host.docker.internal`。MySQL 必须监听容器可达的接口,并为容器来源配置账号授权与防火墙;仅监听 `127.0.0.1` 无法连接。
数据库不必暴露到公网。使用专用账号;迁移账号需要 DDL 权限,长期运行账号可在迁移后改为较小权限。应用不会自动创建数据库。
## 3. 配置 HTTPS
默认发布端口为 `127.0.0.1:3100:3100`,供服务器上的反向代理访问,不对外直接开放 3100。
服务器上安装 Caddy 后,Caddyfile 可使用:
```caddyfile
worthpath.example.com {
reverse_proxy 127.0.0.1:3100
}
```
域名解析到服务器,允许访问 80/443。将示例域名替换成环境文件中的同一个域名,再按服务器已有方式验证并加载 Caddy 配置。
如果已有 Nginx,核心代理配置如下;证书和 HTTPS server 配置由你的服务器提供:
```nginx
location / {
proxy_pass http://127.0.0.1:3100;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 512m;
}
```
所有路径都代理到应用,包括 `/api`、`/mcp`、OAuth 端点和 `/.well-known/*`。不要只代理 `/api`。代理需支持大文件上传(应用备份最大 512 MB),并保留请求 Host。
如果反向代理也在 Docker 中,`127.0.0.1` 是代理容器自身。需让两者加入同一 Docker 网络,将上游改为 `app:3100`,并确认仍保留外部 Host。此时可以移除宿主机端口发布。
当前应用的登录限流存储在进程内,且未配置受信反向代理 IP 解析。代理后的多个用户可能共用限流来源;增加副本不会共享限流计数。较多用户或多副本部署前,需要完善代理信任和共享限流策略。
## 4. 构建、迁移、启动
下面命令会修改所配置的数据库。已有数据先备份,并确认 `DATABASE_URL` 指向正确数据库。
```bash
# 编译前后端,生成 Linux Prisma 客户端;不连接数据库或执行迁移
docker compose build app
# 使用一次性容器执行 Prisma migrate deploy;失败后不要继续启动
docker compose run --rm app node scripts/database.cjs deploy
# 初始化/更新固定 ID 的内置图标;保留用户财务数据和上传图标
docker compose run --rm app node scripts/seed-icons.cjs
# 启动唯一的应用服务
docker compose up -d app
docker compose ps
docker compose logs --tail=100 app
```
上述一次性容器与应用使用相同镜像和环境文件。迁移或图标初始化失败时先解决错误;没有自动迁移、重置数据库或启动时自动改密。初始管理员只在数据库没有管理员时创建;首次网页登录后必须改密。部署到新数据库不会带上本机数据。
## 5. 验证
按顺序检查:
1. 容器保持运行,日志显示 `WorthPath API ready`,没有循环重启。
2. HTTPS 网站打开正常,浏览器网络面板中的 `/assets/*` JS/CSS 返回 200;刷新页面仍能打开。
3. 登录、账户查询正常,登录 Cookie 带 Secure;无需启动 5173 端口的开发服务。
4. `/agent/authorize` 和 `/agent/operation` 加上实际授权参数后,显示正确的授权或草稿确认页。
5. 使用助手页面生成的新 HTTPS MCP 地址重新接入,验证 OAuth 发现、授权和只读查询。旧本机地址不会自动更新。
6. `/api/不存在的路径` 与 `/assets/不存在的文件.js` 不应返回前端 HTML;未授权的服务请求应保持后端错误响应。
7. 重启容器后仍可查询原数据;数据保存在 MySQL,应用容器可以替换。
首页连通性可通过域名检查,也可在服务器本机带正确 Host 检查:
```bash
curl -I https://worthpath.example.com/
curl -I -H 'Host: worthpath.example.com' http://127.0.0.1:3100/
```
本机 HTTP 检查只能确认页面服务连通;生产登录和 Secure Cookie 需要通过 HTTPS 验证。
## 6. 更新与回退
建议为每次发布使用唯一版本标签,例如:
```bash
export WORTHPATH_IMAGE_TAG=20261005-1
docker compose build app
```
更新顺序:备份数据库 → 保存旧版本标签/镜像 → 更新源码 → 构建新镜像 → 执行迁移 → 按需运行图标初始化 → 替换应用 → 完成上述验证。
```bash
docker compose run --rm app node scripts/database.cjs deploy
docker compose up -d --no-deps app
docker compose logs --tail=100 app
```
回退应用时切换到保留的旧镜像标签,使用 `docker compose up -d --no-build app`。应用镜像回退不会回退数据库迁移;必须确认旧代码兼容新 schema,否则需按已验证的数据库恢复方案处理。
停止应用:
```bash
docker compose stop app
```
数据库单独备份到服务器之外,并验证恢复。应用镜像、容器和数据卷均不替代数据库备份;数据库账号、登录授权等完整迁移应采用数据库级迁移方案。个人业务备份导出/恢复仍由用户在网站操作。
## 7. 本地开发与排查
原有 `pnpm dev` 保持前后端分开开发;没有 `apps/api/public/index.html` 时,API 不启用网页托管。本地验证合并网页可以先 `pnpm build`,再将 `apps/web/dist` 复制为 `apps/api/public`,此目录已加入 Git/Docker 忽略规则。清理该目录后恢复纯 API 开发入口。
- 页面 403:检查代理保留的 Host 与 `API_ALLOWED_HOSTS` 是否一致。
- MCP Host/Origin 错误:检查固定 HTTPS 地址和白名单,而不是改成 `*`。
- 登录 Cookie 未发送:检查使用 HTTPS、`COOKIE_SECURE`、网页来源及浏览器 Cookie 状态。
- 数据库连接失败:检查容器可达地址、监听接口、账号来源授权与密码编码,不要打印真实连接字符串。
- 缺少表:确认迁移成功,不要使用 reset 处理生产数据。
- JS/CSS 404:检查镜像构建完成;如果新增 Vite 的其他顶层静态文件目录,需要同步扩展 `apps/api/src/web.ts` 的托管路径。
运行镜像包含 Prisma/迁移所需依赖,优先保证发布操作完整;后续可进一步拆分迁移镜像以减小应用镜像。日志设置了轮转,容器使用非 root 用户。镜像构建和服务器端完整验证必须在安装了 Docker 的环境执行。
+2 -1
View File
@@ -6,7 +6,8 @@ if (
names.some(
(n) =>
/(^|\/)(\.env(?:\..*)?|node_modules|dist|coverage)(\/|$)/.test(n) &&
!n.endsWith('.env.example'),
!n.endsWith('.env.example') &&
n !== '.env.production.example',
) ||
names.some((n) => /\.(db|sqlite|log)$/.test(n))
)