#!/bin/bash # 固定资产管理系统部署脚本 set -euo pipefail PROJECT_DIR="/opt/equipment-management" COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml" PROJECT_NAME="equipment-management" BACKUP_SCRIPT="$PROJECT_DIR/scripts/backup-equipment.sh" DISPOSAL_PRECHECK_SQL="$PROJECT_DIR/backend/prisma/migrations/20260714_asset_permissions_disposal_dingtalk/precheck.sql" MYSQL_CONTAINER="equipment-mysql" SKIP_BACKUP="${SKIP_BACKUP:-0}" CONFIRM_ACTIVE_DISPOSALS_REVIEWED="${CONFIRM_ACTIVE_DISPOSALS_REVIEWED:-}" HEALTHCHECK_ATTEMPTS="${HEALTHCHECK_ATTEMPTS:-12}" HEALTHCHECK_INTERVAL_SECONDS="${HEALTHCHECK_INTERVAL_SECONDS:-5}" HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://localhost:3000/api/health}" EQUIPMENT_CONTAINERS=("equipment-frontend" "equipment-backend" "equipment-mysql") get_container_status() { local container="$1" docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || printf 'missing\n' } verify_equipment_containers() { local container status local failed=0 echo ">>> 固定资产系统容器状态..." for container in "${EQUIPMENT_CONTAINERS[@]}"; do status="$(get_container_status "$container")" echo " $container => $status" if [ "$status" != "running" ]; then echo " 错误:固定资产容器 $container 未处于 running 状态。" >&2 failed=1 fi done return "$failed" } wait_for_backend_health() { local attempt echo ">>> 检查固定资产系统后端健康状态..." for ((attempt = 1; attempt <= HEALTHCHECK_ATTEMPTS; attempt++)); do if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then echo " 后端健康检查通过(第 ${attempt}/${HEALTHCHECK_ATTEMPTS} 次)" return 0 fi if [ "$attempt" -lt "$HEALTHCHECK_ATTEMPTS" ]; then echo " 第 ${attempt}/${HEALTHCHECK_ATTEMPTS} 次检查失败,${HEALTHCHECK_INTERVAL_SECONDS} 秒后重试..." sleep "$HEALTHCHECK_INTERVAL_SECONDS" fi done echo "后端健康检查失败:${HEALTHCHECK_URL},已尝试 ${HEALTHCHECK_ATTEMPTS} 次。" >&2 return 1 } run_equipment_mysql() { docker exec -i "$MYSQL_CONTAINER" sh -eu -c ' if [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then echo "equipment-mysql 缺少 MYSQL_ROOT_PASSWORD,无法安全执行迁移前核查。" >&2 exit 1 fi if [ -z "${MYSQL_DATABASE:-}" ]; then echo "equipment-mysql 缺少 MYSQL_DATABASE,无法确认固定资产数据库。" >&2 exit 1 fi defaults_file="$(mktemp /tmp/equipment-mysql-client.XXXXXX)" trap '\''rm -f "$defaults_file"'\'' EXIT HUP INT TERM chmod 600 "$defaults_file" printf "[client]\nuser=root\npassword=%s\n" "$MYSQL_ROOT_PASSWORD" > "$defaults_file" mysql --defaults-extra-file="$defaults_file" --database="$MYSQL_DATABASE" "$@" ' sh "$@" } run_disposal_migration_precheck() { local mysql_status tree_anomaly_count active_rows active_count active_fingerprint echo ">>> 执行报废流程迁移前只读核查..." if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2 return 1 fi mysql_status="$(get_container_status "$MYSQL_CONTAINER")" if [ "$mysql_status" != "running" ]; then echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2 return 1 fi if ! run_equipment_mysql < "$DISPOSAL_PRECHECK_SQL"; then echo "报废流程迁移前核查执行失败,已阻止部署。" >&2 return 1 fi if ! tree_anomaly_count="$(run_equipment_mysql --batch --skip-column-names --raw <<'SQL' WITH RECURSIVE active_disposal_tree AS ( SELECT disposal.id AS disposal_id, equipment.id AS equipment_id, equipment.parent_id, 0 AS depth, CAST(CONCAT('/', equipment.id, '/') AS CHAR(4096)) AS visited_path, 0 AS cycle_detected FROM disposal_record disposal INNER JOIN equipment equipment ON equipment.id = disposal.equipment_id WHERE disposal.status IN ('pending', 'approved') UNION ALL SELECT tree.disposal_id, child.id, child.parent_id, tree.depth + 1, CONCAT(tree.visited_path, child.id, '/'), LOCATE(CONCAT('/', child.id, '/'), tree.visited_path) > 0 FROM active_disposal_tree tree INNER JOIN equipment child ON child.parent_id = tree.equipment_id WHERE tree.depth < 100 AND tree.cycle_detected = 0 ) SELECT COUNT(*) FROM active_disposal_tree WHERE cycle_detected = 1 OR depth >= 100; SQL )"; then echo "无法确认未完成报废资产树是否存在环或超深层级,已阻止部署。" >&2 return 1 fi tree_anomaly_count="$(printf '%s' "$tree_anomaly_count" | tr -d '[:space:]')" if ! [[ "$tree_anomaly_count" =~ ^[0-9]+$ ]]; then echo "资产树异常数量不可解析,已阻止部署。" >&2 return 1 fi if [ "$tree_anomaly_count" -gt 0 ]; then echo "检测到 $tree_anomaly_count 个父子环或深度达到 100 的未完成报废资产节点。" >&2 echo "此类数据异常不允许通过确认指纹绕过,请先修复数据。" >&2 return 1 fi if ! active_rows="$( printf '%s\n' \ "SELECT id, status, equipment_id, DATE_FORMAT(updated_at, '%Y-%m-%dT%H:%i:%s.%f') FROM disposal_record WHERE status IN ('pending', 'approved') ORDER BY id, status, equipment_id, updated_at;" | run_equipment_mysql --batch --skip-column-names --raw )"; then echo "无法读取未完成报废单指纹数据,已阻止部署。" >&2 return 1 fi active_count="$(printf '%s\n' "$active_rows" | awk 'NF { count += 1 } END { print count + 0 }')" if [ "$active_count" -gt 0 ]; then if ! command -v sha256sum >/dev/null 2>&1; then echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2 return 1 fi active_fingerprint="$(printf '%s\n' "$active_rows" | sha256sum | awk '{ print $1 }')" if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then echo "未完成报废单指纹不可解析,已阻止部署。" >&2 return 1 fi echo "检测到 $active_count 条未完成报废单。" >&2 echo "本次未完成报废单指纹: $active_fingerprint" >&2 if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$active_fingerprint" ]; then echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2 echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$active_fingerprint。" >&2 return 1 fi echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。" else echo "未发现未完成报废单,迁移前核查通过。" fi } if docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE") elif command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD=(docker-compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE") else echo "未找到 docker compose / docker-compose,无法部署。" exit 1 fi echo "==========================================" echo " 固定资产管理系统 - 安全部署脚本" echo "==========================================" echo "项目目录: $PROJECT_DIR" echo "Compose 文件: $COMPOSE_FILE" echo "Compose 项目名: $PROJECT_NAME" echo "备份脚本: $BACKUP_SCRIPT" echo "" if [ ! -f "$COMPOSE_FILE" ]; then echo "未找到 $COMPOSE_FILE" exit 1 fi if ! [[ "$HEALTHCHECK_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then echo "HEALTHCHECK_ATTEMPTS 必须是正整数: $HEALTHCHECK_ATTEMPTS" exit 1 fi if ! [[ "$HEALTHCHECK_INTERVAL_SECONDS" =~ ^[0-9]+$ ]]; then echo "HEALTHCHECK_INTERVAL_SECONDS 必须是非负整数: $HEALTHCHECK_INTERVAL_SECONDS" exit 1 fi cd "$PROJECT_DIR" if [ "$SKIP_BACKUP" = "1" ]; then echo ">>> 已设置 SKIP_BACKUP=1,跳过备份" else if [ ! -x "$BACKUP_SCRIPT" ]; then echo "备份脚本不存在或不可执行: $BACKUP_SCRIPT" exit 1 fi echo ">>> 先执行固定资产系统备份..." "$BACKUP_SCRIPT" fi run_disposal_migration_precheck echo ">>> 先构建固定资产系统镜像..." "${COMPOSE_CMD[@]}" build echo ">>> 镜像构建成功,开始更新固定资产系统容器..." "${COMPOSE_CMD[@]}" up -d --remove-orphans deployment_failed=0 if ! wait_for_backend_health; then deployment_failed=1 fi if ! verify_equipment_containers; then deployment_failed=1 fi if [ "$deployment_failed" -ne 0 ]; then echo "固定资产系统部署校验失败,请根据上述错误排查。" >&2 exit 1 fi echo "" echo "==========================================" echo " 部署完成" SERVER_HOST="${SERVER_HOST:-}" echo " 固定资产前端: http://$SERVER_HOST" echo " 固定资产后端: http://$SERVER_HOST:3000/api" echo "=========================================="