Document public reverse proxy deployment

- Add README instructions for deploying the full report system through local Docker port 4002, frpc, Nginx Proxy Manager, and sstwbg.example.com.

- Document required HTTPS, WebSocket, request-size, timeout, health-check, login, AI, video-frame, and speech verification steps.

- Add TRUST_PROXY support so secure session cookies work behind public HTTPS reverse proxies.

- Preserve upstream X-Forwarded-Proto through the container Nginx API proxy.

- Allow Docker Compose session and trust-proxy variables to be overridden for public deployments.

- Update deployment and Docker docs with the same public reverse-proxy guidance.
This commit is contained in:
2026-05-08 16:27:12 +08:00
parent 6bdb12678a
commit 3313811d2f
7 changed files with 162 additions and 4 deletions

View File

@@ -5,6 +5,7 @@ CORS_ORIGIN="http://localhost:3001,http://localhost:4002,https://localhost:4443"
DATABASE_URL="postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public" DATABASE_URL="postgresql://surclaw:surclaw_dev_password@localhost:5433/surclaw?schema=public"
SESSION_SECRET="change-me-in-production" SESSION_SECRET="change-me-in-production"
SESSION_COOKIE_SECURE="false" SESSION_COOKIE_SECURE="false"
TRUST_PROXY="false"
FILE_STORAGE_DIR="./uploads" FILE_STORAGE_DIR="./uploads"
RUN_DB_MIGRATIONS="true" RUN_DB_MIGRATIONS="true"
RUN_DB_SEED="true" RUN_DB_SEED="true"

View File

