Compare commits

..
3 Commits
20 changed files with 475 additions and 15 deletions
+118
View File
@@ -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='<dadao_prod_owner 的口令>' APP_PW='<dadao_prod_app 的口令>' \
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 | 别人占满连接或内存会波及本站 | 独立实例 |
+1
View File
@@ -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)
## 技术栈
+7 -1
View File
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
ACCESS_TOKEN_MINUTES=30
REFRESH_TOKEN_DAYS=14
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
SMS_PROVIDER=dev
SMS_DEV_CODE=123456
INTERNAL_TEST_LOGIN=false
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
SMS_THROTTLE_SECONDS=60
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS)
# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md
# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
+3 -3
View File
@@ -12,13 +12,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE, OWNER_ROLE
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
# 所以这里按语句拆成元组,逐条执行。
@@ -76,7 +76,7 @@ POLICY_STATEMENTS: tuple[str, ...] = (
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
),
(
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} IN SCHEMA public "
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
),
)
+2 -2
View File
@@ -15,13 +15,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
_TIMESTAMP_COLUMNS = (
+2 -2
View File
@@ -19,13 +19,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
+2 -2
View File
@@ -21,13 +21,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
+3 -1
View File
@@ -11,6 +11,8 @@ from sqlalchemy.dialects.postgresql import UUID as PgUUID
from alembic import op
from app.roles import APP_ROLE
revision = "0005"
down_revision = "0004"
branch_labels = None
@@ -55,7 +57,7 @@ def upgrade() -> None:
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
"CREATE POLICY attachment_isolation ON attachment "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO dadao_app",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO {APP_ROLE}",
):
op.execute(statement)
+1 -1
View File
@@ -82,7 +82,7 @@ POLICY_SQL = (
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
"CREATE POLICY chunk_isolation ON chunk "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO dadao_app",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
)
@@ -60,7 +60,7 @@ POLICY_SQL = (
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO dadao_app",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO {APP_ROLE}",
)
+13 -2
View File
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
sms_throttle_seconds: int = 60
sms_dev_code: str = "123456"
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
internal_test_login: bool = False
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
@@ -100,6 +103,11 @@ class Settings(BaseSettings):
embedding_dim: int = 1024
embedding_model: str = "BAAI/bge-m3"
# ── 前端产物 ──────────────────────────────────────────
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
# 指向 web/dist 的绝对路径;留空则只提供 API。
web_dist_dir: str | None = None
# ── 构建标记(部署校验用)──────────────────────────────
build_commit: str = "unknown"
build_time: str = "unknown"
@@ -121,8 +129,11 @@ class Settings(BaseSettings):
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev":
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
if self.sms_provider == "dev" and not self.internal_test_login:
problems.append(
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
)
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
+28
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
@@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response
from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from .api import attachments, auth, items, knowledge, tickets, workspaces
from .config import get_settings
@@ -27,6 +31,11 @@ logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_runtime()
if settings.env == "prod" and settings.internal_test_login:
logger.warning(
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
)
logger.info(
"服务启动",
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
@@ -138,3 +147,22 @@ async def version() -> dict:
"api_prefix": settings.api_prefix,
"db_role": role,
}
# ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)────────
# 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。
class _SPAStaticFiles(StaticFiles):
"""静态命中就返回,未命中回退 index.html——前端路由(如 /w/<id>)交给浏览器处理。"""
async def get_response(self, path: str, scope: Scope) -> Response:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
return await super().get_response("index.html", scope)
raise
_web_dist = Path(settings.web_dist_dir) if settings.web_dist_dir else None
if _web_dist is not None and (_web_dist / "index.html").is_file():
app.mount("/", _SPAStaticFiles(directory=str(_web_dist), html=True), name="web")
+28
View File
@@ -0,0 +1,28 @@
"""迁移里用到的数据库角色名。
为什么要有这个模块:RLS 的隔离强度取决于「应用角色不是表 owner、没有 BYPASSRLS」。
开发和生产的库在同一个 PG 实例上(fnsvr-01:8432),如果两边共用 `dadao_app` 角色,
拿到开发口令的人就能直连生产库——所以生产用独立角色,由迁移时的环境变量注入。
默认值就是开发/测试的角色名,所以本地和 CI 行为不变。
"""
from __future__ import annotations
import os
import re
# PostgreSQL 标识符:小写字母/数字/下划线,且不能以数字开头
_ROLE_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
def _role_from_env(env_name: str, default: str) -> str:
value = os.getenv(env_name, default)
if not _ROLE_RE.match(value):
raise RuntimeError(f"{env_name} 不是合法的 PostgreSQL 角色名:{value!r}")
return value
# 迁移角色(owner,可建表,绕过 RLS)与应用角色(只能读写数据,受 RLS 约束)
OWNER_ROLE = _role_from_env("OWNER_DB_ROLE", "dadao_owner")
APP_ROLE = _role_from_env("APP_DB_ROLE", "dadao_app")
+2
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
"""
from __future__ import annotations
import pytest
from app.config import Settings
_STRONG_SECRET = "x" * 48
_PROD_DB = "postgresql+asyncpg://dadao_prod_app:pw@192.168.8.160:8432/dadaotask_prod"
def _prod(**overrides: object) -> Settings:
values: dict[str, object] = {
"env": "prod",
"jwt_secret": _STRONG_SECRET,
"database_url": _PROD_DB,
"sms_provider": "aliyun",
}
values.update(overrides)
return Settings(**values)
def test_prod_rejects_dev_sms_without_switch() -> None:
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
_prod(sms_provider="dev").validate_runtime()
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
def test_prod_still_rejects_weak_jwt_secret() -> None:
with pytest.raises(RuntimeError, match="JWT_SECRET"):
_prod(jwt_secret="short").validate_runtime()
+33
View File
@@ -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
+77
View File
@@ -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"
+37
View File
@@ -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
+28
View File
@@ -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; # 知识检索/上传偶发慢请求
}
}
+50
View File
@@ -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 步)负责。"