整理项目文档并优化权限监控与安装脚本

This commit is contained in:
Cheng Zhou
2026-05-21 11:39:00 +08:00
parent 6d682103f3
commit 9305ced664
31 changed files with 508 additions and 4083 deletions
+301 -193
View File
@@ -1,51 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────
# CTMS 一键安装脚本
# ─────────────────────────────────────────────
TARGET_ENV=""
BASE_URL=""
ASSUME_YES=0
SKIP_BUILD=0
SKIP_MIGRATE=0
VERBOSE=0
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$ROOT_DIR/.env"
LOG_DIR="$ROOT_DIR/.install-logs"
usage() {
cat <<'EOF'
用法:
bash scripts/install-ctms.sh dev|main|release [选项]
# ── 颜色 & 样式 ──────────────────────────────
_has_color() { [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null)" -ge 8 ]]; }
选项:
--base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888release 必填或交互输入。
--yes 跳过交互确认,适合自动化执行。
--skip-build 跳过 docker compose up -d --build。
--skip-migrate 跳过 alembic upgrade head。
-h, --help 显示帮助。
if _has_color; then
C_RESET="$(tput sgr0)"
C_BOLD="$(tput bold)"
C_DIM="$(tput dim 2>/dev/null || printf '')"
C_BLUE="$(tput setaf 4)"
C_CYAN="$(tput setaf 6)"
C_GREEN="$(tput setaf 2)"
C_YELLOW="$(tput setaf 3)"
C_RED="$(tput setaf 1)"
C_WHITE="$(tput setaf 7)"
else
C_RESET="" C_BOLD="" C_DIM="" C_BLUE="" C_CYAN=""
C_GREEN="" C_YELLOW="" C_RED="" C_WHITE=""
fi
示例:
bash scripts/install-ctms.sh dev
bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888
bash scripts/install-ctms.sh release --base-url https://ctms.example.com
EOF
# ── 输出函数 ─────────────────────────────────
_banner() {
printf '\n'
printf '%s╔══════════════════════════════════════════════╗%s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}"
printf '%s║%s ██████╗████████╗███╗ ███╗███████╗ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
printf '%s║%s ██╔════╝╚══██╔══╝████╗ ████║██╔════╝ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
printf '%s║%s ██║ ██║ ██╔████╔██║███████╗ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
printf '%s║%s ██║ ██║ ██║╚██╔╝██║╚════██║ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
printf '%s║%s ╚██████╗ ██║ ██║ ╚═╝ ██║███████║ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
printf '%s║%s ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_CYAN}" "${C_BLUE}" "${C_RESET}"
printf '%s║%s 临床试验管理系统 安装程序 %s║%s\n' "${C_BLUE}${C_BOLD}" "${C_DIM}${C_WHITE}" "${C_BLUE}" "${C_RESET}"
printf '%s╚══════════════════════════════════════════════╝%s\n' "${C_BLUE}${C_BOLD}" "${C_RESET}"
printf '\n'
}
log() {
printf '[CTMS] %s\n' "$*"
}
log() { printf ' %s·%s %s\n' "${C_DIM}" "${C_RESET}" "$*"; }
ok() { printf ' %s✔%s %s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" "$*"; }
warn() { printf ' %s⚠%s %s\n' "${C_YELLOW}${C_BOLD}" "${C_RESET}" "$*" >&2; }
step() {
printf '\n[CTMS] >>> %s\n' "$*"
local idx="${_STEP_IDX:-0}"
_STEP_IDX=$(( idx + 1 ))
printf '\n%s┌─ 步骤 %s · %s%s\n' \
"${C_BLUE}${C_BOLD}" "$_STEP_IDX" "$*" "${C_RESET}"
}
fail() {
printf '\n[CTMS] 安装中止: %s\n' "$*" >&2
printf '\n %s✖ 安装中止%s\n' "${C_RED}${C_BOLD}" "${C_RESET}" >&2
printf ' %s%s%s\n\n' "${C_RED}" "$*" "${C_RESET}" >&2
exit 1
}
run() {
log "执行: $*"
printf ' %s$%s %s%s%s\n' "${C_DIM}" "${C_RESET}" "${C_WHITE}" "$*" "${C_RESET}"
"$@"
}
# 运行长命令,默认折叠输出。失败或 --verbose 时回放完整日志。
# 用法: run_quiet <提示语> -- <命令...>
run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
if [[ "$VERBOSE" -eq 1 ]]; then
printf ' %s$%s %s%s%s\n' "${C_DIM}" "${C_RESET}" "${C_WHITE}" "$*" "${C_RESET}"
"$@"
return $?
fi
mkdir -p "$LOG_DIR"
local log_file
log_file="$(mktemp "$LOG_DIR/cmd_XXXXXXXX")"
local start_ts elapsed exit_code
start_ts="$(date +%s)"
set +e
if [[ -t 1 ]]; then
"$@" >"$log_file" 2>&1 &
local cmd_pid=$!
_spinner_for "$cmd_pid" "$label" "$start_ts" &
local spin_pid=$!
wait "$cmd_pid"
exit_code=$?
kill "$spin_pid" 2>/dev/null || true
wait "$spin_pid" 2>/dev/null || true
printf '\r\033[K'
else
printf ' · %s …\n' "$label"
"$@" >"$log_file" 2>&1
exit_code=$?
fi
set -e
elapsed=$(( $(date +%s) - start_ts ))
if [[ "$exit_code" -eq 0 ]]; then
printf ' %s✔%s %s %s(%ss)%s\n' \
"${C_GREEN}${C_BOLD}" "${C_RESET}" "$label" "${C_DIM}" "$elapsed" "${C_RESET}"
return 0
fi
printf '\n %s✖%s %s 失败(耗时 %ss),完整输出:\n\n' \
"${C_RED}${C_BOLD}" "${C_RESET}" "$label" "$elapsed" >&2
sed 's/^/ /' "$log_file" >&2
printf '\n' >&2
fail "命令执行失败,详细日志保留于:${log_file#$ROOT_DIR/}(追加 --verbose 可看实时输出)"
}
_spinner_for() {
local cmd_pid="$1" label="$2" start_ts="$3"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while kill -0 "$cmd_pid" 2>/dev/null; do
local elapsed=$(( $(date +%s) - start_ts ))
printf '\r\033[K %s%s%s %s %s(%ss)%s' \
"${C_CYAN}${C_BOLD}" "${frames[i]}" "${C_RESET}" \
"$label" "${C_DIM}" "$elapsed" "${C_RESET}"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
}
# ── 帮助 ─────────────────────────────────────
usage() {
cat <<EOF
${C_BOLD}用法:${C_RESET}
bash scripts/install-ctms.sh ${C_CYAN}<环境>${C_RESET} [选项]
${C_BOLD}环境:${C_RESET}
${C_CYAN}dev${C_RESET} 本地开发环境(使用默认密钥,不生成 RSA 密钥对)
${C_CYAN}main${C_RESET} 内网测试 / 预发布环境
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
${C_BOLD}选项:${C_RESET}
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
dev/main 默认 http://127.0.0.1:8888
release 必须通过此参数或交互输入提供
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-build${C_RESET} 跳过镜像重新构建(docker compose up -d
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head
${C_WHITE}--verbose${C_RESET} 展开所有命令的实时输出(默认折叠为 spinner)
${C_WHITE}-h, --help${C_RESET} 显示此帮助信息
${C_BOLD}示例:${C_RESET}
bash scripts/install-ctms.sh dev
bash scripts/install-ctms.sh main --base-url http://127.0.0.1:8888
bash scripts/install-ctms.sh release --base-url https://ctms.example.com --yes
EOF
}
# ── 参数解析 ─────────────────────────────────
parse_args() {
if [[ $# -eq 0 ]]; then
usage
@@ -57,39 +177,25 @@ parse_args() {
case "$TARGET_ENV" in
dev|main|release) ;;
-h|--help)
usage
exit 0
;;
-h|--help) usage; exit 0 ;;
*)
usage
fail "目标环境必须是 dev、main 或 release"
fail "未知环境 \"$TARGET_ENV\",有效值为 dev、main 或 release"
;;
esac
while [[ $# -gt 0 ]]; do
case "$1" in
--base-url)
[[ $# -ge 2 ]] || fail "--base-url 需要提供 URL"
[[ $# -ge 2 ]] || fail "--base-url 需要提供一个 URL 参数"
BASE_URL="$2"
shift 2
;;
--yes)
ASSUME_YES=1
shift
;;
--skip-build)
SKIP_BUILD=1
shift
;;
--skip-migrate)
SKIP_MIGRATE=1
shift
;;
-h|--help)
usage
exit 0
;;
--yes) ASSUME_YES=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--skip-migrate) SKIP_MIGRATE=1; shift ;;
--verbose) VERBOSE=1; shift ;;
-h|--help) usage; exit 0 ;;
*)
usage
fail "未知参数: $1"
@@ -98,12 +204,14 @@ parse_args() {
done
}
require_command() {
local command_name="$1"
local install_hint="$2"
if ! command -v "$command_name" >/dev/null 2>&1; then
printf '[CTMS] 缺少依赖: %s\n' "$command_name" >&2
printf '[CTMS] 安装建议: %s\n' "$install_hint" >&2
# ── 依赖检查 ─────────────────────────────────
_require_command() {
local cmd="$1"
local hint="$2"
if ! command -v "$cmd" >/dev/null 2>&1; then
warn "缺少依赖: ${C_BOLD}${cmd}${C_RESET}"
warn "安装方式: $hint"
return 1
fi
}
@@ -111,40 +219,37 @@ require_command() {
check_dependencies() {
step "检查系统依赖"
local missing=0
require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
require_command openssl "Linux: sudo apt-get install -y opensslmacOS: brew install openssl" || missing=1
require_command curl "Linux: sudo apt-get install -y curlmacOS: brew install curl" || missing=1
if [[ "$missing" -ne 0 ]]; then
fail "系统依赖不完整"
fi
_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
_require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1
[[ "$missing" -eq 0 ]] || fail "系统依赖不完整,请按上方提示安装后重试"
if ! docker compose version >/dev/null 2>&1; then
fail "缺少 Docker Compose v2,请确认 docker compose version 可正常执行"
fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行"
fi
log "依赖检查通过"
ok "系统依赖检查通过"
}
# ── 环境参数计算 ──────────────────────────────
compose_project_name() {
case "$TARGET_ENV" in
dev) printf 'ctms_dev' ;;
main) printf 'ctms_main' ;;
dev) printf 'ctms_dev' ;;
main) printf 'ctms_main' ;;
release) printf 'ctms_release' ;;
esac
}
app_env() {
if [[ "$TARGET_ENV" == "dev" ]]; then
printf 'development'
else
printf 'production'
fi
[[ "$TARGET_ENV" == "dev" ]] && printf 'development' || printf 'production'
}
key_id() {
local today
today="$(date +%Y%m%d)"
case "$TARGET_ENV" in
dev) printf 'default' ;;
main) printf 'main-%s' "$today" ;;
dev) printf 'default' ;;
main) printf 'main-%s' "$today" ;;
release) printf 'release-%s' "$today" ;;
esac
}
@@ -152,10 +257,12 @@ key_id() {
default_base_url() {
case "$TARGET_ENV" in
dev|main) printf 'http://127.0.0.1:8888' ;;
release) printf '' ;;
release) printf '' ;;
esac
}
# ── .env 读写 ─────────────────────────────────
read_env_value() {
local key="$1"
[[ -f "$ENV_FILE" ]] || return 0
@@ -173,17 +280,12 @@ read_env_value() {
}
normalize_private_key_to_stdout() {
local escaped_key="$1"
printf '%b' "$escaped_key"
printf '%b' "$1"
}
private_key_is_valid() {
local escaped_key="$1"
[[ -n "$escaped_key" ]] || return 1
if ! normalize_private_key_to_stdout "$escaped_key" | openssl rsa -check -noout >/dev/null 2>&1; then
return 1
fi
return 0
[[ -n "$1" ]] || return 1
normalize_private_key_to_stdout "$1" | openssl rsa -check -noout >/dev/null 2>&1
}
generate_escaped_private_key() {
@@ -195,64 +297,76 @@ generate_jwt_secret() {
openssl rand -hex 32
}
# ── 地址解析 ─────────────────────────────────
resolve_base_url() {
if [[ -z "$BASE_URL" ]]; then
BASE_URL="$(default_base_url)"
fi
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
read -r -p "请输入 release 健康检查域名例如 https://ctms.example.com: " BASE_URL
printf ' %s▸%s 请输入 release 健康检查域名例如 https://ctms.example.com: ' \
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r BASE_URL
fi
if [[ -z "$BASE_URL" ]]; then
fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
fi
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
fail "release 的 --base-url 必须使用 HTTPS"
fail "release 环境的访问地址必须使用 HTTPS"
fi
}
# ── 安装确认 ─────────────────────────────────
confirm_install() {
local project_name="$1"
local runtime_env="$2"
step "确认安装参数"
log "目标环境: $TARGET_ENV"
log "运行 ENV: $runtime_env"
log "Compose 项目名: $project_name"
log "健康检查地址: $BASE_URL"
log "已有 .env: $([[ -f "$ENV_FILE" ]] && printf '会先备份再重写' || printf '不存在,将创建')"
log "已有 pg_data: $([[ -d "$ROOT_DIR/pg_data" ]] && printf '保留' || printf '不存在')"
log "构建镜像: $([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')"
log "数据库迁移: $([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')"
local env_status build_status migrate_status
env_status="$([[ -f "$ENV_FILE" ]] && printf '已存在(将备份后重写)' || printf '不存在(将新建)')"
build_status="$([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')"
migrate_status="$([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')"
printf '\n'
printf ' %s%-18s%s %s\n' "${C_BOLD}" "目标环境" "${C_RESET}" "$TARGET_ENV"
printf ' %s%-18s%s %s\n' "${C_BOLD}" "运行模式" "${C_RESET}" "$runtime_env"
printf ' %s%-18s%s %s\n' "${C_BOLD}" "Compose 项目" "${C_RESET}" "$project_name"
printf ' %s%-18s%s %s\n' "${C_BOLD}" "健康检查地址" "${C_RESET}" "$BASE_URL"
printf ' %s%-18s%s %s\n' "${C_BOLD}" ".env 文件" "${C_RESET}" "$env_status"
printf ' %s%-18s%s %s\n' "${C_BOLD}" "pg_data 目录" "${C_RESET}" "$([[ -d "$ROOT_DIR/pg_data" ]] && printf '已存在(保留)' || printf '不存在')"
printf ' %s%-18s%s %s\n' "${C_BOLD}" "镜像构建" "${C_RESET}" "$build_status"
printf ' %s%-18s%s %s\n' "${C_BOLD}" "数据库迁移" "${C_RESET}" "$migrate_status"
printf '\n'
if [[ "$ASSUME_YES" -eq 1 ]]; then
log "已启用 --yes,跳过确认"
return
fi
printf ' %s▸%s 确认以上参数并继续安装?输入 %syes%s 继续: ' \
"${C_YELLOW}${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
local answer
read -r -p "确认继续安装?输入 yes 继续: " answer
[[ "$answer" == "yes" ]] || fail "用户取消"
read -r answer
[[ "$answer" == "yes" ]] || fail "用户取消安装"
}
# ── .env 写入 ─────────────────────────────────
backup_env_file() {
if [[ -f "$ENV_FILE" ]]; then
local backup_file
backup_file="$ENV_FILE.bak.$(date +%Y%m%d%H%M%S)"
cp "$ENV_FILE" "$backup_file"
log "已备份现有 .env: $backup_file"
ok "已备份现有 .env $(basename "$backup_file")"
fi
}
write_env_file() {
local project_name="$1"
local runtime_env="$2"
local login_key_id="$3"
local jwt_secret="$4"
local rsa_private_key="$5"
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="$4" rsa_private_key="$5"
local temp_file
temp_file="$(mktemp "$ROOT_DIR/.env.tmp.XXXXXX")"
if [[ -f "$ENV_FILE" ]]; then
awk '
BEGIN {
@@ -265,33 +379,29 @@ write_env_file() {
{
drop = 0
for (i = 1; i <= 5; i++) {
if (index($0, prefixes[i]) == 1) {
drop = 1
break
}
if (index($0, prefixes[i]) == 1) { drop = 1; break }
}
if (!drop) print
}
' "$ENV_FILE" > "$temp_file"
fi
{
printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name"
printf 'ENV=%s\n' "$runtime_env"
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
printf 'ENV=%s\n' "$runtime_env"
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key"
} >> "$temp_file"
mv "$temp_file" "$ENV_FILE"
log "已写入 .env"
ok ".env 写入完成"
}
prepare_env_file() {
step "准备 .env"
local project_name="$1"
local runtime_env="$2"
local login_key_id="$3"
local jwt_secret=""
local rsa_private_key=""
step "准备环境配置文件"
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="" rsa_private_key=""
if [[ "$TARGET_ENV" == "dev" ]]; then
jwt_secret="dev-secret"
@@ -300,19 +410,20 @@ prepare_env_file() {
jwt_secret="$(read_env_value JWT_SECRET_KEY || true)"
if [[ -z "$jwt_secret" || "$jwt_secret" == "dev-secret" ]]; then
jwt_secret="$(generate_jwt_secret)"
log "已生成新的 JWT_SECRET_KEY"
ok "已生成新的 JWT_SECRET_KEY"
else
log "保留有 JWT_SECRET_KEY"
ok "保留有 JWT_SECRET_KEY"
fi
rsa_private_key="$(read_env_value LOGIN_RSA_PRIVATE_KEY || true)"
if [[ -z "$rsa_private_key" ]]; then
log "正在生成 RSA 2048 密钥对,请稍候…"
rsa_private_key="$(generate_escaped_private_key)"
log "已生成新的 LOGIN_RSA_PRIVATE_KEY"
ok "已生成新的 LOGIN_RSA_PRIVATE_KEY"
elif private_key_is_valid "$rsa_private_key"; then
log "保留有有效 LOGIN_RSA_PRIVATE_KEY"
ok "保留有有效 LOGIN_RSA_PRIVATE_KEY"
else
fail "现有 LOGIN_RSA_PRIVATE_KEY 不是有效 PEM 私钥请修复 .env 后重试脚本不会自动覆盖已有无效密钥"
fail "现有 LOGIN_RSA_PRIVATE_KEY 不是合法的 PEM 私钥请修复 .env 后重试脚本不会自动覆盖已有密钥"
fi
fi
@@ -320,162 +431,159 @@ prepare_env_file() {
write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key"
}
# ── Docker 流程 ───────────────────────────────
run_compose_config() {
step "检查 Docker Compose 配置"
run docker compose config >/dev/null
log "Docker Compose 配置检查通过"
step "校验 Docker Compose 配置"
run_quiet "Docker Compose 配置语法校验" -- docker compose config
}
run_backend_init() {
if [[ "$TARGET_ENV" == "dev" ]]; then
return
fi
step "初始化生产数据库和管理员"
run docker compose run --rm backend-init
[[ "$TARGET_ENV" != "dev" ]] || return 0
step "初始化生产数据库与管理员账号"
run_quiet "运行 backend-init 容器" -- docker compose run --rm backend-init
}
run_build_and_start() {
if [[ "$SKIP_BUILD" -eq 1 ]]; then
step "启动服务(跳过镜像重新构建)"
run docker compose up -d
return
run_quiet "启动容器" -- docker compose up -d
else
step "构建镜像并启动服务"
run_quiet "构建镜像并启动容器(首次较慢)" -- docker compose up -d --build
fi
step "构建镜像并启动服务"
run docker compose up -d --build
}
run_migrations() {
if [[ "$SKIP_MIGRATE" -eq 1 ]]; then
step "跳过数据库迁移"
log "跳过数据库迁移--skip-migrate"
return
fi
step "执行数据库迁移"
run docker compose run --rm backend python -m alembic upgrade head
run_quiet "Alembic upgrade head" -- docker compose run --rm backend python -m alembic upgrade head
}
# ── 健康检查 ─────────────────────────────────
check_container_status() {
step "检查容器状态"
run docker compose ps
local ps_output
ps_output="$(docker compose ps --services --filter status=running 2>/dev/null || true)"
printf '%s\n' "$ps_output" | grep -qx 'db' || fail "db 容器未运行"
printf '%s\n' "$ps_output" | grep -qx 'backend' || fail "backend 容器未运行"
printf '%s\n' "$ps_output" | grep -qx 'nginx' || fail "nginx 容器未运行"
log "容器状态检查通过"
step "检查容器运行状态"
local running
running="$(docker compose ps --services --filter status=running 2>/dev/null || true)"
for svc in db backend nginx; do
if printf '%s\n' "$running" | grep -qx "$svc"; then
printf ' %s✔%s %-8s 运行中\n' "${C_GREEN}${C_BOLD}" "${C_RESET}" "$svc"
else
printf ' %s✖%s %-8s %s未运行%s\n' "${C_RED}${C_BOLD}" "${C_RESET}" "$svc" "${C_RED}" "${C_RESET}"
fail "$svc 容器未正常运行,请执行 'docker compose ps' 排查"
fi
done
}
check_backend_environment() {
step "检查后端环境变量"
local expected_env="$1"
local expected_key_id="$2"
local expect_rsa="$3"
step "校验后端运行时环境变量"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
local check_code
check_code=$(cat <<'PY'
import os, sys
from app.core.config import settings
from app.core.login_crypto import _normalize_pem
expected_env = "__EXPECTED_ENV__"
expected_key_id = "__EXPECTED_KEY_ID__"
expect_rsa = "__EXPECT_RSA__" == "1"
print("ENV=" + settings.ENV)
print("JWT_SECRET_IS_DEV=" + str(settings.JWT_SECRET_KEY == "dev-secret"))
print("LOGIN_RSA_PRIVATE_KEY_SET=" + str(bool(settings.LOGIN_RSA_PRIVATE_KEY)))
print("LOGIN_RSA_KEY_ID=" + settings.LOGIN_RSA_KEY_ID)
expected_env = os.environ["EXPECTED_ENV"]
expected_key_id = os.environ["EXPECTED_KEY_ID"]
expect_rsa = os.environ["EXPECT_RSA"] == "1"
if settings.ENV != expected_env:
raise SystemExit(f"ENV mismatch: {settings.ENV} != {expected_env}")
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
if settings.LOGIN_RSA_KEY_ID != expected_key_id:
raise SystemExit("LOGIN_RSA_KEY_ID mismatch")
sys.exit("LOGIN_RSA_KEY_ID 不匹配")
if expected_env == "production" and settings.JWT_SECRET_KEY == "dev-secret":
raise SystemExit("production JWT_SECRET_KEY must not be dev-secret")
sys.exit("生产环境不允许使用默认 JWT_SECRET_KEY")
if expect_rsa:
if not settings.LOGIN_RSA_PRIVATE_KEY:
raise SystemExit("LOGIN_RSA_PRIVATE_KEY is required")
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
from cryptography.hazmat.primitives import serialization
serialization.load_pem_private_key(
_normalize_pem(settings.LOGIN_RSA_PRIVATE_KEY).encode("utf-8"),
password=None,
)
PY
)
check_code="${check_code//__EXPECTED_ENV__/$expected_env}"
check_code="${check_code//__EXPECTED_KEY_ID__/$expected_key_id}"
check_code="${check_code//__EXPECT_RSA__/$expect_rsa}"
run docker compose exec -T backend python -c "$check_code"
log "后端环境变量检查通过"
run_quiet "执行后端配置自检" -- docker compose exec -T \
-e EXPECTED_ENV="$expected_env" \
-e EXPECTED_KEY_ID="$expected_key_id" \
-e EXPECT_RSA="$expect_rsa" \
backend python -c "$check_code"
}
check_http_endpoint() {
local path="$1"
local url="${BASE_URL%/}$path"
log "检查接口: $url"
local attempt=1
local max_attempts=10
local delay_seconds=2
log "探测接口: $url"
local attempt=1 max_attempts=10 delay=2
while [[ "$attempt" -le "$max_attempts" ]]; do
if curl -fsS "$url" >/dev/null; then
ok "接口就绪: $path"
return 0
fi
if [[ "$attempt" -lt "$max_attempts" ]]; then
log "接口未就绪,${delay_seconds}s 后重试 (${attempt}/${max_attempts})"
sleep "$delay_seconds"
log "接口未就绪,${delay}s 后重试${attempt}/${max_attempts})…"
sleep "$delay"
fi
attempt=$((attempt + 1))
attempt=$(( attempt + 1 ))
done
if [[ "$path" == "/api/v1/auth/login-key" ]]; then
printf '[CTMS] 登录公钥接口失败。请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否完整 PEM 单行转义文本。\n' >&2
warn "登录公钥接口无响应,请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否完整的单行转义 PEM 文本"
fi
fail "接口检查失败: $url"
}
show_progress_note() {
log "当前阶段: $1"
fail "接口探测失败: $url"
}
run_health_checks() {
local runtime_env="$1"
local login_key_id="$2"
local runtime_env="$1" login_key_id="$2"
local expect_rsa=0
if [[ "$runtime_env" == "production" ]]; then
expect_rsa=1
fi
[[ "$runtime_env" == "production" ]] && expect_rsa=1
check_container_status
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
step "检查 HTTP 接口"
step "探测 HTTP 接口可用性"
check_http_endpoint "/health"
check_http_endpoint "/api/v1/auth/login-key"
log "HTTP 接口检查通过"
ok "HTTP 接口全部就绪"
}
# ── 完成提示 ─────────────────────────────────
show_success() {
printf '\n'
printf '%s┌──────────────────────────────────────────────┐%s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}"
printf '%s│%s ✔ CTMS 安装成功 %s│%s\n' "${C_GREEN}${C_BOLD}" "${C_GREEN}" "${C_GREEN}${C_BOLD}" "${C_RESET}"
printf '%s└──────────────────────────────────────────────┘%s\n' "${C_GREEN}${C_BOLD}" "${C_RESET}"
printf '\n'
printf ' %s访问地址%s %s%s%s\n' "${C_BOLD}" "${C_RESET}" "${C_CYAN}" "$BASE_URL" "${C_RESET}"
printf ' %s环境模式%s %s\n' "${C_BOLD}" "${C_RESET}" "$TARGET_ENV"
printf '\n'
}
# ── 入口 ─────────────────────────────────────
main() {
_banner
parse_args "$@"
cd "$ROOT_DIR"
local project_name
local runtime_env
local login_key_id
local project_name runtime_env login_key_id
project_name="$(compose_project_name)"
runtime_env="$(app_env)"
login_key_id="$(key_id)"
resolve_base_url
check_dependencies
show_progress_note "准备写入环境配置"
confirm_install "$project_name" "$runtime_env"
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
show_progress_note "校验并启动 Docker 流程"
run_compose_config
run_backend_init
run_build_and_start
run_migrations
show_progress_note "执行健康检查"
run_health_checks "$runtime_env" "$login_key_id"
step "安装完成"
log "访问地址: $BASE_URL"
show_success
}
main "$@"