From ec67794f88fb709c24a3aa3b5ba8c2a2decbbd4d Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 8 May 2026 13:32:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=9F=E4=BA=A7=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/ccdi_function.sh | 504 ++++++++++++++++++ .../2026-05-08-ccdi-function-prod-script.md | 81 +++ 2 files changed, 585 insertions(+) create mode 100755 deploy/ccdi_function.sh create mode 100644 docs/reports/implementation/2026-05-08-ccdi-function-prod-script.md diff --git a/deploy/ccdi_function.sh b/deploy/ccdi_function.sh new file mode 100755 index 00000000..9c07f01e --- /dev/null +++ b/deploy/ccdi_function.sh @@ -0,0 +1,504 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) + +# ==================== 生产配置区:按服务器实际路径修改 ==================== + +# JDK 安装目录。留空时使用服务器已有 JAVA_HOME;仍为空时使用 PATH 中的 java。 +BACKEND_JAVA_HOME="" + +# 后端 Jar 所在目录。生产目录结构为:脚本在外层,Jar 位于 backend/ruoyi-admin.jar。 +APP_HOME="${SCRIPT_DIR}/backend" + +# 后端 Jar 文件名。 +JAR_NAME="ruoyi-admin.jar" + +# Spring Profile。 +SPRING_PROFILES_ACTIVE="uat" + +# JVM 参数。 +JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" + +# 额外应用启动参数,例如:--server.port=8080 +APP_ARGS="" + +# 停止进程等待秒数。 +STOP_WAIT_SECONDS=30 + +# ==================== 以下为脚本逻辑,一般不需要修改 ==================== + +if [ "${APP_HOME#/}" = "${APP_HOME}" ]; then + APP_HOME="${SCRIPT_DIR}/${APP_HOME}" +fi +APP_HOME="${APP_HOME%/}" +JAR_PATH="${APP_HOME}/${JAR_NAME}" +FRONTEND_DIR="${SCRIPT_DIR}/frontend" +LOG_DIR="${APP_HOME}/logs" +CONSOLE_LOG="${LOG_DIR}/backend-console.log" +PID_FILE="${LOG_DIR}/backend-java.pid" +APP_MARKER="-Dccdi.backend.prod.home=${APP_HOME}" +JAVA_CMD="java" +RELATIVE_JAR_PATH="" +TIMESTAMP=$(date '+%Y%m%d%H%M%S') +BACKUP_ROOT="${SCRIPT_DIR}/backups" +BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}" +WORK_ROOT="${SCRIPT_DIR}/.deploy-work" +WORK_DIR="${WORK_ROOT}/release-${TIMESTAMP}" +RELEASE_ZIP="" +RELEASE_JAR="" +RELEASE_DIST_ZIP="" +FRONTEND_SOURCE_DIR="" + +case "${APP_HOME}" in + "${SCRIPT_DIR}"/*) + RELATIVE_JAR_PATH="${APP_HOME#${SCRIPT_DIR}/}/${JAR_NAME}" + ;; +esac + +timestamp() { + date '+%Y-%m-%d %H:%M:%S' +} + +log_info() { + printf '[%s] %s\n' "$(timestamp)" "$1" +} + +log_error() { + printf '[%s] %s\n' "$(timestamp)" "$1" >&2 +} + +usage() { + cat <<'EOF' +用法: ./ccdi_function.sh [deploy|restart|stop] [上线压缩包路径] + +命令: + deploy 备份 backend/ 与 frontend/,部署上线包,然后重启后端并持续输出日志 + restart 停止旧后端进程,启动 backend/ruoyi-admin.jar,并持续输出日志 + stop 停止当前脚本目录对应的 backend/ruoyi-admin.jar 进程 + +上线包要求: + 未传上线压缩包路径时,脚本会自动使用当前脚本目录下唯一的 .zip 文件。 + 上线压缩包根层必须包含 ruoyi-admin.jar 和 dist.zip。 + dist.zip 解压后必须包含 dist/index.html。 + +生产目录示例: + ccdi_function.sh + backend/ + frontend/ + ccdi_YYYYMMDD.zip + +说明: + restart 和 deploy 启动成功后会持续输出 backend/logs/backend-console.log。 + 按 Ctrl+C 仅退出日志查看,不会停止已启动的后端进程。 +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + log_error "缺少命令: $1" + exit 1 + fi +} + +resolve_path() { + input_path="$1" + + case "${input_path}" in + /*) + printf '%s\n' "${input_path}" + ;; + *) + input_dir=$(dirname "${input_path}") + input_base=$(basename "${input_path}") + printf '%s/%s\n' "$(CDPATH= cd -- "${input_dir}" && pwd)" "${input_base}" + ;; + esac +} + +resolve_java_cmd() { + configured_java_home="${BACKEND_JAVA_HOME}" + if [ -z "${configured_java_home}" ]; then + configured_java_home="${JAVA_HOME:-}" + fi + + if [ -n "${configured_java_home}" ]; then + configured_java_home="${configured_java_home%/}" + if [ ! -x "${configured_java_home}/bin/java" ]; then + log_error "配置的 JAVA_HOME 无效,未找到可执行文件: ${configured_java_home}/bin/java" + exit 1 + fi + + JAVA_HOME="${configured_java_home}" + export JAVA_HOME + JAVA_CMD="${JAVA_HOME}/bin/java" + else + require_command "java" + JAVA_CMD="java" + fi + + log_info "使用 Java 命令: ${JAVA_CMD}" +} + +get_process_table() { + if ! ps -ef 2>/dev/null; then + log_error "执行 ps -ef 失败,无法扫描旧进程" + return 1 + fi +} + +is_backend_process_line() { + process_line="$1" + + case "${process_line}" in + *""*) + return 1 + ;; + *" -jar ${JAR_PATH}"*) + return 0 + ;; + *"${APP_MARKER}"*) + return 0 + ;; + esac + + if [ -n "${RELATIVE_JAR_PATH}" ]; then + case "${process_line}" in + *" -jar ${RELATIVE_JAR_PATH}"*) + return 0 + ;; + esac + fi + + return 1 +} + +is_managed_pid() { + check_pid="$1" + if [ -z "${check_pid}" ] || ! kill -0 "${check_pid}" 2>/dev/null; then + return 1 + fi + + if ! process_table=$(get_process_table); then + return 1 + fi + + while IFS= read -r process_line; do + set -- ${process_line} + line_pid="${2:-}" + if [ "${line_pid}" = "${check_pid}" ] && is_backend_process_line "${process_line}"; then + return 0 + fi + done </dev/null || true) + if is_managed_pid "${pid_file_value}"; then + all_pids="${all_pids} ${pid_file_value}" + fi + fi + + while IFS= read -r process_line; do + set -- ${process_line} + scan_pid="${2:-}" + case "${scan_pid}" in + ''|*[!0-9]*) + continue + ;; + esac + + if is_backend_process_line "${process_line}"; then + all_pids="${all_pids} ${scan_pid}" + fi + done </dev/null || true +} + +start_backend() { + resolve_java_cmd + + if [ ! -f "${JAR_PATH}" ]; then + log_error "未找到后端 Jar: ${JAR_PATH}" + exit 1 + fi + + if ! running_pids=$(collect_pids); then + log_error "扫描后端进程失败" + exit 1 + fi + if [ -n "${running_pids}" ]; then + log_error "检测到后端已在运行: ${running_pids}" + exit 1 + fi + + mkdir -p "${LOG_DIR}" + printf '\n===== %s start =====\n' "$(timestamp)" >>"${CONSOLE_LOG}" + + profile_arg="" + if [ -n "${SPRING_PROFILES_ACTIVE}" ]; then + profile_arg="--spring.profiles.active=${SPRING_PROFILES_ACTIVE}" + fi + + log_info "开始启动后端 Jar: ${JAR_PATH}" + nohup "${JAVA_CMD}" "${APP_MARKER}" ${JAVA_OPTS} -jar "${JAR_PATH}" ${profile_arg} ${APP_ARGS} >>"${CONSOLE_LOG}" 2>&1 & + echo $! >"${PID_FILE}" + + sleep 3 + + starter_pid=$(sed -n '1p' "${PID_FILE}" 2>/dev/null || true) + if [ -z "${starter_pid}" ] || ! kill -0 "${starter_pid}" 2>/dev/null; then + log_error "启动命令未保持运行,请检查日志: ${CONSOLE_LOG}" + exit 1 + fi + + log_info "后端启动完成,PID: ${starter_pid}" +} + +stop_backend() { + if ! pids=$(collect_pids); then + log_error "扫描后端进程失败" + exit 1 + fi + + if [ -z "${pids}" ]; then + log_info "未发现运行中的后端进程" + rm -f "${PID_FILE}" + return 0 + fi + + log_info "准备停止后端进程: ${pids}" + for pid in ${pids}; do + kill -TERM "${pid}" 2>/dev/null || true + done + + elapsed=0 + remaining_pids="${pids}" + while [ -n "${remaining_pids}" ] && [ "${elapsed}" -lt "${STOP_WAIT_SECONDS}" ]; do + sleep 1 + elapsed=$((elapsed + 1)) + remaining_pids="" + for pid in ${pids}; do + if kill -0 "${pid}" 2>/dev/null; then + remaining_pids="${remaining_pids} ${pid}" + fi + done + remaining_pids=$(printf '%s\n' "${remaining_pids}" | xargs 2>/dev/null || true) + done + + if [ -n "${remaining_pids}" ]; then + log_info "仍有进程未退出,执行强制停止: ${remaining_pids}" + for pid in ${remaining_pids}; do + kill -KILL "${pid}" 2>/dev/null || true + done + fi + + rm -f "${PID_FILE}" + log_info "后端停止完成" +} + +follow_logs() { + require_command "tail" + + mkdir -p "${LOG_DIR}" + touch "${CONSOLE_LOG}" + log_info "持续输出日志中,按 Ctrl+C 仅退出日志查看,不会停止后端进程" + tail -n 200 -F "${CONSOLE_LOG}" +} + +restart_action() { + stop_backend + start_backend + follow_logs +} + +resolve_release_zip() { + if [ -n "${RELEASE_ZIP}" ]; then + RELEASE_ZIP=$(resolve_path "${RELEASE_ZIP}") + else + mkdir -p "${WORK_ROOT}" + candidate_file="${WORK_ROOT}/zip-candidates-${TIMESTAMP}.txt" + find "${SCRIPT_DIR}" -maxdepth 1 -type f -name '*.zip' ! -name 'dist.zip' | sort >"${candidate_file}" + candidate_count=$(wc -l <"${candidate_file}" | tr -d ' ') + + if [ "${candidate_count}" -eq 0 ]; then + log_error "未在脚本目录找到上线压缩包,请传入压缩包路径" + exit 1 + fi + + if [ "${candidate_count}" -gt 1 ]; then + log_error "脚本目录存在多个上线压缩包,请显式传入压缩包路径" + cat "${candidate_file}" >&2 + exit 1 + fi + + RELEASE_ZIP=$(sed -n '1p' "${candidate_file}") + rm -f "${candidate_file}" + fi + + if [ ! -f "${RELEASE_ZIP}" ]; then + log_error "上线压缩包不存在: ${RELEASE_ZIP}" + exit 1 + fi +} + +assert_deploy_layout() { + if [ ! -d "${APP_HOME}" ]; then + log_error "未找到后端目录: ${APP_HOME}" + exit 1 + fi + + if [ ! -d "${FRONTEND_DIR}" ]; then + log_error "未找到前端目录: ${FRONTEND_DIR}" + exit 1 + fi +} + +backup_dir() { + source_dir="$1" + target_dir="$2" + + mkdir -p "${target_dir}" + if [ -n "$(find "${source_dir}" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + cp -a "${source_dir}/." "${target_dir}/" + log_info "已备份 ${source_dir} 到 ${target_dir}" + else + log_info "目录为空,已创建空备份目录: ${target_dir}" + fi +} + +backup_current_files() { + mkdir -p "${BACKUP_DIR}" + backup_dir "${APP_HOME}" "${BACKUP_DIR}/backend" + backup_dir "${FRONTEND_DIR}" "${BACKUP_DIR}/frontend" +} + +extract_release_package() { + mkdir -p "${WORK_DIR}/release" "${WORK_DIR}/frontend" + log_info "开始解压上线压缩包: ${RELEASE_ZIP}" + unzip -q "${RELEASE_ZIP}" -d "${WORK_DIR}/release" + + RELEASE_JAR="${WORK_DIR}/release/${JAR_NAME}" + RELEASE_DIST_ZIP="${WORK_DIR}/release/dist.zip" + if [ ! -f "${RELEASE_JAR}" ]; then + log_error "上线压缩包根层缺少 ${JAR_NAME}" + exit 1 + fi + if [ ! -f "${RELEASE_DIST_ZIP}" ]; then + log_error "上线压缩包根层缺少 dist.zip" + exit 1 + fi + + unzip -q "${RELEASE_DIST_ZIP}" -d "${WORK_DIR}/frontend" + FRONTEND_SOURCE_DIR="${WORK_DIR}/frontend/dist" + if [ ! -f "${FRONTEND_SOURCE_DIR}/index.html" ]; then + log_error "dist.zip 解压后未找到 dist/index.html" + exit 1 + fi +} + +deploy_backend() { + target_jar="${APP_HOME}/${JAR_NAME}" + deploying_jar="${APP_HOME}/.${JAR_NAME}.deploying" + + cp "${RELEASE_JAR}" "${deploying_jar}" + mv "${deploying_jar}" "${target_jar}" + log_info "后端 Jar 已部署: ${target_jar}" +} + +deploy_frontend() { + find "${FRONTEND_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -a "${FRONTEND_SOURCE_DIR}/." "${FRONTEND_DIR}/" + log_info "前端文件已部署到: ${FRONTEND_DIR}" +} + +cleanup_work_dir() { + rm -rf "${WORK_DIR}" +} + +deploy_action() { + RELEASE_ZIP="${1:-}" + + require_command "find" + require_command "unzip" + + resolve_release_zip + assert_deploy_layout + backup_current_files + + trap cleanup_work_dir 0 + extract_release_package + deploy_backend + deploy_frontend + cleanup_work_dir + trap - 0 + + log_info "部署完成,备份目录: ${BACKUP_DIR}" + log_info "开始重启后端并输出日志" + restart_action +} + +main() { + action="${1:-help}" + case "${action}" in + deploy) + shift + if [ "$#" -gt 1 ]; then + usage + exit 1 + fi + deploy_action "${1:-}" + ;; + restart) + shift + if [ "$#" -ne 0 ]; then + usage + exit 1 + fi + restart_action + ;; + stop) + shift + if [ "$#" -ne 0 ]; then + usage + exit 1 + fi + stop_backend + ;; + -h|--help|help) + usage + ;; + *) + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/docs/reports/implementation/2026-05-08-ccdi-function-prod-script.md b/docs/reports/implementation/2026-05-08-ccdi-function-prod-script.md new file mode 100644 index 00000000..76ba9779 --- /dev/null +++ b/docs/reports/implementation/2026-05-08-ccdi-function-prod-script.md @@ -0,0 +1,81 @@ +# 生产统一脚本实施记录 + +## 保存路径确认 + +- 本次为生产部署与启停脚本改动,实施记录保存到 `docs/reports/implementation/`,符合仓库实施文档目录规范。 +- 生产脚本保存到 `deploy/ccdi_function.sh`,与既有生产部署脚本位于同一目录,便于上线包或生产目录统一维护。 + +## 修改目标 + +- 新增一个生产统一入口脚本 `ccdi_function.sh`。 +- 脚本需要支持 `deploy`、`restart`、`stop`。 +- `deploy` 集成生产备份、部署、重启流程,参考 `deploy/deploy-release-prod.sh`。 +- `restart` 和 `stop` 集成生产后端进程控制流程,参考 `deploy/start-java-backend-prod.sh`。 + +## 修改内容 + +- 新增 `deploy/ccdi_function.sh` + - 使用 `/bin/sh` 写法,避免服务器执行 `sh ccdi_function.sh` 时出现 Bash 专属语法兼容问题。 + - 保留脚本顶部生产配置区,可直接配置 `BACKEND_JAVA_HOME`、`APP_HOME`、`JAR_NAME`、`SPRING_PROFILES_ACTIVE`、`JAVA_OPTS`、`APP_ARGS` 和 `STOP_WAIT_SECONDS`。 + - `deploy` 支持显式传入上线包路径:`./ccdi_function.sh deploy /path/to/ccdi_YYYYMMDD.zip`。 + - `deploy` 未传上线包路径时,自动使用脚本同级目录下唯一的 `.zip` 文件,并排除 `dist.zip`。 + - 上线包根层校验 `ruoyi-admin.jar` 和 `dist.zip`。 + - `dist.zip` 解压后校验 `dist/index.html`。 + - 部署前备份当前 `backend/` 与 `frontend/` 到 `backups/YYYYMMDDHHMMSS/`。 + - 后端部署为原子替换 `backend/ruoyi-admin.jar`。 + - 前端部署为清空 `frontend/` 后复制 `dist/` 内文件。 + - `deploy` 完成文件部署后直接调用脚本内 `restart_action`,不再依赖外部 `start-java-backend-prod.sh`。 + - `restart` 流程为 `stop_backend`、`start_backend`、`follow_logs`。 + - `stop` 使用 `ps -ef` 扫描当前生产目录对应的 `backend/ruoyi-admin.jar` 进程,忽略 `` 行,并清理 PID 文件。 + - `restart` 与 `deploy` 启动成功后持续输出 `backend/logs/backend-console.log`,按 `Ctrl+C` 仅退出日志查看,不停止后端进程。 + +## 使用方式 + +生产目录示例: + +```text +上线目录/ +├── ccdi_function.sh +├── backend/ +├── frontend/ +└── ccdi_YYYYMMDD.zip +``` + +部署: + +```bash +./ccdi_function.sh deploy +./ccdi_function.sh deploy /path/to/ccdi_YYYYMMDD.zip +``` + +重启: + +```bash +./ccdi_function.sh restart +``` + +停止: + +```bash +./ccdi_function.sh stop +``` + +## 验证记录 + +- 执行 `sh -n deploy/ccdi_function.sh` + - 结果:通过 + - 说明:脚本 Shell 语法正确。 +- 执行 `sh deploy/ccdi_function.sh --help` + - 结果:通过 + - 说明:帮助信息正常输出,包含 `deploy`、`restart`、`stop` 三个入口。 +- 使用 `/tmp` 构造最小生产目录、旧 `backend/`、旧 `frontend/`、上线压缩包、假 Java 和假日志输出命令后执行 `deploy` + - 结果:通过 + - 说明:已验证旧文件备份、新 Jar 覆盖、前端 `dist/` 文件部署、部署后重启流程。 +- 在同一 `/tmp` 验证环境执行 `stop` + - 结果:通过 + - 说明:已验证 `stop` 可停止由统一脚本启动的后端进程。 + +## 影响范围 + +- 仅新增生产统一脚本与本实施记录。 +- 不修改 Java 业务代码、前端业务代码、数据库脚本和既有生产脚本。