1157 lines
39 KiB
Bash
1157 lines
39 KiB
Bash
#!/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}" <<EOF
|
||
# ${APP_NAME} 生产环境配置
|
||
EOF
|
||
fi
|
||
|
||
env_set_value "LOCAL_DB_URL" "${LOCAL_DB_URL_INPUT}" "${env_file}"
|
||
env_set_value "LOCAL_DB_ANON_KEY" "$(env_get_value "LOCAL_DB_ANON_KEY" "${env_file}" || printf '%s' "local-anon-key")" "${env_file}"
|
||
env_set_value "LOCAL_DB_SERVICE_ROLE_KEY" "$(env_get_value "LOCAL_DB_SERVICE_ROLE_KEY" "${env_file}" || printf '%s' "local-service-role-key")" "${env_file}"
|
||
env_set_value "LOCAL_STORAGE_DIR" "${DATA_DIR}/storage" "${env_file}"
|
||
env_set_value "BACKUP_DIR" "${DATA_DIR}/backups" "${env_file}"
|
||
env_set_value "DEPLOY_RUN_PORT" "${WEB_PORT}" "${env_file}"
|
||
env_set_value "MIAOJING_API_PORT" "${API_PORT}" "${env_file}"
|
||
env_set_value "MIAOJING_CONSOLE_PORT" "${CONSOLE_PORT}" "${env_file}"
|
||
env_set_value "ADMIN_INVITE_CODE" "${invite_code}" "${env_file}"
|
||
env_set_value "ADMIN_DEFAULT_PASSWORD" "${admin_default_password}" "${env_file}"
|
||
env_set_value "DATA_ENCRYPTION_KEY" "${encryption_key}" "${env_file}"
|
||
env_set_value "JWT_SECRET" "${jwt_secret}" "${env_file}"
|
||
env_set_value "GENERATION_INTERNAL_SECRET" "${generation_secret}" "${env_file}"
|
||
env_set_value "COZE_PROJECT_ENV" "PROD" "${env_file}"
|
||
env_set_value "NODE_ENV" "production" "${env_file}"
|
||
env_set_value "APP_BIND_HOST" "$(env_get_value "APP_BIND_HOST" "${env_file}" || printf '%s' "127.0.0.1")" "${env_file}"
|
||
env_set_value "NEXT_PUBLIC_APP_URL" "${app_base_url}" "${env_file}"
|
||
env_set_value "APP_BASE_URL" "${app_base_url}" "${env_file}"
|
||
env_set_value "ENABLE_DANGER_ADMIN_CLEAR_USERS" "$(env_get_value "ENABLE_DANGER_ADMIN_CLEAR_USERS" "${env_file}" || printf '%s' "false")" "${env_file}"
|
||
env_set_value "DB_POOL_MAX" "$(env_get_value "DB_POOL_MAX" "${env_file}" || printf '%s' "20")" "${env_file}"
|
||
env_set_value "DB_CONNECTION_TIMEOUT_MS" "$(env_get_value "DB_CONNECTION_TIMEOUT_MS" "${env_file}" || printf '%s' "5000")" "${env_file}"
|
||
env_set_value "DB_IDLE_TIMEOUT_MS" "$(env_get_value "DB_IDLE_TIMEOUT_MS" "${env_file}" || printf '%s' "30000")" "${env_file}"
|
||
env_set_value "HTTP_REQUEST_TIMEOUT_MS" "$(env_get_value "HTTP_REQUEST_TIMEOUT_MS" "${env_file}" || printf '%s' "190000")" "${env_file}"
|
||
env_set_value "HTTP_HEADERS_TIMEOUT_MS" "$(env_get_value "HTTP_HEADERS_TIMEOUT_MS" "${env_file}" || printf '%s' "65000")" "${env_file}"
|
||
env_set_value "HTTP_KEEP_ALIVE_TIMEOUT_MS" "$(env_get_value "HTTP_KEEP_ALIVE_TIMEOUT_MS" "${env_file}" || printf '%s' "5000")" "${env_file}"
|
||
env_set_value "HTTP_MAX_HEADERS_COUNT" "$(env_get_value "HTTP_MAX_HEADERS_COUNT" "${env_file}" || printf '%s' "200")" "${env_file}"
|
||
env_set_value "DEPLOY_NODE_MAJOR" "${NODE_MAJOR}" "${env_file}"
|
||
if [ -n "${NODE_BIN_DIR:-}" ]; then
|
||
env_set_value "DEPLOY_NODE_BIN_DIR" "${NODE_BIN_DIR}" "${env_file}"
|
||
fi
|
||
|
||
chmod 600 "${env_file}"
|
||
log "已写入环境配置:${env_file}(保留原有非部署配置项)"
|
||
}
|
||
|
||
write_ecosystem_file() {
|
||
local npm_path pm2_path
|
||
npm_path="$(command -v npm)"
|
||
pm2_path="${PATH}"
|
||
cat > "${PROJECT_DIR}/ecosystem.config.cjs" <<EOF
|
||
module.exports = {
|
||
apps: [
|
||
{
|
||
name: 'miaojing-api',
|
||
cwd: $(js_quote "${PROJECT_DIR}"),
|
||
script: $(js_quote "${npm_path}"),
|
||
args: 'run start',
|
||
interpreter: 'none',
|
||
exec_mode: 'fork',
|
||
instances: 1,
|
||
max_memory_restart: '512M',
|
||
restart_delay: 3000,
|
||
env: {
|
||
NODE_ENV: 'production',
|
||
COZE_PROJECT_ENV: 'PROD',
|
||
APP_RUNTIME_ROLE: 'backend',
|
||
DEPLOY_RUN_PORT: '${API_PORT}',
|
||
DEPLOY_NODE_BIN_DIR: $(js_quote "${NODE_BIN_DIR:-}"),
|
||
PATH: $(js_quote "${pm2_path}"),
|
||
},
|
||
},
|
||
{
|
||
name: 'miaojing-web',
|
||
cwd: $(js_quote "${PROJECT_DIR}"),
|
||
script: $(js_quote "${npm_path}"),
|
||
args: 'run start',
|
||
interpreter: 'none',
|
||
exec_mode: 'fork',
|
||
instances: 1,
|
||
max_memory_restart: '512M',
|
||
restart_delay: 3000,
|
||
env: {
|
||
NODE_ENV: 'production',
|
||
COZE_PROJECT_ENV: 'PROD',
|
||
APP_RUNTIME_ROLE: 'frontend',
|
||
BACKEND_INTERNAL_URL: 'http://127.0.0.1:${API_PORT}',
|
||
CONSOLE_INTERNAL_URL: 'http://127.0.0.1:${CONSOLE_PORT}',
|
||
DEPLOY_RUN_PORT: '${WEB_PORT}',
|
||
DEPLOY_NODE_BIN_DIR: $(js_quote "${NODE_BIN_DIR:-}"),
|
||
PATH: $(js_quote "${pm2_path}"),
|
||
},
|
||
},
|
||
{
|
||
name: 'miaojing-console',
|
||
cwd: $(js_quote "${PROJECT_DIR}"),
|
||
script: $(js_quote "${npm_path}"),
|
||
args: 'run start',
|
||
interpreter: 'none',
|
||
exec_mode: 'fork',
|
||
instances: 1,
|
||
max_memory_restart: '512M',
|
||
restart_delay: 3000,
|
||
env: {
|
||
NODE_ENV: 'production',
|
||
COZE_PROJECT_ENV: 'PROD',
|
||
APP_RUNTIME_ROLE: 'console',
|
||
DEPLOY_RUN_PORT: '${CONSOLE_PORT}',
|
||
DEPLOY_NODE_BIN_DIR: $(js_quote "${NODE_BIN_DIR:-}"),
|
||
PATH: $(js_quote "${pm2_path}"),
|
||
},
|
||
},
|
||
],
|
||
};
|
||
EOF
|
||
log "已生成 PM2 配置。"
|
||
}
|
||
|
||
backup_before_upgrade() {
|
||
if [ "${MODE}" != "upgrade" ]; then
|
||
return 0
|
||
fi
|
||
|
||
log "升级前开始备份数据库、环境配置和本地存储..."
|
||
cd "${PROJECT_DIR}"
|
||
mkdir -p "${DATA_DIR}/backups"
|
||
|
||
if [ -f "${PROJECT_DIR}/.env.local" ]; then
|
||
set +u; set -a
|
||
# shellcheck disable=SC1091
|
||
source "${PROJECT_DIR}/.env.local"
|
||
set +a; set -u
|
||
fi
|
||
|
||
if [ -f "${SOURCE_DIR}/scripts/backup-create.sh" ]; then
|
||
LOCAL_DB_URL="${LOCAL_DB_URL:-$LOCAL_DB_URL_INPUT}" BACKUP_DIR="${DATA_DIR}/backups" COZE_WORKSPACE_PATH="${PROJECT_DIR}" \
|
||
bash "${SOURCE_DIR}/scripts/backup-create.sh" 2>&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}" <<EOF
|
||
deployed_at=$(date -Iseconds)
|
||
project_dir=${PROJECT_DIR}
|
||
data_dir=${DATA_DIR}
|
||
web_port=${WEB_PORT}
|
||
api_port=${API_PORT}
|
||
console_port=${CONSOLE_PORT}
|
||
EOF
|
||
}
|
||
|
||
print_success() {
|
||
local mode_label
|
||
mode_label="部署"
|
||
if [ "${MODE}" = "upgrade" ]; then
|
||
mode_label="升级"
|
||
fi
|
||
|
||
echo
|
||
echo "=============================================="
|
||
echo "✅ ${APP_NAME} ${mode_label}成功"
|
||
echo "=============================================="
|
||
echo "访问地址:http://${SERVER_HOST_IP}:${WEB_PORT}"
|
||
echo "管理后台:http://${SERVER_HOST_IP}:${WEB_PORT}/console"
|
||
echo "管理员账号:${ADMIN_ACCOUNT}"
|
||
echo "管理员邮箱:${ADMIN_EMAIL}"
|
||
if [ -n "${ADMIN_PASSWORD:-}" ]; then
|
||
echo "管理员密码:${ADMIN_PASSWORD}"
|
||
else
|
||
echo "管理员密码:升级时未修改,请继续使用原密码"
|
||
fi
|
||
echo "项目目录:${PROJECT_DIR}"
|
||
echo "数据目录:${DATA_DIR}"
|
||
echo "日志文件:${LOG_FILE}"
|
||
if [ -n "${BACKUP_FILE:-}" ]; then
|
||
echo "升级前备份:${BACKUP_FILE}"
|
||
fi
|
||
echo "生产安全提醒:正式上线请通过 Nginx/HTTPS 访问,只开放 80/443/SSH,并禁止公网直连 ${API_PORT}/${CONSOLE_PORT}。"
|
||
echo "=============================================="
|
||
}
|
||
|
||
main() {
|
||
collect_inputs
|
||
detect_host_ip
|
||
prepare_log
|
||
log "当前源码目录:${SOURCE_DIR}"
|
||
log "执行模式:${MODE}"
|
||
check_prerequisites
|
||
backup_before_upgrade
|
||
migrate_local_storage
|
||
sync_project_files
|
||
write_env_file
|
||
write_ecosystem_file
|
||
initialize_database
|
||
ensure_admin_user
|
||
build_project
|
||
run_security_audit
|
||
start_services
|
||
wait_for_health
|
||
mark_deployment
|
||
print_success
|
||
}
|
||
|
||
main "$@"
|