@@ -126,6 +126,7 @@ cp .env.example .env.local
- `DATABASE_URL`PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432` - `DATABASE_URL`PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432`
- `SESSION_SECRET`:后端 Session Cookie 签名密钥,生产环境必须替换。 - `SESSION_SECRET`:后端 Session Cookie 签名密钥,生产环境必须替换。
- `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false` - `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`
- `TRUST_PROXY`:是否信任反向代理传入的 `X-Forwarded-*` 头。`# XXX` 公网 HTTPS 经过 Nginx Proxy Manager、frpc/frps 或其他反向代理转发时建议设为 `true`,否则 `SESSION_COOKIE_SECURE=true` 时登录 Cookie 可能无法正确写入。
- `FILE_STORAGE_DIR`后端文件存储目录。Docker Compose 默认挂载到 `/app/uploads` - `FILE_STORAGE_DIR`后端文件存储目录。Docker Compose 默认挂载到 `/app/uploads`
- `VITE_API_PROXY_TARGET`Vite 开发服务器的 `/api` 代理目标。直接运行 `npm run server:dev` 时用 `http://localhost:3100`;连接 Docker Compose API 时用 `http://localhost:3002` - `VITE_API_PROXY_TARGET`Vite 开发服务器的 `/api` 代理目标。直接运行 `npm run server:dev` 时用 `http://localhost:3100`;连接 Docker Compose API 时用 `http://localhost:3002`
- `VITE_ENABLE_LOCAL_FALLBACK`:是否允许生产构建继续使用浏览器本地兼容回退。开发模式默认启用,生产默认关闭。 - `VITE_ENABLE_LOCAL_FALLBACK`:是否允许生产构建继续使用浏览器本地兼容回退。开发模式默认启用,生产默认关闭。
@@ -248,6 +249,84 @@ docker-compose down
- `nginx.conf` 已支持 `/api/speech/iat` WebSocket upgrade。 - `nginx.conf` 已支持 `/api/speech/iat` WebSocket upgrade。
完整 Docker 说明见 [docs/docker.md](./docs/docker.md)。 完整 Docker 说明见 [docs/docker.md](./docs/docker.md)。
## 公网反向代理部署
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 sstwbg.example.com 的部署链路。 -->
推荐公网链路:
```text
浏览器 https://sstwbg.example.com
-> 公网服务器 Nginx Proxy Manager
-> frps/frpc 映射端口
-> 本机 Docker web:4002
-> 容器 Nginx /api 代理
-> api:3100
```
本机部署并确认 Docker 服务正常:
```bash
# XXX 在部署机器上使用当前最新版分支。
git fetch origin
git checkout surclaw-system-backendized-20260502
git pull --ff-only origin surclaw-system-backendized-20260502
# XXX 公网 HTTPS 部署建议替换 Session 密钥,并信任外层反向代理。
export SESSION_SECRET="替换为足够长的随机字符串"
export SESSION_COOKIE_SECURE="true"
export TRUST_PROXY="true"
docker-compose up -d --build
curl http://127.0.0.1:4002/api/health
```
如果使用 frpc 把本机 `4002` 映射到公网服务器,示例配置如下,端口号按你的 frps 实际开放端口调整:
```toml
# XXX frpc 示例:把本机 Docker 前端入口 4002 暴露到公网服务器的一个内网监听端口。
[[proxies]]
name = "surclaw-web"
type = "tcp"
localIP = "127.0.0.1"
localPort = 4002
remotePort = 4002
```
Nginx Proxy Manager 中为 `sstwbg.example.com` 新建 Proxy Host
- `Domain Names``sstwbg.example.com`
- `Scheme``http`
- `Forward Hostname / IP`frps 可访问到的映射地址,通常是 `127.0.0.1`、公网服务器内网 IP 或 frps 指定监听地址。
- `Forward Port`frpc 暴露出来的 `remotePort`,例如 `4002`
- `Websockets Support`:开启。`# XXX` 语音识别使用 `/api/speech/iat` WebSocket必须透传 Upgrade。
- `Block Common Exploits`:开启。
- `SSL`:申请或绑定 `sstwbg.example.com` 证书,开启 `Force SSL``# XXX` 浏览器麦克风权限要求 HTTPS普通公网 HTTP 下语音识别不可用。
Nginx Proxy Manager 的 `Advanced` 可加入:
```nginx
# XXX 图文报告、关键帧和模板图片可能较大,公网代理请求体上限需与容器 Nginx/API 保持一致。
client_max_body_size 100m;
# XXX WebSocket 语音听写需要较长连接时间。
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
```
公网验收顺序:
```text
1. 打开 https://sstwbg.example.com/api/health确认 API 健康检查可访问。
2. 打开 https://sstwbg.example.com 登录 admin / 123456。
3. 进入系统设置,确认 AI Provider 和讯飞语音配置有效。
4. 进入报告编辑页测试上传视频、自动抽帧、报告保存、AI 对话和语音听写。
5. 浏览器控制台执行 window.isSecureContext应返回 true否则语音麦克风权限不会开放。
```
如果登录失败或刷新后掉登录,优先检查 `SESSION_SECRET` 是否稳定、`SESSION_COOKIE_SECURE=true` 时是否同时设置了 `TRUST_PROXY=true`,以及外层 NPM 是否把 `X-Forwarded-Proto: https` 传给后端链路。
如果只有语音识别失败,优先检查 NPM 的 `Websockets Support`、HTTPS 证书、浏览器麦克风权限、系统设置中的讯飞 APPID/APIKey/APISecret以及后端容器是否能访问讯飞 `wss://iat-api.xfyun.cn/v2/iat`
## 当前限制 ## 当前限制
- 前端登录、工作台统计、报告读写、报告媒体引用、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话和语音听写已接入真实后端 Session/API/代理。 - 前端登录、工作台统计、报告读写、报告媒体引用、模板读写、字段库、模板图片资源、视频/关键帧文件、用户管理、部门模板授权、系统设置、签名文件、AI 对话和语音听写已接入真实后端 Session/API/代理。

View File

@@ -28,8 +28,10 @@ services:
API_BODY_LIMIT: 100mb API_BODY_LIMIT: 100mb
CORS_ORIGIN: http://localhost:4002,https://localhost:4443,http://localhost:3001 CORS_ORIGIN: http://localhost:4002,https://localhost:4443,http://localhost:3001
DATABASE_URL: postgresql://surclaw:surclaw_dev_password@db:5432/surclaw?schema=public DATABASE_URL: postgresql://surclaw:surclaw_dev_password@db:5432/surclaw?schema=public
SESSION_SECRET: change-me-in-production # XXX Public HTTPS reverse-proxy deployments can override these from the shell or .env.
SESSION_COOKIE_SECURE: "false" SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
FILE_STORAGE_DIR: /app/uploads FILE_STORAGE_DIR: /app/uploads
RUN_DB_MIGRATIONS: "true" RUN_DB_MIGRATIONS: "true"
RUN_DB_SEED: "true" RUN_DB_SEED: "true"

