#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' CYAN='\033[0;36m' NC='\033[0m' NON_INTERACTIVE=false AUTO_INSTALL_DEPS="" AUTO_INSTALL_SYSTEMD="" AUTO_START_SYSTEMD="" AUTO_INSTALL_NGINX="" AUTO_RELOAD_NGINX="" AUTO_USE_TLS="" AUTO_USE_DOCKER="" log() { echo -e "${GREEN}[bigtime]${NC} $*" } warn() { echo -e "${YELLOW}[bigtime]${NC} $*" } fail() { echo -e "${RED}[bigtime]${NC} $*" >&2 exit 1 } ask() { local prompt="$1" local default="${2:-}" if ${NON_INTERACTIVE}; then printf '%s' "${default}" return fi local value if [[ -n "${default}" ]]; then read -r -p "${prompt} [${default}]: " value printf '%s' "${value:-$default}" else read -r -p "${prompt}: " value printf '%s' "${value}" fi } ask_required() { local prompt="$1" local default="${2:-}" local value="" while [[ -z "${value}" ]]; do value="$(ask "${prompt}" "${default}")" if [[ -z "${value}" ]]; then warn "该项不能为空,请重新输入。" fi done printf '%s' "${value}" } validate_domain_like() { local value="$1" [[ -z "${value}" ]] && return 1 [[ "${value}" =~ ^[A-Za-z0-9.-]+$ ]] } validate_file_parent_exists_or_rootable() { local value="$1" local parent parent="$(dirname "${value}")" [[ -d "${parent}" ]] || [[ "${parent}" == "/etc/bigtime" ]] || [[ "${parent}" == "/etc/nginx/conf.d" ]] } validate_numeric() { local value="$1" [[ "${value}" =~ ^[0-9]+([.][0-9]+)?$ ]] } confirm() { local prompt="$1" local default="${2:-Y}" local override="${3:-}" if [[ -n "${override}" ]]; then [[ "${override}" =~ ^[Yy]([Ee][Ss])?$|^true$|^1$ ]] return fi if ${NON_INTERACTIVE}; then [[ "${default}" == "Y" ]] return fi local suffix="[Y/n]" [[ "${default}" == "N" ]] && suffix="[y/N]" local value read -r -p "${prompt} ${suffix}: " value value="${value:-$default}" [[ "${value}" =~ ^[Yy]$ ]] } command_exists() { command -v "$1" >/dev/null 2>&1 } detect_pkg_manager() { if command_exists apt-get; then echo "apt" return fi if command_exists dnf; then echo "dnf" return fi if command_exists yum; then echo "yum" return fi echo "" } install_packages() { local pkg_manager="$1" case "${pkg_manager}" in apt) sudo apt-get update sudo apt-get install -y python3 python3-venv python3-pip nginx curl git build-essential nodejs npm ;; dnf) sudo dnf install -y python3 python3-pip nginx curl git gcc-c++ make nodejs npm ;; yum) sudo yum install -y python3 python3-pip nginx curl git gcc-c++ make nodejs npm ;; *) warn "未识别包管理器,跳过依赖安装。请手动安装 python3、venv、pip、nginx、node、npm。" ;; esac } render_systemd_unit() { local service_name="$1" local app_module="$2" local port="$3" local workers="$4" local memory_max="$5" local unit_path="$6" sudo tee "${unit_path}" >/dev/null </dev/null </dev/null </dev/null </dev/null 2>&1 || warn "用户 ${SERVICE_USER} 当前不存在,请确认后续 systemd 配置。" SERVICE_GROUP="${SERVICE_GROUP:-$(ask_required 'systemd 运行用户组' "$(id -gn)")}" # 自动尝试获取公网 IP 作为默认域名值 local detected_ip="" if [[ -z "${DOMAIN:-}" ]]; then log "正在尝试自动获取公网 IP..." detected_ip=$(curl -s --connect-timeout 5 https://ifconfig.me || curl -s --connect-timeout 5 https://api.ipify.org || echo "") if [[ -n "${detected_ip}" ]]; then log "自动检测到公网 IP: ${detected_ip}" fi fi DOMAIN="${DOMAIN:-$(ask_required '部署域名(可填写 IP 或 localhost)' "${detected_ip:-localhost}")}" validate_domain_like "${DOMAIN}" || warn "域名/IP 形态看起来不标准,请再次确认: ${DOMAIN}" ENV_FILE="${ENV_FILE:-$(ask_required '环境变量文件路径' '/etc/bigtime/bigtime.env')}" validate_file_parent_exists_or_rootable "${ENV_FILE}" || warn "环境文件父目录当前不存在,脚本会尝试创建: $(dirname "${ENV_FILE}")" PYTHON_BIN="${PYTHON_BIN:-$(ask 'Python 可执行文件路径' "${APP_DIR}/.venv/bin/python")}" [[ -n "${PYTHON_BIN}" ]] || fail "Python 路径不能为空" local SKIP_ENV_CONFIG=false if [[ -f "${ENV_FILE}" ]]; then echo "" if confirm "检测到环境变量文件 ${ENV_FILE} 已存在,是否跳过详细参数配置并保留现有文件?" "Y"; then SKIP_ENV_CONFIG=true fi fi if ! ${SKIP_ENV_CONFIG}; then echo "" echo -e "${CYAN}运行参数${NC}" TICKERS="${TICKERS:-$(ask '默认股票池(逗号分隔)' 'AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN')}" FIN_DATA_SOURCE="${FIN_DATA_SOURCE:-$(ask '行情数据源(finnhub/yfinance/financial_datasets)' 'finnhub')}" MODEL_NAME="${MODEL_NAME:-$(ask '默认模型名' 'qwen3-max')}" MAX_COMM_CYCLES="${MAX_COMM_CYCLES:-$(ask_required '最大讨论轮数' '2')}" validate_numeric "${MAX_COMM_CYCLES}" || fail "最大讨论轮数必须是数字: ${MAX_COMM_CYCLES}" MARGIN_REQUIREMENT="${MARGIN_REQUIREMENT:-$(ask_required '保证金比例' '0.5')}" validate_numeric "${MARGIN_REQUIREMENT}" || fail "保证金比例必须是数字: ${MARGIN_REQUIREMENT}" echo "" echo -e "${CYAN}密钥配置${NC}" FINANCIAL_DATASETS_API_KEY="${FINANCIAL_DATASETS_API_KEY:-$(ask 'FINANCIAL_DATASETS_API_KEY(可留空)' '')}" FINNHUB_API_KEY="${FINNHUB_API_KEY:-$(ask 'FINNHUB_API_KEY(live 模式建议填写)' '')}" POLYGON_API_KEY="${POLYGON_API_KEY:-$(ask 'POLYGON_API_KEY(可留空)' '')}" OPENAI_API_KEY="${OPENAI_API_KEY:-$(ask 'OPENAI_API_KEY(可留空)' '')}" OPENAI_BASE_URL="${OPENAI_BASE_URL:-$(ask 'OPENAI_BASE_URL(可留空)' '')}" DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$(ask 'DASHSCOPE_API_KEY(可留空)' '')}" MEMORY_API_KEY="${MEMORY_API_KEY:-$(ask 'MEMORY_API_KEY(可留空)' '')}" if [[ "${FIN_DATA_SOURCE}" == "finnhub" && -z "${FINNHUB_API_KEY}" ]]; then warn "你选择了 finnhub 作为数据源,但 FINNHUB_API_KEY 为空。live 模式下通常会失败。" fi if [[ -z "${OPENAI_API_KEY}" && -z "${DASHSCOPE_API_KEY}" ]]; then warn "OPENAI_API_KEY 和 DASHSCOPE_API_KEY 都为空,模型调用可能无法工作。" fi if confirm "使用 Docker 沙盒执行技能?" "N" "${AUTO_USE_DOCKER}"; then SKILL_SANDBOX_MODE="docker" else SKILL_SANDBOX_MODE="none" fi echo "" echo -e "${CYAN}当前部署摘要${NC}" echo " 应用目录: ${APP_DIR}" echo " 运行用户: ${SERVICE_USER}:${SERVICE_GROUP}" echo " 域名: ${DOMAIN}" echo " 环境文件: ${ENV_FILE}" echo " Python: ${PYTHON_BIN}" echo " 数据源: ${FIN_DATA_SOURCE:-}" echo " 模型: ${MODEL_NAME:-}" echo " 沙盒模式: ${SKILL_SANDBOX_MODE:-none}" echo "" if ! confirm "确认以上配置并继续写入系统文件?" "Y"; then fail "用户取消部署。" fi else echo -e "${GREEN}将使用现有的环境文件,跳过详细参数配置。${NC}" fi if [[ ! -x "${PYTHON_BIN}" ]]; then warn "未找到 ${PYTHON_BIN},准备创建虚拟环境。" python3 -m venv "${APP_DIR}/.venv" "${APP_DIR}/.venv/bin/python" -m pip install --upgrade pip PYTHON_BIN="${APP_DIR}/.venv/bin/python" fi log "安装后端依赖" "${PYTHON_BIN}" -m pip install -e "${APP_DIR}" log "构建前端" (cd "${APP_DIR}/frontend" && npm install && npm run build) if ! ${SKIP_ENV_CONFIG}; then log "写入环境变量文件 ${ENV_FILE}" write_env_file fi if confirm "生成并安装 systemd unit?" "Y" "${AUTO_INSTALL_SYSTEMD}"; then render_systemd_unit "Agent Service" "backend.apps.agent_service:app" "8000" "1" "1024M" "/etc/systemd/system/bigtime-agent.service" render_systemd_unit "Trading Service" "backend.apps.trading_service:app" "8001" "1" "768M" "/etc/systemd/system/bigtime-trading.service" render_systemd_unit "News Service" "backend.apps.news_service:app" "8002" "1" "768M" "/etc/systemd/system/bigtime-news.service" render_systemd_unit "Runtime Service" "backend.apps.runtime_service:app" "8003" "1" "1536M" "/etc/systemd/system/bigtime-runtime.service" sudo systemctl daemon-reload if confirm "立即启用并启动 bigtime-* 服务?" "Y" "${AUTO_START_SYSTEMD}"; then sudo systemctl enable --now bigtime-agent.service sudo systemctl enable --now bigtime-trading.service sudo systemctl enable --now bigtime-news.service sudo systemctl enable --now bigtime-runtime.service fi fi if confirm "生成并安装 nginx 配置?" "Y" "${AUTO_INSTALL_NGINX}"; then local use_tls="no" if confirm "使用 HTTPS/Let's Encrypt 证书路径?" "N" "${AUTO_USE_TLS}"; then SSL_CERT_PATH="${SSL_CERT_PATH:-$(ask_required 'SSL 证书 fullchain.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem")}" SSL_KEY_PATH="${SSL_KEY_PATH:-$(ask_required 'SSL 私钥 privkey.pem 路径' "/etc/letsencrypt/live/${DOMAIN}/privkey.pem")}" local ssl_err=0 [[ -f "${SSL_CERT_PATH}" ]] || { warn "SSL 证书文件不存在: ${SSL_CERT_PATH}"; ssl_err=1; } [[ -f "${SSL_KEY_PATH}" ]] || { warn "SSL 私钥文件不存在: ${SSL_KEY_PATH}"; ssl_err=1; } [[ -f "/etc/letsencrypt/options-ssl-nginx.conf" ]] || { warn "缺失 /etc/letsencrypt/options-ssl-nginx.conf,请检查 certbot 配置"; ssl_err=1; } [[ -f "/etc/letsencrypt/ssl-dhparams.pem" ]] || { warn "缺失 /etc/letsencrypt/ssl-dhparams.pem,请检查 certbot 配置"; ssl_err=1; } if [[ ${ssl_err} -eq 0 ]]; then use_tls="yes" else warn "由于 SSL 关键文件缺失,将回退至 HTTP 模式,以确保 Nginx 能通过配置检查。" use_tls="no" fi else SSL_CERT_PATH="" SSL_KEY_PATH="" fi NGINX_TARGET="/etc/nginx/conf.d/bigtime.conf" render_nginx_conf "${NGINX_TARGET}" "${use_tls}" if confirm "立即执行 nginx -t 并生效配置?" "Y" "${AUTO_RELOAD_NGINX}"; then log "正在验证 Nginx 配置..." if ! sudo nginx -t; then fail "Nginx 配置检查失败!请根据上方报错信息调整。常见的错误包括:80/443 端口被占用,或 server_name 冲突。" fi if systemctl is-active --quiet nginx; then log "Nginx 正在运行,执行 reload..." sudo systemctl reload nginx else log "Nginx 未运行,尝试启动..." sudo systemctl enable --now nginx fi # 关键修复:确保 nginx 用户对 /root 路径有 x 权限 if [[ "${APP_DIR}" == /root/* ]]; then log "检测到应用部署在 /root 下,正在修复父目录访问权限..." sudo chmod o+x /root 2>/dev/null || true sudo chmod o+x "$(dirname "${APP_DIR}")" 2>/dev/null || true sudo chmod -R o+rX "${APP_DIR}" fi log "Nginx 配置已生效。" fi fi echo "" log "部署向导完成" echo "应用目录: ${APP_DIR}" echo "环境文件: ${ENV_FILE}" echo "Python: ${PYTHON_BIN}" echo "沙盒模式: ${SKILL_SANDBOX_MODE}" echo "" echo "验证建议:" echo " curl http://127.0.0.1:8003/health" echo " curl http://127.0.0.1:8003/api/runtime/current" echo " sudo systemctl status bigtime-runtime.service" echo " tail -f ${APP_DIR}/runs//logs/gateway.log" } main "$@"