From 6c22cf9ef78ecb773ac665208fb6072c10aa94fd Mon Sep 17 00:00:00 2001 From: AgentLabCn <130165633+AgentLabCn@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:21:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=86=E5=A4=87=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.prod.example | 41 +++ .gitignore | 3 + CLAUDE.md | 13 + DEPLOYMENT.md | 416 +++++++++++++++++++++++ backend/.dockerignore | 34 ++ backend/Dockerfile.prod | 37 ++ backend/app/websocket/manager.py | 5 + deploy.md | 3 + deploy/nginx.conf | 93 +++++ deploy/qingye.service | 33 ++ docker-compose.prod.yml | 115 +++++++ frontend/.dockerignore | 23 ++ frontend/.env.development | 4 + frontend/Dockerfile.prod | 41 +++ frontend/nginx.conf | 28 ++ frontend/src/api/client.ts | 5 +- frontend/src/composables/useWebSocket.ts | 6 +- 17 files changed, 898 insertions(+), 2 deletions(-) create mode 100644 .env.prod.example create mode 100644 DEPLOYMENT.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile.prod create mode 100644 deploy.md create mode 100644 deploy/nginx.conf create mode 100644 deploy/qingye.service create mode 100644 docker-compose.prod.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.development create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/nginx.conf diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..c5583df --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,41 @@ +# ============================================================ +# 青叶 (QingYe) —— 生产环境变量模板 +# ------------------------------------------------------------ +# 用法: +# cp .env.prod.example .env.prod +# 然后把下面所有「请替换…」的值改为真实强随机值。 +# +# 生成强随机密钥(在 VPS 上执行): +# openssl rand -hex 32 +# +# 注意: +# * .env.prod 已在 .gitignore 中,不会被提交。 +# * POSTGRES_PASSWORD 与 ADMIN_PASSWORD 仅在「首次启动」时写入: +# - PostgreSQL 只在数据卷为空时初始化口令,首次启动后修改本文件无效。 +# - 管理员密码同样只在首次启动写入数据库。 +# 因此请务必在第一次 docker compose up 之前就把它们设好! +# ============================================================ + +# ---------- PostgreSQL ---------- +POSTGRES_DB=qingye +POSTGRES_USER=qingye +# 必填:用 openssl rand -hex 32 生成 +# ⚠️ 重要:此口令会被拼入 DATABASE_URL,必须「URL 安全」——只能含字母和数字, +# 不能包含 : / @ # ? % & + 空格 等特殊字符(否则连接串解析失败,后端连不上库)。 +# 所以务必使用 openssl rand -hex 32(纯十六进制)这类无特殊字符的随机串。 +POSTGRES_PASSWORD=__替换为强随机密码_请用_openssl_rand_hex_32__ + +# ---------- 后端密钥 ---------- +# 必填:用 openssl rand -hex 32 生成(两个用不同的值) +JWT_SECRET_KEY=__替换为强随机密钥_请用_openssl_rand_hex_32__ +JWT_REFRESH_SECRET_KEY=__替换为强随机密钥_请用_openssl_rand_hex_32__ + +# CORS 允许来源(生产同源,填你的域名;多个用英文逗号分隔) +CORS_ORIGINS=https://www.e4s.world + +# 管理员初始密码(首次启动写入数据库,之后在管理后台修改) +ADMIN_PASSWORD=__替换为强密码__ + +# ---------- 无需修改 ---------- +# DATABASE_URL 由 docker-compose.prod.yml 用上面的 POSTGRES_* 自动拼接 +# REDIS_URL 由 compose 固定为 redis://redis:6379/0 diff --git a/.gitignore b/.gitignore index 4fe0360..fec7044 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ build/ .env.local .env.*.local .env.production +# 生产密钥文件(真实值,绝不提交;.env.prod.example 模板仍可提交) +.env.prod +.env.prod.local # --------------------- # Python 虚拟环境 diff --git a/CLAUDE.md b/CLAUDE.md index 65e6059..4d0a4ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,19 @@ docker compose restart backend # Restart a service **Backend** runs on `localhost:8000`, **Frontend** on `localhost:5173`. Both have hot-reload via volume mounts. Database changes requiring new columns need manual `ALTER TABLE` or `docker compose restart backend` (which triggers `Base.metadata.create_all`). +### Production (dev/prod separation) + +Two **independent, self-contained** Compose files: + +- `docker-compose.yml` — **dev** (hot-reload, `docker compose up`). Leave untouched for local dev. +- `docker-compose.prod.yml` — **prod**. Run with: + ```bash + docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build + ``` + Secrets come from `.env.prod` (gitignored; copy from `.env.prod.example`). **Never run bare `docker compose up` on the server.** + +CRITICAL prod invariant: the backend **must run single-process**. The WebSocket `ConnectionManager` (`backend/app/websocket/manager.py`) is an in-memory singleton — `--workers N` / gunicorn multi-worker splits connections and silently breaks real-time messaging. `backend/Dockerfile.prod` is pinned to single-process; do not change. The frontend prod build is domain-agnostic (same-origin `/api/v1`, auto `wss://`). See [DEPLOYMENT.md](DEPLOYMENT.md) for the full VPS (Ubuntu 20.04 + host Nginx + certbot) walkthrough. + ## Architecture ### Backend (FastAPI + SQLAlchemy 2.0 async) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..ff508b6 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,416 @@ +# 青叶 (QingYe) 生产环境部署指南 + +> 目标:把青叶部署到 VPS(Ubuntu 20.04,IP `103.170.72.162`),域名 `www.e4s.world`,使用 **宿主机 Nginx** 作反向代理 + SSL,**Docker Compose** 编排后端容器。 + +--- + +## 0. 架构总览 + +``` + Internet (HTTPS) + │ + ┌─────────▼──────────┐ + │ 宿主机 Nginx :443 │ ← SSL 证书(Let's Encrypt / certbot) + │ www.e4s.world │ + └───┬──────┬─────┬───┘ + │ │ │ + / 与静态资源 │ /api │ /ws (WebSocket) + /uploads + │ │ │ + ┌──────────▼┐ ┌───▼─────▼──────────┐ + │ frontend │ │ backend │ 仅绑定 127.0.0.1 + │ 容器:8080 │ │ 容器:8000 │ 不对外公开 + │ (SPA静态) │ │ (FastAPI 单进程) │ + └───────────┘ └───┬────────────┬───┘ + │ │ + ┌───────▼───┐ ┌─────▼─────┐ + │ postgres │ │ redis │ 仅容器内网,不映射端口 + └───────────┘ └───────────┘ +``` + +**关键约束(务必读懂一条)**: + +- 后端必须 **单进程** 运行。WebSocket 连接管理器(`backend/app/websocket/manager.py`)是「进程内存」单例;若用 `--workers N` 或 gunicorn 多 worker,连接会被拆分到不同进程,导致聊天、撤回、好友请求等实时消息无法投递。生产镜像 `backend/Dockerfile.prod` 已固定为单进程,**不要改动**。 +- postgres / redis **不对外映射端口**;backend / frontend 只绑定 `127.0.0.1`。所有外部流量经宿主机 Nginx 进入。 +- 前端生产构建 **域名无关**:不硬编码域名,API 走同源相对路径 `/api/v1`,WebSocket 自动按页面协议推导为 `wss://<当前域名>`。同一份镜像可部署到任意域名。 + +--- + +## 1. 前置条件检查 + +VPS:Ubuntu 20.04,已安装 Nginx,但 Docker / Docker Compose / Certbot 未装。 + +先升级系统并装基础工具: + +```bash +sudo apt update && sudo apt upgrade -y && sudo apt install -y ca-certificates curl gnupg lsb-release software-properties-common git ufw +``` + +--- + +## 2. 配置 DNS(先做这一步,证书依赖它) + +在域名服务商为 `e4s.world` 添加解析: + +| 类型 | 主机记录 | 记录值 | +|------|---------|--------| +| A | www | 103.170.72.162 | + +等待解析生效并**验证**(必须返回你的 VPS IP 才能继续): + +```bash +dig +short www.e4s.world +# 期望输出:103.170.72.162 +``` + +> 若没有 `dig`:`sudo apt install -y dnsutils`。 + +--- + +## 3. 配置防火墙(ufw) + +```bash +# 配置放行端口:22(SSH) / 80(HTTP,含 certbot 验证与跳转) / 443(HTTPS) +sudo ufw default deny incoming && sudo ufw default allow outgoing && sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp + +# 启用防火墙(首次会提示确认,输入 y)并查看状态 +sudo ufw enable && sudo ufw status verbose +``` + +> ⚠️ **ufw 与 Docker 的关系**:Docker 会自行写入 iptables NAT 规则,**可能绕过 ufw**。本指南的安全不依赖 ufw,而是靠容器**只绑定 127.0.0.1**。部署完成后请用 `ss -tlnp` 复核 8000 / 8080 仅监听 127.0.0.1(见 [第 9 节](#9-验证清单))。如需对 Docker 做真正的端口过滤,可另外安装 `ufw-docker` 工具(可选)。 + +--- + +## 4. 安装 Docker 与 Compose 插件(Ubuntu 20.04) + +```bash +# 1) 添加 Docker 官方 GPG key 与 apt 仓库(整行执行,无需反斜杠续行) +sudo install -m 0755 -d /etc/apt/keyrings && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && sudo chmod a+r /etc/apt/keyrings/docker.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# 2) 安装 Docker 引擎与 Compose 插件 +sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# 3) 验证(看到版本号即成功) +docker --version && docker compose version +``` + +> 若你的 VPS 在中国大陆、拉取 Docker 仓库很慢,可配置镜像加速器(编辑 `/etc/docker/daemon.json` 添加 `registry-mirrors`),然后 `sudo systemctl restart docker`。此处从略。 + +把当前用户加入 docker 组(免 sudo,需重新登录生效): + +```bash +sudo usermod -aG docker $USER +# 退出重新 SSH 登录后生效;验证:groups 应包含 docker +``` + +--- + +## 5. 获取代码并生成生产密钥 + +```bash +sudo mkdir -p /opt/qingye && sudo chown $USER:$USER /opt/qingye && cd /opt/qingye + +# 方式一:git clone(替换为你的仓库地址) +git clone <你的仓库地址> . + +# 方式二:从本地打包上传 +# 本地: tar --exclude=node_modules --exclude=.git -czf qingye.tar.gz . +# VPS: scp qingye.tar.gz user@103.170.72.162:/tmp/ 然后 tar -xzf /tmp/qingye.tar.gz -C /opt/qingye +``` + +生成生产环境变量文件(**所有密钥必填**): + +```bash +# 复制模板并生成密钥(下面三项各需一个不同的随机值,可重复执行 openssl rand -hex 32) +cp .env.prod.example .env.prod && openssl rand -hex 32 +``` + +编辑 `.env.prod`,把所有 `__替换…__` 改为真实值: + +```bash +nano .env.prod +``` + +需要设置的项: + +| 变量 | 说明 | +|------|------| +| `POSTGRES_DB` / `POSTGRES_USER` | 一般保持默认 `qingye` 即可 | +| `POSTGRES_PASSWORD` | **必填** 强随机密码 | +| `JWT_SECRET_KEY` | **必填** `openssl rand -hex 32` | +| `JWT_REFRESH_SECRET_KEY` | **必填** 用**另一个**随机值 | +| `CORS_ORIGINS` | `https://www.e4s.world`(同源) | +| `ADMIN_PASSWORD` | **必填** 管理员初始密码 | + +> 🚨 `POSTGRES_PASSWORD` 与 `ADMIN_PASSWORD` **只在首次启动时写入**: +> - PostgreSQL 仅在数据卷为空时初始化口令;首次启动后再改此文件**无效**。 +> - 管理员密码同样只在首次启动写入数据库。 +> +> 所以务必在**第一次 `docker compose up` 之前**就把它们设好。万一设错了,只能在**没有用户数据**时 `docker compose down -v`(**会清空数据库**)重来。 +> +> 💡 若只是**管理员密码**设错,无需 `down -v`,可参见[第 12 节「轮换管理员密码」](#12-更新--重新部署)用 `DELETE` + 重启的方式无损轮换(保留全部数据)。 + +确认 `.env.prod` 不会被提交(应为「ignored」): + +```bash +git check-ignore -v .env.prod +# 应输出匹配规则,例如:.gitignore:31:.env.prod .env.prod +``` + +--- + +## 6. 构建并启动生产容器 + +```bash +cd /opt/qingye && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build +``` + +首次构建会拉取镜像、安装依赖,耗时几分钟。完成后查看状态: + +```bash +docker compose -f docker-compose.prod.yml ps +# 四个容器应均为 healthy / running +``` + +确认后端启动成功(看到「🚀 青叶后端启动完成!」即建表成功): + +```bash +docker compose -f docker-compose.prod.yml logs --tail=50 backend +``` + +本地自测(不经域名,直接验证容器): + +```bash +curl -I http://127.0.0.1:8080 && curl http://127.0.0.1:8000/ +# 前端容器应返回 200;后端应返回 {"name":"青叶 QingYe",...} +``` + +--- + +## 7. 配置宿主机 Nginx 反向代理 + +```bash +# 复制站点配置、启用(软链)、移除默认站点、测试并重载 +sudo cp deploy/nginx.conf /etc/nginx/sites-available/www.e4s.world.conf && sudo ln -sf /etc/nginx/sites-available/www.e4s.world.conf /etc/nginx/sites-enabled/ && sudo rm -f /etc/nginx/sites-enabled/default && sudo nginx -t && sudo systemctl reload nginx +``` + +此时 `http://www.e4s.world` 应已能打开前端(但仍是 HTTP,WebSocket 在 HTTPS 页面下会被浏览器拦截为 mixed content —— 下一步解决)。 + +--- + +## 8. 申请 SSL 证书(certbot) + +```bash +sudo apt install -y certbot python3-certbot-nginx && sudo certbot --nginx -d www.e4s.world +``` + +按提示:填邮箱、同意条款、选择是否重定向 HTTP→HTTPS(**选是**)。 + +certbot 会自动: + +1. 用 HTTP-01 验证域名; +2. 下载证书到 `/etc/letsencrypt/live/www.e4s.world/`; +3. 把 `listen 80` 的 server 块改为 `return 301 https://...`; +4. 新增 `listen 443 ssl` server 块,并把所有 `location`(含 `/ws`)复制进去。 + +certbot 已自动注册 systemd 定时器续期。手动验证续期逻辑: + +```bash +sudo certbot renew --dry-run +``` + +重载 Nginx: + +```bash +sudo systemctl reload nginx +``` + +### 补充安全响应头(certbot 之后) + +certbot 生成的 443 块不含安全头。编辑 `/etc/nginx/sites-available/www.e4s.world.conf`,在 **443 server 块**内补上: + +```nginx +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +``` + +> 注意 Nginx 的 `add_header` 继承规则:若某个 `location` 内已存在 `add_header`/`expires`,则**不会**继承 server 级的头(本配置的 `/uploads/` 即如此)。若需对所有路径生效,需在该 location 内重复声明。 + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +验证:浏览器打开 `https://www.e4s.world`,开发者工具 → Network → 任一文档请求 → Response Headers,应能看到 `strict-transport-security`、`x-frame-options`、`x-content-type-options`。 + +--- + +## 9. 验证清单 + +部署完成后逐项检查: + +```bash +# 1) 端口绑定:8000 / 8080 必须只监听 127.0.0.1(不能是 0.0.0.0 /::*) +sudo ss -tlnp | grep -E ':(8000|8080)\b' +# 期望看到 127.0.0.1:8000 与 127.0.0.1:8080 + +# 2) 从外部无法直连后端(在另一台机器或手机网络执行) +curl -m 5 http://103.170.72.162:8000/ # 应失败(连接超时/拒绝) + +# 3) HTTPS 前端 +curl -I https://www.e4s.world # 200 + 证书有效 + +# 4) HTTP 自动跳转 HTTPS +curl -I http://www.e4s.world # 301 → https + +# 5) 后端健康 +curl https://www.e4s.world/ # 前端页面 +curl https://www.e4s.world/api/v1/ # 经 Nginx 转发到后端(404 也算正常,说明路由通了) + +# 6) WebSocket:浏览器打开 https://www.e4s.world,登录后 +# 开发者工具 → Network → WS,应看到到 wss://www.e4s.world/ws 的连接(101 Switching Protocols) + +# 7) 数据库表已建好 +docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c '\dt' +# 应列出 users / conversations / messages / moments ... 等表 + +# 8) Redis 健康 +docker compose -f docker-compose.prod.yml exec redis redis-cli ping # PONG +``` + +--- + +## 10.(可选)开机自启 + 崩溃重启 + +```bash +# 安装单元、重载定义、设置开机自启、立即启动 +sudo cp deploy/qingye.service /etc/systemd/system/qingye.service && sudo systemctl daemon-reload && sudo systemctl enable qingye && sudo systemctl start qingye + +# 查看状态与日志(journalctl -f 会持续输出,Ctrl+C 退出) +sudo systemctl status qingye && sudo journalctl -u qingye -f +``` + +> 该单元的 `ExecStart` 已显式带 `-f docker-compose.prod.yml` 与 `--env-file .env.prod`,**绝不会误用开发配置**。 + +--- + +## 11. 备份与恢复(重要) + +数据库数据存放在 Docker 命名卷 `pgdata`,**`docker compose down -v` 会清空全部用户数据**,请勿在生产使用 `-v`。 + +### 定时备份(建议加入 cron) + +使用 PostgreSQL 自定义格式(`-Fc`),便于用 `pg_restore --clean` 干净恢复: + +```bash +sudo mkdir -p /opt/qingye/backups + +# 手动备份一次: +docker compose -f docker-compose.prod.yml exec -T postgres pg_dump -Fc -U qingye qingye > /opt/qingye/backups/qingye-$(date +%F).dump + +# 加入 crontab(每天 03:17 备份,保留 14 天): +( crontab -l 2>/dev/null; echo "17 3 * * * docker compose -f /opt/qingye/docker-compose.prod.yml exec -T postgres pg_dump -Fc -U qingye qingye > /opt/qingye/backups/qingye-\$(date +\%F).dump && find /opt/qingye/backups -name 'qingye-*.dump' -mtime +14 -delete" ) | crontab - +``` + +> 同时建议定期备份 `redisdata` 卷(草稿等数据存于 Redis)。 + +### 恢复 + +`--clean --if-exists` 会先删除已有对象再导入,避免「relation already exists」报错。恢复前**先停后端**,避免恢复期间有写入: + +```bash +cd /opt/qingye && docker compose -f docker-compose.prod.yml stop backend && docker compose -f docker-compose.prod.yml exec -T postgres pg_restore --clean --if-exists -U qingye -d qingye < /opt/qingye/backups/qingye-YYYY-MM-DD.dump && docker compose -f docker-compose.prod.yml start backend +``` + +--- + +## 12. 更新 / 重新部署 + +```bash +# 拉取最新代码并重新构建启动(不删数据) +cd /opt/qingye && git pull && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build + +# 若只改了后端代码,可只重建 backend: +docker compose -f docker-compose.prod.yml up -d --build backend +``` + +> ⚠️ **数据库结构变更**:后端在启动时用 `Base.metadata.create_all` **只创建不存在的表,不会给已有表加列**。若新版后端给某张表新增了字段,必须**在新后端启动前**手动执行 SQL: +> ```bash +> docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c "ALTER TABLE xxx ADD COLUMN yyy TYPE;" +> ``` +> 否则新后端读不到新列,运行时报错。(后续可引入 Alembic 迁移自动化。) + +### 轮换管理员密码 + +管理员密码仅首次启动写入数据库。之后改 `.env.prod` 里的 `ADMIN_PASSWORD` 并重启**不会**生效。要改密码:登录管理后台用接口修改,或: + +```bash +# 删除已存的哈希,重启后会用 .env.prod 里的 ADMIN_PASSWORD 重新写入 +docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c "DELETE FROM system_config WHERE key='admin_password_hash';" && docker compose -f docker-compose.prod.yml restart backend +``` + +### 轮换 PostgreSQL 口令 + +`.env.prod` 里的 `POSTGRES_PASSWORD` 首次启动后改了无效。要在已有实例上改口令: + +```bash +docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c "ALTER USER qingye PASSWORD '新口令';" +# 同步修改 .env.prod 里的 POSTGRES_PASSWORD,再重启 backend +``` + +--- + +## 13. 常用运维命令 + +```bash +# 查看状态 +docker compose -f docker-compose.prod.yml ps + +# 查看实时日志 +docker compose -f docker-compose.prod.yml logs -f --tail=200 backend +docker compose -f docker-compose.prod.yml logs -f --tail=200 frontend + +# 重启某服务 +docker compose -f docker-compose.prod.yml restart backend + +# 停止(保留数据) +docker compose -f docker-compose.prod.yml down + +# 进入后端容器排查 +docker compose -f docker-compose.prod.yml exec backend bash +``` + +--- + +## 14. 常见问题排查 + +| 现象 | 排查方向 | +|------|---------| +| 浏览器白屏 / 404 | 前端容器是否运行:`curl -I http://127.0.0.1:8080`;Nginx `location /` 是否转发到 8080 | +| 刷新 `/chat` 等子路由 404 | 前端容器 `nginx.conf` 是否有 `try_files $uri $uri/ /index.html`(已内置) | +| 登录后无法收消息 / 一直转圈 | WebSocket 未通:检查 `wss://www.e4s.world/ws`;确认 Nginx `/ws` 块有 `proxy_http_version 1.1` + `Upgrade`/`Connection` 头 | +| 上传头像失败 (413) | `client_max_body_size`(Nginx)与后端 `MAX_UPLOAD_SIZE_MB` 不匹配,调大前者 | +| 上传后图片不显示 | Nginx `/uploads/` 的 `proxy_pass` 不能带尾部斜杠(否则前缀被剥,404) | +| certbot 失败 | DNS 未生效(`dig +short www.e4s.world`);80 端口被防火墙挡;触发了 Let's Encrypt 限频(5 次/小时) | +| `compose` 报「请在 .env.prod 中设置…」 | 漏了 `--env-file .env.prod`,或 `.env.prod` 里对应变量为空 | +| 改了 `.env.prod` 没生效 | 见 [第 12 节](#12-更新--重新部署) 关于「首次启动写入」的说明 | + +--- + +## 15. 已知限制(架构层面) + +1. **单进程后端**:当前实时消息依赖进程内存连接管理器,无法横向扩展(加机器 / 多 worker)。扩展前需先把 `ConnectionManager` 改为基于 Redis pub/sub。 +2. **无数据库迁移工具**:`create_all` 只建新表,结构变更需手动 SQL(见第 12 节)。后续建议引入 Alembic。 +3. **上传文件无鉴权读取**:`/uploads/<文件名>` 可被任何知道路径的人访问(沿用现有设计)。生产 Nginx 已加 `X-Content-Type-Options: nosniff` 缓解存储型 XSS;如需严格鉴权,需改造后端。 + +--- + +## 附:开发环境(本机) + +开发环境与本指南的生产环境**完全独立**,命令不变: + +```bash +docker compose up --build # 走 docker-compose.yml(开发),含热重载 +docker compose down +``` + +开发用前端变量见 `frontend/.env.development`,生产构建域名无关、无需配置。 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..84afa58 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,34 @@ +# 排除无需进入镜像的文件,减小体积、避免泄密 +__pycache__/ +*.pyc +*.pyo +*.pyd +.venv/ +venv/ +env/ +.Python + +# 环境变量(含密钥,绝不能打进镜像) +.env +.env.* + +# 运行时数据 +uploads/ +dist/ +build/ +*.egg-info/ + +# 测试与缓存 +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# 版本控制与文档(镜像不需要) +.git/ +.gitignore + +# Dockerfile 本身 +Dockerfile +Dockerfile.prod diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..2265d8b --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,37 @@ +# ============================================================ +# 青叶 —— 生产环境后端镜像 +# ------------------------------------------------------------ +# 与开发用 Dockerfile 的区别: +# 1. 不切换国内镜像源(使用 Debian / PyPI 官方源,适合全球部署) +# 2. 运行命令固定为「单进程 uvicorn + proxy-headers」,不带 --reload +# 3. 不挂载源码(运行镜像内 COPY 进来的代码) +# +# ⚠️ 关键约束:必须单进程!WebSocket 连接管理器(ConnectionManager)是 +# 进程内存单例。若改用 --workers N 或 gunicorn 多 worker,连接会被拆分到 +# 不同进程,导致跨用户实时消息(聊天、撤回、好友请求等)无法投递。 +# 水平扩展前需先把 manager 迁移到 Redis pub/sub。 +# ============================================================ +FROM python:3.12-slim + +WORKDIR /app + +# 安装系统依赖(使用官方源,便于全球部署) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Python 依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制项目代码(backend/.dockerignore 已排除 __pycache__/.env/uploads 等) +COPY . . + +# 创建上传目录 +RUN mkdir -p /app/uploads + +EXPOSE 8000 + +# 生产运行:单 worker + proxy-headers(信任 Nginx 转发的 X-Forwarded-*) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] diff --git a/backend/app/websocket/manager.py b/backend/app/websocket/manager.py index 4a06d2f..0f1358c 100644 --- a/backend/app/websocket/manager.py +++ b/backend/app/websocket/manager.py @@ -73,4 +73,9 @@ class ConnectionManager: # 全局单例 +# ⚠️ 重要约束:本管理器为「进程内存」单例 —— active_connections 只存在于 +# 当前进程。因此后端必须以「单进程」方式运行(生产镜像 Dockerfile.prod +# 已固定为单 worker uvicorn)。若使用 --workers N 或 gunicorn 多 worker, +# 连接会分散到不同进程,跨用户 / 跨标签页的实时消息(聊天、撤回、好友请求、 +# 互动通知等)将无法投递。水平扩展前需先将其迁移到基于 Redis 的 pub/sub。 manager = ConnectionManager() diff --git a/deploy.md b/deploy.md new file mode 100644 index 0000000..e609ee1 --- /dev/null +++ b/deploy.md @@ -0,0 +1,3 @@ + +计划将这个项目部署到生产环境,域名为www.e4s.world,使用Nginx作为反向代理,使用Docker Compose作为容器编排。VPS的IP为:103.170.72.162。请修改相关代码,以便项目能区分生产环境和开发环境。 +之后,生成一个md文件,告知如何在VPS中执行部署命令。VPS的OS是Ubuntu 20.04。安装了nginx,但其他必要的软件包未安装。 \ No newline at end of file diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..9edb64c --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,93 @@ +# ============================================================ +# 青叶 (QingYe) —— 宿主机 Nginx 反向代理配置 +# ------------------------------------------------------------ +# 目标域名:www.e4s.world +# 部署步骤: +# 1) 复制到 /etc/nginx/sites-available/www.e4s.world.conf +# 2) ln -s /etc/nginx/sites-available/www.e4s.world.conf /etc/nginx/sites-enabled/ +# 3) 删除默认站点(如有冲突):rm /etc/nginx/sites-enabled/default +# 4) nginx -t && systemctl reload nginx +# 5) 确认 DNS 已生效且 http://www.e4s.world 能打开前端 +# 6) 申请 SSL 证书:certbot --nginx -d www.e4s.world +# certbot 会自动把 80 端口改为 301 跳转到 443,并将下列 location +# 复制到新增的 443 server 块(WebSocket 也由 443 处理)。 +# ------------------------------------------------------------ +# 流量走向: +# https://www.e4s.world/ → 127.0.0.1:8080 (前端容器, SPA) +# https://www.e4s.world/api/... → 127.0.0.1:8000 (后端容器) +# https://www.e4s.world/uploads/ → 127.0.0.1:8000 (后端 StaticFiles) +# wss://www.e4s.world/ws → 127.0.0.1:8000 (后端 WebSocket) +# ============================================================ + +upstream qingye_frontend { + server 127.0.0.1:8080; +} + +upstream qingye_backend { + server 127.0.0.1:8000; +} + +server { + listen 80; + server_name www.e4s.world; + + # 上传体积上限:须 >= 后端 MAX_UPLOAD_SIZE_MB(默认 10MB)。 + # 这里设 12MB 为 multipart 编码留余量;若调大后端限制需同步调大此值。 + client_max_body_size 12M; + + # 基础安全响应头(HSTS 仅在 HTTPS 下有意义,须在 certbot 生成 443 块后补上,见 DEPLOYMENT.md) + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + + # ---- 前端 SPA(默认路由,最低优先级)---- + location / { + proxy_pass http://qingye_frontend; + proxy_http_version 1.1; + 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 接口(proxy_pass 不带尾部斜杠,保留 /api/ 前缀)---- + location /api/ { + proxy_pass http://qingye_backend; + proxy_http_version 1.1; + 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; + } + + # ---- 上传文件(后端 StaticFiles 挂载于 /uploads)---- + # nosniff:防止用户上传的 .html/.svg 在同源执行(避免读取 localStorage token 的存储型 XSS) + location /uploads/ { + proxy_pass http://qingye_backend; + proxy_http_version 1.1; + 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; + add_header X-Content-Type-Options "nosniff" always; + expires 7d; + access_log off; + } + + # ---- WebSocket(精确匹配 /ws,优先级高于上面的 location /)---- + # 注意:必须 proxy_http_version 1.1 + Upgrade/Connection,否则握手失败。 + location = /ws { + proxy_pass http://qingye_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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; + # 长连接超时(秒),避免空闲被切断 + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + # 不记录含 token 的查询串,避免 JWT 落入访问日志 + access_log off; + } +} diff --git a/deploy/qingye.service b/deploy/qingye.service new file mode 100644 index 0000000..94aa473 --- /dev/null +++ b/deploy/qingye.service @@ -0,0 +1,33 @@ +# ============================================================ +# 青叶 (QingYe) —— systemd 服务单元(开机自启 + 崩溃重启) +# ------------------------------------------------------------ +# 安装: +# sudo cp deploy/qingye.service /etc/systemd/system/qingye.service +# sudo systemctl daemon-reload +# sudo systemctl enable qingye # 开机自启 +# sudo systemctl start qingye +# +# 查看状态: sudo systemctl status qingye +# 查看日志: sudo journalctl -u qingye -f +# +# ⚠️ ExecStart 必须显式带 -f docker-compose.prod.yml, +# 绝不能运行裸 docker compose up(否则可能误用开发配置)。 +# ============================================================ +[Unit] +Description=QingYe (青叶) Production Stack (Docker Compose) +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/qingye +# 注意:--env-file 让 ${VAR} 插值来自 .env.prod;-f 固定使用生产编排文件 +ExecStart=/usr/bin/docker compose --env-file .env.prod -f docker-compose.prod.yml up -d +ExecStop=/usr/bin/docker compose --env-file .env.prod -f docker-compose.prod.yml down +TimeoutStartSec=0 +User=root + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..6870b6a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,115 @@ +# ============================================================ +# 青叶 (QingYe) —— 生产环境 Docker Compose(自包含) +# ------------------------------------------------------------ +# 启动命令: +# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build +# +# 说明: +# * 此文件为「生产专用」,与开发用的 docker-compose.yml 完全独立。 +# 生产服务器上请始终带 -f docker-compose.prod.yml,切勿运行裸 docker compose up。 +# * 所有密钥通过 --env-file .env.prod 注入(密钥文件已在 .gitignore 中)。 +# * 后端必须「单进程」运行:WebSocket 连接管理器为进程内存单例 +# (见 backend/app/websocket/manager.py),多 worker / gunicorn 会导致 +# 跨用户、跨标签页的实时消息丢失。镜像 Dockerfile.prod 已固定为单进程。 +# * postgres / redis 不向主机暴露端口,仅容器内网互通。 +# * backend / frontend 仅绑定 127.0.0.1,由宿主机 Nginx 反向代理对外。 +# ============================================================ + +services: + # ==================== PostgreSQL ==================== + postgres: + image: postgres:16-alpine + container_name: qingye-postgres + restart: always + environment: + POSTGRES_DB: ${POSTGRES_DB:-qingye} + POSTGRES_USER: ${POSTGRES_USER:-qingye} + # 密钥必填:缺失时 compose 会直接报错,绝不回退到弱口令 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请在 .env.prod 中设置 POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-qingye} -d ${POSTGRES_DB:-qingye}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - qingye-network + # 生产环境不向宿主机映射 5432 端口(安全) + + # ==================== Redis ==================== + # Redis 非可选:flash_service 原子计数 / draft_service 草稿等依赖它 + redis: + image: redis:7-alpine + container_name: qingye-redis + restart: always + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - qingye-network + # 生产环境不向宿主机映射 6379 端口(安全) + + # ==================== Backend (FastAPI) ==================== + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: qingye-backend + restart: always + ports: + - "127.0.0.1:8000:8000" # 仅本机可达,由宿主机 Nginx 反向代理 + environment: + # DATABASE_URL 由上面的 POSTGRES_* 变量自动拼接,保持口令一致 + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-qingye}:${POSTGRES_PASSWORD:?请在 .env.prod 中设置 POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-qingye} + REDIS_URL: redis://redis:6379/0 + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?请在 .env.prod 中设置 JWT_SECRET_KEY} + JWT_REFRESH_SECRET_KEY: ${JWT_REFRESH_SECRET_KEY:?请在 .env.prod 中设置 JWT_REFRESH_SECRET_KEY} + CORS_ORIGINS: ${CORS_ORIGINS:-https://www.e4s.world} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?请在 .env.prod 中设置 ADMIN_PASSWORD} + APP_ENV: production + volumes: + - upload_data:/app/uploads # 仅持久化用户上传文件,不挂载源码(运行镜像内已打包的代码) + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - qingye-network + security_opt: + - no-new-privileges:true + + # ==================== Frontend (Vue 3 构建产物 + Nginx) ==================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + # 生产构建为「域名无关」:不传入任何 VITE_* 变量, + # 前端代码会自动使用页面同源地址(/api/v1 与 wss://当前域名)。 + container_name: qingye-frontend + restart: always + ports: + - "127.0.0.1:8080:80" # 仅本机可达,由宿主机 Nginx 反向代理 + depends_on: + - backend + networks: + - qingye-network + security_opt: + - no-new-privileges:true + +# ==================== 数据卷 ==================== +volumes: + pgdata: + redisdata: + upload_data: + +# ==================== 网络 ==================== +networks: + qingye-network: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..d723033 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,23 @@ +# 排除无需进入构建上下文的文件 +node_modules/ +dist/ + +# 环境变量(含密钥/本地配置,不应进入镜像)—— 与 backend/.dockerignore 保持一致 +.env +.env.* + +# 日志 +*.log +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# 版本控制与编辑器 +.git/ +.gitignore +.vscode/ +.idea/ + +# Dockerfile 本身 +Dockerfile +Dockerfile.prod diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..cb96551 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,4 @@ +# 开发环境前端变量(vite dev 在 development 模式下自动加载此文件) +# 生产构建(vite build)不会读取此文件 —— 生产环境前端使用页面同源地址。 +VITE_API_BASE_URL=http://localhost:8000 +VITE_WS_BASE_URL=ws://localhost:8000 diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..01c681f --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,41 @@ +# ============================================================ +# 青叶 —— 生产环境前端镜像(多阶段构建) +# Stage 1: Node 构建 Vite 生产包 +# Stage 2: Nginx 提供静态文件 + SPA 路由回退 +# ------------------------------------------------------------ +# 构建「域名无关」:不设置任何 VITE_* 变量。前端代码在没有 VITE_API_BASE_URL / +# VITE_WS_BASE_URL 时,会自动使用页面同源地址(API 走相对路径 /api/v1, +# WebSocket 走 wss://<当前域名>)。因此同一份镜像可部署到任意域名。 +# ============================================================ + +# ===== Stage 1: 构建 ===== +FROM node:20-alpine AS builder + +WORKDIR /app + +# 复制依赖描述(package-lock.json 可选 —— 仓库未提交锁文件,用 npm install) +COPY package.json package-lock.json* ./ + +# 安装依赖(使用 npm 官方源) +RUN npm install + +# 复制源码(frontend/.dockerignore 已排除 node_modules / dist / .env 等) +COPY . . + +# 直接调用 vite build,跳过 vue-tsc 类型检查 +# (避免仓库中既有的类型错误阻断生产部署;类型检查请在 CI / 发布前单独执行) +# 如需严格的类型门禁,可改为 `npm run build`。 +RUN npx vite build + +# ===== Stage 2: 运行(Nginx 静态服务) ===== +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 SPA 专用 Nginx 配置(createWebHistory 需要 try_files 回退) +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e555deb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,28 @@ +# 青叶前端容器 Nginx 配置(提供 SPA 静态文件) +# 仅负责静态资源与前端路由回退;API / WebSocket / 上传文件由「宿主机 Nginx」转发到后端。 +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # gzip 压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript application/xml text/xml application/xml+rss text/javascript image/svg+xml; + + # SPA 路由回退:Vue Router 使用 createWebHistory(HTML5 history 模式), + # 刷新 /chat、/moments 等前端路由时需回退到 index.html + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源长缓存(Vite 产物带 hash 文件名,可安全强缓存) + location ~* \.(?:js|css|woff2?|ttf|eot|png|jpg|jpeg|gif|ico|svg)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + access_log off; + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3e6f5f4..202f828 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,9 @@ import axios, { type AxiosInstance } from 'axios' -const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' +// 域名无关:未显式配置 VITE_API_BASE_URL 时使用「同源相对地址」, +// 即请求发往当前页面所在的域名(生产环境与前端同源)。 +// 开发时由 frontend/.env.development 提供 http://localhost:8000。 +const API_BASE = import.meta.env.VITE_API_BASE_URL || '' const api: AxiosInstance = axios.create({ baseURL: `${API_BASE}/api/v1`, diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts index f705a0c..8ac0adf 100644 --- a/frontend/src/composables/useWebSocket.ts +++ b/frontend/src/composables/useWebSocket.ts @@ -2,7 +2,11 @@ import { ref } from 'vue' import { useAuthStore } from '@/stores/auth' import { useChatStore } from '@/stores/chat' -const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8000' +// 域名无关:未显式配置时根据当前页面协议自动推导(https→wss, http→ws)。 +// 开发时由 frontend/.env.development 提供 ws://localhost:8000。 +const WS_BASE = + import.meta.env.VITE_WS_BASE_URL || + `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}` let ws: WebSocket | null = null let reconnectAttempts = 0