chore(deploy): 成品部署链路、依赖分组与运维基建

部署方式:服务器不再接收源码、不执行构建,改为本地构建成品后交付
- deploy-artifacts.sh:构建→备份→校验→原子替换,支持回滚、镜像/备份轮转、
  mysql 与 postgres 双变体镜像;后端以 docker save|ssh|docker load 交付
- 依赖分组:requirements/{base,storage}.txt + db/{mysql,postgres,sqlite}.txt,
  镜像按所选数据库只装对应驱动(MySQL 变体 565MB → 521MB)

编排与运行时加固
- compose:MySQL/Redis/backend 端口仅绑 127.0.0.1;密钥(SECRET_KEY、
  DATA_ENCRYPTION_KEY、DATA_ENCRYPTION_OLD_KEYS)、PROD_CORS_ORIGINS、
  FORWARDED_ALLOW_IPS、DEMO_ENABLE 一律由 docker/.env 注入
- Dockerfile:对齐依赖分组、以非 root(uid=1000) 运行;.dockerignore 排除全部真实 .env.*
- nginx:静态资源长缓存、隐藏文件拒绝、接口文档仅内网可访问、放行 ACME http-01 校验路径

工程基建
- 新增 CI:backend(uv sync + ruff + pytest)与 frontend(web type-check+vitest、app type-check)
- 新增 DEPLOYMENT.md 运维手册(部署/回滚/密钥轮换/每日巡检/证书/排查)
This commit is contained in:
zhangtao
2026-10-04 04:26:03 +08:00
parent 0cf2e24b92
commit ce9401196a
20 changed files with 851 additions and 91 deletions
+5
View File
@@ -15,7 +15,12 @@ __pycache__
# 虚拟环境
venv
.venv
# 环境变量文件:必须排除所有真实密钥文件(.env 只能匹配到名为 .env 的文件,
# 匹配不到 .env.prod / .env.dev / .env.test —— 它们含真实口令与 API Key)
.env
**/.env
**/.env.*
!**/.env.example
# 编辑器/IDE
.vscode
+36
View File
@@ -0,0 +1,36 @@
name: backend
on:
push:
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"
pull_request:
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"
workflow_dispatch:
defaults:
run:
working-directory: backend
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: 安装依赖(含 sqlite 与 mysql 驱动;测试用 sqlite + fakeredis)
run: uv sync --extra sqlite --extra mysql
- name: 代码检查(ruff)
run: uv run ruff check
- name: 单元测试(pytest)
run: uv run pytest -q
+66
View File
@@ -0,0 +1,66 @@
name: frontend
on:
push:
paths:
- "frontend/**"
- ".github/workflows/frontend-ci.yml"
pull_request:
paths:
- "frontend/**"
- ".github/workflows/frontend-ci.yml"
workflow_dispatch:
jobs:
web:
name: web(类型检查 + 单元测试)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend/web
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/web/pnpm-lock.yaml
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 类型检查
run: pnpm run type-check
- name: 单元测试
run: pnpm run test
app:
name: app(类型检查)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend/app
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/app/pnpm-lock.yaml
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 类型检查
run: pnpm run type-check
+3 -1
View File
@@ -73,4 +73,6 @@ CLAUDE.md
AGENTS.md
.codebuddy
.agent
.workbuddy
.workbuddy
# AgentTeams 协作运行期状态(本地,不入库)
.agent-teams/
+119
View File
@@ -0,0 +1,119 @@
# FastapiAdmin 部署运维手册
> 适用环境:阿里云单机 Docker 部署(`service.fastapiadmin.com`)
> 最近更新:2026-10-04(本轮密钥轮换与加固之后)
---
## 1. 架构与目录
| 组件 | 说明 | 对外端口 |
| --- | --- | --- |
| nginx | TLS 终止、静态站点、反向代理 `/api/v1` | 80 / 443 |
| backend | FastAPI(uvicorn,1 worker,非 root 运行 uid=1000) | 仅 127.0.0.1:8001 |
| mysql | MySQL 8.0,数据卷 `mysql_data` | 仅 127.0.0.1:3306 |
| redis | Redis 7(会话/缓存/限流) | 仅 127.0.0.1:6379 |
服务器布局(`/home/FastapiAdmin`):
```
docker/ # 唯一部署目录,不含业务源码
├── docker-compose.yaml # 服务编排(端口仅 127.0.0.1,密钥由 .env 注入)
├── docker-compose.override.yaml # nginx 版本固定 + 缓存配置挂载 + ACME 目录
├── .env # 真实凭据(600,不入库)
├── .env.example # 占位模板(入库)
├── backend/Dockerfile # 源码构建路径(正式部署不用它)
├── nginx/
│ ├── nginx.conf # 站点配置(含文档封堵、隐藏文件拒绝、ACME 放行)
│ ├── cache-headers.inc # 静态资源长缓存片段
│ ├── acme/ # ACME http-01 校验目录
│ ├── ssl/ # 证书与私钥(600,不入库)
│ └── {web,app,docs}/dist # 三端静态成品(由本地构建后上传)
└── mysql/ redis/ # 初始化 SQL 与 redis.conf
backups/ # 部署前自动备份(每类保留 5 份)
```
仓库侧:`deploy-artifacts.sh`(本机执行的成品部署脚本)、`backend/requirements/`(依赖分组)、`.github/workflows/`(CI)。
---
## 2. 日常部署
在本机仓库根目录执行(需要本机可免密 SSH 到服务器、且本机 Docker 可用——`colima start`):
```bash
./deploy-artifacts.sh web # 构建并部署 Web 管理后台
./deploy-artifacts.sh app # 构建并部署移动端 H5
./deploy-artifacts.sh backend # 本地构建 MySQL 版镜像 → 传输 → 只重建 backend
./deploy-artifacts.sh all # 三样全发
./deploy-artifacts.sh verify # 公网四端验证
./deploy-artifacts.sh backups # 列出服务器备份
./deploy-artifacts.sh prune # 轮转清理旧备份与旧镜像
./deploy-artifacts.sh rollback web|app|backend
```
要点:
- **服务器不接收源码、不执行构建**:后端以 `docker save → ssh → docker load` 交付镜像,前端以 tar 管道交付成品。
- 每次部署前自动备份到 `backups/<标签>-<时间戳>`,备份失败即中止,不会带着假备份上线。
- 只重建变化的容器;MySQL/Redis/Nginx 不受影响。
- PostgreSQL 版本镜像:`./deploy-artifacts.sh backend-pg`(**只构建与自检,不切换线上**)。切换需人工把 `BACKEND_IMAGE_TAG` 指到该镜像并确保 compose 的数据库配置已改。
---
## 3. 密钥与口令
| 项 | 位置 | 说明 |
| --- | --- | --- |
| `SECRET_KEY` | 服务器 `docker/.env` | JWT 签名密钥,64 位 hex,随机生成 |
| `DATA_ENCRYPTION_KEY` | 服务器 `docker/.env` | 落库敏感数据加密主密钥 |
| `DATA_ENCRYPTION_OLD_KEYS` | 服务器 `docker/.env` | 轮换前的旧密钥,仅用于解密历史密文 |
| 种子账号口令 | 服务器 `/root/.fa-seed-passwords`(600) | `super`/`admin`/`user`,登录后请自行修改并删除该文件 |
**轮换密钥的正确步骤**(不可跳步,否则历史密文无法解密):
1. 把当前 `DATA_ENCRYPTION_KEY`(未设置时取当前 `SECRET_KEY`)追加到 `DATA_ENCRYPTION_OLD_KEYS`;
2. 生成新值:`openssl rand -hex 32`,写入 `SECRET_KEY` / `DATA_ENCRYPTION_KEY`;
3. `docker compose up -d --force-recreate backend`;
4. 验证历史密文仍可解密(例如存储源口令),再删除 `OLD_KEYS` 中不再需要的旧值。
> 注意:更换 `SECRET_KEY` 会使已签发的令牌全部失效,用户需重新登录。
---
## 4. 运行状态与巡检
- **每日巡检**:`systemctl list-timers fa-health-check.timer`(每天 08:30,带 5 分钟随机延迟)
- 巡检内容:4 个容器健康、`/api/v1/monitor/health/check` 与 `/web/`、`/app/` 可用性、证书剩余天数、磁盘、swap、备份与镜像堆积
- 结果:`/var/log/fa-health.log`;有告警时写入 syslog(`journalctl -t fa-health`)且服务退出码非 0
- 手动执行:`systemctl start fa-health-check.service && tail -n 30 /var/log/fa-health.log`
常用排查命令:
```bash
cd /home/FastapiAdmin/docker
docker compose ps
docker logs --tail 100 backend
docker inspect backend --format '{{.State.Health.Status}}'
ss -tlnp | grep docker-proxy # 确认对外只有 80/443
```
---
## 5. 证书
- 当前证书:`docker/nginx/ssl/server.pem`(`CN=service.fastapiadmin.com`),由巡检脚本监控到期天数
- 续期(重新签发后):替换 `server.pem` 与 `server.key` → `docker exec nginx nginx -s reload`
- 已放行 ACME http-01 校验路径(`/.well-known/acme-challenge/` → `docker/nginx/acme`),如需切换到 acme.sh 自动续期,把校验文件写入该目录即可,无需再改 nginx 配置
---
## 6. 已知遗留问题
安全与质量债的完整清单见 `AUDIT-REPORT.md` 与各分报告(`backend/audit-*.md`、`frontend/audit-*.md`、`docker/audit-deploy.md`)。当前仍待处理的高优先级项:
1. 会话失效机制:停用/删除/改密后旧会话仍有效(最长 7 天)——需应用侧改造;
2. 数据权限注入不完整:`CRUDBase` 的删除/清空/改状态路径未注入数据权限;
3. Web i18n 与通知状态枚举:`85/101` 个页面未接入 i18n;通知状态枚举与后端语义冲突;
4. 测试覆盖:后端仅 2 个用例,夹具掩盖缺陷(见 `backend/audit-testing.md`);
5. 多租户需求与实现错位(见 `backend/audit-requirements.md`)。
+22 -8
View File
@@ -32,11 +32,7 @@ dependencies = [
"ua-parser==1.0.2", # 解析 User-Agent 获取 OS/浏览器
"uvicorn==0.49.0", # uvicorn web 框架
"websockets>=16.0,<17.0", # websocket 通信
"aiosqlite==0.22.1",
"aiomysql>=0.2.0",
"asyncpg==0.31.0",
"psycopg[binary]==3.3.2",
"pymysql==1.2.0",
"aiosqlite==0.22.1", # 内嵌知识库(app/modules/ai/chat)固定使用 sqlite,故列入核心
"openai==2.46.0",
"pydantic[email]>=2.12.5",
"cryptography==50.0.0", # Fernet 对称加密(存储源密码加密)
@@ -47,10 +43,28 @@ dependencies = [
"cos-python-sdk-v5==1.9.44",
]
# 数据库驱动按需安装:uv sync --extra mysql / --extra postgres / --extra sqlite
# 默认(不指定 extra)只装核心依赖,启动时会提示缺少对应驱动
[project.optional-dependencies]
mysql = ["aiomysql>=0.2.0"]
postgres = ["asyncpg==0.31.0"]
sqlite = ["aiosqlite==0.22.1"]
mysql = [
"aiomysql>=0.2.0", # 异步驱动(ASYNC_DB_URI)
"pymysql==1.2.0", # 同步驱动(DB_URI)
]
postgres = [
"asyncpg==0.31.0", # 异步驱动(ASYNC_DB_URI)
"psycopg[binary]==3.3.2", # 同步驱动(DB_URI,自带 libpq)
]
sqlite = [
"aiosqlite==0.22.1",
]
# 一个进程同时支持三种库(体积最大,适合本地开发)
all-db = [
"aiomysql>=0.2.0",
"pymysql==1.2.0",
"asyncpg==0.31.0",
"psycopg[binary]==3.3.2",
"aiosqlite==0.22.1",
]
[dependency-groups]
dev = [
+14 -41
View File
@@ -1,41 +1,14 @@
# 与 pyproject.toml [project] dependencies 一致(生产 pip;不含 dev 组:pytest / ruff / fakeredis)
agno==2.5.8 # AI agent框架
aiofiles==24.1.0 # 文件操作
aiosqlite==0.22.1 # sqlite 异步操作数据库
alembic==1.18.4 # 数据库迁移
apscheduler==3.11.0 # 定时任务
croniter==6.2.4 # cron 表达式校验
aiomysql>=0.2.0 # 异步mysql操作数据库
asyncpg==0.31.0 # 异步postgresql操作数据库
bleach==6.4.0 # 过滤 HTML 标签
tinycss2==1.5.1 # bleach CSS 清洗依赖
click==8.1.7 # 命令行参数解析
fastapi==0.138.2 # fastapi 框架
greenlet==3.5.2 # 协程核心依赖(SQLAlchemy 异步)
httpx==0.28.1 # HTTP 客户端
jinja2==3.1.6 # 模板引擎
loguru==0.7.3 # 日志
openai==2.46.0 # OpenAI API
openpyxl==3.1.5 # Excel
psutil==7.2.2 # 系统信息
py-ip2region==3.0.4 # IP归属地离线查询(ip2region官方binding,内置xdb本地解析)
psycopg[binary]==3.3.2 # postgresql 同步驱动(含预编译 libpq,开箱即用)
pydantic[email]>=2.12.5 # 邮箱校验
pydantic-settings>=2.6.1,<3 # 配置设置
pyjwt==2.13.0 # OAuth2
pymysql==1.2.0 # MySQL 异步操作数据库
python-multipart==0.0.32 # request.form() 对表单进行「解析」时安装
redis==7.1.0 # Redis 异步操作数据库
rich==15.0.0 # 终端打印美化
sqlalchemy==2.0.51 # 数据库ORM
sqlglot[rs]==27.8.0 # sql 解析
typer==0.26.7 # 命令行工具
ua-parser==1.0.2 # 获取用户UA
uvicorn==0.49.0 # ASGI 框架
websockets>=16.0,<17.0 # websocket 通信
cryptography==50.0.0 # Fernet 对称加密(存储源密码加密)
paramiko==5.0.0 # SFTP 协议
boto3==1.43.72 # S3 协议
esdk-obs-python==3.26.6 # 华为云 OBS 协议
alibabacloud-oss-v2==1.3.2 # 阿里云 OSS 协议
cos-python-sdk-v5==1.9.44 # 腾讯云 COS 协议
# ============================================================================
# 完整依赖入口(核心 + 全部数据库驱动 + 对象存储)
# 适用于:源码部署、本机开发、以及需要「一个镜像同时支持 mysql/postgres/sqlite」的场景。
#
# 想要更小的生产镜像,请按需组合,例如:
# MySQL : -r requirements/base.txt -r requirements/db/mysql.txt -r requirements/storage.txt
# Postgres : -r requirements/base.txt -r requirements/db/postgres.txt -r requirements/storage.txt
# SQLite : -r requirements/base.txt -r requirements/db/sqlite.txt
# ============================================================================
-r requirements/base.txt
-r requirements/db/mysql.txt
-r requirements/db/postgres.txt
-r requirements/db/sqlite.txt
-r requirements/storage.txt
+34
View File
@@ -0,0 +1,34 @@
# ============================================================================
# 核心依赖(与数据库、对象存储无关,任何部署方式都需要)
# 用法:pip install -r requirements/base.txt -r requirements/db/<你的数据库>.txt
# ============================================================================
agno==2.5.8 # AI agent 框架
aiosqlite==0.22.1 # AI 模块内嵌知识库固定用 sqlite(与主库类型无关),故列入核心
aiofiles==24.1.0 # 异步文件操作
alembic==1.18.4 # 数据库迁移
apscheduler==3.11.0 # 定时任务
bleach==6.4.0 # HTML 过滤
click==8.1.7 # 命令行参数解析(typer 依赖)
croniter==6.2.4 # cron 表达式校验
fastapi==0.138.2 # Web 框架
greenlet==3.5.2 # SQLAlchemy 异步必需
httpx==0.28.1 # HTTP 客户端
jinja2==3.1.6 # 模板引擎(代码生成)
loguru==0.7.3 # 日志
openai==2.46.0 # OpenAI 兼容接口
openpyxl==3.1.5 # Excel 导入导出
psutil==7.2.2 # 系统信息采集
py-ip2region==3.0.4 # IP 归属地离线查询
pydantic[email]>=2.12.5 # 数据校验 + 邮箱
pydantic-settings>=2.6.1,<3 # 配置管理
pyjwt==2.13.0 # 令牌签发校验
python-multipart==0.0.32 # 表单解析
redis==7.1.0 # 缓存 / 会话
rich==15.0.0 # 终端输出美化
sqlalchemy==2.0.51 # ORM
sqlglot[rs]==27.8.0 # SQL 解析
tinycss2==1.5.1 # bleach 依赖
typer==0.26.7 # CLI 入口(main.py)
ua-parser==1.0.2 # UA 解析
uvicorn==0.49.0 # ASGI 服务器
websockets>=16.0,<17.0 # WebSocket
+7
View File
@@ -0,0 +1,7 @@
# ============================================================================
# MySQL 驱动(DATABASE_TYPE=mysql 时必需)
# aiomysql / pymysql:异步与同步连接,分别对应 ASYNC_DB_URI 与 DB_URI
# 用法:pip install -r requirements/base.txt -r requirements/db/mysql.txt
# ============================================================================
aiomysql>=0.2.0 # 异步驱动(ASYNC_DB_URI: mysql+aiomysql://)
pymysql==1.2.0 # 同步驱动(DB_URI: mysql+pymysql://,迁移与后台任务用)
+9
View File
@@ -0,0 +1,9 @@
# ============================================================================
# PostgreSQL 驱动(DATABASE_TYPE=postgres 时必需)
# asyncpg:异步驱动,对应 ASYNC_DB_URI(postgresql+asyncpg://)
# psycopg[binary]:同步驱动(含预编译 libpq),对应 DB_URI(postgresql+psycopg://)
# 注意:psycopg[binary] 自带 libpq,无需在系统里额外安装 postgresql 客户端库
# 用法:pip install -r requirements/base.txt -r requirements/db/postgres.txt
# ============================================================================
asyncpg==0.31.0 # 异步驱动
psycopg[binary]==3.3.2 # 同步驱动
+6
View File
@@ -0,0 +1,6 @@
# ============================================================================
# SQLite 驱动(DATABASE_TYPE=sqlite 时必需)
# 适用于本地开发与单机小规模部署;AINI 的 AI 模块内嵌知识库也复用该驱动
# 用法:pip install -r requirements/base.txt -r requirements/db/sqlite.txt
# ============================================================================
aiosqlite==0.22.1 # 异步驱动(sqlite+aiosqlite:///)
+11
View File
@@ -0,0 +1,11 @@
# ============================================================================
# 对象存储与文件传输(本地存储源之外的能力;存储源配置里选用 S3 / OSS / COS / OBS / SFTP 时必需)
# 仅使用「本地存储源」的部署可以不装这一组,镜像可再小约 45MB
# 用法:pip install -r requirements/base.txt -r requirements/db/mysql.txt -r requirements/storage.txt
# ============================================================================
boto3==1.43.72 # S3 及兼容协议
alibabacloud-oss-v2==1.3.2 # 阿里云 OSS
cos-python-sdk-v5==1.9.44 # 腾讯云 COS
esdk-obs-python==3.26.6 # 华为云 OBS
paramiko==5.0.0 # SFTP
cryptography==50.0.0 # 存储源密码的 Fernet 加密
+17 -9
View File
@@ -184,12 +184,10 @@ source = { virtual = "." }
dependencies = [
{ name = "agno" },
{ name = "aiofiles" },
{ name = "aiomysql" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "alibabacloud-oss-v2" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "bleach" },
{ name = "boto3" },
{ name = "click" },
@@ -206,12 +204,10 @@ dependencies = [
{ name = "openpyxl" },
{ name = "paramiko" },
{ name = "psutil" },
{ name = "psycopg", extra = ["binary"] },
{ name = "py-ip2region" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pymysql" },
{ name = "python-multipart" },
{ name = "redis" },
{ name = "rich" },
@@ -225,11 +221,20 @@ dependencies = [
]
[package.optional-dependencies]
all-db = [
{ name = "aiomysql" },
{ name = "aiosqlite" },
{ name = "asyncpg" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pymysql" },
]
mysql = [
{ name = "aiomysql" },
{ name = "pymysql" },
]
postgres = [
{ name = "asyncpg" },
{ name = "psycopg", extra = ["binary"] },
]
sqlite = [
{ name = "aiosqlite" },
@@ -247,14 +252,15 @@ dev = [
requires-dist = [
{ name = "agno", specifier = "==2.5.8" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiomysql", specifier = ">=0.2.0" },
{ name = "aiomysql", marker = "extra == 'all-db'", specifier = ">=0.2.0" },
{ name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" },
{ name = "aiosqlite", specifier = "==0.22.1" },
{ name = "aiosqlite", marker = "extra == 'all-db'", specifier = "==0.22.1" },
{ name = "aiosqlite", marker = "extra == 'sqlite'", specifier = "==0.22.1" },
{ name = "alembic", specifier = "==1.18.4" },
{ name = "alibabacloud-oss-v2", specifier = "==1.3.2" },
{ name = "apscheduler", specifier = "==3.11.0" },
{ name = "asyncpg", specifier = "==0.31.0" },
{ name = "asyncpg", marker = "extra == 'all-db'", specifier = "==0.31.0" },
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = "==0.31.0" },
{ name = "bleach", specifier = "==6.4.0" },
{ name = "boto3", specifier = "==1.43.72" },
@@ -272,12 +278,14 @@ requires-dist = [
{ name = "openpyxl", specifier = "==3.1.5" },
{ name = "paramiko", specifier = "==5.0.0" },
{ name = "psutil", specifier = "==7.2.2" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'all-db'", specifier = "==3.3.2" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = "==3.3.2" },
{ name = "py-ip2region", specifier = "==3.0.4" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.6.1,<3" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pymysql", specifier = "==1.2.0" },
{ name = "pymysql", marker = "extra == 'all-db'", specifier = "==1.2.0" },
{ name = "pymysql", marker = "extra == 'mysql'", specifier = "==1.2.0" },
{ name = "python-multipart", specifier = "==0.0.32" },
{ name = "redis", specifier = "==7.1.0" },
{ name = "rich", specifier = "==15.0.0" },
@@ -289,7 +297,7 @@ requires-dist = [
{ name = "uvicorn", specifier = "==0.49.0" },
{ name = "websockets", specifier = ">=16.0,<17.0" },
]
provides-extras = ["mysql", "postgres", "sqlite"]
provides-extras = ["mysql", "postgres", "sqlite", "all-db"]
[package.metadata.requires-dev]
dev = [
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env bash
# ============================================================================
# FastapiAdmin 成品部署脚本(本地构建 → 服务器只接收成品)
#
# 设计目标:
# 1. 服务器上不放业务源码、不跑构建,只接收构建好的成品;
# 2. 后端用「镜像」交付(docker save | ssh docker load),前端用 tar 管道交付;
# 3. 每次上线前先在服务器 backups/ 留一份备份,可一键回滚;
# 4. 只重建变化的那一个容器,MySQL/Redis/Nginx 不重启。
#
# 用法:
# ./deploy-artifacts.sh web # Web 管理后台(nginx /web)
# ./deploy-artifacts.sh app # 移动端 H5(nginx /app)
# ./deploy-artifacts.sh backend # 构建并部署 MySQL 版后端镜像(切换线上)
# ./deploy-artifacts.sh backend-pg # 只构建并自检 PostgreSQL 版镜像,不切换线上
# ./deploy-artifacts.sh all # 三样全发(后端默认 MySQL 版)
# ./deploy-artifacts.sh verify # 公网四端验证
# ./deploy-artifacts.sh backups # 列出服务器上的备份
# ./deploy-artifacts.sh rollback web # 回滚 Web 到最近一次备份
# ./deploy-artifacts.sh rollback app # 回滚 H5 到最近一次备份
#
# 前置条件:
# - 本机 SSH 密钥可免密登录 root@$FA_SERVER
# - backend 目标需要本机 docker(Colima / Docker Desktop 均可)
# - frontend/web、frontend/app 依赖已安装(pnpm install)
#
# 环境变量覆盖:FA_SERVER、FA_REMOTE_ROOT、FA_BACKUP_DIR
# ============================================================================
set -euo pipefail
SERVER="${FA_SERVER:-8.137.99.5}"
REMOTE_ROOT="${FA_REMOTE_ROOT:-/home/FastapiAdmin}"
BACKUP_DIR="${FA_BACKUP_DIR:-/home/FastapiAdmin/backups}"
BACKUP_KEEP="${FA_BACKUP_KEEP:-5}" # 每个标签最多保留的备份份数
IMAGE_KEEP="${FA_IMAGE_KEEP:-2}" # 每类镜像最多保留的版本数(当前 + 回滚点)
STAMP="$(date +%Y%m%d-%H%M%S)"
KEY="$HOME/.ssh/id_ed25519"
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15)
log() { printf '\033[0;34m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; }
ok() { printf '\033[0;32m ✅ %s\033[0m\n' "$*"; }
die() { printf '\033[0;31m ❌ %s\033[0m\n' "$*"; exit 1; }
ssh_do() { ssh "${SSH_OPTS[@]}" -i "$KEY" "root@${SERVER}" "$@"; }
# 把本地目录内容流式发到服务器目标目录。
# 服务器侧流程:解包到暂存区 → 校验入口文件 → 备份旧目录到 backups/<label>-<时间> →
# 原子替换 → 修正属主。任何一步失败都中止,绝不留下半成品。
# 用法: push_dir <本地目录> <远端目录> <备份标签>
push_dir() {
local src="$1" dst="$2" label="$3"
[ -d "$src" ] || die "本地目录不存在: $src"
local ts tar_cmd ssh_cmd
ts="$(date +%Y%m%d-%H%M%S)"
tar_cmd="COPYFILE_DISABLE=1 tar -czf - -C $(printf '%q' "$src") ."
ssh_cmd="
set -e
dst=$(printf '%q' "$dst")
bakdir=$(printf '%q' "$BACKUP_DIR")
bak=\"\$bakdir/$(printf '%q' "$label")-\$(date +%Y%m%d-%H%M%S)\"
rm -rf /tmp/fa-stage && mkdir -p /tmp/fa-stage
tar -xzf - --no-same-owner -C /tmp/fa-stage
find /tmp/fa-stage \( -name '._*' -o -name '.DS_Store' \) -delete
[ -f /tmp/fa-stage/index.html ] || { echo 'ERROR: 缺少 index.html,中止'; exit 1; }
new=\$(find /tmp/fa-stage -type f | wc -l)
[ \"\$new\" -gt 0 ] || { echo 'ERROR: 上传内容为空,中止'; exit 1; }
if [ -e \"\$dst\" ]; then
mkdir -p \"\$bakdir\"
rm -rf \"\$bak\"
cp -a \"\$dst\" \"\$bak\"
old=\$(find \"\$bak\" -type f | wc -l)
[ \"\$old\" -gt 0 ] || { echo 'ERROR: 备份为空,中止'; exit 1; }
echo \" 备份 OK: \${bak} (\${old} 文件)\"
fi
rm -rf \"\$dst\" && mkdir -p \"\$dst\"
for e in \$(ls -A /tmp/fa-stage); do mv \"/tmp/fa-stage/\$e\" \"\$dst\"/; done
rm -rf /tmp/fa-stage
chown -R root:root \"\$dst\"
echo \" 上线 OK: \$new 文件, \$(du -sh \"\$dst\" | cut -f1), 属主 \$(stat -c %U:%G \"\$dst\")\"
"
log "上传 $(basename "$src") → $dst"
if ! eval "$tar_cmd" 2>/dev/null | ssh_do "$ssh_cmd"; then
die "部署失败:远端未做替换,备份保留,可安全重试"
fi
_rotate_backups "$label"
}
# 每个标签只保留最近 BACKUP_KEEP 份备份(web 一份 23MB,不轮转会持续吃磁盘)
_rotate_backups() {
local label="$1" keep="$BACKUP_KEEP"
ssh_do "
bakdir=${BACKUP_DIR}
ls -1dt \"\$bakdir/${label}-\"* 2>/dev/null | tail -n +\$(( ${keep} + 1 )) | while read -r d; do
rm -rf \"\$d\" && echo \" 轮转删除旧备份: \$(basename \$d)\"
done
true
"
}
# ---------- Web 管理后台 ----------
deploy_web() {
log "构建 Web 管理后台(vue-tsc 类型检查由 type-check 单独负责,这里只出产物)"
( cd frontend/web && pnpm run build:prod >/tmp/fa-web-build.log 2>&1 ) \
|| { tail -20 /tmp/fa-web-build.log; die "web 构建失败"; }
ok "web 构建完成:$(du -sh frontend/web/dist | cut -f1)"
push_dir frontend/web/dist "${REMOTE_ROOT}/docker/nginx/web/dist" "web"
ok "Web 已上线"
}
# ---------- 移动端 H5 ----------
deploy_app() {
log "构建移动端 H5"
( cd frontend/app && pnpm run build:h5 >/tmp/fa-app-build.log 2>&1 ) \
|| { tail -20 /tmp/fa-app-build.log; die "app 构建失败"; }
ok "H5 构建完成:$(du -sh frontend/app/dist/build/h5 | cut -f1)"
# nginx /app 用 alias 指向 app/dist,真实入口在 build/h5 下;
# try_files 回退到 /app/index.html,少一层就 500 内部重定向死循环
push_dir frontend/app/dist/build/h5 \
"${REMOTE_ROOT}/docker/nginx/app/dist/build/h5" "app-h5"
ok "H5 已上线"
}
# ---------- 后端镜像 ----------
# deploy_backend <mysql|postgres|sqlite> [switch]
# switch=1(默认)构建并切换线上;switch=0 只构建+装载+自检,不动生产
deploy_backend() {
local db="${1:-mysql}" switch="${2:-1}"
case "$db" in mysql|postgres|sqlite) ;; *) die "数据库类型只能是 mysql / postgres / sqlite" ;; esac
local tag="backend:${db}-${STAMP}"
command -v docker >/dev/null || die "本机没有 docker,无法构建镜像"
log "准备构建上下文"
rm -rf /tmp/fa-ctx && mkdir -p /tmp/fa-ctx/backend
( cd backend && tar -cf - \
--exclude='.mypy_cache' --exclude='.pytest_cache' --exclude='__pycache__' \
--exclude='logs' --exclude='tests' --exclude='dist' --exclude='.venv' \
--exclude='.DS_Store' --exclude='.ruff_cache' --exclude='.codebuddy' \
--exclude='.vscode' --exclude='.python-version' \
--exclude='env/.env.dev' --exclude='env/.env.test' \
. ) | tar -xf - -C /tmp/fa-ctx/backend/
# 依赖清单目录化:核心 + 数据库驱动分离,镜像里只装所选数据库的驱动
rm -rf /tmp/fa-ctx/requirements && mkdir -p /tmp/fa-ctx/requirements
cp -R backend/requirements/. /tmp/fa-ctx/requirements/
# 镜像内的 .env.prod 只是兜底:数据库/Redis 真实地址由 compose 运行时注入。
# 这里抹掉本地私网地址与所有密钥,避免把开发凭据烤进镜像。
python3 - "$db" <<'PY'
import re, pathlib, sys
db = sys.argv[1]
p = pathlib.Path("/tmp/fa-ctx/backend/env/.env.prod")
force = {"DATABASE_HOST": "localhost", "REDIS_HOST": "localhost"}
if db == "postgres":
force.update({"DATABASE_TYPE": "postgres", "DATABASE_PORT": "5432", "DATABASE_USER": "postgres"})
drop_prefixes = ("OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL")
out = []
for line in p.read_text().splitlines():
m = re.match(r"^(\s*)([A-Z_]+)(\s*=\s*)(.*)$", line)
if m and m.group(2) in force:
out.append(f"{m.group(1)}{m.group(2)}{m.group(3)}{force[m.group(2)]}")
elif m and m.group(2).startswith(drop_prefixes):
continue
else:
out.append(line)
p.write_text("\n".join(out) + "\n")
PY
# 只装所选数据库的驱动:mysql 变体不装 asyncpg/psycopg,postgres 变体不装 aiomysql/pymysql
# 运行用户为非 root(UID 1000):容器逃逸后的影响面更小
cat > /tmp/fa-ctx/Dockerfile <<EOF
FROM python:3.12-slim
ARG DB=${db}
ENV TZ=Asia/Shanghai PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 LANG=C.UTF-8 DB=\${DB}
WORKDIR /home
COPY requirements/ /home/requirements/
RUN pip install --no-cache-dir \\
-r /home/requirements/base.txt \\
-r /home/requirements/db/\${DB}.txt \\
-r /home/requirements/storage.txt \\
-i https://pypi.tuna.tsinghua.edu.cn/simple
COPY backend/ /home/
# 运行时需要写入的目录(日志、上传、迁移版本目录)预先建好并交给运行用户
RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin app \\
&& mkdir -p /home/logs /home/static/upload /home/alembic/versions \\
&& chown -R app:app /home
USER app
EXPOSE 8001
CMD ["python", "main.py", "run", "--env=prod"]
EOF
cat > /tmp/fa-ctx/.dockerignore <<'EOF'
**/__pycache__
**/*.pyc
**/.mypy_cache
**/.pytest_cache
**/logs
**/tests
**/.DS_Store
EOF
log "构建镜像 ${tag} (linux/amd64,依赖层命中缓存时约 5 秒)"
docker build --platform linux/amd64 -t "$tag" /tmp/fa-ctx >/tmp/fa-build.log 2>&1 \
|| { tail -25 /tmp/fa-build.log; die "镜像构建失败"; }
ok "镜像构建完成:$(docker images "$tag" --format '{{.Size}}')"
log "传输镜像(docker save | gzip | ssh docker load)"
docker save "$tag" | gzip -1 | ssh_do 'gunzip | docker load' >/dev/null
ok "镜像已加载到服务器"
if [ "$switch" != "1" ]; then
# 只构建与装载,不切换线上。用于生产数据库与镜像变体不一致时做验证。
ok "镜像 ${tag} 已就绪但未切换(避免把 ${db} 变体切到当前生产环境)"
local drivers
case "$db" in
postgres) drivers="asyncpg psycopg" ;;
mysql) drivers="aiomysql pymysql" ;;
sqlite) drivers="aiosqlite" ;;
esac
if ssh_do "
set -e
docker run --rm --platform linux/amd64 --entrypoint python ${tag} -c '
import importlib, sys
mods = sys.argv[1:]
for m in mods:
importlib.import_module(m)
print(\" 驱动可用:\", m)
print(\" 自检通过:\", len(mods), \"个 ${db} 驱动\")
' ${drivers}
" 2>/dev/null; then
ok "镜像自检通过:${db} 驱动可正常导入"
else
die "镜像自检失败:${db} 驱动无法导入"
fi
return 0
fi
log "切换并只重建 backend(MySQL/Redis/Nginx 不动)"
ssh_do "
set -e
cd ${REMOTE_ROOT}/docker
# 容器以非 root(UID 1000)运行;宿主绑定挂载的上传目录必须属于该 UID,否则无法写入
mkdir -p backend/static/upload
chown -R 1000:1000 backend/static/upload
docker tag backend:3.0.0 backend:prev 2>/dev/null || true
docker tag ${tag} backend:3.0.0
docker compose up -d --force-recreate backend
for i in 1 2 3 4 5 6 7; do
sleep 15
st=\$(docker inspect backend --format '{{.State.Health.Status}}')
echo \" 健康检查: \$st\"
[ \"\$st\" = healthy ] && break
done
echo \" 运行用户: \$(docker inspect backend --format '{{.Config.User}}')\"
docker inspect backend --format ' 镜像={{.Config.Image}} 状态={{.State.Status}}'
"
ok "后端已上线(回滚:docker tag backend:prev backend:3.0.0 && docker compose up -d backend)"
}
# ---------- 验证 ----------
verify() {
log "公网四端验证"
local fail=0
for p in "/" "/web/" "/app/" "/api/v1/monitor/health/check"; do
printf ' %-42s ' "$p"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://service.fastapiadmin.com${p}")
printf 'HTTP %s\n' "$code"
[ "$code" = "200" ] || fail=1
done
[ "$fail" -eq 0 ] && ok "四端全部 200" || die "存在非 200 端点"
}
# ---------- 轮转清理:备份目录与镜像仓库,避免磁盘无限增长 ----------
prune() {
log "轮转清理(备份每标签保留 ${BACKUP_KEEP} 份,镜像每类保留 ${IMAGE_KEEP} 个)"
ssh_do "
bakdir=${BACKUP_DIR}
keep=${BACKUP_KEEP}
if [ -d \"\$bakdir\" ]; then
for label in \$(ls -1 \"\$bakdir\" | sed 's/-[0-9]\{8\}-[0-9]\{6\}\$//' | sort -u); do
n=0
for d in \$(ls -1dt \"\$bakdir\"/\$label-* 2>/dev/null); do
n=\$((n+1))
if [ \"\$n\" -gt \"\$keep\" ]; then rm -rf \"\$d\"; echo \" 清理备份: \$(basename \$d)\"; fi
done
done
fi
true # 显式成功,避免末端条件表达式在 set -e 下打断后续清理
"
# 镜像 tag 形如 <db>-YYYYmmdd-HHMMSS,按 tag 排序即时间序(比 CreatedAt 可靠:秒级相同会乱序)
local prefix t
for prefix in "mysql-" "postgres-" "sqlite-" "release-" "deploy-"; do
while IFS= read -r t; do
[ -n "$t" ] || continue
docker rmi "backend:${t}" >/dev/null 2>&1 && ok "清理本机镜像 backend:${t}"
done < <(docker images "backend:${prefix}*" --format '{{.Tag}}' 2>/dev/null \
| sort -r | tail -n +$((IMAGE_KEEP + 1)))
done
for prefix in "mysql-" "postgres-" "sqlite-" "release-" "deploy-"; do
for t in $(ssh_do "docker images 'backend:${prefix}*' --format '{{.Tag}}' 2>/dev/null | sort -r | tail -n +$((IMAGE_KEEP + 1))"); do
[ -n "$t" ] || continue
ssh_do "docker rmi 'backend:${t}' >/dev/null 2>&1 && echo ' 清理服务器镜像 backend:${t}'"
done
done
ssh_do "df -h / | tail -1 | sed 's/^/ 文件系统: /'"
ok "清理完成"
}
# ---------- 备份与回滚 ----------
list_backups() {
log "服务器备份(${BACKUP_DIR})"
ssh_do "ls -1t ${BACKUP_DIR} 2>/dev/null | head -20 || echo ' (还没有备份)'"
}
# rollback <web|app|backend>
rollback() {
local what="${1:-}"
case "$what" in
web) _rollback_dir web "${REMOTE_ROOT}/docker/nginx/web/dist" ;;
app) _rollback_dir app-h5 "${REMOTE_ROOT}/docker/nginx/app/dist/build/h5" ;;
backend) _rollback_image ;;
*) die "用法: $0 rollback <web|app|backend>" ;;
esac
verify
}
_rollback_dir() {
local label="$1" dst="$2"
log "回滚 $label → $dst"
ssh_do "
set -e
bak=\$(ls -1dt ${BACKUP_DIR}/${label}-* 2>/dev/null | head -1)
[ -n \"\$bak\" ] || { echo ' ERROR: 没有可用备份'; exit 1; }
[ -f \"\$bak/index.html\" ] || { echo \" ERROR: 备份 \$bak 缺少 index.html\"; exit 1; }
rm -rf '${dst}.broken' && mv '${dst}' '${dst}.broken'
mkdir -p '${dst}'
for e in \$(ls -A \"\$bak\"); do cp -a \"\$bak/\$e\" '${dst}'/; done
rm -rf '${dst}.broken'
chown -R root:root '${dst}'
echo \" 已回滚自: \${bak} (\$(find '${dst}' -type f | wc -l) 文件)\"
"
}
_rollback_image() {
log "回滚后端到 backend:prev"
ssh_do "
set -e
cd ${REMOTE_ROOT}/docker
docker image inspect backend:prev >/dev/null 2>&1 || { echo ' ERROR: backend:prev 不存在'; exit 1; }
docker tag backend:prev backend:3.0.0
docker compose up -d --force-recreate backend
sleep 30
docker inspect backend --format ' 已回滚: 镜像={{.Config.Image}} 健康={{.State.Health.Status}}'
"
}
case "${1:-}" in
web) deploy_web; verify; prune ;;
app) deploy_app; verify; prune ;;
backend) deploy_backend mysql 1; verify; prune ;;
backend-pg) deploy_backend postgres 0 ;; # 只构建+自检,绝不自动切换生产
all) deploy_web; deploy_app; deploy_backend mysql 1; verify; prune ;;
verify) verify ;;
prune) prune ;;
backups) list_backups ;;
rollback) rollback "${2:-}" ;;
*) sed -n '2,30p' "$0"; exit 1 ;;
esac
+28 -12
View File
@@ -1,28 +1,44 @@
# Docker 部署环境变量(案例)
# ============================================================================
# Docker 部署环境变量示例
# 用法:cp docker/.env.example docker/.env 然后改成真实值(.env 不会被提交)
# 注意:本文件会被提交到仓库,只放占位符,不要写真实口令
# ============================================================================
# 数据库配置
MYSQL_ROOT_PASSWORD=your_mysql_root_password
# ---------- 数据库(MySQL)----------
MYSQL_ROOT_PASSWORD=change_me_root_password
MYSQL_DATABASE=fastapiadmin
MYSQL_USER=fastapiadmin
MYSQL_PASSWORD=your_mysql_password
MYSQL_PASSWORD=change_me_app_password
MYSQL_PORT=3306
# 说明:compose 把 MySQL 端口绑定在 127.0.0.1(只对本机开放),
# 容器之间通过 app_network 互联,不依赖宿主机端口。
# Redis配置
REDIS_PASSWORD=your_redis_password
# ---------- Redis ----------
REDIS_PASSWORD=change_me_redis_password
REDIS_PORT=6379
# 后端配置
# ---------- 后端 ----------
BACKEND_PORT=8001
# 使用的后端镜像 tag:
# 3.0.0 表示「当前线上版本」(deploy-artifacts.sh 部署时写入)
# mysql-<时间戳> MySQL 驱动变体
# postgres-<时间戳> PostgreSQL 驱动变体(切换数据库时手动指定)
BACKEND_IMAGE_TAG=3.0.0
# 日志级别(DEBUG/INFO/WARNING/ERROR)
LOGGER_LEVEL=INFO
# 运行环境,对应 backend/env/.env.{dev,prod}
DEPLOY_ENV=prod
# 数据库类型(仅当使用外部数据库、不靠 compose 注入时才有意义)
# DATABASE_TYPE=mysql
# Nginx配置
# ---------- Nginx ----------
HTTP_PORT=80
HTTPS_PORT=443
# nginx 镜像版本(由 docker-compose.override.yaml 读取;留空用 1.27-alpine)
NGINX_TAG=1.27-alpine
# 项目配置
# ---------- 项目 ----------
PROJECT_NAME=FastapiAdmin
# 部署环境
DEPLOY_ENV=prod
# ---------- 部署方式 ----------
# 健康检查宽限期(秒):容器首次启动需要跑迁移,慢机器上可调大
HEALTHCHECK_START_PERIOD=40s
+33 -13
View File
@@ -1,26 +1,46 @@
# 使用 python:3.12-slim 镜像作为基础镜像
# ============================================================================
# FastapiAdmin 后端镜像(源码构建路径)
#
# 构建参数 DB 决定安装哪套数据库驱动:
# docker build --build-arg DB=mysql -f docker/backend/Dockerfile . # 默认
# docker build --build-arg DB=postgres -f docker/backend/Dockerfile .
#
# 注意:正式部署建议用 deploy-artifacts.sh(本地构建镜像 → docker save/load),
# 这样服务器上不需要源码,也不需要构建工具链。
# ============================================================================
FROM python:3.12-slim
# 使用 LABEL 替代 MAINTAINER
LABEL maintainer="948080782@qq.com"
# 设置时区
ENV TZ Asia/Shanghai
ARG DB=mysql
ENV TZ=Asia/Shanghai \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \
DB=${DB}
# 设置容器内工作目录
WORKDIR /home
# 先复制依赖清单并安装(利用 layer 缓存:代码变更时不重装依赖)
COPY ./backend/requirements.txt .
# 依赖清单先复制(layer 缓存:只改代码时不重装依赖)
COPY ./backend/requirements/ /home/requirements/
# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 只装所选数据库的驱动 + 存储与加密依赖
RUN pip install --no-cache-dir \
-r /home/requirements/base.txt \
-r /home/requirements/db/${DB}.txt \
-r /home/requirements/storage.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制应用代码(backend/.venv、logs、缓存等由根 .dockerignore 排除)
COPY ./backend/ .
# 应用代码(backend/.venv、logs、缓存、真实 .env.* 由根 .dockerignore 排除)
COPY ./backend/ /home/
# 以非 root 用户运行;运行时需要写的目录预先建好并交给该用户
RUN useradd --uid 1000 --create-home --shell /usr/sbin/nologin app \
&& mkdir -p /home/logs /home/static/upload /home/alembic/versions \
&& chown -R app:app /home
USER app
# 暴露端口
EXPOSE 8001
# 运行应用
CMD ["python", "main.py", "run", "--env=prod"]
+7
View File
@@ -0,0 +1,7 @@
# 优化项:静态资源缓存 + nginx 版本固定。撤销:删除本文件并重建 nginx。
services:
nginx:
image: nginx:${NGINX_TAG:-1.27-alpine}
volumes:
- ./nginx/cache-headers.inc:/etc/nginx/cache-headers.inc:ro
- ./nginx/acme:/usr/share/nginx/html/acme:ro
+17 -5
View File
@@ -20,7 +20,7 @@ services:
MYSQL_USER: "${MYSQL_USER:-fastapiadmin}"
MYSQL_PASSWORD: "${MYSQL_PASSWORD:?错误: 请设置 MYSQL_PASSWORD 环境变量}"
ports:
- "${MYSQL_PORT:-3306}:3306"
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
@@ -56,7 +56,7 @@ services:
environment:
TZ: "Asia/Shanghai"
ports:
- "${REDIS_PORT:-6379}:6379"
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
# 挂载自定义 redis.conf(若存在则覆盖默认配置)
@@ -115,10 +115,22 @@ services:
# 日志级别
LOGGER_LEVEL: "${LOGGER_LEVEL:-INFO}"
# 信任反向代理(nginx)传来的 X-Forwarded-Proto/For:否则 prod 的
# HTTPSRedirectMiddleware 会把 https 请求误判为 http 造成 301 循环
FORWARDED_ALLOW_IPS: "*"
# HTTPSRedirectMiddleware 会把 https 请求误判为 http 造成 301 循环。
# 只信任应用网络子网(默认 10.255.178.0/24),不要用 "*"——否则客户端可伪造
# X-Forwarded-For 绕过登录限流与来源 IP 记录。
FORWARDED_ALLOW_IPS: "${FORWARDED_ALLOW_IPS:-10.255.178.0/24}"
# 令牌签名密钥与数据加密密钥(必须显式提供,禁止使用源码默认值)
SECRET_KEY: "${SECRET_KEY:?错误: 请设置 SECRET_KEY 环境变量(openssl rand -hex 32)}"
DATA_ENCRYPTION_KEY: "${DATA_ENCRYPTION_KEY:-}"
# 轮换前的旧主密钥(逗号分隔),仅用于解密历史密文
DATA_ENCRYPTION_OLD_KEYS: "${DATA_ENCRYPTION_OLD_KEYS:-}"
# 生产跨域白名单(逗号分隔)。未配置时应用不再回落通配
PROD_CORS_ORIGINS: "${PROD_CORS_ORIGINS:-}"
# 演示模式:为 true 时禁止写操作。当前线上为 true,如需可写请设为 false
DEMO_ENABLE: "${DEMO_ENABLE:-true}"
ports:
- "${BACKEND_PORT:-8001}:8001"
# 仅绑定本机:数据面不对公网开放,容器之间经 app_network 互联
- "127.0.0.1:${BACKEND_PORT:-8001}:8001"
volumes:
# ── 开发热更新(可选)─────────────────────
# 取消注释将宿主机代码覆盖容器内代码。生产环境保持注释,
+15
View File
@@ -0,0 +1,15 @@
# 静态资源长缓存 + 隐藏文件防护。被 nginx.conf 的 /web、/app location include。
# 撤销:删除本文件 + 移除 nginx.conf 中对应 include 行,然后重建 nginx。
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* ^/(web|app|docs)/(js|css|assets|static)/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location ~* ^/(web|app|docs)/[^/]*\.(?:png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot)$ {
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
+33 -2
View File
@@ -69,11 +69,22 @@ http {
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# ==================== HTTP -> HTTPS 重定向 ====================
# HTTP server块 - 重定向到HTTPS
# HTTP server块 - 重定向到HTTPS(放行 ACME 校验路径,供证书自动续期使用)
server {
listen 80;
server_name service.fastapiadmin.com;
return 301 https://$server_name$request_uri;
# 证书续期校验路径(ACME http-01):必须在 301 之前放行,
# 否则签发/续期校验请求会被重定向到 https 而导致验证失败。
location ^~ /.well-known/acme-challenge/ {
root /usr/share/nginx/html/acme;
default_type "text/plain";
access_log off;
}
location / {
return 301 https://$server_name$request_uri;
}
}
# ==================== HTTPS Server ====================
@@ -101,17 +112,37 @@ http {
# ==================== 前端(/web) ====================
location /web {
include cache-headers.inc; # 静态资源长缓存(优化项,可删除)
alias /usr/share/nginx/html/web/dist;
try_files $uri $uri/ /web/index.html; #解决页面刷新404问题
}
location ~ /\. { deny all; access_log off; log_not_found off; }
# ==================== 小程序 H5(/app) ====================
# 已禁用:app 不开源,如需使用请取消注释
location /app {
include cache-headers.inc; # 静态资源长缓存(优化项,可删除)
alias /usr/share/nginx/html/app/dist/build/h5;
try_files $uri $uri/ /app/index.html; #解决页面刷新404问题
}
location ~ /\. { deny all; access_log off; log_not_found off; }
# ==================== 封堵接口文档(仅本机/内网可访问)====================
location ~ ^/api/v1/(docs|redoc|openapi\.json)$ {
allow 127.0.0.1;
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
proxy_pass http://backend:8001;
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;
}
# ==================== 后端 API 代理 ====================
location /api/v1 {
limit_req zone=api_limit burst=60 nodelay;