356 lines
12 KiB
Bash
356 lines
12 KiB
Bash
#!/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"
|
||
DISPOSAL_MIGRATION_NAME="20260714_asset_permissions_disposal_dingtalk"
|
||
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")
|
||
DISPOSAL_PRECHECK_REQUIRED=0
|
||
INITIAL_DISPOSAL_FINGERPRINT=""
|
||
CURRENT_DISPOSAL_FINGERPRINT=""
|
||
CURRENT_ACTIVE_DISPOSAL_COUNT=0
|
||
|
||
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 "$@"
|
||
}
|
||
|
||
get_disposal_migration_state() {
|
||
local migrations_table_exists state
|
||
|
||
migrations_table_exists="$(
|
||
printf '%s\n' \
|
||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = '_prisma_migrations';" |
|
||
run_equipment_mysql --batch --skip-column-names --raw
|
||
)"
|
||
migrations_table_exists="$(printf '%s' "$migrations_table_exists" | tr -d '[:space:]')"
|
||
if [ "$migrations_table_exists" = "0" ]; then
|
||
printf 'pending\n'
|
||
return 0
|
||
fi
|
||
if [ "$migrations_table_exists" != "1" ]; then
|
||
echo "无法确认 Prisma 迁移表状态。" >&2
|
||
return 1
|
||
fi
|
||
|
||
state="$(run_equipment_mysql --batch --skip-column-names --raw <<SQL
|
||
SELECT CASE
|
||
WHEN SUM(CASE WHEN finished_at IS NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'incomplete'
|
||
WHEN SUM(CASE WHEN finished_at IS NOT NULL AND rolled_back_at IS NULL THEN 1 ELSE 0 END) > 0 THEN 'applied'
|
||
ELSE 'pending'
|
||
END
|
||
FROM _prisma_migrations
|
||
WHERE migration_name = '$DISPOSAL_MIGRATION_NAME';
|
||
SQL
|
||
)"
|
||
state="$(printf '%s' "$state" | tr -d '[:space:]')"
|
||
case "$state" in
|
||
applied|pending|incomplete) printf '%s\n' "$state" ;;
|
||
*) echo "报废迁移状态不可解析: $state" >&2; return 1 ;;
|
||
esac
|
||
}
|
||
|
||
calculate_active_disposal_fingerprint() {
|
||
local display_rows="${1:-0}"
|
||
local snapshot_output missing_root_count tree_anomaly_count review_row_count
|
||
local actual_review_row_count review_rows active_fingerprint active_count
|
||
|
||
if [ ! -r "$DISPOSAL_PRECHECK_SQL" ]; then
|
||
echo "迁移前核查脚本不存在或不可读: $DISPOSAL_PRECHECK_SQL" >&2
|
||
return 1
|
||
fi
|
||
if ! snapshot_output="$(
|
||
run_equipment_mysql --batch --skip-column-names --raw < "$DISPOSAL_PRECHECK_SQL"
|
||
)"; then
|
||
echo "报废流程迁移前一致性快照核查执行失败,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
|
||
missing_root_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "MISSING_ROOT_COUNT" { print $2 }')"
|
||
tree_anomaly_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "TREE_ANOMALY_COUNT" { print $2 }')"
|
||
active_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "ACTIVE_DISPOSAL_COUNT" { print $2 }')"
|
||
review_row_count="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW_COUNT" { print $2 }')"
|
||
review_rows="$(printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { sub(/^[^\t]*\t/, ""); print }')"
|
||
|
||
if ! [[ "$missing_root_count" =~ ^[0-9]+$ ]]; then
|
||
echo "缺失根资产数量不可解析,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
if [ "$missing_root_count" -gt 0 ]; then
|
||
echo "检测到 $missing_root_count 条未完成报废单缺少根资产,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
|
||
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_count" =~ ^[0-9]+$ ]]; then
|
||
echo "未完成报废单数量不可解析,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
if ! [[ "$review_row_count" =~ ^[0-9]+$ ]]; then
|
||
echo "人工核对行数量不可解析,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
actual_review_row_count="$(
|
||
printf '%s\n' "$snapshot_output" | awk -F '\t' '$1 == "REVIEW_ROW" { count += 1 } END { print count + 0 }'
|
||
)"
|
||
if [ "$actual_review_row_count" -ne "$review_row_count" ] || \
|
||
{ [ "$active_count" -gt 0 ] && [ "$review_row_count" -lt "$active_count" ]; }; then
|
||
echo "一致性快照的统计与人工核对行不一致,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
|
||
if [ "$display_rows" = "1" ]; then
|
||
echo ">>> 同一一致性快照中的未完成报废单及完整资产树:"
|
||
if [ "$review_row_count" -gt 0 ]; then
|
||
printf '%s\n' "$review_rows"
|
||
else
|
||
echo " (无未完成报废单)"
|
||
fi
|
||
fi
|
||
|
||
if ! command -v sha256sum >/dev/null 2>&1; then
|
||
echo "当前主机缺少 sha256sum,无法生成未完成报废单指纹,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
# 人工展示与指纹使用同一个 snapshot_output 中的 REVIEW_ROW,避免二次查询产生漂移。
|
||
active_fingerprint="$(printf '%s\n' "$review_rows" | sha256sum | awk '{ print $1 }')"
|
||
if ! [[ "$active_fingerprint" =~ ^[0-9a-f]{64}$ ]]; then
|
||
echo "未完成报废单指纹不可解析,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
|
||
CURRENT_ACTIVE_DISPOSAL_COUNT="$active_count"
|
||
CURRENT_DISPOSAL_FINGERPRINT="$active_fingerprint"
|
||
}
|
||
|
||
run_disposal_migration_precheck() {
|
||
local mysql_status migration_state
|
||
|
||
mysql_status="$(get_container_status "$MYSQL_CONTAINER")"
|
||
if [ "$mysql_status" != "running" ]; then
|
||
echo "无法确认生产 MySQL:$MYSQL_CONTAINER 当前状态为 $mysql_status,已阻止部署。" >&2
|
||
return 1
|
||
fi
|
||
|
||
migration_state="$(get_disposal_migration_state)"
|
||
case "$migration_state" in
|
||
applied)
|
||
echo ">>> 报废流程迁移已应用,跳过一次性迁移前核查。"
|
||
return 0
|
||
;;
|
||
incomplete)
|
||
echo "检测到未完成且未回滚的报废流程迁移记录,已阻止部署;请先恢复 Prisma 迁移状态。" >&2
|
||
return 1
|
||
;;
|
||
pending)
|
||
DISPOSAL_PRECHECK_REQUIRED=1
|
||
;;
|
||
esac
|
||
|
||
echo ">>> 执行报废流程迁移前只读核查..."
|
||
calculate_active_disposal_fingerprint 1
|
||
INITIAL_DISPOSAL_FINGERPRINT="$CURRENT_DISPOSAL_FINGERPRINT"
|
||
|
||
if [ "$CURRENT_ACTIVE_DISPOSAL_COUNT" -gt 0 ]; then
|
||
echo "检测到 $CURRENT_ACTIVE_DISPOSAL_COUNT 条未完成报废单。" >&2
|
||
echo "本次完整资产树指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2
|
||
if [ "$CONFIRM_ACTIVE_DISPOSALS_REVIEWED" != "$CURRENT_DISPOSAL_FINGERPRINT" ]; then
|
||
echo "已默认阻止部署。请业务负责人逐单核对上方资产树。" >&2
|
||
echo "核对完成后,仅本次执行可显式设置 CONFIRM_ACTIVE_DISPOSALS_REVIEWED=$CURRENT_DISPOSAL_FINGERPRINT。" >&2
|
||
return 1
|
||
fi
|
||
echo "已按当前集合指纹显式确认未完成报废单,本次允许继续部署。"
|
||
else
|
||
echo "未发现未完成报废单,迁移前核查通过。"
|
||
fi
|
||
}
|
||
|
||
recheck_disposal_migration_precheck() {
|
||
local migration_state
|
||
if [ "$DISPOSAL_PRECHECK_REQUIRED" != "1" ]; then
|
||
return 0
|
||
fi
|
||
|
||
echo ">>> 镜像构建后复核报废资产树是否变化..."
|
||
migration_state="$(get_disposal_migration_state)"
|
||
case "$migration_state" in
|
||
applied)
|
||
echo "报废流程迁移已由其他发布成功应用,允许继续。"
|
||
return 0
|
||
;;
|
||
incomplete)
|
||
echo "构建期间出现未完成的报废流程迁移记录,已阻止更新容器。" >&2
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
calculate_active_disposal_fingerprint
|
||
if [ "$CURRENT_DISPOSAL_FINGERPRINT" != "$INITIAL_DISPOSAL_FINGERPRINT" ]; then
|
||
echo "镜像构建期间未完成报废单或完整资产树发生变化,已阻止更新容器。" >&2
|
||
echo "构建前指纹: $INITIAL_DISPOSAL_FINGERPRINT" >&2
|
||
echo "当前指纹: $CURRENT_DISPOSAL_FINGERPRINT" >&2
|
||
return 1
|
||
fi
|
||
echo "报废单及完整资产树指纹未变化。"
|
||
}
|
||
|
||
if [ "${DEPLOY_SH_FUNCTIONS_ONLY:-0}" = "1" ]; then
|
||
return 0 2>/dev/null || exit 0
|
||
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
|
||
|
||
recheck_disposal_migration_precheck
|
||
|
||
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:-<server-host>}"
|
||
echo " 固定资产前端: http://$SERVER_HOST"
|
||
echo " 固定资产后端: http://$SERVER_HOST:3000/api"
|
||
echo "=========================================="
|