View File

@@ -64,6 +64,7 @@ AI 和语音密钥由后端 Settings API 保存并由代理使用,前端不再
- `DATABASE_URL`PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432` - `DATABASE_URL`PostgreSQL 连接串。Docker Compose 暴露到宿主机的默认端口是 `5433`,容器内部仍使用 `db:5432`
- `SESSION_SECRET`Session Cookie 签名密钥。 - `SESSION_SECRET`Session Cookie 签名密钥。
- `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`,生产 HTTPS 应设为 `true` - `SESSION_COOKIE_SECURE`:是否只通过 HTTPS 发送 Session Cookie。本地 HTTP/Compose 默认 `false`,生产 HTTPS 应设为 `true`
- `TRUST_PROXY`:是否信任反向代理传入的 `X-Forwarded-*` 头。`# XXX` 公网 HTTPS 经过 Nginx Proxy Manager、frpc/frps 或其他反向代理转发时建议设为 `true`
- `FILE_STORAGE_DIR`后端文件目录。Docker Compose 默认 `/app/uploads`,并挂载到 `uploads_data` volume。 - `FILE_STORAGE_DIR`后端文件目录。Docker Compose 默认 `/app/uploads`,并挂载到 `uploads_data` volume。
- `RUN_DB_MIGRATIONS`Docker API 容器启动时是否执行 `prisma migrate deploy`,默认 `true` - `RUN_DB_MIGRATIONS`Docker API 容器启动时是否执行 `prisma migrate deploy`,默认 `true`
- `RUN_DB_SEED`Docker API 容器启动时是否执行 `prisma db seed`,默认 `true` - `RUN_DB_SEED`Docker API 容器启动时是否执行 `prisma db seed`,默认 `true`
@@ -97,6 +98,38 @@ docker-compose up -d --build
更完整的 Docker 说明、生产变量、证书和备份恢复见 [Docker 化部署](./docker.md)。 更完整的 Docker 说明、生产变量、证书和备份恢复见 [Docker 化部署](./docker.md)。
## 公网反向代理
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口经 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定域名。 -->
推荐链路:
```text
浏览器 https://sstwbg.example.com
-> 公网服务器 Nginx Proxy Manager
-> frps/frpc 映射端口
-> 本机 Docker web:4002
-> 容器 Nginx /api
-> api:3100
```
公网部署建议变量:
```bash
# XXX HTTPS 生产入口建议开启安全 Cookie并让后端信任外层代理协议头。
export SESSION_SECRET="替换为足够长的随机字符串"
export SESSION_COOKIE_SECURE="true"
export TRUST_PROXY="true"
docker-compose up -d --build
```
Nginx Proxy Manager 代理 `sstwbg.example.com` 时:
- 代理目标指向 frpc 暴露的 `4002` 映射端口。
- 开启 `Websockets Support`,否则 `/api/speech/iat` 语音 WebSocket 会失败。
- 绑定 SSL 证书并开启 `Force SSL`,否则浏览器不会开放公网麦克风权限。
- Advanced 中建议设置 `client_max_body_size 100m;``proxy_read_timeout 3600s;``proxy_send_timeout 3600s;`
## 麦克风访问 ## 麦克风访问
浏览器不允许普通局域网 HTTP 页面调用麦克风代码无法绕过这个限制。Docker 演示环境建议使用: 浏览器不允许普通局域网 HTTP 页面调用麦克风代码无法绕过这个限制。Docker 演示环境建议使用:

View File

