From 57767c77a101e0627612fd2546f40716176d5e5d Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 30 Oct 2026 18:49:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20openclaw-01=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=E3=80=81systemd?= =?UTF-8?q?=20=E5=8D=95=E5=85=83=E4=B8=8E=E9=83=A8=E7=BD=B2=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 2.Design/DEPLOY.md | 118 +++++++++++++++++++++++ README.md | 1 + backend/requirements.txt | 2 + deploy/prod/dadaotask-api.service | 33 +++++++ deploy/prod/deploy.sh | 77 +++++++++++++++ deploy/prod/env.production.example | 37 +++++++ deploy/prod/nginx.dadaotask.conf.example | 28 ++++++ deploy/prod/provision-db.sh | 50 ++++++++++ 8 files changed, 346 insertions(+) create mode 100644 2.Design/DEPLOY.md create mode 100644 deploy/prod/dadaotask-api.service create mode 100755 deploy/prod/deploy.sh create mode 100644 deploy/prod/env.production.example create mode 100644 deploy/prod/nginx.dadaotask.conf.example create mode 100755 deploy/prod/provision-db.sh diff --git a/2.Design/DEPLOY.md b/2.Design/DEPLOY.md new file mode 100644 index 0000000..98277e6 --- /dev/null +++ b/2.Design/DEPLOY.md @@ -0,0 +1,118 @@ +# 部署(生产 · openclaw-01) + +> 适用于当前唯一的线上环境:`openclaw-01`(192.168.8.188),访问入口 `http://192.168.8.188:8200`。 +> 本地开发见 [`DEVELOPMENT.md`](DEVELOPMENT.md)。部署铁律见仓库根 [`AGENTS.md`](../AGENTS.md) 第 2 节。 + +## 1. 生产拓扑 + +``` +浏览器 ──HTTP──▶ openclaw-01:8200 + └ gunicorn(2 × UvicornWorker) ── app.main:app + ├ /api/v1/** 业务 API + └ / 前端产物 web/dist(单页应用,404 回退 index.html) + │ + ┌────────────────────────┼────────────────────────┐ + ▼ ▼ ▼ + fnsvr-01:8432 PG 17.6 fnsvr-01:6379 Redis 本机 backend/var/uploads + 库 dadaotask_prod db 6(独立库号) 附件文件 + 角色 dadao_prod_app(RLS) + 角色 dadao_prod_owner(迁移) +``` + +为什么是这个形状: + +- **生产库和开发库在同一个 PG 实例**(fnsvr-01),所以生产用**独立的库 + 独立的角色**。复用 `dadao_app` 等于拿到开发口令就能连生产。迁移里的 `GRANT`/`DEFAULT PRIVILEGES` 按 `APP_DB_ROLE` / `OWNER_DB_ROLE` 生成,靠 `backend/app/roles.py` 注入。 +- **Redis 用 6 号库**隔离:共享实例没有密码,靠库号避免和别的项目撞键(验证码、幂等键)。 +- **没有 nginx**:openclaw 账号无 sudo 密码,改不了系统 nginx,所以后端单端口同时托管 API 与前端产物(同源,免 CORS)。root 愿意配合时按 `deploy/prod/nginx.dadaotask.conf.example` 加反代,不用改应用。 +- **用户级 systemd**:openclaw-01 上所有服务(easymemo、portal-site…)都是 `systemctl --user` 起的,本方案沿用,全程不需要 root。 + +## 2. 首次部署(一次性) + +前置:能 SSH 到 openclaw-01;fnsvr-01 的 root 权限(只在建库这一步需要)。 + +### 2.1 建生产库与角色(在 fnsvr-01 上,root) + +```bash +# 口令自己生成,别用示例里的 +OWNER_PW='' APP_PW='' \ + bash deploy/prod/provision-db.sh +``` + +会创建:库 `dadaotask_prod`(owner `dadao_prod_owner`)、角色 `dadao_prod_owner` / `dadao_prod_app`(`NOSUPERUSER NOBYPASSRLS`,否则 RLS 会被跳过)、`public` schema 归 owner、`REVOKE CREATE ... FROM PUBLIC`。建表与 RLS 策略由 alembic 负责(2.4)。 + +### 2.2 代码落地(在 openclaw-01 上,openclaw 身份) + +```bash +git clone --branch main --single-branch http://gitea.tatta.cn/Tatta/DadaoTask.git ~/app/dadaotask +``` + +### 2.3 配置(不进仓库) + +```bash +mkdir -p ~/.config/dadaotask && chmod 700 ~/.config/dadaotask +cp ~/app/dadaotask/deploy/prod/env.production.example ~/.config/dadaotask/env.production +chmod 600 ~/.config/dadaotask/env.production +vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__ +``` + +必填项:`DATABASE_URL` / `MIGRATION_DATABASE_URL`(2.1 的口令)、`JWT_SECRET`(≥32 位随机串)、`REDIS_URL`、`WEB_DIST_DIR`、`UPLOAD_DIR`。 + +**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。 + +### 2.4 装服务单元 + +```bash +mkdir -p ~/.config/systemd/user +cp ~/app/dadaotask/deploy/prod/dadaotask-api.service ~/.config/systemd/user/ +systemctl --user daemon-reload +``` + +### 2.5 首次上线 + +```bash +bash ~/app/dadaotask/deploy/prod/deploy.sh +``` + +## 3. 日常发布(每次上线) + +```bash +# 本地:提交并 push 到 main(生产只跑仓库 HEAD) +# 服务器: +bash ~/app/dadaotask/deploy/prod/deploy.sh +``` + +脚本做六件事,任何一步失败即中止:代码对齐 `origin/main`(`git reset --hard`,禁止服务器手工改)→ 装 Python 依赖 → `alembic upgrade head`(用迁移角色)→ **在服务器上** `npm ci && npm run build` → 写 `env.build` 并重启 → 校验 `/health` 与 `/version` 的 commit 等于 HEAD。 + +前端产物只在服务器上构建,**不允许**本地上传 dist(AGENTS.md 铁律 3)。 + +## 4. 部署后校验 + +```bash +curl -s http://192.168.8.188:8200/health # {"status":"ok","db":true,...} +curl -s http://192.168.8.188:8200/version # commit 必须等于 git rev-parse --short HEAD +curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产物) +``` + +浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。 + +## 5. 运维 + +```bash +systemctl --user status dadaotask-api # 状态 +systemctl --user restart dadaotask-api # 重启 +journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout) +``` + +- 附件落盘:`backend/var/uploads`(按工作区分子目录),删库不删文件,备份时一起打包 +- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构 +- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致) + +## 6. 已知限制(当前阶段) + +| 限制 | 影响 | 解除方式 | +|---|---|---| +| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) | +| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` | +| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) | +| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 | +| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 | diff --git a/README.md b/README.md index 0082afc..d9f89cd 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) - 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md) - 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md) +- 生产部署(openclaw-01):[`2.Design/DEPLOY.md`](2.Design/DEPLOY.md) - 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md) ## 技术栈 diff --git a/backend/requirements.txt b/backend/requirements.txt index 1197f15..ed18c70 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 +# 生产进程管理:gunicorn + UvicornWorker(开发用 `uvicorn --reload` 即可) +gunicorn==23.0.0 sqlalchemy[asyncio]==2.0.36 asyncpg==0.30.0 alembic==1.14.0 diff --git a/deploy/prod/dadaotask-api.service b/deploy/prod/dadaotask-api.service new file mode 100644 index 0000000..e5ba5e2 --- /dev/null +++ b/deploy/prod/dadaotask-api.service @@ -0,0 +1,33 @@ +# 达道Task 生产服务(用户级 systemd,openclaw-01 上不需要 root) +# +# 安装: +# cp deploy/prod/dadaotask-api.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now dadaotask-api +# +# 两份 EnvironmentFile: +# env.production —— 真实配置与口令(chmod 600,不进仓库) +# env.build —— 由 deploy.sh 每次部署重写(BUILD_COMMIT / BUILD_TIME),供 /version 校验 +[Unit] +Description=DadaoTask API (prod, gunicorn + uvicorn workers) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/openclaw/app/dadaotask/backend +EnvironmentFile=/home/openclaw/.config/dadaotask/env.production +EnvironmentFile=/home/openclaw/.config/dadaotask/env.build +Environment=PYTHONUNBUFFERED=1 +ExecStart=/home/openclaw/app/dadaotask/.venv/bin/gunicorn app.main:app \ + -k uvicorn.workers.UvicornWorker -w 2 \ + -b 0.0.0.0:8200 \ + --timeout 60 --graceful-timeout 30 --keep-alive 5 \ + --access-logfile - --error-logfile - +Restart=always +RestartSec=3 +KillSignal=SIGTERM +TimeoutStopSec=40 + +[Install] +WantedBy=default.target diff --git a/deploy/prod/deploy.sh b/deploy/prod/deploy.sh new file mode 100755 index 0000000..2e7d7ba --- /dev/null +++ b/deploy/prod/deploy.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# 生产部署脚本 —— openclaw-01(用户级 systemd,全程不需要 root) +# +# 遵守 AGENTS.md 的部署铁律: +# 1. 生产只跑仓库 HEAD(git reset --hard origin/main,禁止服务器手工改) +# 2. 前端产物只在服务器上由 HEAD 构建(npm ci && npm run build) +# 3. 部署后校验 /version 的 commit == git rev-parse --short HEAD +# +# 用法(在 openclaw-01 上,openclaw 身份): +# bash ~/app/dadaotask/deploy/prod/deploy.sh +# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。 +set -euo pipefail + +APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask} +CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask} +ENV_FILE=$CONF_DIR/env.production +SERVICE=${SERVICE:-dadaotask-api} +PORT=${PORT:-8200} +REPO_URL=${REPO_URL:-https://gitea.tatta.cn/Tatta/DadaoTask.git} + +step() { printf '\n== %s ==\n' "$*"; } +die() { printf '!! %s\n' "$*" >&2; exit 1; } + +step "0. 前置检查" +test -f "$ENV_FILE" || die "缺少 $ENV_FILE(参考 deploy/prod/env.production.example)" + +if ! command -v node >/dev/null; then die "node 未安装(前端必须在服务器上构建)"; fi +node_major=$(node -v | sed 's/^v\([0-9]*\).*/\1/') +[ "$node_major" -ge 20 ] || die "node 版本过低:$(node -v),需要 20+" + +step "1. 代码对齐仓库 HEAD" +if [ ! -d "$APP_DIR/.git" ]; then + git clone --branch main --single-branch "$REPO_URL" "$APP_DIR" +fi +cd "$APP_DIR" +git fetch --prune origin +git checkout -q main +git reset --hard -q origin/main # 生产不允许领先/落后仓库,也不允许脏改动 +COMMIT=$(git rev-parse HEAD) +SHORT=$(git rev-parse --short HEAD) +echo " HEAD=$COMMIT ($SHORT)" + +step "2. Python 依赖" +[ -x .venv/bin/python ] || python3 -m venv .venv +.venv/bin/pip install -q --upgrade pip +.venv/bin/pip install -q -r backend/requirements.txt + +step "3. 数据库迁移(迁移角色,非应用角色)" +set -a; . "$ENV_FILE"; set +a +( cd backend && ../.venv/bin/alembic upgrade head ) + +step "4. 前端构建(只在服务器上、只用 HEAD 的源码)" +( cd web && npm ci --no-audit --no-fund && npm run build ) +test -f web/dist/index.html || die "前端构建产物缺失:web/dist/index.html" + +step "5. 写入构建标记并重启服务" +mkdir -p "$CONF_DIR" backend/var/uploads +chmod 700 "$CONF_DIR" +printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(date -Iseconds)" >"$CONF_DIR/env.build" +chmod 600 "$CONF_DIR/env.build" "$ENV_FILE" +systemctl --user daemon-reload +systemctl --user enable "$SERVICE" >/dev/null 2>&1 || true +systemctl --user restart "$SERVICE" + +step "6. 校验(/health + /version 的 commit)" +for _ in $(seq 1 30); do + curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break + sleep 1 +done +HEALTH=$(curl -fsS "http://127.0.0.1:$PORT/health") || die "健康检查失败:服务没起来(journalctl --user -u $SERVICE -n 100)" +COMMIT_HTTP=$(curl -fsS "http://127.0.0.1:$PORT/version" | .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["commit"])') \ + || die "/version 读取失败" +echo " health=$HEALTH" +echo " version.commit=$COMMIT_HTTP" +[ "$COMMIT_HTTP" = "$SHORT" ] || die "/version 的 commit($COMMIT_HTTP) 与 HEAD($SHORT) 不一致" + +printf '\n部署完成:http://%s:%s (commit %s)\n' "$(hostname -I | awk '{print $1}')" "$PORT" "$SHORT" diff --git a/deploy/prod/env.production.example b/deploy/prod/env.production.example new file mode 100644 index 0000000..ef56148 --- /dev/null +++ b/deploy/prod/env.production.example @@ -0,0 +1,37 @@ +# 生产环境变量模板(部署到 openclaw-01) +# 用法:复制到 ~/.config/dadaotask/env.production(chmod 600),把 __CHANGE_ME__ 换成真实口令。 +# 真实口令不进仓库;本文件只描述字段。 +ENV=prod +APP_NAME=达道Task + +# ── 数据库:fnsvr-01 共享 PG 上**独立的库 + 独立的角色** ────────────── +# 生产不复用 dev 的 dadao_app/dadao_owner 角色:同集群同角色 = 拿到开发口令即可连生产。 +# 角色名必须在迁移前注入(迁移里的 GRANT 按它生成)。 +OWNER_DB_ROLE=dadao_prod_owner +APP_DB_ROLE=dadao_prod_app +DATABASE_URL=postgresql+asyncpg://dadao_prod_app:__APP_PW__@192.168.8.160:8432/dadaotask_prod +MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_prod_owner:__OWNER_PW__@192.168.8.160:8432/dadaotask_prod + +# ── Redis:fnsvr-01 共享实例,独立 db 号隔离(不使用默认 0 号库)────── +REDIS_URL=redis://192.168.8.160:6379/6 + +# ── 认证 ───────────────────────────────────────────────────────────── +# 生产校验:长度 ≥ 32 且不是开发默认值 +JWT_SECRET=__CHANGE_ME_32_CHARS_OR_MORE__ +ACCESS_TOKEN_MINUTES=30 +REFRESH_TOKEN_DAYS=14 + +# 内测登录:短信通道还没接(非 dev provider 直接 501),先把固定验证码显式打开。 +# 只允许内网访问;短信通道接好后把这两行改成 SMS_PROVIDER=<供应商> 并删掉开关。 +INTERNAL_TEST_LOGIN=true +SMS_PROVIDER=dev +SMS_DEV_CODE=123456 +SMS_THROTTLE_SECONDS=60 + +# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用 +CORS_ORIGINS=http://127.0.0.1:8200,http://192.168.8.188:8200 + +# ── 前端产物与上传目录(绝对路径)───────────────────────────────────── +WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist +UPLOAD_DIR=/home/openclaw/app/dadaotask/backend/var/uploads +MAX_UPLOAD_MB=20 diff --git a/deploy/prod/nginx.dadaotask.conf.example b/deploy/prod/nginx.dadaotask.conf.example new file mode 100644 index 0000000..2c31402 --- /dev/null +++ b/deploy/prod/nginx.dadaotask.conf.example @@ -0,0 +1,28 @@ +# 给 root 用的 nginx 反代(当前没启用:openclaw 账号无 sudo,先跑 8200 单端口) +# +# 装法(root): +# cp deploy/prod/nginx.dadaotask.conf.example /etc/nginx/sites-available/dadaotask +# ln -s /etc/nginx/sites-available/dadaotask /etc/nginx/sites-enabled/dadaotask +# nginx -t && systemctl reload nginx +# +# 说明:后端已直接在 8200 上同时托管 API 和前端产物(web/dist), +# 所以这里只做一层透传,不需要单独的静态站点 root。 +server { + listen 80; + listen [::]:80; + server_name _; # 有域名后改成真实域名 + + client_max_body_size 25m; # 附件上限 20MB,留余量 + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location / { + proxy_pass http://127.0.0.1:8200; + 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; + proxy_read_timeout 120s; # 知识检索/上传偶发慢请求 + } +} diff --git a/deploy/prod/provision-db.sh b/deploy/prod/provision-db.sh new file mode 100755 index 0000000..6c3d609 --- /dev/null +++ b/deploy/prod/provision-db.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# 在 fnsvr-01 的共享 PostgreSQL 上给「生产」建独立的库与角色。 +# +# 为什么独立角色:生产库和开发库在同一个实例(fnsvr-01:8432)。复用 dadao_app +# 等于拿到开发口令就能连生产;所以生产用 dadao_prod_owner / dadao_prod_app, +# 迁移里的 GRANT 与 DEFAULT PRIVILEGES 会按 APP_DB_ROLE / OWNER_DB_ROLE 生成。 +# +# 在 fnsvr-01 上以 root 执行(需要 docker 权限): +# OWNER_PW='...' APP_PW='...' bash deploy/prod/provision-db.sh +# +# 幂等:角色或库已存在时跳过创建(不会重置已有口令)。 +set -euo pipefail + +CONTAINER=${CONTAINER:-1Panel-postgresql-wIKz} +DB=${DB:-dadaotask_prod} +OWNER_ROLE=${OWNER_ROLE:-dadao_prod_owner} +APP_ROLE=${APP_ROLE:-dadao_prod_app} +: "${OWNER_PW:?需要 OWNER_PW 环境变量(迁移角色口令)}" +: "${APP_PW:?需要 APP_PW 环境变量(应用角色口令)}" + +echo "== 建角色与库:$DB(owner=$OWNER_ROLE app=$APP_ROLE)==" +docker exec -i "$CONTAINER" psql -U dbadmin -d postgres -v ON_ERROR_STOP=1 -q \ + -v owner_role="$OWNER_ROLE" -v owner_pw="$OWNER_PW" \ + -v app_role="$APP_ROLE" -v app_pw="$APP_PW" -v db="$DB" <<'SQL' +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'owner_role', :'owner_pw') + WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'owner_role')\gexec +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_role', :'app_pw') + WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_role')\gexec +-- 应用角色绝不能是 superuser / bypassrls,否则 RLS 直接被跳过 +SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE', :'app_role')\gexec +SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS', :'owner_role')\gexec +SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'owner_role') + WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db')\gexec +SQL + +echo "== 库内权限(schema 归 owner,app 只能读写数据)==" +docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -v ON_ERROR_STOP=1 -q \ + -v owner_role="$OWNER_ROLE" -v app_role="$APP_ROLE" <<'SQL' +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +SELECT format('ALTER SCHEMA public OWNER TO %I', :'owner_role')\gexec +SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'app_role')\gexec +SELECT format('GRANT USAGE ON SCHEMA public TO %I', :'app_role')\gexec +SQL + +echo "== 自检 ==" +docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -tAc \ + "select current_database(), (select rolname from pg_roles where rolname='$APP_ROLE'), + (select rolsuper from pg_roles where rolname='$APP_ROLE'), + (select rolbypassrls from pg_roles where rolname='$APP_ROLE')" +echo "完成。注意:建表与 RLS 由 alembic(deploy.sh 第 3 步)负责。"