mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-13 20:51:09 +00:00
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:
@@ -15,7 +15,12 @@ __pycache__
|
||||
# 虚拟环境
|
||||
venv
|
||||
.venv
|
||||
# 环境变量文件:必须排除所有真实密钥文件(.env 只能匹配到名为 .env 的文件,
|
||||
# 匹配不到 .env.prod / .env.dev / .env.test —— 它们含真实口令与 API Key)
|
||||
.env
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
# 编辑器/IDE
|
||||
.vscode
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -73,4 +73,6 @@ CLAUDE.md
|
||||
AGENTS.md
|
||||
.codebuddy
|
||||
.agent
|
||||
.workbuddy
|
||||
.workbuddy
|
||||
# AgentTeams 协作运行期状态(本地,不入库)
|
||||
.agent-teams/
|
||||
|
||||
+119
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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://,迁移与后台任务用)
|
||||
@@ -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 # 同步驱动
|
||||
@@ -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:///)
|
||||
@@ -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 加密
|
||||
Generated
+17
-9
@@ -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 = [
|
||||
|
||||
Executable
+369
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
# ── 开发热更新(可选)─────────────────────
|
||||
# 取消注释将宿主机代码覆盖容器内代码。生产环境保持注释,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user