@@ -110,8 +110,34 @@ https://localhost:4443
- 使用医院内网域名。 - 使用医院内网域名。
- 通过可信 CA 或内网 CA 签发证书。 - 通过可信 CA 或内网 CA 签发证书。
-`SESSION_COOKIE_SECURE` 设为 `true` -`SESSION_COOKIE_SECURE` 设为 `true`
-`TRUST_PROXY` 设为 `true``# XXX` 如果 HTTPS 在 Nginx Proxy Manager、frpc/frps 等外层代理终止,后端需要信任 `X-Forwarded-Proto` 才能正确写入安全 Cookie。
-`CORS_ORIGIN` 改成真实前端 HTTPS 来源。 -`CORS_ORIGIN` 改成真实前端 HTTPS 来源。
## 公网域名反向代理
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口经 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 sstwbg.example.com。 -->
如果使用 `本机 Docker 4002 -> frpc -> 公网服务器 Nginx Proxy Manager -> sstwbg.example.com`,推荐流程:
```bash
# XXX 使用公网 HTTPS 入口时compose 变量可从 shell 或 .env 覆盖。
export SESSION_SECRET="替换为足够长的随机字符串"
export SESSION_COOKIE_SECURE="true"
export TRUST_PROXY="true"
docker-compose up -d --build
```
Nginx Proxy Manager 配置要点:
- `Domain Names` 使用 `sstwbg.example.com`
- `Forward Hostname / IP` 指向 frpc 在公网服务器暴露的地址。
- `Forward Port` 填 frpc 暴露的端口,例如 `4002`
- 开启 `Websockets Support`,保证 `/api/speech/iat` 语音听写 WebSocket 可升级。
- SSL 页签绑定证书并开启 `Force SSL`,保证浏览器开放麦克风权限。
- Advanced 中建议设置 `client_max_body_size 100m;``proxy_read_timeout 3600s;``proxy_send_timeout 3600s;`
公网验收先访问 `https://sstwbg.example.com/api/health`再登录并测试报告保存、视频抽帧、AI 对话和语音听写。
## 生产部署前必须修改 ## 生产部署前必须修改
`docker-compose.yaml` 当前适合演示和院内试运行,生产前至少修改: `docker-compose.yaml` 当前适合演示和院内试运行,生产前至少修改:
@@ -121,6 +147,7 @@ https://localhost:4443
- `SESSION_SECRET`:替换为高强度随机值。 - `SESSION_SECRET`:替换为高强度随机值。
- `CORS_ORIGIN`:只保留真实前端来源。 - `CORS_ORIGIN`:只保留真实前端来源。
- `SESSION_COOKIE_SECURE`HTTPS 部署时设为 `true` - `SESSION_COOKIE_SECURE`HTTPS 部署时设为 `true`
- `TRUST_PROXY`:经反向代理提供 HTTPS 时设为 `true`
- `RUN_DB_SEED`:不需要每次启动 seed 时可设为 `false` - `RUN_DB_SEED`:不需要每次启动 seed 时可设为 `false`
- AI/讯飞演示凭据:通过系统设置替换为正式凭据;已经暴露过的密钥应轮换。 - AI/讯飞演示凭据:通过系统设置替换为正式凭据;已经暴露过的密钥应轮换。
- HTTPS 证书:替换自签名本机证书。 - HTTPS 证书:替换自签名本机证书。

View File

@@ -3,6 +3,12 @@ map $http_upgrade $connection_upgrade {
'' close; '' close;
} }
# XXX Preserve the public HTTPS scheme when an outer reverse proxy terminates TLS.
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
}
server { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
@@ -25,7 +31,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
@@ -62,7 +68,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {

View File

@@ -15,6 +15,16 @@ const bootstrap = async () => {
const app = await NestFactory.create(AppModule, { bodyParser: false }); const app = await NestFactory.create(AppModule, { bodyParser: false });
const port = Number(process.env.API_PORT ?? 3100); const port = Number(process.env.API_PORT ?? 3100);
const bodyLimit = process.env.API_BODY_LIMIT ?? '100mb'; const bodyLimit = process.env.API_BODY_LIMIT ?? '100mb';
const trustProxy = process.env.TRUST_PROXY;
if (trustProxy && trustProxy !== 'false') {
// # XXX Public reverse-proxy deployments need Express to trust X-Forwarded-* headers.
const numericTrustProxy = Number(trustProxy);
app
.getHttpAdapter()
.getInstance()
.set('trust proxy', trustProxy === 'true' ? 1 : Number.isNaN(numericTrustProxy) ? trustProxy : numericTrustProxy);
}
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.use(json({ limit: bodyLimit })); app.use(json({ limit: bodyLimit }));