Compare commits

...

2 Commits

Author SHA1 Message Date
yuxuanhui b8429efa3d feat: configure Gitea deployment and environment-managed WorldQuant credentials
Deploy production / deploy (push) Has been cancelled
2026-09-09 09:58:09 +08:00
yuxuanhui 20645d6d17 feat: 统一研究模块表格布局与详情交互 2026-09-09 09:29:30 +08:00
37 changed files with 2123 additions and 1288 deletions
+3 -1
View File
@@ -8,4 +8,6 @@ ENCRYPTION_KEY=replace-with-generated-fernet-key
LOCAL_PORT=8080 LOCAL_PORT=8080
# Public deployment only; bare DNS hostname, without scheme or path. # Public deployment only; bare DNS hostname, without scheme or path.
DOMAIN=alpha.example.com 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=
+27
View File
@@ -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
+1 -1
View File
@@ -19,4 +19,4 @@ output/
.playwright-cli/ .playwright-cli/
backups/ backups/
account.json account.json
+22
View File
@@ -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 注入仍待服务器运行确认。
@@ -0,0 +1,19 @@
# 统一列表与详情交互
Type: task
Status: ready-for-agent
## Scope
六个研究模块使用 table + detail,除 QuantFlow 独立详情页外均为 60% 右侧抽屉。所有列表页连续 Flex 高度链,移除页面顶部标题描述,按钮区位于筛选下方。
## Selected learning
采用 20260724-bounded-list-flex-height-chain:当前应用外壳已有有界 Flex,研究页面仍整页滚动。改为固定控制区和分页、表格数据区独立滚动。Semi UI 2.103.0 本机类型与当前官方文档均支持 SideSheet 字符串 width。
## Validation
完成:`pnpm --dir frontend typecheck`、`pnpm --dir frontend build`、`git diff --check` 通过。
相关 Playwright 回归 11/11 通过(Alpha 管理、回测、数据目录、QuantFlow、研究流水线、跨页研究导航、特征转模板)。
隔离测试服务与浏览器数据验证:1440px/390px 视口抽屉宽度分别 864px/234px;长列表只滚动数据区,分页 y=834 不随内容滚动;body 高度不超过视口。QuantFlow 独立详情支持前进、后退、刷新。
独立只读核验 `/root/route_review` complete:未发现路由或草稿恢复的严重阻断。
## Outcome
Completion: complete
应用选定 Flex 高度链经验,未修改经验库。共享 ResearchList 管理固定控制区、分页及数据视口;ResearchDetail 使用 60% 右侧抽屉。QuantFlow 使用独立 hash 详情路由。保留工作区同时出现的部署相关改动,本次未提交或部署。
+9 -1
View File
@@ -10,6 +10,7 @@
```bash ```bash
python3 scripts/init_env.py python3 scripts/init_env.py
# 编辑 .env,填写 WQ_EMAIL 与 WQ_PASSWORD,再启动
docker compose up -d --build --wait docker compose up -d --build --wait
docker compose ps docker compose ps
``` ```
@@ -20,7 +21,7 @@ docker compose ps
首次使用: 首次使用:
1. 登录系统,在“个人信息”保存 WorldQuant 邮箱和密码,点击“连接 WorldQuant”。 1. 登录系统,在“个人信息 → 连接设置”点击“连接 WorldQuant”。
2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。 2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。
3. 在“Alpha 管理”切换“待提交 / 已提交”。待提交先选创建日期范围,按天同步;已提交可选提交日期范围按天同步,或全量同步。相同起止日期表示单日,日期边界为 UTC,均包含隐藏记录。也可导入指定 Alpha ID。任务面板显示当前日期、进度、错误、取消和重试。 3. 在“Alpha 管理”切换“待提交 / 已提交”。待提交先选创建日期范围,按天同步;已提交可选提交日期范围按天同步,或全量同步。相同起止日期表示单日,日期边界为 UTC,均包含隐藏记录。也可导入指定 Alpha ID。任务面板显示当前日期、进度、错误、取消和重试。
4. 点击 Alpha 打开详情。研究记录保存在本地;PnL 点击获取后缓存。详情的“本地自相关”可发起检测,列表也可选中最多 100 条批量检测。建议先全量同步已提交 Alpha,建立比较基准。下次同步会更新平台数据并保留本地研究记录。 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)。真实平台权限、当前协议与限额尚未联调。 公共接口位于 `/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 部署 ## 公网 HTTPS 部署
`compose.public.yaml` 是独立配置,不与本机配置叠加。先在服务器完成上面的密钥初始化,将 `.env` 中 `DOMAIN` 改为自己的域名(无协议、路径、端口)。DNS 指向服务器,允许入站 TCP 80/443,UDP 443 可选。 `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` | 仅首次空库初始化管理员,重启不会重置现有密码 | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 仅首次空库初始化管理员,重启不会重置现有密码 |
| `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 | | `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 |
| `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码和模型 API Key | | `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码和模型 API Key |
| `WQ_EMAIL` / `WQ_PASSWORD` | WorldQuant 邮箱和密码,成对设置;本地 `.env`,生产 Gitea Secrets |
| `LOCAL_PORT` | 本机入口端口,默认 8080 | | `LOCAL_PORT` | 本机入口端口,默认 8080 |
| `DOMAIN` | 公网域名 | | `DOMAIN` | 公网域名 |
| `AI_REQUEST_LIMIT` | 每轮模型请求上限,默认 12 | | `AI_REQUEST_LIMIT` | 每轮模型请求上限,默认 12 |
@@ -110,6 +116,8 @@ docker compose -f compose.public.yaml logs --tail=100 web
| `AI_OUTPUT_TOKENS` | 每次模型输出上限,默认 4096 | | `AI_OUTPUT_TOKENS` | 每次模型输出上限,默认 4096 |
| `AI_TIMEOUT` | 每轮累计活动执行时限(秒),默认 180,等待确认不计入 | | `AI_TIMEOUT` | 每轮累计活动执行时限(秒),默认 180,等待确认不计入 |
WorldQuant 凭据不再从 `account.json` 读取。进程环境变量优先于项目根目录 `.env`;启动时更新数据库中的加密凭据,配置后页面禁止覆盖。修改凭据后重建后端容器(Gitea 重新部署);已绑定账户不能更换邮箱。两个变量都未设置时兼容已有页面配置,只有一个时启动失败。
WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码和模型 API Key;切勿在正常升级时重新生成它。 WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码和模型 API Key;切勿在正常升级时重新生成它。
修改系统密码(同时撤销所有系统会话): 修改系统密码(同时撤销所有系统会话):
+10 -1
View File
@@ -1,12 +1,16 @@
"""Deployment configuration; secrets are required and never included in API responses.""" """Deployment configuration; secrets are required and never included in API responses."""
from pathlib import Path
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from pydantic import Field, SecretStr, model_validator from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): 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" database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
admin_username: str = "admin" admin_username: str = "admin"
@@ -15,6 +19,8 @@ class Settings(BaseSettings):
public_origin: str = "http://localhost:8080" public_origin: str = "http://localhost:8080"
cookie_secure: bool = False cookie_secure: bool = False
session_hours: int = Field(default=24, ge=1, le=168) 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" wq_base_url: str = "https://api.worldquantbrain.com"
request_timeout: float = 30 request_timeout: float = 30
retry_attempts: int = Field(default=4, ge=1, le=8) retry_attempts: int = Field(default=4, ge=1, le=8)
@@ -26,6 +32,9 @@ class Settings(BaseSettings):
@model_validator(mode="after") @model_validator(mode="after")
def validate_secrets(self): 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()) Fernet(self.encryption_key.get_secret_value().encode())
if self.cookie_secure and not self.public_origin.startswith("https://"): if self.cookie_secure and not self.public_origin.startswith("https://"):
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN") raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
+7 -4
View File
@@ -53,7 +53,7 @@ from .schemas import (
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password 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 = ( keys = (
"email", "email",
"wq_user_id", "wq_user_id",
@@ -70,6 +70,7 @@ def account_output(account, client):
return { return {
**{k: getattr(account, k) for k in keys}, **{k: getattr(account, k) for k in keys},
"configured": bool(account.password_encrypted), "configured": bool(account.password_encrypted),
"credentials_source": "environment" if settings.wq_email else "database",
"session": client.session_info(), "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"]) @api.get("/account", response_model=AccountOutput, tags=["account"])
async def get_account(): async def get_account():
async with sessions() as db: 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"]) @api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
async def credentials(body: CredentialsInput): async def credentials(body: CredentialsInput):
if settings.wq_email:
raise HTTPException(409, "WorldQuant 凭据由环境变量管理,请修改部署配置并重启服务")
async with sessions() as db: async with sessions() as db:
account = await db.get(Account, 1) account = await db.get(Account, 1)
if account.wq_user_id and account.email.casefold() != body.email.casefold(): 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.email = body.email
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode() account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
await db.commit() 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"]) @api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
async def preferences(body: PreferencesInput): 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(): for key, value in body.model_dump().items():
setattr(account, key, value) setattr(account, key, value)
await db.commit() await db.commit()
return account_output(account, runner.client) return account_output(account, runner.client, settings)
async def account_job(kind): async def account_job(kind):
async with sessions() as db: async with sessions() as db:
+1
View File
@@ -287,6 +287,7 @@ class PlatformSessionOutput(BaseModel):
class AccountOutput(BaseModel): class AccountOutput(BaseModel):
email: str | None email: str | None
configured: bool configured: bool
credentials_source: Literal["environment", "database"]
wq_user_id: str | None wq_user_id: str | None
profile: dict profile: dict
connection_status: str connection_status: str
+13 -3
View File
@@ -26,7 +26,7 @@ def cipher(settings) -> Fernet:
async def bootstrap(db, settings): 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): if not await db.get(Admin, 1):
db.add( db.add(
Admin( Admin(
@@ -35,8 +35,18 @@ async def bootstrap(db, settings):
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()), password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
) )
) )
if not await db.get(Account, 1): account = await db.get(Account, 1)
db.add(Account(id=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.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
await db.commit() await db.commit()
+66
View File
@@ -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'
+74
View File
@@ -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:
+2
View File
@@ -29,6 +29,8 @@ services:
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12} AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
AI_TIMEOUT: ${AI_TIMEOUT:-180} AI_TIMEOUT: ${AI_TIMEOUT:-180}
WQ_EMAIL: ${WQ_EMAIL:-}
WQ_PASSWORD: ${WQ_PASSWORD:-}
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
depends_on: depends_on:
db: db:
+2
View File
@@ -28,6 +28,8 @@ services:
AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12} AI_TOOL_LIMIT: ${AI_TOOL_LIMIT:-12}
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
AI_TIMEOUT: ${AI_TIMEOUT:-180} AI_TIMEOUT: ${AI_TIMEOUT:-180}
WQ_EMAIL: ${WQ_EMAIL:-}
WQ_PASSWORD: ${WQ_PASSWORD:-}
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com} WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
depends_on: depends_on:
+86
View File
@@ -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)。
+2 -1
View File
@@ -24,7 +24,8 @@ export function contextLabel(context: PageContext): string {
} }
export function pageFromHash(hash: string): WorkspacePage { export function pageFromHash(hash: string): WorkspacePage {
const page = hash.replace(/^#/, ""); const value = hash.replace(/^#/, "");
const page = value.startsWith("quantflow/") ? "quantflow" : value;
return Object.hasOwn(contextLabels, page) return Object.hasOwn(contextLabels, page)
? (page as WorkspacePage) ? (page as WorkspacePage)
: "alphas"; : "alphas";
+4 -3
View File
@@ -318,7 +318,8 @@ export function BacktestPage({
}} }}
/> />
)} )}
<div className="backtest-spacer" /> </div>
<div className="backtest-toolbar">
<Button <Button
type="tertiary" type="tertiary"
onClick={() => { onClick={() => {
@@ -438,7 +439,7 @@ export function BacktestPage({
<SideSheet <SideSheet
title={preview ? "确认回测输入" : "准备候选"} title={preview ? "确认回测输入" : "准备候选"}
visible={editor && drawerVisible} visible={editor && drawerVisible}
width={Math.min(880, window.innerWidth - chatOffset)} width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
onCancel={() => setEditor(false)} onCancel={() => setEditor(false)}
@@ -730,7 +731,7 @@ export function BacktestPage({
<SideSheet <SideSheet
title={run?.name || "回测详情"} title={run?.name || "回测详情"}
visible={!!runId && drawerVisible} visible={!!runId && drawerVisible}
width={Math.min(1120, window.innerWidth - chatOffset)} width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
onCancel={() => { onCancel={() => {
+6 -1
View File
@@ -7,6 +7,7 @@
gap: 12px; gap: 12px;
} }
.backtest-toolbar { .backtest-toolbar {
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -65,8 +66,12 @@
margin: 0; margin: 0;
} }
.backtest-page-view { .backtest-page-view {
height: 100%; display: flex;
flex-direction: column;
flex: 1;
min-height: 0; min-height: 0;
min-width: 0;
overflow: hidden;
} }
.backtest-tool-summary { .backtest-tool-summary {
display: flex; display: flex;
+1 -1
View File
@@ -162,7 +162,7 @@ export function AlphaDetail({
closeOnEsc={!taskPanelOpen} closeOnEsc={!taskPanelOpen}
className="alpha-detail" className="alpha-detail"
visible={Boolean(id) && !suspended} visible={Boolean(id) && !suspended}
width={Math.min(800, window.innerWidth - chatOffset)} width={`min(60vw, calc(100vw - ${chatOffset}px))`}
style={{ right: chatOffset }} style={{ right: chatOffset }}
maskStyle={{ right: chatOffset }} maskStyle={{ right: chatOffset }}
title={ title={
+45 -34
View File
@@ -233,9 +233,16 @@ export function AccountPage({
aria-label="WorldQuant 连接设置" aria-label="WorldQuant 连接设置"
> >
<h2>WorldQuant 连接</h2> <h2>WorldQuant 连接</h2>
{account.credentials_source === "environment" && (
<p className="muted">
凭据由环境变量管理。修改本地 .env 或 Gitea Secrets
后重新部署生效。
</p>
)}
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (account.credentials_source === "environment") return;
void action("save", async () => { void action("save", async () => {
await api("/account/credentials", { await api("/account/credentials", {
method: "PUT", method: "PUT",
@@ -246,41 +253,45 @@ export function AccountPage({
}); });
}} }}
> >
<div className="credentials-grid"> {account.credentials_source !== "environment" && (
<label> <div className="credentials-grid">
WorldQuant 邮箱 <label>
<Input WorldQuant 邮箱
aria-label="WorldQuant 邮箱" <Input
value={email} aria-label="WorldQuant 邮箱"
onChange={setEmail} value={email}
autoComplete="off" onChange={setEmail}
/> autoComplete="off"
</label> />
<label> </label>
WorldQuant 密码 <label>
<Input WorldQuant 密码
aria-label="WorldQuant 密码" <Input
mode="password" aria-label="WorldQuant 密码"
value={password} mode="password"
onChange={setPassword} value={password}
placeholder={ onChange={setPassword}
account.configured placeholder={
? "已保存,输入新密码可更新" account.configured
: "输入平台密码" ? "已保存,输入新密码可更新"
} : "输入平台密码"
autoComplete="new-password" }
/> autoComplete="new-password"
</label> />
</div> </label>
</div>
)}
<div className="inline-actions"> <div className="inline-actions">
<Button {account.credentials_source !== "environment" && (
type="tertiary" <Button
htmlType="submit" type="tertiary"
disabled={!email || !password || blocked} htmlType="submit"
loading={busy === "save"} disabled={!email || !password || blocked}
> loading={busy === "save"}
保存凭据 >
</Button> 保存凭据
</Button>
)}
<Button <Button
theme="solid" theme="solid"
disabled={!account.configured || blocked} disabled={!account.configured || blocked}
+48 -45
View File
@@ -514,45 +514,6 @@ export function AlphaPage({
</span> </span>
</span> </span>
</div> </div>
<SavedViews
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns}
onRestore={(view) => {
const {
submission: savedSubmission,
sort: savedSort,
direction: savedDirection,
limit,
offset: _offset,
...rest
} = view.filters;
const values = Object.fromEntries(
Object.entries(rest)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setFilters(values);
setDraft(values);
setPage(1);
setSelected([]);
if (
savedSubmission === "SUBMITTED" ||
savedSubmission === "UNSUBMITTED"
)
setSubmission(savedSubmission);
setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit);
const next = [
...new Set([
"name",
...view.columns.filter((c) => c in columnLabels),
]),
];
setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next));
}}
/>
<form <form
className="filter-panel" className="filter-panel"
onSubmit={(e) => { onSubmit={(e) => {
@@ -592,12 +553,6 @@ export function AlphaPage({
optionList={stateOptions} optionList={stateOptions}
onChange={(v) => updateDraft("research_state", v)} onChange={(v) => updateDraft("research_state", v)}
/> />
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置
</Button>
</div> </div>
<details className="advanced-filters"> <details className="advanced-filters">
<summary> <summary>
@@ -724,7 +679,55 @@ export function AlphaPage({
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。 数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
</p> </p>
</details> </details>
<div className="inline-actions filter-actions">
<Button htmlType="submit" theme="solid">
查询
</Button>
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
重置
</Button>
</div>
</form> </form>
<SavedViews
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns}
onRestore={(view) => {
const {
submission: savedSubmission,
sort: savedSort,
direction: savedDirection,
limit,
offset: _offset,
...rest
} = view.filters;
const values = Object.fromEntries(
Object.entries(rest)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setFilters(values);
setDraft(values);
setPage(1);
setSelected([]);
if (
savedSubmission === "SUBMITTED" ||
savedSubmission === "UNSUBMITTED"
)
setSubmission(savedSubmission);
setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit);
const next = [
...new Set([
"name",
...view.columns.filter((c) => c in columnLabels),
]),
];
setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next));
}}
/>
<div className="table-toolbar"> <div className="table-toolbar">
<div> <div>
<strong> <strong>
+36 -40
View File
@@ -981,24 +981,6 @@ export function DatasetPage({
) )
} }
/> />
<Button
loading={busy}
disabled={!scopeValid}
onClick={() => void sync()}
>
同步目录
</Button>
<Button
onClick={() => {
remember();
setHistoryOpen(true);
}}
>
已保存输入 ({drafts.length})
</Button>
<span className="muted">
{formatTime(data.synced_at, account?.timezone)}
</span>
</div> </div>
{scopeError && ( {scopeError && (
<Banner <Banner
@@ -1041,9 +1023,27 @@ export function DatasetPage({
optionList={options(data.categories[browse.category] ?? [])} optionList={options(data.categories[browse.category] ?? [])}
onChange={(v) => editBrowse({ subcategory: String(v ?? "") })} onChange={(v) => editBrowse({ subcategory: String(v ?? "") })}
/> />
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
</div> </div>
<div className="catalog-tools"> <div className="catalog-tools">
<Button
loading={busy}
disabled={!scopeValid}
onClick={() => void sync()}
>
同步目录
</Button>
<Button
onClick={() => {
remember();
setHistoryOpen(true);
}}
>
已保存输入 ({drafts.length})
</Button>
<span className="muted">
{formatTime(data.synced_at, account?.timezone)}
</span>
<Button onClick={() => setBrowse(initialBrowse)}>重置筛选</Button>
<span aria-live="polite">{summary}</span> <span aria-live="polite">{summary}</span>
{restoreAction} {restoreAction}
{inputAction} {inputAction}
@@ -1075,24 +1075,11 @@ export function DatasetPage({
{...panelProps} {...panelProps}
className="catalog-sheet catalog-fields" className="catalog-sheet catalog-fields"
visible={fieldsOpen && !suspended && active} visible={fieldsOpen && !suspended && active}
width={viewport < 900 ? viewport : viewport * 0.75} width="60%"
mask={top === "fields"} mask={top === "fields"}
> >
<div className="catalog-layer" ref={fieldsRef}> <div className="catalog-layer" ref={fieldsRef}>
{fieldError && <Banner type="danger" description={fieldError} />} {fieldError && <Banner type="danger" description={fieldError} />}
<div className="catalog-tools">
<Button onClick={onChat}>AI 助手</Button>
<Button aria-label="关闭字段列表" onClick={close}>
关闭
</Button>
<span>{summary}</span>
{restoreAction}
{inputAction}
<Button loading={busy} onClick={() => void sync(selected?.id)}>
刷新字段
</Button>
{jobStatus}
</div>
<div className="catalog-tools"> <div className="catalog-tools">
<span> <span>
{scope.region} · {scope.universe} · Delay {scope.delay} {scope.region} · {scope.universe} · Delay {scope.delay}
@@ -1130,9 +1117,22 @@ export function DatasetPage({
editBrowse({ coverage_min: String(v ?? "") }, true) editBrowse({ coverage_min: String(v ?? "") }, true)
} }
/> />
</div>
<div className="catalog-tools">
<Button onClick={() => setFieldBrowse(initialBrowse)}> <Button onClick={() => setFieldBrowse(initialBrowse)}>
重置筛选 重置筛选
</Button> </Button>
<Button onClick={onChat}>AI 助手</Button>
<Button aria-label="关闭字段列表" onClick={close}>
关闭
</Button>
<span>{summary}</span>
{restoreAction}
{inputAction}
<Button loading={busy} onClick={() => void sync(selected?.id)}>
刷新字段
</Button>
{jobStatus}
</div> </div>
<Table<Entry> <Table<Entry>
className="alpha-table catalog-table" className="alpha-table catalog-table"
@@ -1161,11 +1161,7 @@ export function DatasetPage({
zIndex={1101} zIndex={1101}
className="catalog-sheet catalog-detail" className="catalog-sheet catalog-detail"
visible={!!detail && !suspended && active} visible={!!detail && !suspended && active}
width={ width="60%"
detail?.field && viewport >= 900
? viewport * 0.3
: Math.min(560, viewport)
}
> >
<div className="catalog-layer" ref={detailRef}> <div className="catalog-layer" ref={detailRef}>
<div className="catalog-tools"> <div className="catalog-tools">
@@ -1288,7 +1284,7 @@ export function DatasetPage({
zIndex={1102} zIndex={1102}
className="catalog-sheet" className="catalog-sheet"
visible={inputOpen && !suspended && active} visible={inputOpen && !suspended && active}
width={Math.min(560, viewport)} width="60%"
> >
<div className="catalog-layer" ref={inputRef}> <div className="catalog-layer" ref={inputRef}>
<div className="catalog-tools"> <div className="catalog-tools">
@@ -1349,7 +1345,7 @@ export function DatasetPage({
zIndex={1103} zIndex={1103}
className="catalog-sheet" className="catalog-sheet"
visible={historyOpen && !suspended && active} visible={historyOpen && !suspended && active}
width={Math.min(560, viewport)} width="60%"
> >
<div className="catalog-layer" ref={historyRef}> <div className="catalog-layer" ref={historyRef}>
<div className="catalog-tools"> <div className="catalog-tools">
+80 -40
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Banner, Banner,
@@ -41,6 +42,7 @@ export function FeaturesPage({
onAction: (action: UIAction) => void; onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void; onContext: (context: PageContext) => void;
}) { }) {
const [detailOpen, setDetailOpen] = useState(false);
const [draft, setDraft] = useState<Feature>(blank); const [draft, setDraft] = useState<Feature>(blank);
const [asset, setAsset] = useState<FeatureAsset | null>(null); const [asset, setAsset] = useState<FeatureAsset | null>(null);
const [items, setItems] = useState<FeatureAsset[]>([]); const [items, setItems] = useState<FeatureAsset[]>([]);
@@ -95,6 +97,7 @@ export function FeaturesPage({
); );
}, [active, action]); }, [active, action]);
async function load(item: FeatureAsset) { async function load(item: FeatureAsset) {
setDetailOpen(true);
setAsset(item); setAsset(item);
setDraft(item.content); setDraft(item.content);
setVersions(await api(`/research/assets/${item.id}/versions`)); setVersions(await api(`/research/assets/${item.id}/versions`));
@@ -127,27 +130,12 @@ export function FeaturesPage({
return saved; return saved;
} }
return ( return (
<section className="research-workspace"> <section className="research-workspace research-list-page">
<div className="section-toolbar"> {error && !detailOpen && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>特征工程</h2> resetKey={JSON.stringify([q, page])}
<p className="muted"> label="特征方案"
围绕固定字段记录处理步骤与经济假设,再进入模板、候选和回测闭环。 filters={
</p>
</div>
<Button
onClick={() => {
setAsset(null);
setDraft(blank());
setVersions([]);
}}
>
新建特征方案
</Button>
</div>
{error && <Banner type="danger" description={error} />}
<div className="research-layout">
<aside className="research-library">
<Input <Input
aria-label="搜索特征方案" aria-label="搜索特征方案"
placeholder="搜索方案" placeholder="搜索方案"
@@ -157,24 +145,76 @@ export function FeaturesPage({
setPage(1); setPage(1);
}} }}
/> />
{items.map((item) => ( }
<Button actions={
key={item.id} <Button
block theme="solid"
theme={asset?.id === item.id ? "light" : "borderless"} onClick={() => {
onClick={() => void run("load", () => load(item))} setAsset(null);
> setDraft(blank());
{item.name} · v{item.version} setVersions([]);
</Button> setDetailOpen(true);
))} }}
<Pagination >
size="small" 新建特征方案
currentPage={page} </Button>
pageSize={25} }
total={total} pagination={
onPageChange={setPage} <>
/> <span>共 {total} 个方案</span>
</aside> {total > 0 && (
<Pagination
size="small"
currentPage={page}
pageSize={25}
total={total}
onPageChange={setPage}
/>
)}
</>
}
>
<table className="research-table">
<thead>
<tr>
<th>方案名称</th>
<th>版本</th>
<th>经济假设</th>
<th>固定输入</th>
<th>处理步骤</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<Button
theme="borderless"
onClick={() => void run("load", () => load(item))}
>
{item.name}
</Button>
</td>
<td>v{item.version}</td>
<td>{item.content.hypothesis || "—"}</td>
<td>{item.content.input_ids.length}</td>
<td>{item.content.steps.length}</td>
</tr>
))}
</tbody>
</table>
{!items.length && (
<p className="research-list-empty">
暂无特征方案,请新建方案或调整筛选条件。
</p>
)}
</ResearchList>
<ResearchDetail
title={asset ? asset.name : "新建特征方案"}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
>
{error && <Banner type="danger" description={error} />}
<div className="research-main"> <div className="research-main">
<div className="inline-actions"> <div className="inline-actions">
{asset && ( {asset && (
@@ -380,7 +420,7 @@ export function FeaturesPage({
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。 保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
</p> </p>
</div> </div>
</div> </ResearchDetail>
</section> </section>
); );
} }
+98 -90
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { ResearchSelect } from "./ResearchSelect"; import { ResearchSelect } from "./ResearchSelect";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -61,94 +62,65 @@ export function OperatorsPage({ active }: { active: boolean }) {
if (active) void task(load); if (active) void task(load);
}, [active, query, category, favorite, offset]); }, [active, query, category, favorite, offset]);
return ( return (
<div className="research-workspace"> <div className="research-workspace research-list-page">
<header className="research-page-heading"> {error && !selected && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>算子库</h2> resetKey={JSON.stringify([query, category, favorite, offset])}
<p> label="算子库"
平台定义只读,本地收藏和研究备注独立保存。同步时间: filters={
{formatTime(result.fetched_at)} <div className="research-toolbar">
</p> <Input
</div> aria-label="搜索算子"
<Button placeholder="名称、参数或描述"
theme="solid" value={query}
loading={busy} onChange={(value) => {
onClick={() => setQuery(value);
void task(async () => { setOffset(0);
await post("/catalog/operators/refresh"); }}
await load(); />
}) <ResearchSelect
} label="算子分类"
> value={category}
同步算子目录 optionList={[
</Button> { value: "", label: "全部分类" },
</header> ...result.categories.map((value) => ({ value, label: value })),
{error && <Banner type="danger" description={error} />} ]}
<div className="research-toolbar"> onChange={(value) => {
<Input setCategory(value as string);
aria-label="搜索算子" setOffset(0);
placeholder="名称、参数或描述" }}
value={query} />
onChange={(value) => { <Checkbox
setQuery(value); checked={favorite}
setOffset(0); onChange={(event) => {
}} setFavorite(!!event.target.checked);
/> setOffset(0);
<ResearchSelect }}
label="算子分类" >
value={category} 只看收藏
optionList={[ </Checkbox>
{ value: "", label: "全部分类" }, </div>
...result.categories.map((value) => ({ value, label: value })), }
]} actions={
onChange={(value) => { <>
setCategory(value as string); <Button
setOffset(0); theme="solid"
}} loading={busy}
/> onClick={() =>
<Checkbox void task(async () => {
checked={favorite} await post("/catalog/operators/refresh");
onChange={(event) => { await load();
setFavorite(!!event.target.checked); })
setOffset(0); }
}} >
> 同步算子目录
只看收藏 </Button>
</Checkbox> <span className="muted">
</div> 同步时间:{formatTime(result.fetched_at)}
<div className="research-operator-layout"> </span>
<section className="research-card"> </>
<table className="research-table"> }
<thead> pagination={
<tr>
<th>算子</th>
<th>分类</th>
<th>定义</th>
</tr>
</thead>
<tbody>
{result.items.map((item) => (
<tr key={item.name}>
<td>
<Button
theme="borderless"
onClick={() => setSelected(item)}
>
{item.local.favorite ? "已收藏 · " : ""}
{item.name}
</Button>
</td>
<td>{item.category}</td>
<td>
<code>{item.definition}</code>
</td>
</tr>
))}
</tbody>
</table>
{!result.items.length && (
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
)}
<div className="research-toolbar"> <div className="research-toolbar">
<span>共 {result.total} 个</span> <span>共 {result.total} 个</span>
<Button <Button
@@ -164,7 +136,43 @@ export function OperatorsPage({ active }: { active: boolean }) {
下一页 下一页
</Button> </Button>
</div> </div>
</section> }
>
<table className="research-table">
<thead>
<tr>
<th>算子</th>
<th>分类</th>
<th>定义</th>
</tr>
</thead>
<tbody>
{result.items.map((item) => (
<tr key={item.name}>
<td>
<Button theme="borderless" onClick={() => setSelected(item)}>
{item.local.favorite ? "已收藏 · " : ""}
{item.name}
</Button>
</td>
<td>{item.category}</td>
<td>
<code>{item.definition}</code>
</td>
</tr>
))}
</tbody>
</table>
{!result.items.length && (
<p>暂无算子。连接平台并同步目录,或调整筛选条件。</p>
)}
</ResearchList>
<ResearchDetail
title={selected?.name || "算子详情"}
visible={active && !!selected}
onClose={() => setSelected(null)}
>
{error && <Banner type="danger" description={error} />}
{selected && ( {selected && (
<section className="research-card"> <section className="research-card">
<h3>{selected.name}</h3> <h3>{selected.name}</h3>
@@ -225,7 +233,7 @@ export function OperatorsPage({ active }: { active: boolean }) {
</div> </div>
</section> </section>
)} )}
</div> </ResearchDetail>
</div> </div>
); );
} }
+71 -37
View File
@@ -1,3 +1,4 @@
import { ResearchList, ResearchDetail } from "./ResearchList";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19"; import { Banner, Button, Pagination } from "@douyinfe/semi-ui-19";
import { api, formatTime } from "../api"; import { api, formatTime } from "../api";
@@ -23,7 +24,8 @@ export function PipelinePage({
localStorage.getItem("research-selected-flow"), localStorage.getItem("research-selected-flow"),
); );
const [run, setRun] = useState<FlowRun | null>(null); const [run, setRun] = useState<FlowRun | null>(null);
const [creating, setCreating] = useState(!selected); const [creating, setCreating] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
@@ -59,46 +61,78 @@ export function PipelinePage({
}; };
}, [active, page, selected, revision]); }, [active, page, selected, revision]);
function select(id: string) { function select(id: string) {
setRun(null);
setDetailOpen(true);
setSelected(id); setSelected(id);
localStorage.setItem("research-selected-flow", id); localStorage.setItem("research-selected-flow", id);
setCreating(false); setCreating(false);
} }
return ( return (
<section className="research-workspace"> <section className="research-workspace research-list-page">
<div className="section-toolbar"> {error && !detailOpen && <Banner type="danger" description={error} />}
<div> <ResearchList
<h2>研究流水线</h2> resetKey={JSON.stringify([page])}
<p className="muted"> label="研究流水线"
生成 → 校验与设参 → 回测 → 评估决策 → 增强 → 重新展开 actions={
</p> <Button
</div> theme="solid"
<Button onClick={() => setCreating(true)}>新建自动研究</Button> onClick={() => {
</div> setCreating(true);
{error && <Banner type="danger" description={error} />} setDetailOpen(true);
<div className="research-layout"> }}
<aside className="research-library"> >
<h3>研究运行</h3> 新建自动研究
{items.map((item) => ( </Button>
<button }
className={`research-library-item ${selected === item.id ? "selected" : ""}`} pagination={
key={item.id} <>
onClick={() => select(item.id)} <span>共 {total} 次运行</span>
> {total > 0 && (
<strong>{item.name}</strong> <Pagination
<span> size="small"
{flowStatus[item.status]} · 第 {item.round} 轮 total={total}
</span> currentPage={page}
<span>{formatTime(item.created_at)}</span> pageSize={25}
</button> onPageChange={setPage}
))} />
<Pagination )}
size="small" </>
total={total} }
currentPage={page} >
pageSize={25} <table className="research-table">
onPageChange={setPage} <thead>
/> <tr>
</aside> <th>研究名称</th>
<th>状态</th>
<th>轮次</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<Button theme="borderless" onClick={() => select(item.id)}>
{item.name}
</Button>
</td>
<td>{flowStatus[item.status]}</td>
<td>{item.round}</td>
<td>{formatTime(item.created_at)}</td>
</tr>
))}
</tbody>
</table>
{!items.length && (
<p className="research-list-empty">暂无研究运行,请新建自动研究。</p>
)}
</ResearchList>
<ResearchDetail
title={creating ? "新建自动研究" : "研究运行详情"}
visible={active && detailOpen}
onClose={() => setDetailOpen(false)}
>
{error && <Banner type="danger" description={error} />}
<div className="research-main"> <div className="research-main">
{creating ? ( {creating ? (
<FlowLaunchForm <FlowLaunchForm
@@ -119,7 +153,7 @@ export function PipelinePage({
) )
)} )}
</div> </div>
</div> </ResearchDetail>
</section> </section>
); );
} }
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef, type ReactNode } from "react";
import { SideSheet } from "@douyinfe/semi-ui-19";
/** Parent supplies a bounded flex area; only the data viewport owns scrolling. */
export function ResearchList({
label,
resetKey,
filters,
actions,
pagination,
children,
}: {
label: string;
resetKey: string;
filters?: ReactNode;
actions: ReactNode;
pagination: ReactNode;
children: ReactNode;
}) {
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
viewport.current?.scrollTo({ top: 0, left: 0 });
}, [resetKey]);
return (
<section className="research-list" aria-label={label}>
{filters && <div className="research-list-filters">{filters}</div>}
<div className="research-list-actions">{actions}</div>
<div
ref={viewport}
className="research-list-scroll"
tabIndex={0}
aria-label={`${label}数据区`}
>
{children}
</div>
<div className="table-pagination">{pagination}</div>
</section>
);
}
/** Keep drafts mounted while closed; inactive workspace pages must hide portals. */
export function ResearchDetail({
title,
visible,
onClose,
children,
}: {
title: string;
visible: boolean;
onClose: () => void;
children: ReactNode;
}) {
return (
<SideSheet
title={title}
className="research-detail"
placement="right"
width="60%"
visible={visible}
onCancel={onClose}
closeOnEsc
keepDOM
motion={false}
>
<div className="research-detail-content">{children}</div>
</SideSheet>
);
}
File diff suppressed because it is too large Load Diff
+92 -84
View File
@@ -1,73 +1,25 @@
.research-workspace { .research-workspace {
height: 100%; display: flex;
overflow: auto; flex-direction: column;
padding: 24px 28px; flex: 1;
color: var(--semi-color-text-0); min-height: 0;
min-width: 0; min-width: 0;
overflow: hidden;
color: var(--semi-color-text-0);
} }
.research-page-heading,
.research-section-heading { .research-section-heading {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
} }
.research-page-heading {
margin-bottom: 24px;
}
.research-page-heading h2 {
font-size: 22px;
margin: 0 0 8px;
}
.research-workspace p, .research-workspace p,
.research-detail-content p,
.research-hint { .research-hint {
font-size: 13px; font-size: 13px;
color: var(--semi-color-text-2); color: var(--semi-color-text-2);
line-height: 1.6; line-height: 1.6;
} }
.research-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.research-library {
position: sticky;
top: 0;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 12px;
}
.research-library h3,
.research-card h3 {
font-size: 16px;
margin: 0 0 12px;
}
.research-library > h3 {
margin-top: 28px;
}
.research-library-item {
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
border: 0;
border-radius: 6px;
padding: 12px;
text-align: left;
width: 100%;
color: inherit;
cursor: pointer;
margin: 4px 0;
}
.research-library-item.selected,
.research-library-item:hover {
background: var(--semi-color-fill-0);
}
.research-library-item span {
font-size: 12px;
color: var(--semi-color-text-2);
}
.research-main { .research-main {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -80,6 +32,10 @@
background: var(--semi-color-bg-1); background: var(--semi-color-bg-1);
min-width: 0; min-width: 0;
} }
.research-card h3 {
font-size: 16px;
margin: 0 0 12px;
}
.research-card label, .research-card label,
.research-editor label { .research-editor label {
display: flex; display: flex;
@@ -127,13 +83,15 @@
justify-self: start; justify-self: start;
} }
.research-code-editor textarea, .research-code-editor textarea,
.research-workspace code { .research-workspace code,
.research-detail-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.research-workspace pre { .research-workspace pre,
.research-detail-content pre {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-size: 12px; font-size: 12px;
@@ -170,19 +128,16 @@
.research-table details { .research-table details {
margin-top: 8px; margin-top: 8px;
} }
.research-workspace summary { .research-workspace summary,
.research-detail-content summary {
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.research-comparison-grid, .research-comparison-grid {
.research-operator-layout {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px; gap: 20px;
} }
.research-operator-layout {
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
}
.research-comparison-grid article { .research-comparison-grid article {
padding-top: 12px; padding-top: 12px;
} }
@@ -192,31 +147,10 @@
padding: 18px 12px 8px; padding: 18px 12px 8px;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.research-layout {
grid-template-columns: 170px minmax(0, 1fr);
gap: 16px;
}
.research-workspace {
padding: 20px;
}
.research-operator-layout,
.research-comparison-grid { .research-comparison-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 760px) {
.research-layout {
display: block;
}
.research-library {
position: static;
max-height: 200px;
margin-bottom: 16px;
}
.research-page-heading {
align-items: start;
}
}
.research-sr-only { .research-sr-only {
position: absolute; position: absolute;
@@ -381,3 +315,77 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
/* Lists inherit the viewport boundary; controls and pagination never join data scrolling. */
.research-list {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.research-list-filters,
.research-list-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 0 0 auto;
gap: 12px;
padding: 12px;
}
.research-list-filters {
border-bottom: 1px solid var(--line);
}
.research-list-filters > .semi-input-wrapper {
flex: 1;
min-width: 160px;
}
.research-list-filters > .research-toolbar {
width: 100%;
}
.research-list .research-toolbar {
margin: 0;
}
.research-list-scroll {
flex: 1;
min-height: 0;
min-width: 0;
overflow: auto;
overscroll-behavior: contain;
}
.research-list-scroll .research-table th {
position: sticky;
top: 0;
z-index: 1;
background: var(--canvas);
}
.research-list-empty {
padding: 40px 16px;
text-align: center;
}
.research-detail .semi-sidesheet-body {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.research-detail-content {
min-width: 0;
color: var(--ink);
}
.research-detail-content .research-main {
display: flex;
flex-direction: column;
gap: 16px;
}
.quantflow-detail-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.quantflow-detail-actions {
flex-shrink: 0;
padding: 8px 0 12px;
}
+3
View File
@@ -240,6 +240,9 @@ label:not(.semi-checkbox):not(.semi-radio) {
.primary-filters > .semi-select { .primary-filters > .semi-select {
width: 136px; width: 136px;
} }
.filter-actions {
margin-top: var(--space-2);
}
.advanced-filters { .advanced-filters {
margin-top: var(--space-2); margin-top: var(--space-2);
} }
+1
View File
@@ -71,6 +71,7 @@ export type AlphaDetail = Alpha & {
export type Account = { export type Account = {
email: string | null; email: string | null;
configured: boolean; configured: boolean;
credentials_source?: "environment" | "database";
wq_user_id: string | null; wq_user_id: string | null;
profile: Record<string, unknown>; profile: Record<string, unknown>;
connection_status: string; connection_status: string;
+3 -3
View File
@@ -89,7 +89,7 @@ test("目录筛选、双层详情、完整输入、排除、备注与刷新", as
const fields = page.getByRole("dialog", { name: "数据字段", exact: true }); const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await expect(fields).toBeVisible(); await expect(fields).toBeVisible();
await expect(fields).toContainText("全部 123 个字段"); await expect(fields).toContainText("全部 123 个字段");
expect((await fields.boundingBox())!.width).toBeCloseTo(1080, 0); expect((await fields.boundingBox())!.width).toBeCloseTo(864, 0);
await page.getByLabel("搜索字段").fill("字段 12"); await page.getByLabel("搜索字段").fill("字段 12");
await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount( await expect(fields.getByRole("button", { name: /TEST 字段/ })).toHaveCount(
3, 3,
@@ -99,7 +99,7 @@ test("目录筛选、双层详情、完整输入、排除、备注与刷新", as
.click(); .click();
const detail = page.getByRole("dialog", { name: "字段详情", exact: true }); const detail = page.getByRole("dialog", { name: "字段详情", exact: true });
await expect(detail).toContainText("FUTURE_TYPE"); await expect(detail).toContainText("FUTURE_TYPE");
expect((await detail.boundingBox())!.width).toBeCloseTo(432, 0); expect((await detail.boundingBox())!.width).toBeCloseTo(864, 0);
await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设"); await page.getByLabel("研究备注", { exact: true }).fill("保留字段研究假设");
await page.getByRole("button", { name: "保存备注", exact: true }).click(); await page.getByRole("button", { name: "保存备注", exact: true }).click();
await expect(page.getByText("研究备注已保存")).toBeVisible(); await expect(page.getByText("研究备注已保存")).toBeVisible();
@@ -173,7 +173,7 @@ test("窄屏抽屉、键盘隔离和研究范围切换", async ({ page }) => {
await openFields(page); await openFields(page);
const fields = page.getByRole("dialog", { name: "数据字段", exact: true }); const fields = page.getByRole("dialog", { name: "数据字段", exact: true });
await expect(fields).toBeVisible(); await expect(fields).toBeVisible();
expect((await fields.boundingBox())!.width).toBeCloseTo(390, 0); expect((await fields.boundingBox())!.width).toBeCloseTo(234, 0);
await page await page
.getByRole("button", { name: "TEST 字段 000", exact: true }) .getByRole("button", { name: "TEST 字段 000", exact: true })
.click(); .click();
+3 -2
View File
@@ -19,7 +19,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
.getByRole("button", { name: "QuantFlow", exact: true }) .getByRole("button", { name: "QuantFlow", exact: true })
.click(); .click();
await expect( await expect(
page.getByRole("heading", { name: "QuantFlow", exact: true }), page.getByRole("region", { name: "QuantFlow", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.request.put("/api/v1/account/credentials", { await page.request.put("/api/v1/account/credentials", {
headers, headers,
@@ -114,6 +114,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
}) })
).json(); ).json();
await page.reload(); await page.reload();
await page.getByRole("button", { name: "新建流程", exact: true }).click();
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究"); await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究");
async function add(kind: string, label: string) { async function add(kind: string, label: string) {
await page.getByLabel("新增节点类型").selectOption(kind); await page.getByLabel("新增节点类型").selectOption(kind);
@@ -179,7 +180,7 @@ test("native QuantFlow canvas saves versions, branches and restores artifacts",
.getByRole("option") .getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) }) .filter({ hasText: fixed.id.slice(0, 8) })
.click(); .click();
await page.getByRole("heading", { name: "QuantFlow", exact: true }).click(); await page.getByLabel("自动研究假设").click();
for (const [label, value] of [ for (const [label, value] of [
["最大轮数", "1"], ["最大轮数", "1"],
["最大模拟条目数", "2"], ["最大模拟条目数", "2"],
+10 -6
View File
@@ -19,7 +19,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
.getByRole("button", { name: "研究流水线", exact: true }) .getByRole("button", { name: "研究流水线", exact: true })
.click(); .click();
await expect( await expect(
page.getByRole("heading", { name: "研究流水线", exact: true }), page.getByRole("region", { name: "研究流水线", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.request.put("/api/v1/account/credentials", { await page.request.put("/api/v1/account/credentials", {
headers, headers,
@@ -94,6 +94,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
).ok(), ).ok(),
).toBe(true); ).toBe(true);
await page.reload(); await page.reload();
await page.getByRole("button", { name: "新建自动研究", exact: true }).click();
await page.getByLabel("自动研究名称").fill("浏览器有限研究"); await page.getByLabel("自动研究名称").fill("浏览器有限研究");
await page.getByLabel("自动研究假设").fill("研究排名稳定性"); await page.getByLabel("自动研究假设").fill("研究排名稳定性");
await page.getByRole("combobox", { name: "自动研究固定输入" }).click(); await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
@@ -101,7 +102,7 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
.getByRole("option") .getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) }) .filter({ hasText: fixed.id.slice(0, 8) })
.click(); .click();
await page.getByRole("heading", { name: "研究流水线", exact: true }).click(); await page.getByLabel("自动研究名称").click();
for (const [label, value] of [ for (const [label, value] of [
["最大轮数", "1"], ["最大轮数", "1"],
["最大模拟条目数", "2"], ["最大模拟条目数", "2"],
@@ -110,16 +111,16 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
]) ])
await page.getByLabel(label, { exact: true }).fill(value); await page.getByLabel(label, { exact: true }).fill(value);
await page.getByRole("button", { name: "核对并启动研究" }).click(); await page.getByRole("button", { name: "核对并启动研究" }).click();
await expect(page.getByRole("dialog", {name: "确认自动研究授权"})).toContainText( await expect(
"最多 1 轮、2 条模拟、2 次模型调用", page.getByRole("dialog", { name: "确认自动研究授权" }),
); ).toContainText("最多 1 轮、2 条模拟、2 次模型调用");
const started = page.waitForResponse( const started = page.waitForResponse(
(r) => (r) =>
r.url().endsWith("/research/flows/runs") && r.url().endsWith("/research/flows/runs") &&
r.request().method() === "POST", r.request().method() === "POST",
); );
await page await page
.getByRole("dialog", {name: "确认自动研究授权"}) .getByRole("dialog", { name: "确认自动研究授权" })
.getByRole("button", { name: "confirm", exact: true }) .getByRole("button", { name: "confirm", exact: true })
.click(); .click();
const response = await started; const response = await started;
@@ -143,6 +144,9 @@ test("finite pipeline confirms scope and budget, executes native steps and resto
expect(completed.model_calls_used).toBe(2); expect(completed.model_calls_used).toBe(2);
expect(completed.steps).toHaveLength(8); expect(completed.steps).toHaveLength(8);
await page.reload(); await page.reload();
await page
.getByRole("button", { name: "浏览器有限研究", exact: true })
.click();
await expect( await expect(
page.getByRole("region", { name: "研究运行详情" }), page.getByRole("region", { name: "研究运行详情" }),
).toContainText("浏览器有限研究"); ).toContainText("浏览器有限研究");
+4 -2
View File
@@ -15,7 +15,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
await page.getByLabel("密码", { exact: true }).fill("browser-test-password"); await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click(); await page.getByRole("button", { name: "进入工作空间" }).click();
await expect( await expect(
page.getByRole("heading", { name: "特征工程", exact: true }), page.getByRole("button", { name: "新建特征方案", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.request.put("/api/v1/account/credentials", { await page.request.put("/api/v1/account/credentials", {
headers, headers,
@@ -62,6 +62,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
).json(); ).json();
expect(fixed.id).toBeTruthy(); expect(fixed.id).toBeTruthy();
await page.reload(); await page.reload();
await page.getByRole("button", { name: "新建特征方案", exact: true }).click();
await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收"); await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收");
await page await page
.getByLabel("特征经济假设", { exact: true }) .getByLabel("特征经济假设", { exact: true })
@@ -71,7 +72,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
.getByRole("option") .getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) }) .filter({ hasText: fixed.id.slice(0, 8) })
.click(); .click();
await page.getByRole("heading", { name: "特征工程", exact: true }).click(); await page.getByLabel("特征方案名称", { exact: true }).click();
await page.getByRole("button", { name: "添加处理步骤" }).click(); await page.getByRole("button", { name: "添加处理步骤" }).click();
await page.getByLabel("步骤 1 名称").fill("横截面排名"); await page.getByLabel("步骤 1 名称").fill("横截面排名");
await page.getByLabel("步骤 1 理由").fill("减少尺度影响"); await page.getByLabel("步骤 1 理由").fill("减少尺度影响");
@@ -97,6 +98,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
expect(templates.items[0].provenance.feature.content.input_ids).toEqual([ expect(templates.items[0].provenance.feature.content.input_ids).toEqual([
fixed.id, fixed.id,
]); ]);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click(); await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列"); await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
await page.getByRole("button", { name: "保存为新视图" }).click(); await page.getByRole("button", { name: "保存为新视图" }).click();
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Deploy on the target Linux Docker host with configuration injected by Gitea.
# Returns nonzero on configuration/build/migration/health failure. Never removes data.
set -Eeuo pipefail
umask 077
cd "$(dirname "${BASH_SOURCE[0]}")/.."
# Fail before touching the host; never read a checkout's local .env file.
for key in WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY DATABASE_NETWORK PUBLIC_ORIGIN; do
if [[ -z "${!key:-}" ]]; then
echo "Missing required Gitea configuration: $key" >&2
exit 1
fi
done
state_dir="${DEPLOY_STATE_DIR:-/opt/wq-alpha}"
if [[ "$state_dir" != /* || ! -d "$state_dir" || ! -w "$state_dir" ]]; then
echo 'DEPLOY_STATE_DIR must be an existing writable absolute directory.' >&2
exit 1
fi
command -v flock >/dev/null
# Stable across checkouts; stores only a lock and release metadata, never credentials.
exec 9>"$state_dir/deploy.lock"
flock -n 9 || { echo 'Another production deployment is running.' >&2; exit 1; }
export DEPLOY_TAG="${DEPLOY_TAG:-$(git rev-parse HEAD)}"
compose=(docker compose --env-file /dev/null -p wq-alpha-production -f compose.production.yaml)
trap 'rc=$?; "${compose[@]}" --profile jobs ps -a || true; exit "$rc"' EXIT
"${compose[@]}" config --quiet
"${compose[@]}" --profile jobs config --quiet
"${compose[@]}" build backend web
# Validate secrets and DB connectivity before interrupting the running version.
"${compose[@]}" run --rm --no-deps backend python -c '
import asyncio
from app.config import Settings
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
async def check():
settings = Settings()
engine = create_async_engine(settings.database_url)
try:
async with engine.connect() as connection:
await connection.execute(text("SELECT 1"))
finally:
await engine.dispose()
try:
asyncio.run(check())
except Exception:
raise SystemExit("Production configuration/database preflight failed; check env and database access.") from None
'
# Record immutable image references before switching; do not prune old images.
previous_images=$("${compose[@]}" images --quiet)
if [[ -n "$previous_images" ]]; then
docker image inspect --format '{{.Id}} {{json .RepoTags}}' $previous_images > "$state_dir/previous-images.txt"
fi
"${compose[@]}" stop web backend
"${compose[@]}" --profile jobs run --rm --no-deps migrate
"${compose[@]}" up -d --no-build --remove-orphans --wait --wait-timeout 180 backend web
printf '%s\n' "$DEPLOY_TAG" > "$state_dir/current-release.txt"
echo "Production is healthy; release $DEPLOY_TAG"
+1
View File
@@ -14,6 +14,7 @@ content = "\n".join([
f"ADMIN_PASSWORD={secrets.token_urlsafe(24)}", f"ADMIN_PASSWORD={secrets.token_urlsafe(24)}",
f"POSTGRES_PASSWORD={secrets.token_hex(24)}", f"POSTGRES_PASSWORD={secrets.token_hex(24)}",
f"ENCRYPTION_KEY={base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}", f"ENCRYPTION_KEY={base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}",
"WQ_EMAIL=", "WQ_PASSWORD=",
"LOCAL_PORT=8080", "DOMAIN=alpha.example.com", "", "LOCAL_PORT=8080", "DOMAIN=alpha.example.com", "",
]) ])
try: try: