#!/usr/bin/env bash set -Eeuo pipefail APP_NAME="妙境 AI 创作平台" APP_MARKER=".miaojing-deployment" DEFAULT_PROJECT_DIR="/opt/miaojingAI" DEFAULT_DATA_DIR="/var/lib/miaojingAI" DEFAULT_WEB_PORT="5000" DEFAULT_API_PORT="5100" DEFAULT_CONSOLE_PORT="5200" DEFAULT_ADMIN_ACCOUNT="admin" DEFAULT_ADMIN_EMAIL="admin@example.com" DEFAULT_DOMAIN="" DEFAULT_NODE_MAJOR="24" MIRRORS=( "https://registry.npmmirror.com" "https://registry.npmjs.org" "https://mirrors.cloud.tencent.com/npm/" "https://mirrors.huaweicloud.com/repository/npm/" ) NODE_DIST_MIRRORS=( "https://npmmirror.com/mirrors/node" "https://mirrors.tuna.tsinghua.edu.cn/nodejs-release" "https://mirrors.cloud.tencent.com/nodejs-release" "https://mirrors.huaweicloud.com/nodejs" "https://nodejs.org/dist" ) SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOG_FILE="" PROJECT_DIR="" DATA_DIR="" WEB_PORT="" API_PORT="" CONSOLE_PORT="" ADMIN_ACCOUNT="" ADMIN_EMAIL="" ADMIN_PASSWORD="" LOCAL_DB_URL_INPUT="" MODE="" BACKUP_FILE="" SERVER_HOST_IP="" EXISTING_LOCAL_STORAGE_DIR="" APP_PUBLIC_URL="" NODE_MAJOR="${DEPLOY_NODE_MAJOR:-${DEFAULT_NODE_MAJOR}}" NODE_INSTALL_ROOT="${DEPLOY_NODE_INSTALL_DIR:-}" NODE_BIN_DIR="" NODE_VERSION="" NPM_BIN="npm" log() { local message="$*" if [ -n "${LOG_FILE:-}" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${message}" | tee -a "${LOG_FILE}" else echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${message}" fi } log_pipe() { if [ -n "${LOG_FILE:-}" ]; then tee -a "${LOG_FILE}" else cat fi } fail() { local message="$*" echo echo "❌ 部署失败:${message}" | tee -a "${LOG_FILE:-/dev/null}" >&2 if [ -n "${LOG_FILE:-}" ]; then echo "详细日志:${LOG_FILE}" >&2 fi if [ -n "${BACKUP_FILE:-}" ]; then echo "已生成升级前备份:${BACKUP_FILE}" >&2 echo "如需回滚,可在部署目录执行:pnpm backup:restore \"${BACKUP_FILE}\"" >&2 fi exit 1 } trap 'fail "脚本执行中断,请查看上方错误日志。"' ERR require_command() { local command_name="$1" local install_hint="$2" if ! command -v "${command_name}" >/dev/null 2>&1; then fail "缺少命令 ${command_name}。${install_hint}" fi } prompt_value() { local var_name="$1" local label="$2" local default_value="$3" local value="" read -r -p "${label} [${default_value}]: " value printf -v "${var_name}" '%s' "${value:-$default_value}" } prompt_secret() { local var_name="$1" local label="$2" local value="" while [ -z "${value}" ]; do read -r -s -p "${label}: " value echo if [ -z "${value}" ]; then echo "该项不能为空,请重新输入。" fi done printf -v "${var_name}" '%s' "${value}" } random_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 else head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' fi } env_quote() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" value="${value//\$/\\\$}" value="${value//\`/\\\`}" value="${value//$'\n'/\\n}" printf '"%s"' "${value}" } env_get_value() { local key="$1" local file="$2" local line value if [ ! -f "${file}" ]; then return 1 fi while IFS= read -r line || [ -n "${line}" ]; do case "${line}" in "${key}="*) value="${line#*=}" value="${value%$'\r'}" if [[ "${value}" == \"*\" ]] && [[ "${value}" == *\" ]]; then value="${value:1:${#value}-2}" value="${value//\\\"/\"}" value="${value//\\\\/\\}" fi printf '%s\n' "${value}" return 0 ;; esac done < "${file}" return 1 } env_set_value() { local key="$1" local value="$2" local file="$3" local quoted tmp_file quoted="$(env_quote "${value}")" tmp_file="$(mktemp)" if [ -f "${file}" ]; then awk -v key="${key}" -v replacement="${key}=${quoted}" ' BEGIN { found = 0 } $0 ~ "^" key "=" { if (found == 0) print replacement found = 1 next } { print } END { if (found == 0) print replacement } ' "${file}" > "${tmp_file}" else printf '%s=%s\n' "${key}" "${quoted}" > "${tmp_file}" fi mv "${tmp_file}" "${file}" } js_quote() { local value="$1" value="${value//\\/\\\\}" value="${value//\'/\\\'}" value="${value//$'\n'/\\n}" printf "'%s'" "${value}" } detect_host_ip() { SERVER_HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" SERVER_HOST_IP="${SERVER_HOST_IP:-127.0.0.1}" } prepend_node_path() { if [ -n "${NODE_BIN_DIR:-}" ] && [ -d "${NODE_BIN_DIR}" ]; then case ":${PATH}:" in *":${NODE_BIN_DIR}:"*) ;; *) export PATH="${NODE_BIN_DIR}:${PATH}" ;; esac NPM_BIN="${NODE_BIN_DIR}/npm" else NPM_BIN="npm" fi } node_major_version() { node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || printf '0' } node_version_matches_target() { command -v node >/dev/null 2>&1 && [ "$(node_major_version)" = "${NODE_MAJOR}" ] } node_platform_arch() { local machine machine="$(uname -m)" case "${machine}" in x86_64|amd64) printf 'linux-x64' ;; aarch64|arm64) printf 'linux-arm64' ;; *) fail "暂不支持当前 CPU 架构:${machine}。部署脚本支持 x86_64/amd64 和 arm64/aarch64。" ;; esac } detect_latest_node_version() { local mirror="$1" curl -fsSL "${mirror}/index.json" \ | sed -n "s/.*\"version\"[[:space:]]*:[[:space:]]*\"\\(v${NODE_MAJOR}\\.[0-9][^\"]*\\)\".*/\\1/p" \ | head -n 1 } install_node_from_mirrors() { local platform_arch version mirror archive_url tmp_dir archive install_dir node_bin platform_arch="$(node_platform_arch)" NODE_INSTALL_ROOT="${NODE_INSTALL_ROOT:-${DATA_DIR}/node}" mkdir -p "${NODE_INSTALL_ROOT}" tmp_dir="$(mktemp -d)" for mirror in "${NODE_DIST_MIRRORS[@]}"; do log "尝试从 Node.js 镜像源获取 ${NODE_MAJOR}.x LTS:${mirror}" version="$(NODE_MAJOR="${NODE_MAJOR}" detect_latest_node_version "${mirror}" || true)" if [ -z "${version}" ]; then log "当前镜像源未获取到 Node.js ${NODE_MAJOR}.x 版本索引,切换下一个源。" continue fi archive="node-${version}-${platform_arch}.tar.xz" archive_url="${mirror}/${version}/${archive}" log "准备下载 Node.js ${version}:${archive_url}" if ! curl -fL --retry 2 --connect-timeout 15 -o "${tmp_dir}/${archive}" "${archive_url}" 2>&1 | log_pipe; then log "Node.js 下载失败,切换下一个镜像源。" continue fi install_dir="${NODE_INSTALL_ROOT}/node-${version}-${platform_arch}" rm -rf "${install_dir}" mkdir -p "${install_dir}" tar -xJf "${tmp_dir}/${archive}" -C "${NODE_INSTALL_ROOT}" NODE_BIN_DIR="${install_dir}/bin" node_bin="${NODE_BIN_DIR}/node" if [ -x "${node_bin}" ]; then prepend_node_path NODE_VERSION="$("${node_bin}" -v)" log "Node.js ${NODE_VERSION} 安装完成,路径:${NODE_BIN_DIR}" rm -rf "${tmp_dir}" return 0 fi done rm -rf "${tmp_dir}" return 1 } ensure_node_runtime() { if ! [[ "${NODE_MAJOR}" =~ ^(22|24)$ ]]; then fail "DEPLOY_NODE_MAJOR 只允许设置为 22 或 24,当前值:${NODE_MAJOR}" fi if node_version_matches_target; then NODE_VERSION="$(node -v)" log "Node.js 版本符合生产要求:${NODE_VERSION}" return 0 fi if command -v node >/dev/null 2>&1; then log "当前 Node.js 版本为 $(node -v),将自动安装并切换到 Node.js ${NODE_MAJOR}.x LTS。" else log "未检测到 Node.js,将自动安装 Node.js ${NODE_MAJOR}.x LTS。" fi install_node_from_mirrors || fail "Node.js ${NODE_MAJOR}.x LTS 自动安装失败,请检查网络或手动安装后重试。" if ! node_version_matches_target; then fail "Node.js 已安装但版本校验失败,当前版本:$(node -v 2>/dev/null || printf '未检测到')" fi } normalize_data_dir_from_storage() { local storage_dir="$1" if [ -z "${storage_dir}" ]; then return 1 fi storage_dir="$(realpath -m "${storage_dir}")" if [ "$(basename "${storage_dir}")" = "storage" ]; then dirname "${storage_dir}" else printf '%s\n' "${storage_dir}" fi } read_marker_value() { local key="$1" local file="$2" local marker_key marker_value if [ ! -f "${file}" ]; then return 1 fi while IFS='=' read -r marker_key marker_value; do if [ "${marker_key}" = "${key}" ]; then printf '%s\n' "${marker_value}" return 0 fi done < "${file}" return 1 } detect_existing_deployment() { if [ -f "${PROJECT_DIR}/package.json" ] && { [ -f "${PROJECT_DIR}/${APP_MARKER}" ] || [ -f "${PROJECT_DIR}/.env.local" ]; }; then MODE="upgrade" else MODE="install" fi } load_existing_defaults() { local marker_data_dir="" if [ -f "${PROJECT_DIR}/${APP_MARKER}" ]; then marker_data_dir="$(read_marker_value "data_dir" "${PROJECT_DIR}/${APP_MARKER}" || true)" fi if [ -f "${PROJECT_DIR}/.env.local" ]; then # shellcheck disable=SC1090 set +u; set -a; source "${PROJECT_DIR}/.env.local"; set +a; set -u if [ -n "${LOCAL_STORAGE_DIR:-}" ]; then EXISTING_LOCAL_STORAGE_DIR="$(realpath -m "${LOCAL_STORAGE_DIR}")" DATA_DIR="$(normalize_data_dir_from_storage "${LOCAL_STORAGE_DIR}")" elif [ -n "${BACKUP_DIR:-}" ]; then DATA_DIR="$(dirname "$(realpath -m "${BACKUP_DIR}")")" elif [ -n "${marker_data_dir}" ]; then DATA_DIR="${marker_data_dir}" fi LOCAL_DB_URL_INPUT="${LOCAL_DB_URL:-${LOCAL_DB_URL_INPUT:-postgresql://postgres:postgres@localhost:5432/miaojing}}" WEB_PORT="${DEPLOY_RUN_PORT:-${WEB_PORT:-$DEFAULT_WEB_PORT}}" API_PORT="${MIAOJING_API_PORT:-${API_PORT:-$DEFAULT_API_PORT}}" CONSOLE_PORT="${MIAOJING_CONSOLE_PORT:-${CONSOLE_PORT:-$DEFAULT_CONSOLE_PORT}}" ADMIN_EMAIL="${ADMIN_EMAIL:-${DEFAULT_ADMIN_EMAIL}}" elif [ -n "${marker_data_dir}" ]; then DATA_DIR="${marker_data_dir}" fi if [ -z "${APP_PUBLIC_URL}" ] && [ -f "${PROJECT_DIR}/.env.local" ]; then APP_PUBLIC_URL="$(env_get_value "NEXT_PUBLIC_APP_URL" "${PROJECT_DIR}/.env.local" || true)" fi if [ -z "${APP_PUBLIC_URL}" ] && [ -f "${PROJECT_DIR}/.env.local" ]; then APP_PUBLIC_URL="$(env_get_value "APP_BASE_URL" "${PROJECT_DIR}/.env.local" || true)" fi if [ -z "${EXISTING_LOCAL_STORAGE_DIR}" ] && [ -d "${PROJECT_DIR}/local-storage" ]; then EXISTING_LOCAL_STORAGE_DIR="$(realpath -m "${PROJECT_DIR}/local-storage")" elif [ -z "${EXISTING_LOCAL_STORAGE_DIR}" ] && [ -n "${marker_data_dir}" ] && [ -d "${marker_data_dir}/storage" ]; then EXISTING_LOCAL_STORAGE_DIR="$(realpath -m "${marker_data_dir}/storage")" fi } validate_port() { local label="$1" local value="$2" if ! [[ "${value}" =~ ^[0-9]+$ ]] || [ "${value}" -lt 1 ] || [ "${value}" -gt 65535 ]; then fail "${label}必须是 1-65535 之间的数字。" fi } validate_inputs() { validate_port "前端访问端口" "${WEB_PORT}" validate_port "后端 API 内部端口" "${API_PORT}" validate_port "管理后台内部端口" "${CONSOLE_PORT}" if [ "${WEB_PORT}" = "${API_PORT}" ] || [ "${WEB_PORT}" = "${CONSOLE_PORT}" ] || [ "${API_PORT}" = "${CONSOLE_PORT}" ]; then fail "前端、后端 API、管理后台端口不能重复。" fi if [ -z "${ADMIN_ACCOUNT}" ] || [ -z "${ADMIN_EMAIL}" ]; then fail "管理员账号和管理员邮箱不能为空。" fi if ! [[ "${ADMIN_EMAIL}" =~ ^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$ ]]; then fail "管理员邮箱格式不正确。" fi if [ -z "${LOCAL_DB_URL_INPUT}" ]; then fail "PostgreSQL 连接地址不能为空。" fi if [ -n "${APP_PUBLIC_URL}" ] && ! [[ "${APP_PUBLIC_URL}" =~ ^https?://[^[:space:]]+$ ]]; then fail "正式访问地址必须是 http:// 或 https:// 开头的完整地址。" fi if [ "${MODE}" = "install" ] && [ "${ADMIN_PASSWORD}" = "admin123" ]; then fail "生产环境不允许使用默认管理员密码 admin123,请设置高强度密码。" fi } collect_inputs() { echo "==============================================" echo "${APP_NAME} 一键部署/升级脚本" echo "==============================================" echo "请按提示填写部署参数。直接回车将使用默认值。" echo prompt_value PROJECT_DIR "项目部署目录" "${DEPLOY_PROJECT_DIR:-$DEFAULT_PROJECT_DIR}" PROJECT_DIR="$(realpath -m "${PROJECT_DIR}")" DATA_DIR="${DEPLOY_DATA_DIR:-$DEFAULT_DATA_DIR}" WEB_PORT="${DEPLOY_WEB_PORT:-$DEFAULT_WEB_PORT}" API_PORT="${DEPLOY_API_PORT:-$DEFAULT_API_PORT}" CONSOLE_PORT="${DEPLOY_CONSOLE_PORT:-$DEFAULT_CONSOLE_PORT}" ADMIN_ACCOUNT="${DEPLOY_ADMIN_ACCOUNT:-$DEFAULT_ADMIN_ACCOUNT}" ADMIN_EMAIL="${DEPLOY_ADMIN_EMAIL:-$DEFAULT_ADMIN_EMAIL}" LOCAL_DB_URL_INPUT="${DEPLOY_LOCAL_DB_URL:-postgresql://postgres:postgres@localhost:5432/miaojing}" detect_existing_deployment load_existing_defaults if [ "${MODE}" = "install" ]; then echo echo "检测结果:目标目录未部署项目,将执行首次部署流程。" else echo echo "检测结果:目标目录已存在部署,将执行安全升级流程。" fi prompt_value DATA_DIR "数据存储目录" "${DATA_DIR}" DATA_DIR="$(realpath -m "${DATA_DIR}")" prompt_value WEB_PORT "前端访问端口" "${WEB_PORT}" prompt_value API_PORT "后端 API 内部端口" "${API_PORT}" prompt_value CONSOLE_PORT "管理后台内部端口" "${CONSOLE_PORT}" prompt_value ADMIN_ACCOUNT "管理员账号/昵称" "${ADMIN_ACCOUNT}" prompt_value ADMIN_EMAIL "管理员邮箱" "${ADMIN_EMAIL}" prompt_value APP_PUBLIC_URL "正式访问地址(有域名请填 https://域名,留空则使用服务器IP和端口)" "${APP_PUBLIC_URL:-$DEFAULT_DOMAIN}" if [ "${MODE}" = "install" ]; then prompt_secret ADMIN_PASSWORD "管理员密码" prompt_value LOCAL_DB_URL_INPUT "PostgreSQL 连接地址" "${LOCAL_DB_URL_INPUT}" else read -r -s -p "管理员密码(升级时可留空表示不修改): " ADMIN_PASSWORD echo prompt_value LOCAL_DB_URL_INPUT "PostgreSQL 连接地址" "${LOCAL_DB_URL_INPUT}" fi validate_inputs } prepare_log() { mkdir -p "${DATA_DIR}/logs" LOG_FILE="${DATA_DIR}/logs/deploy-$(date +%Y%m%d-%H%M%S).log" touch "${LOG_FILE}" chmod 600 "${LOG_FILE}" log "日志文件:${LOG_FILE}" } check_prerequisites() { log "检查运行依赖..." require_command tar "请安装 tar。" require_command rsync "请安装 rsync。" require_command curl "请安装 curl。" ensure_node_runtime prepend_node_path require_command node "Node.js 自动安装后仍不可用,请检查 PATH。" require_command npm "Node.js 自动安装后 npm 仍不可用,请检查 Node.js 安装包。" require_command psql "请安装 PostgreSQL 客户端,例如 postgresql-client。" require_command pg_dump "请安装 PostgreSQL 客户端,例如 postgresql-client。" log "当前使用 Node.js:$(node -v),npm:$(npm -v)" if ! command -v pnpm >/dev/null 2>&1; then log "未检测到 pnpm,准备通过 npm 安装 pnpm@9..." install_pnpm fi if ! command -v pm2 >/dev/null 2>&1; then log "未检测到 pm2,准备通过 npm 安装 pm2..." install_pm2 fi } npm_install_global_with_mirrors() { local package_name="$1" local mirror for mirror in "${MIRRORS[@]}"; do log "尝试使用镜像源安装 ${package_name}:${mirror}" if "${NPM_BIN}" --registry="${mirror}" install -g "${package_name}" 2>&1 | log_pipe; then log "${package_name} 安装成功。" return 0 fi log "镜像源不可用或安装失败,切换下一个源。" done return 1 } install_pnpm() { npm_install_global_with_mirrors "pnpm@9" || fail "pnpm 安装失败,请检查网络或手动安装。" } install_pm2() { npm_install_global_with_mirrors "pm2" || fail "pm2 安装失败,请检查网络或手动安装。" } install_dependencies_with_mirrors() { local mirror for mirror in "${MIRRORS[@]}"; do log "尝试使用依赖镜像源:${mirror}" pnpm config set registry "${mirror}" >/dev/null 2>&1 || true if pnpm install --frozen-lockfile --reporter=append-only 2>&1 | log_pipe; then log "依赖安装成功,使用源:${mirror}" return 0 fi log "依赖安装失败,切换下一个镜像源。" done fail "所有依赖镜像源均安装失败,请检查网络。" } sync_project_files() { if [ "${SOURCE_DIR}" = "${PROJECT_DIR}" ]; then log "源码目录与部署目录一致,跳过代码同步。" return 0 fi log "同步项目代码到部署目录:${PROJECT_DIR}" mkdir -p "${PROJECT_DIR}" rsync -a --delete \ --exclude ".git" \ --exclude "node_modules" \ --exclude ".next" \ --exclude "dist" \ --exclude "backups" \ --exclude "local-storage" \ --exclude ".env.local" \ --exclude ".codex_tmp" \ "${SOURCE_DIR}/" "${PROJECT_DIR}/" 2>&1 | log_pipe } migrate_local_storage() { if [ "${MODE}" != "upgrade" ]; then return 0 fi local target_storage="${DATA_DIR}/storage" if [ -z "${EXISTING_LOCAL_STORAGE_DIR}" ] || [ ! -d "${EXISTING_LOCAL_STORAGE_DIR}" ]; then log "未检测到旧版本地存储目录,跳过本地存储迁移。" return 0 fi if [ "$(realpath -m "${EXISTING_LOCAL_STORAGE_DIR}")" = "$(realpath -m "${target_storage}")" ]; then log "本地存储目录未变化,跳过迁移:${target_storage}" return 0 fi log "同步旧本地存储到新的持久化目录:${EXISTING_LOCAL_STORAGE_DIR} -> ${target_storage}" mkdir -p "${target_storage}" rsync -a "${EXISTING_LOCAL_STORAGE_DIR}/" "${target_storage}/" 2>&1 | log_pipe } write_env_file() { local env_file encryption_key jwt_secret generation_secret invite_code admin_default_password app_base_url existing_admin_password env_file="${PROJECT_DIR}/.env.local" encryption_key="$(env_get_value "DATA_ENCRYPTION_KEY" "${env_file}" || random_hex)" jwt_secret="$(env_get_value "JWT_SECRET" "${env_file}" || random_hex)" generation_secret="$(env_get_value "GENERATION_INTERNAL_SECRET" "${env_file}" || random_hex)" invite_code="$(env_get_value "ADMIN_INVITE_CODE" "${env_file}" || true)" invite_code="${invite_code:-miaojing-admin-$(random_hex | cut -c1-8)}" existing_admin_password="$(env_get_value "ADMIN_DEFAULT_PASSWORD" "${env_file}" || true)" admin_default_password="${ADMIN_PASSWORD:-${existing_admin_password}}" app_base_url="${APP_PUBLIC_URL:-http://${SERVER_HOST_IP}:${WEB_PORT}}" mkdir -p "${DATA_DIR}/storage" "${DATA_DIR}/backups" if [ ! -f "${env_file}" ]; then cat > "${env_file}" < "${PROJECT_DIR}/ecosystem.config.cjs" <&1 | log_pipe > "${DATA_DIR}/logs/.last-backup-path" BACKUP_FILE="$(tail -n 1 "${DATA_DIR}/logs/.last-backup-path" || true)" log "升级前备份完成:${BACKUP_FILE}" else log "未找到旧版备份脚本,执行基础文件备份。" BACKUP_FILE="${DATA_DIR}/backups/miaojing-files-$(date +%Y%m%d-%H%M%S).tar.gz" if [ -d "${DATA_DIR}/storage" ]; then tar -czf "${BACKUP_FILE}" -C "${PROJECT_DIR}" .env.local -C "${DATA_DIR}" storage else tar -czf "${BACKUP_FILE}" -C "${PROJECT_DIR}" .env.local fi log "基础备份完成:${BACKUP_FILE}" fi } initialize_database() { log "检查数据库连接..." psql "${LOCAL_DB_URL_INPUT}" -v ON_ERROR_STOP=1 -c "SELECT 1;" >/dev/null log "执行数据库结构初始化/升级 SQL(幂等,不会删除用户数据)..." psql "${LOCAL_DB_URL_INPUT}" -v ON_ERROR_STOP=1 -f "${PROJECT_DIR}/scripts/init-database.sql" 2>&1 | log_pipe if [ -f "${PROJECT_DIR}/scripts/database-optimization-patch.sql" ]; then psql "${LOCAL_DB_URL_INPUT}" -v ON_ERROR_STOP=1 -f "${PROJECT_DIR}/scripts/database-optimization-patch.sql" 2>&1 | log_pipe fi apply_runtime_schema_patch } apply_runtime_schema_patch() { log "补齐生产运行所需的动态配置表..." psql "${LOCAL_DB_URL_INPUT}" -v ON_ERROR_STOP=1 <<'SQL' 2>&1 | log_pipe CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE SCHEMA IF NOT EXISTS auth; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'auth' AND p.proname = 'uid' ) THEN EXECUTE 'CREATE FUNCTION auth.uid() RETURNS UUID AS $fn$ SELECT NULLIF(current_setting(''request.jwt.claim.sub'', true), '''')::UUID; $fn$ LANGUAGE SQL STABLE'; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'auth' AND p.proname = 'role' ) THEN EXECUTE 'CREATE FUNCTION auth.role() RETURNS TEXT AS $fn$ SELECT COALESCE(NULLIF(current_setting(''request.jwt.claim.role'', true), ''''), ''anon''); $fn$ LANGUAGE SQL STABLE'; END IF; END $$; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255), ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark'; UPDATE profiles SET preferred_theme = 'dark' WHERE preferred_theme IS NULL OR preferred_theme NOT IN ('dark', 'light'); ALTER TABLE site_config ADD COLUMN IF NOT EXISTS site_description TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS site_keywords TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS announcement TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS membership_enabled BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN IF NOT EXISTS terms_of_service TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS privacy_policy TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS about_us TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS help_center TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS filing_info TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS filing_url TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT ''; ALTER TABLE works ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS views_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE announcements ADD COLUMN IF NOT EXISTS type VARCHAR(32) NOT NULL DEFAULT 'site'; ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128); ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT ''; ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image'; CREATE TABLE IF NOT EXISTS system_api_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider VARCHAR(128), name VARCHAR(255) NOT NULL, api_url TEXT NOT NULL DEFAULT '', model_name VARCHAR(255) NOT NULL, note TEXT NOT NULL DEFAULT '', api_key_encrypted TEXT NOT NULL DEFAULT '', api_key_preview VARCHAR(64) NOT NULL DEFAULT '', type VARCHAR(16) NOT NULL DEFAULT 'image', credits_per_use INTEGER NOT NULL DEFAULT 10, is_active BOOLEAN NOT NULL DEFAULT true, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order); CREATE TABLE IF NOT EXISTS payment_methods ( id VARCHAR(64) PRIMARY KEY, type VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT FALSE, public_config JSONB NOT NULL DEFAULT '{}'::jsonb, secret_config_encrypted JSONB NOT NULL DEFAULT '{}'::jsonb, secret_config_preview JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); INSERT INTO payment_methods (id, type, name, is_active) VALUES ('pm-alipay', 'alipay', '支付宝', true), ('pm-wechat', 'wechat', '微信支付', false), ('pm-manual', 'manual', '手动转账', false), ('pm-stripe', 'stripe', 'Stripe', false) ON CONFLICT (id) DO NOTHING; ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS user_id UUID; ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS provider VARCHAR(128); ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS model_name VARCHAR(255); ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS api_url TEXT; ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb; CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC); CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC); ALTER TABLE site_config ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30; UPDATE site_config SET log_retention_days = LEAST(90, GREATEST(1, log_retention_days)); CREATE TABLE IF NOT EXISTS platform_log_settings ( id INTEGER PRIMARY KEY DEFAULT 1, retention_days INTEGER NOT NULL DEFAULT 30, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); INSERT INTO platform_log_settings (id, retention_days) VALUES (1, 30) ON CONFLICT (id) DO NOTHING; CREATE TABLE IF NOT EXISTS platform_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), type VARCHAR(32) NOT NULL, level VARCHAR(16) NOT NULL DEFAULT 'info', action VARCHAR(128) NOT NULL, message TEXT NOT NULL, user_id UUID, user_name VARCHAR(255), user_email VARCHAR(255), target_type VARCHAR(64), target_id VARCHAR(255), ip_address VARCHAR(64), user_agent TEXT, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS platform_logs_type_created_idx ON platform_logs (type, created_at DESC); CREATE INDEX IF NOT EXISTS platform_logs_level_created_idx ON platform_logs (level, created_at DESC); CREATE INDEX IF NOT EXISTS platform_logs_user_created_idx ON platform_logs (user_id, created_at DESC); CREATE INDEX IF NOT EXISTS platform_logs_created_idx ON platform_logs (created_at DESC); CREATE INDEX IF NOT EXISTS platform_logs_user_name_idx ON platform_logs (LOWER(COALESCE(user_name, ''))); CREATE INDEX IF NOT EXISTS platform_logs_user_email_idx ON platform_logs (LOWER(COALESCE(user_email, ''))); CREATE TABLE IF NOT EXISTS email_settings ( id INTEGER PRIMARY KEY DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT FALSE, smtp_host VARCHAR(255), smtp_port INTEGER NOT NULL DEFAULT 465, smtp_secure BOOLEAN NOT NULL DEFAULT TRUE, smtp_user VARCHAR(255), smtp_password_encrypted TEXT, smtp_password_preview VARCHAR(64), from_email VARCHAR(255), from_name VARCHAR(255), reply_to VARCHAR(255), app_name VARCHAR(120), app_base_url TEXT, logo_url TEXT, contact_email VARCHAR(255), copyright TEXT, code_length INTEGER NOT NULL DEFAULT 6, code_charset VARCHAR(32) NOT NULL DEFAULT 'alphanumeric', code_ttl_minutes INTEGER NOT NULL DEFAULT 5, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS email_verification_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL, code_hash TEXT NOT NULL, type VARCHAR(32) NOT NULL, user_id UUID, ip_address VARCHAR(64), attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 5, is_used BOOLEAN NOT NULL DEFAULT FALSE, locked_until TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS email_send_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL, type VARCHAR(64) NOT NULL, ip_address VARCHAR(64), status VARCHAR(32) NOT NULL, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS email_codes_email_type_idx ON email_verification_codes (LOWER(email), type, created_at DESC); CREATE INDEX IF NOT EXISTS email_codes_ip_created_idx ON email_verification_codes (ip_address, created_at DESC); CREATE INDEX IF NOT EXISTS email_send_logs_email_created_idx ON email_send_logs (LOWER(email), created_at DESC); CREATE INDEX IF NOT EXISTS email_send_logs_ip_created_idx ON email_send_logs (ip_address, created_at DESC); SQL } ensure_admin_user() { if [ -z "${ADMIN_PASSWORD:-}" ] && [ "${MODE}" = "upgrade" ]; then log "升级模式未输入管理员密码,跳过管理员密码更新。" return 0 fi log "创建/更新管理员账号..." psql "${LOCAL_DB_URL_INPUT}" \ -v ON_ERROR_STOP=1 \ -v admin_email="${ADMIN_EMAIL}" \ -v admin_account="${ADMIN_ACCOUNT}" \ -v admin_password="${ADMIN_PASSWORD}" <<'SQL' 2>&1 | log_pipe CREATE TEMP TABLE _deploy_admin_input ( email TEXT NOT NULL, account TEXT NOT NULL, password TEXT NOT NULL ); INSERT INTO _deploy_admin_input (email, account, password) VALUES (:'admin_email', :'admin_account', :'admin_password'); DO $$ DECLARE r RECORD; v_admin_id UUID; BEGIN SELECT * INTO r FROM _deploy_admin_input LIMIT 1; SELECT id INTO v_admin_id FROM profiles WHERE lower(email) = lower(r.email) LIMIT 1; IF v_admin_id IS NULL THEN SELECT id INTO v_admin_id FROM auth.users WHERE lower(email) = lower(r.email) LIMIT 1; END IF; IF v_admin_id IS NULL THEN v_admin_id := gen_random_uuid(); END IF; INSERT INTO auth.users (id, email, password_hash, raw_user_meta_data, created_at) VALUES ( v_admin_id, r.email, crypt(r.password, gen_salt('bf')), jsonb_build_object('nickname', r.account), NOW() ) ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, raw_user_meta_data = EXCLUDED.raw_user_meta_data; SELECT id INTO v_admin_id FROM auth.users WHERE lower(email) = lower(r.email) LIMIT 1; INSERT INTO profiles ( id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active, email_verified, email_verified_at, email_bound_at, email_sender_domain ) VALUES ( v_admin_id, r.email, r.account, 'admin', 'enterprise', 9999, 999, 0, true, true, NOW(), NOW(), split_part(r.email, '@', 2) ) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, nickname = EXCLUDED.nickname, role = 'admin', membership_tier = 'enterprise', credits_balance = GREATEST(profiles.credits_balance, 9999), daily_quota_limit = GREATEST(profiles.daily_quota_limit, 999), is_active = true, email_verified = true, email_verified_at = COALESCE(profiles.email_verified_at, NOW()), email_bound_at = COALESCE(profiles.email_bound_at, NOW()), email_sender_domain = COALESCE(NULLIF(profiles.email_sender_domain, ''), EXCLUDED.email_sender_domain), updated_at = NOW(); END $$; SQL } build_project() { log "开始安装依赖..." cd "${PROJECT_DIR}" install_dependencies_with_mirrors log "开始生产构建..." pnpm run check:boundaries 2>&1 | log_pipe pnpm run build 2>&1 | log_pipe } run_security_audit() { log "执行生产依赖漏洞扫描..." cd "${PROJECT_DIR}" local mirror audit_status audit_status=1 for mirror in "${MIRRORS[@]}"; do log "尝试使用漏洞库源执行 pnpm audit:${mirror}" if pnpm audit --prod --audit-level=high --registry="${mirror}" 2>&1 | log_pipe; then audit_status=0 break fi log "当前源审计失败或发现高危漏洞,继续尝试下一个源。" done if [ "${audit_status}" -ne 0 ]; then fail "生产依赖漏洞扫描未通过。请先处理 high/critical 级别漏洞后再上线。" fi if ! pnpm audit --prod --audit-level=moderate --registry="https://registry.npmjs.org" 2>&1 | log_pipe; then log "提醒:仍存在 moderate 级别漏洞。脚本不会阻断升级,但正式上线前建议升级相关依赖链并重新构建验证。" fi } start_services() { log "启动/重载 PM2 服务..." cd "${PROJECT_DIR}" pm2 startOrReload ecosystem.config.cjs --update-env 2>&1 | log_pipe pm2 save 2>&1 | log_pipe || true } wait_for_health() { log "等待服务启动并执行健康检查..." local api_url="http://127.0.0.1:${WEB_PORT}/api/health" local console_url="http://127.0.0.1:${WEB_PORT}/console" local attempt for attempt in $(seq 1 30); do if curl -fsS "${api_url}" >/dev/null 2>&1 && curl -fsS "${console_url}" >/dev/null 2>&1; then log "健康检查通过:前端、后端 API、管理后台均可访问。" return 0 fi sleep 2 done fail "健康检查失败,请检查 PM2 日志:pm2 logs miaojing-web" } mark_deployment() { cat > "${PROJECT_DIR}/${APP_MARKER}" <