From b8429efa3d5dc2243fda34553ae4bf97c8c81651 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Wed, 9 Sep 2026 09:58:09 +0800 Subject: [PATCH] feat: configure Gitea deployment and environment-managed WorldQuant credentials --- .env.example | 4 +- .gitea/workflows/deploy-production.yaml | 27 ++++++++ .gitignore | 2 +- .scratch/gitea-deployment/spec.md | 22 +++++++ README.md | 10 ++- backend/app/config.py | 11 +++- backend/app/main.py | 11 ++-- backend/app/schemas.py | 1 + backend/app/security.py | 16 ++++- backend/tests/test_env_credentials.py | 66 +++++++++++++++++++ compose.production.yaml | 74 +++++++++++++++++++++ compose.public.yaml | 2 + compose.yaml | 2 + docs/deployment-gitea.md | 86 +++++++++++++++++++++++++ frontend/src/pages/AccountPage.tsx | 79 +++++++++++++---------- frontend/src/types.ts | 1 + scripts/deploy-production.sh | 60 +++++++++++++++++ scripts/init_env.py | 1 + 18 files changed, 430 insertions(+), 45 deletions(-) create mode 100644 .gitea/workflows/deploy-production.yaml create mode 100644 .scratch/gitea-deployment/spec.md create mode 100644 backend/tests/test_env_credentials.py create mode 100644 compose.production.yaml create mode 100644 docs/deployment-gitea.md create mode 100644 scripts/deploy-production.sh diff --git a/.env.example b/.env.example index f8956b1..d79fcd1 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,6 @@ ENCRYPTION_KEY=replace-with-generated-fernet-key LOCAL_PORT=8080 # Public deployment only; bare DNS hostname, without scheme or path. DOMAIN=alpha.example.com -# WorldQuant email/password are configured after system login, not in this file. +# WorldQuant credentials; configure both. Single quotes preserve literal $ and #. +WQ_EMAIL= +WQ_PASSWORD= diff --git a/.gitea/workflows/deploy-production.yaml b/.gitea/workflows/deploy-production.yaml new file mode 100644 index 0000000..0ce5188 --- /dev/null +++ b/.gitea/workflows/deploy-production.yaml @@ -0,0 +1,27 @@ +name: Deploy production +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + # Register a dedicated runner on the target host with label wq-production:host. + runs-on: wq-production + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + with: + persist-credentials: false + - name: Build, migrate and deploy + shell: bash + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }} + WQ_EMAIL: ${{ secrets.WQ_EMAIL }} + WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }} + ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} + ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }} + DATABASE_NETWORK: ${{ vars.DATABASE_NETWORK }} + PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }} + run: bash scripts/deploy-production.sh diff --git a/.gitignore b/.gitignore index 3091979..e194fce 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ output/ .playwright-cli/ backups/ -account.json \ No newline at end of file +account.json diff --git a/.scratch/gitea-deployment/spec.md b/.scratch/gitea-deployment/spec.md new file mode 100644 index 0000000..9f94cd9 --- /dev/null +++ b/.scratch/gitea-deployment/spec.md @@ -0,0 +1,22 @@ +# Gitea 生产部署 + +采用已选择的 compound 经验 1,经当前项目核验:复用锁定依赖的 Dockerfile、单 worker 和数据库健康接口;新增独立生产 Compose、外部 PostgreSQL 配置、同机 host Runner 工作流。生产凭据通过 Gitea Secrets 注入,非敏感配置通过 Variables 注入,不生成凭据文件。数据库网络为可配置参数,不沿用旧项目常量作为强制约定。 + +部署在构建完成后停止写入、执行一次性迁移,再启动健康检查。固定项目名与按提交标记镜像;使用宿主机文件锁串行化。保留本地和独立公网部署的现有行为。不执行远程部署、不修改知识库。 + +验证:Compose 展开及缺失配置拒绝、脚本语法、镜像构建、隔离数据库上的迁移和 HTTP 健康检查。真实服务器 Runner、数据库、TLS 入口需首次部署验收。 + +## 验证结果(2026-09-09) + +- Docker Engine 29.6.2 / Compose 5.3.1:默认和 jobs profile 配置校验通过;5 个必填配置缺失时均拒绝展开。 +- 前后端生产镜像构建通过(前端依赖 lottie-web 存在既有 eval 构建警告)。 +- 独立测试项目、临时 PostgreSQL 17、独立外部网络:首次迁移、页面/API 健康、管理员登录与 Secure/HttpOnly Cookie、重复迁移和重启均通过。测试容器、卷和网络已清理,未操作现有应用数据库。 +- Bash 语法与 workflow YAML 解析通过;模拟 Docker 验证成功流程及构建、预检、迁移、健康失败分支,确认提前失败不停止服务、迁移失败不启动服务、失败不记录成功版本。 +- 实际 Gitea/Runner、Linux flock 互斥、公网 TLS、生产库与恢复流程未在目标服务器验证。本机存在用户并行前端改动,本任务未修改这些文件。 +- 经验 1 已应用;后端无可写 named volume,未添加不适用的卷初始化 Job;凭据按用户最新要求由 Gitea 注入。知识库未修改。 + +## Gitea Secrets 调整 + +按用户确认改为步骤级 Secrets/Variables 注入;删除生产环境示例文件和相关忽略例外,不再依赖服务器凭据文件。脚本显式使用 `/dev/null` 作为 env-file,5 个必填值缺失时立即停止;锁与版本记录独立保存在 `/opt/wq-alpha`。 + +本轮验证:Compose 默认/jobs 展开、5 个缺失配置拒绝、真实隔离容器内特殊字符密码完整性、Bash 语法和工作流映射均通过。模拟 Docker 验证成功、锁冲突、缺失变量和构建/预检/迁移/健康失败分支;未输出密钥。未重新运行未变化的迁移和前端构建;真实 Gitea 注入仍待服务器运行确认。 diff --git a/README.md b/README.md index 29218b1..6b34bec 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ ```bash python3 scripts/init_env.py +# 编辑 .env,填写 WQ_EMAIL 与 WQ_PASSWORD,再启动 docker compose up -d --build --wait docker compose ps ``` @@ -20,7 +21,7 @@ docker compose ps 首次使用: -1. 登录系统,在“个人信息”保存 WorldQuant 邮箱和密码,点击“连接 WorldQuant”。 +1. 登录系统,在“个人信息 → 连接设置”点击“连接 WorldQuant”。 2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。 3. 在“Alpha 管理”切换“待提交 / 已提交”。待提交先选创建日期范围,按天同步;已提交可选提交日期范围按天同步,或全量同步。相同起止日期表示单日,日期边界为 UTC,均包含隐藏记录。也可导入指定 Alpha ID。任务面板显示当前日期、进度、错误、取消和重试。 4. 点击 Alpha 打开详情。研究记录保存在本地;PnL 点击获取后缓存。详情的“本地自相关”可发起检测,列表也可选中最多 100 条批量检测。建议先全量同步已提交 Alpha,建立比较基准。下次同步会更新平台数据并保留本地研究记录。 @@ -83,6 +84,10 @@ Chatbox 来源使用 `kind=chatbox`,会话 ID 为 `reference`,生成轮次 I 公共接口位于 `/api/v1/backtests`,对接与验证记录见 [实施规格](.scratch/backtest/spec.md) 和 [回测验收记录](.scratch/backtest/verification.md)。真实平台权限、当前协议与限额尚未联调。 +## Gitea 自动部署(复用已有 PostgreSQL) + +使用独立的 `compose.production.yaml` 和 `.gitea/workflows/deploy-production.yaml`。配置步骤、数据库账号密码位置及升级处理见 [Gitea 部署说明](docs/deployment-gitea.md)。 + ## 公网 HTTPS 部署 `compose.public.yaml` 是独立配置,不与本机配置叠加。先在服务器完成上面的密钥初始化,将 `.env` 中 `DOMAIN` 改为自己的域名(无协议、路径、端口)。DNS 指向服务器,允许入站 TCP 80/443,UDP 443 可选。 @@ -103,6 +108,7 @@ docker compose -f compose.public.yaml logs --tail=100 web | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 仅首次空库初始化管理员,重启不会重置现有密码 | | `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 | | `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码和模型 API Key | +| `WQ_EMAIL` / `WQ_PASSWORD` | WorldQuant 邮箱和密码,成对设置;本地 `.env`,生产 Gitea Secrets | | `LOCAL_PORT` | 本机入口端口,默认 8080 | | `DOMAIN` | 公网域名 | | `AI_REQUEST_LIMIT` | 每轮模型请求上限,默认 12 | @@ -110,6 +116,8 @@ docker compose -f compose.public.yaml logs --tail=100 web | `AI_OUTPUT_TOKENS` | 每次模型输出上限,默认 4096 | | `AI_TIMEOUT` | 每轮累计活动执行时限(秒),默认 180,等待确认不计入 | +WorldQuant 凭据不再从 `account.json` 读取。进程环境变量优先于项目根目录 `.env`;启动时更新数据库中的加密凭据,配置后页面禁止覆盖。修改凭据后重建后端容器(Gitea 重新部署);已绑定账户不能更换邮箱。两个变量都未设置时兼容已有页面配置,只有一个时启动失败。 + WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码和模型 API Key;切勿在正常升级时重新生成它。 修改系统密码(同时撤销所有系统会话): diff --git a/backend/app/config.py b/backend/app/config.py index 18b9072..863ed81 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,12 +1,16 @@ """Deployment configuration; secrets are required and never included in API responses.""" +from pathlib import Path + from cryptography.fernet import Fernet from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file="../.env", extra="ignore") + model_config = SettingsConfigDict( + env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore", hide_input_in_errors=True + ) database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq" admin_username: str = "admin" @@ -15,6 +19,8 @@ class Settings(BaseSettings): public_origin: str = "http://localhost:8080" cookie_secure: bool = False session_hours: int = Field(default=24, ge=1, le=168) + wq_email: str = "" + wq_password: SecretStr = SecretStr("") wq_base_url: str = "https://api.worldquantbrain.com" request_timeout: float = 30 retry_attempts: int = Field(default=4, ge=1, le=8) @@ -26,6 +32,9 @@ class Settings(BaseSettings): @model_validator(mode="after") def validate_secrets(self): + self.wq_email = self.wq_email.strip() + if bool(self.wq_email) != bool(self.wq_password.get_secret_value()): + raise ValueError("WQ_EMAIL and WQ_PASSWORD must be configured together") Fernet(self.encryption_key.get_secret_value().encode()) if self.cookie_secure and not self.public_origin.startswith("https://"): raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN") diff --git a/backend/app/main.py b/backend/app/main.py index 600bc29..9a9fa62 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -53,7 +53,7 @@ from .schemas import ( from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password -def account_output(account, client): +def account_output(account, client, settings): keys = ( "email", "wq_user_id", @@ -70,6 +70,7 @@ def account_output(account, client): return { **{k: getattr(account, k) for k in keys}, "configured": bool(account.password_encrypted), + "credentials_source": "environment" if settings.wq_email else "database", "session": client.session_info(), } @@ -210,10 +211,12 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None): @api.get("/account", response_model=AccountOutput, tags=["account"]) async def get_account(): async with sessions() as db: - return account_output(await db.get(Account, 1), runner.client) + return account_output(await db.get(Account, 1), runner.client, settings) @api.put("/account/credentials", response_model=AccountOutput, tags=["account"]) async def credentials(body: CredentialsInput): + if settings.wq_email: + raise HTTPException(409, "WorldQuant 凭据由环境变量管理,请修改部署配置并重启服务") async with sessions() as db: account = await db.get(Account, 1) if account.wq_user_id and account.email.casefold() != body.email.casefold(): @@ -224,7 +227,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None): account.email = body.email account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode() await db.commit() - return account_output(account, runner.client) + return account_output(account, runner.client, settings) @api.patch("/account/preferences", response_model=AccountOutput, tags=["account"]) async def preferences(body: PreferencesInput): @@ -233,7 +236,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None): for key, value in body.model_dump().items(): setattr(account, key, value) await db.commit() - return account_output(account, runner.client) + return account_output(account, runner.client, settings) async def account_job(kind): async with sessions() as db: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 045ea22..685ba1e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -287,6 +287,7 @@ class PlatformSessionOutput(BaseModel): class AccountOutput(BaseModel): email: str | None configured: bool + credentials_source: Literal["environment", "database"] wq_user_id: str | None profile: dict connection_status: str diff --git a/backend/app/security.py b/backend/app/security.py index d4a1095..4d2e477 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -26,7 +26,7 @@ def cipher(settings) -> Fernet: async def bootstrap(db, settings): - """Only initialize missing singleton records; deployments never reset existing passwords.""" + """Initialize singletons and apply environment credentials without resetting the admin password.""" if not await db.get(Admin, 1): db.add( Admin( @@ -35,8 +35,18 @@ async def bootstrap(db, settings): password_hash=password_hasher.hash(settings.admin_password.get_secret_value()), ) ) - if not await db.get(Account, 1): - db.add(Account(id=1)) + account = await db.get(Account, 1) + if account is None: + account = Account(id=1) + db.add(account) + if settings.wq_email: + # The environment must not bypass the single-account data ownership boundary. + if account.wq_user_id and (account.email or "").casefold() != settings.wq_email.casefold(): + raise ValueError("WQ_EMAIL conflicts with the bound WorldQuant account") + account.email = settings.wq_email + account.password_encrypted = cipher(settings).encrypt( + settings.wq_password.get_secret_value().encode() + ).decode() await db.execute(delete(LoginSession).where(LoginSession.expires_at < now())) await db.commit() diff --git a/backend/tests/test_env_credentials.py b/backend/tests/test_env_credentials.py new file mode 100644 index 0000000..512e8b1 --- /dev/null +++ b/backend/tests/test_env_credentials.py @@ -0,0 +1,66 @@ +"""Environment credential precedence, rotation and account ownership boundaries.""" + +import pytest +from pydantic import SecretStr, ValidationError + +from app.config import Settings +from app.models import Account, Admin +from app.security import bootstrap, cipher + + +def test_dotenv_and_process_precedence(settings, tmp_path, monkeypatch): + env = tmp_path / '.env' + env.write_text("WQ_EMAIL=local@example.com\nWQ_PASSWORD='literal-$value#password'\n") + values = settings.model_dump(exclude={'wq_email', 'wq_password'}) + local = Settings(_env_file=env, **values) + assert local.wq_email == 'local@example.com' + assert local.wq_password.get_secret_value() == 'literal-$value#password' + monkeypatch.setenv('WQ_EMAIL', 'production@example.com') + monkeypatch.setenv('WQ_PASSWORD', 'production-secret') + production = Settings(_env_file=env, **values) + assert production.wq_email == 'production@example.com' + assert production.wq_password.get_secret_value() == 'production-secret' + assert 'production-secret' not in repr(production) + + +@pytest.mark.parametrize('email,password', [('person@example.com', ''), ('', 'private-value')]) +def test_partial_configuration_fails_without_leaking(settings, email, password): + values = settings.model_dump(exclude={'wq_email', 'wq_password'}) + with pytest.raises(ValidationError) as error: + Settings(_env_file=None, **values, wq_email=email, wq_password=password) + assert 'must be configured together' in str(error.value) + assert 'private-value' not in str(error.value) + assert 'person@example.com' not in str(error.value) + + +async def test_env_rotation_api_lock_and_bound_account(app, logged_in): + settings = app.state.settings + settings.wq_email = 'person@example.com' + settings.wq_password = SecretStr('initial-env-secret') + async with app.state.sessions() as db: + original_admin = (await db.get(Admin, 1)).password_hash + await bootstrap(db, settings) + account = await db.get(Account, 1) + assert cipher(settings).decrypt(account.password_encrypted.encode()) == b'initial-env-secret' + account.wq_user_id = 'bound-user' + await db.commit() + settings.wq_password = SecretStr('rotated-env-secret') + async with app.state.sessions() as db: + await bootstrap(db, settings) + account = await db.get(Account, 1) + assert cipher(settings).decrypt(account.password_encrypted.encode()) == b'rotated-env-secret' + assert (await db.get(Admin, 1)).password_hash == original_admin + response = await logged_in.get('/api/v1/account') + assert response.json()['credentials_source'] == 'environment' + assert response.json()['configured'] is True + assert 'secret' not in response.text and 'password' not in response.text + response = await logged_in.put('/api/v1/account/credentials', json={ + 'email': 'person@example.com', 'password': 'manual-secret', + }) + assert response.status_code == 409 + settings.wq_email = 'other@example.com' + async with app.state.sessions() as db: + with pytest.raises(ValueError, match='bound WorldQuant account'): + await bootstrap(db, settings) + await db.rollback() + assert (await db.get(Account, 1)).email == 'person@example.com' diff --git a/compose.production.yaml b/compose.production.yaml new file mode 100644 index 0000000..a7bf9fd --- /dev/null +++ b/compose.production.yaml @@ -0,0 +1,74 @@ +# Independent production configuration; do not merge with compose.yaml. +name: wq-alpha-production + +x-backend: &backend + image: wq-alpha-production-backend:${DEPLOY_TAG:-local} + build: + context: . + dockerfile: Dockerfile.backend + environment: + DATABASE_URL: ${DATABASE_URL:?Set the Gitea DATABASE_URL secret} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY} + PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set the public HTTPS origin} + WQ_EMAIL: ${WQ_EMAIL:?Set the Gitea WQ_EMAIL secret} + WQ_PASSWORD: ${WQ_PASSWORD:?Set the Gitea WQ_PASSWORD secret} + COOKIE_SECURE: "true" + AI_REQUEST_LIMIT: ${AI_REQUEST_LIMIT:-12} + AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12} + AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} + AI_TIMEOUT: ${AI_TIMEOUT:-180} + WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com} + networks: + - default + - database + +services: + backend: + <<: *backend + # Migration is run separately with all application writers stopped. + command: [uvicorn, 'app.main:create_app', --factory, --host, 0.0.0.0, --port, '8000', --workers, '1', --proxy-headers] + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=3)"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + stop_grace_period: 60s + restart: unless-stopped + migrate: + <<: *backend + profiles: [jobs] + command: [alembic, upgrade, head] + restart: 'no' + web: + image: wq-alpha-production-web:${DEPLOY_TAG:-local} + build: + context: . + dockerfile: Dockerfile.frontend + environment: + SITE_ADDRESS: http://:80 + ports: + - '127.0.0.1:${WEB_PORT:-8112}:80' + volumes: + - caddy_data:/data + - caddy_config:/config + depends_on: + backend: + condition: service_healthy + healthcheck: + test: [CMD-SHELL, 'wget -q -O /dev/null http://127.0.0.1/api/v1/health && wget -q -O /dev/null http://127.0.0.1/'] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + restart: unless-stopped + +networks: + database: + external: true + name: ${DATABASE_NETWORK:?Set the existing PostgreSQL Docker network} +volumes: + caddy_data: + caddy_config: diff --git a/compose.public.yaml b/compose.public.yaml index d86d532..9a8ed29 100644 --- a/compose.public.yaml +++ b/compose.public.yaml @@ -29,6 +29,8 @@ services: AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12} AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} AI_TIMEOUT: ${AI_TIMEOUT:-180} + WQ_EMAIL: ${WQ_EMAIL:-} + WQ_PASSWORD: ${WQ_PASSWORD:-} COOKIE_SECURE: "true" depends_on: db: diff --git a/compose.yaml b/compose.yaml index 759f5da..287645b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,8 @@ services: AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12} AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} AI_TIMEOUT: ${AI_TIMEOUT:-180} + WQ_EMAIL: ${WQ_EMAIL:-} + WQ_PASSWORD: ${WQ_PASSWORD:-} COOKIE_SECURE: "false" WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com} depends_on: diff --git a/docs/deployment-gitea.md b/docs/deployment-gitea.md new file mode 100644 index 0000000..88229e5 --- /dev/null +++ b/docs/deployment-gitea.md @@ -0,0 +1,86 @@ +# Gitea 生产部署 + +本方案在目标 Linux 服务器上由 Gitea host Runner 构建并启动 Docker Compose,复用已有 PostgreSQL。入口绑定 `127.0.0.1:8112`,由宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。 + +## 1. 填写配置和数据库账号密码 + +进入 **Gitea 仓库 → 设置 → Actions → Secrets / Variables**,按下表创建同名配置。敏感值填写在 Secrets,工作流仅在部署步骤通过环境变量注入,不生成服务器凭据文件。 + +| 位置 | 名称 | 填写内容 | +| --- | --- | --- | +| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@数据库网络别名:5432/数据库名` | +| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 | +| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 | +| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 | +| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 | +| Variables(必填) | `DATABASE_NETWORK` | PostgreSQL 所在的现有 Docker 网络,例如 `1panel-network` | +| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` | +| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` | + +端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。 + +数据库连接串示例(实际填写时替换示例值): + +```text +postgresql+asyncpg://wq_user:YOUR_PASSWORD@postgresql:5432/wq_alpha +``` + +生成加密密钥: + +```bash +python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())' +``` + +`DATABASE_URL` 的用户名和密码中的特殊字符须做 URL 百分号编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`、`%` → `%25`。在 Gitea 输入框填写值本身,不添加包裹引号,不填写 `DATABASE_URL=` 前缀。管理员密码中的 `$`、引号等按原样填写,由环境变量传递,无需 shell 转义。不要在日志打印变量,或开启 `set -x`。 + +在现有 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。确认网络存在、数据库别名可解析、PostgreSQL 允许该 Docker 网络访问。应用使用异步驱动 `asyncpg`;远程数据库或强制 TLS 的实例需另外按该实例的 TLS 配置调整连接参数,此模板默认同机 Docker 网络。 + +如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。 + +WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面只保留连接操作。修改 `WQ_PASSWORD` 后重新运行部署即可更新。`WQ_EMAIL` 必须与已有绑定账户一致,避免混入其他账户数据;生产不读取 `account.json` 或本地 `.env`。 + +## 2. 配置 Gitea Runner + +在目标 Docker 宿主机直接运行专用 Runner,注册标签 **`wq-production:host`**,工作流的 `runs-on` 对应 `wq-production`。建议专用 Runner 配置 `runner.capacity: 1`。不要使用指向其他机器的 Docker context;这里的 host 模式也不能被当成“容器化 Runner 自动进入宿主机”。 + +Runner 用户需要 Docker 权限,以及写入预先创建的 `/opt/wq-alpha` 目录的权限。该目录仅保存锁文件和版本记录,不保存凭据。宿主机需具备 Git、Bash、Node.js 20(checkout v4)、`flock`(通常由 util-linux 提供)和支持 `up --wait --wait-timeout` 的 Docker Compose v2 或 v5。镜像使用锁文件构建,服务器需能访问 GitHub checkout action、基础镜像仓库和依赖源。本地验证环境为 Docker Engine 29.6.2、Compose 5.3.1;你的 Gitea/Runner 版本需在首次运行核验。 + +在 Gitea 仓库启用 Actions,提交并推送这些配置后,`main` 的 push 或手动运行会部署。Runner 应只接收可信仓库的任务,因为它具备生产主机 Docker 权限。凭据通过上述 Secrets 注入,脚本使用 `--env-file /dev/null` 防止误读检出目录的开发 `.env`。部署脚本用 `/opt/wq-alpha/deploy.lock` 实现跨 checkout 的互斥,冲突部署直接失败,可稍后重新运行。 + +## 3. 配置反向代理并首次运行 + +为 `PUBLIC_ORIGIN` 对应域名配置 HTTPS,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。代理须运行在宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。 + +首次部署建议在 Gitea Actions 页面手动运行工作流。需要在服务器排障运行时,须先从安全渠道将上表必填配置注入当前进程环境,再执行: + +```bash +bash scripts/deploy-production.sh +``` + +固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。 + +首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。 + +## 4. 升级、备份和失败处理 + +升级包含停机窗口;提前结束或暂停长时间任务。每次发布前通过现有数据库管理工具备份专用库,并在独立安全位置备份加密密钥及必要配置,先在独立库验证恢复。此工作流不会自动备份或自动恢复数据库。 + +构建及预检失败时旧服务继续运行。停止服务后的迁移或健康检查失败需要人工处理;不要对可能已变更的 schema 直接自动降级。脚本将切换前的镜像 ID/标签记录到 `/opt/wq-alpha/previous-images.txt`,最后成功的提交记录到 `current-release.txt`,保留旧镜像且不执行 prune。失败重试前另存这些记录,避免后续尝试覆盖回退参考。 + +如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。 + +以下排查命令不依赖凭据环境变量(可能含业务信息的日志请勿公开): + +```bash +docker ps -a --filter label=com.docker.compose.project=wq-alpha-production +docker logs --tail=100 wq-alpha-production-backend-1 +docker logs --tail=100 wq-alpha-production-web-1 +``` + +普通维护不要使用 `down -v`。这里的数据库由外部管理,备份与恢复应在数据库管理端进行。 + +## 配置依据 + +采用 compound 经验 1 中的生产/开发隔离、显式外部网络、必填凭据、固定项目名、独立迁移和部署健康检查。未照搬旧项目端口、业务 Job 或卷权限初始化:本应用后端不写 named volume,已有镜像使用 UID 10001。 + +凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 外部网络](https://docs.docker.com/reference/compose-file/networks/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 标签](https://gitea.com/gitea/runner/src/branch/main/README.md)。 diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 964ac24..171ac2f 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -233,9 +233,16 @@ export function AccountPage({ aria-label="WorldQuant 连接设置" >

WorldQuant 连接

+ {account.credentials_source === "environment" && ( +

+ 凭据由环境变量管理。修改本地 .env 或 Gitea Secrets + 后重新部署生效。 +

+ )}
{ e.preventDefault(); + if (account.credentials_source === "environment") return; void action("save", async () => { await api("/account/credentials", { method: "PUT", @@ -246,41 +253,45 @@ export function AccountPage({ }); }} > -
- - -
+ {account.credentials_source !== "environment" && ( +
+ + +
+ )}
- + {account.credentials_source !== "environment" && ( + + )}