chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# 分钟级 binlog 归档。每分钟检查活动 binlog 的 Position,仅在有新增已提交事务时
|
||||
# 轮转;每个对象以 server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=backup-common.sh
|
||||
source "${SCRIPT_DIR}/backup-common.sh"
|
||||
|
||||
MYSQL_DATA_DIR='/var/lib/mysql'
|
||||
work_dir=''
|
||||
cleanup() {
|
||||
if [[ -n "${work_dir}" && -d "${work_dir}" ]]; then
|
||||
rm -rf -- "${work_dir}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
require_archive_config() {
|
||||
backup_require_env BACKUP_DIR >/dev/null
|
||||
backup_require_env BACKUP_OSS_URI >/dev/null
|
||||
backup_require_env BACKUP_PASSPHRASE >/dev/null
|
||||
}
|
||||
|
||||
mysql_binlog_query() {
|
||||
local mysql_cid="$1" query="$2" output code
|
||||
output="$(docker exec "${mysql_cid}" sh -c \
|
||||
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "$1"' \
|
||||
sh "${query}" 2>&1)" || {
|
||||
code=$?
|
||||
backup_error "MySQL binlog 查询失败(${query}):${output}"
|
||||
return "${code}"
|
||||
}
|
||||
printf '%s\n' "${output}"
|
||||
}
|
||||
|
||||
master_status() {
|
||||
local mysql_cid="$1"
|
||||
mysql_binlog_query "${mysql_cid}" 'SHOW BINARY LOG STATUS;' | awk 'NR == 1 { print $1 "\t" $2 }'
|
||||
}
|
||||
|
||||
state_active_binlog() {
|
||||
local state_dir="$1" key="$2" value
|
||||
[[ -f "${state_dir}/active-binlog.env" ]] || return
|
||||
value="$(awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${state_dir}/active-binlog.env")"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
write_active_binlog_state() {
|
||||
local state_dir="$1" filename="$2" position="$3"
|
||||
{
|
||||
printf 'filename=%s\n' "${filename}"
|
||||
printf 'position=%s\n' "${position}"
|
||||
printf 'observed_at=%s\n' "$(date -Iseconds)"
|
||||
} > "${state_dir}/active-binlog.env"
|
||||
}
|
||||
|
||||
state_last_index() {
|
||||
local state_dir="$1"
|
||||
local value
|
||||
[[ -f "${state_dir}/last-archived.env" ]] || return
|
||||
value="$(awk -F= '$1 == "binlog_index" { print $2; exit }' "${state_dir}/last-archived.env")"
|
||||
[[ "${value}" =~ ^[0-9]+$ ]] && printf '%s' "${value}"
|
||||
}
|
||||
|
||||
write_state() {
|
||||
local state_dir="$1" filename="$2" index="$3" raw_sha="$4" cipher_sha="$5" remote_dir="$6"
|
||||
{
|
||||
printf 'filename=%s\n' "${filename}"
|
||||
printf 'binlog_index=%s\n' "${index}"
|
||||
printf 'raw_sha256=%s\n' "${raw_sha}"
|
||||
printf 'cipher_sha256=%s\n' "${cipher_sha}"
|
||||
printf 'remote_dir=%s\n' "${remote_dir}"
|
||||
} > "${state_dir}/${filename}.${raw_sha}.state"
|
||||
cp "${state_dir}/${filename}.${raw_sha}.state" "${state_dir}/last-archived.env"
|
||||
}
|
||||
|
||||
archive_one() {
|
||||
local mysql_cid="$1" state_dir="$2" server_uuid="$3" oss_uri="$4" filename="$5"
|
||||
local source_path source_sha raw_file raw_sha cipher_file cipher_sha cipher_size binlog_index object_id remote_dir
|
||||
source_path="${MYSQL_DATA_DIR}/${filename}"
|
||||
if ! docker exec "${mysql_cid}" test -f "${source_path}" >/dev/null 2>&1; then
|
||||
backup_error "待归档 binlog 已从 MySQL 数据目录消失:${filename};PITR 链路已断裂"
|
||||
exit 1
|
||||
fi
|
||||
raw_file="${work_dir}/${filename}"
|
||||
docker cp "${mysql_cid}:${source_path}" "${raw_file}"
|
||||
raw_sha="$(backup_sha256 "${raw_file}")"
|
||||
source_sha="$(docker exec "${mysql_cid}" sha256sum "${source_path}" | awk '{print $1}')"
|
||||
[[ "${source_sha}" == "${raw_sha}" ]] || {
|
||||
backup_error "binlog 拷贝校验失败:${filename}"
|
||||
exit 1
|
||||
}
|
||||
if compgen -G "${state_dir}/${filename}.${raw_sha}.state" >/dev/null; then
|
||||
return
|
||||
fi
|
||||
binlog_index="$(backup_binlog_index "${filename}")"
|
||||
object_id="${filename}.${raw_sha}"
|
||||
remote_dir="${oss_uri}/mysql/binlog/${server_uuid}/${object_id}"
|
||||
cipher_file="${work_dir}/${object_id}.enc"
|
||||
backup_encrypt_file "${raw_file}" "${cipher_file}"
|
||||
cipher_sha="$(backup_sha256 "${cipher_file}")"
|
||||
cipher_size="$(backup_file_size "${cipher_file}")"
|
||||
printf '%s %s\n' "${cipher_sha}" 'binlog.enc' > "${work_dir}/${object_id}.enc.sha256"
|
||||
{
|
||||
printf 'format_version=1\n'
|
||||
printf 'server_uuid=%s\n' "${server_uuid}"
|
||||
printf 'source_binlog=%s\n' "${filename}"
|
||||
printf 'binlog_index=%s\n' "${binlog_index}"
|
||||
printf 'raw_sha256=%s\n' "${raw_sha}"
|
||||
printf 'payload=binlog.enc\n'
|
||||
printf 'payload_sha256=%s\n' "${cipher_sha}"
|
||||
printf 'payload_size_bytes=%s\n' "${cipher_size}"
|
||||
printf 'created_at=%s\n' "$(date -Iseconds)"
|
||||
} > "${work_dir}/${object_id}.complete.env"
|
||||
|
||||
backup_oss_upload "${cipher_file}" "${remote_dir}/binlog.enc"
|
||||
backup_oss_upload "${work_dir}/${object_id}.enc.sha256" "${remote_dir}/binlog.enc.sha256"
|
||||
# complete.env 最后上传,恢复程序只使用有该文件的 binlog。
|
||||
backup_oss_upload "${work_dir}/${object_id}.complete.env" "${remote_dir}/complete.env"
|
||||
write_state "${state_dir}" "${filename}" "${binlog_index}" "${raw_sha}" "${cipher_sha}" "${remote_dir}"
|
||||
rm -f -- "${raw_file}" "${cipher_file}" "${work_dir}/${object_id}.enc.sha256" "${work_dir}/${object_id}.complete.env"
|
||||
}
|
||||
|
||||
archive() {
|
||||
backup_require_cmd docker
|
||||
backup_require_cmd ossutil
|
||||
backup_require_cmd openssl
|
||||
require_archive_config
|
||||
local force="${1:-}" backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index
|
||||
local current_status current_file current_pos previous_file previous_pos final_status final_file final_pos binlog_list closed_binlog_list
|
||||
if [[ -n "${force}" && "${force}" != '--force' ]]; then
|
||||
printf '用法: %s archive [--force]\n' "$0" >&2
|
||||
exit 1
|
||||
fi
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
backup_validate_dir "${backup_dir}"
|
||||
oss_uri="$(backup_require_env BACKUP_OSS_URI)"
|
||||
backup_validate_oss_uri "${oss_uri}"
|
||||
backup_lock_or_exit "${backup_dir}/.binlog.lock"
|
||||
mysql_cid="$(backup_mysql_container_id)"
|
||||
server_uuid="$(backup_server_uuid)"
|
||||
state_dir="${backup_dir}/binlog-state/${server_uuid}"
|
||||
work_dir="${backup_dir}/.tmp-binlog-$$"
|
||||
mkdir -p "${state_dir}" "${work_dir}"
|
||||
|
||||
current_status="$(master_status "${mysql_cid}")"
|
||||
IFS=$'\t' read -r current_file current_pos <<< "${current_status}"
|
||||
[[ -n "${current_file}" && "${current_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取当前 binlog 位点'; exit 1; }
|
||||
backup_binlog_index "${current_file}" >/dev/null
|
||||
previous_file="$(state_active_binlog "${state_dir}" filename || true)"
|
||||
previous_pos="$(state_active_binlog "${state_dir}" position || true)"
|
||||
|
||||
if [[ "${force}" == '--force' || -z "${previous_file}" || -z "${previous_pos}" || "${current_file}" != "${previous_file}" || "${current_pos}" != "${previous_pos}" ]]; then
|
||||
docker exec "${mysql_cid}" sh -c \
|
||||
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null
|
||||
else
|
||||
backup_log "活动 binlog 无新增已提交事务,跳过轮转"
|
||||
fi
|
||||
binlog_list="$(mysql_binlog_query "${mysql_cid}" 'SHOW BINARY LOGS;')"
|
||||
first_binlog="$(awk 'NR == 1 { print $1; exit }' <<< "${binlog_list}")"
|
||||
[[ -n "${first_binlog}" ]] || { backup_error '无法读取 binlog 列表'; exit 1; }
|
||||
last_index="$(state_last_index "${state_dir}" || true)"
|
||||
first_index="$(backup_binlog_index "${first_binlog}")"
|
||||
if [[ -n "${last_index}" && "${first_index}" -gt $((last_index + 1)) ]]; then
|
||||
backup_error "发现未归档 binlog 缺口:已归档至序号 ${last_index},当前最早为 ${first_index}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
closed_binlog_list="$(awk 'NR > 1 { print previous } { previous = $1 }' <<< "${binlog_list}")"
|
||||
expected_index="${last_index}"
|
||||
while IFS= read -r filename; do
|
||||
[[ -n "${filename}" ]] || continue
|
||||
index="$(backup_binlog_index "${filename}")"
|
||||
if [[ -n "${expected_index}" && "${index}" -le "${expected_index}" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -n "${expected_index}" && "${index}" -ne $((expected_index + 1)) ]]; then
|
||||
backup_error "发现未归档 binlog 缺口:期望序号 $((expected_index + 1)),实际为 ${index}"
|
||||
exit 1
|
||||
fi
|
||||
archive_one "${mysql_cid}" "${state_dir}" "${server_uuid}" "${oss_uri}" "${filename}"
|
||||
expected_index="${index}"
|
||||
done <<< "${closed_binlog_list}"
|
||||
final_status="$(master_status "${mysql_cid}")"
|
||||
IFS=$'\t' read -r final_file final_pos <<< "${final_status}"
|
||||
[[ -n "${final_file}" && "${final_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取归档后的 binlog 位点'; exit 1; }
|
||||
write_active_binlog_state "${state_dir}" "${final_file}" "${final_pos}"
|
||||
backup_log "binlog 归档完成,实例 ${server_uuid}"
|
||||
}
|
||||
|
||||
verify() {
|
||||
backup_require_cmd ossutil
|
||||
require_archive_config
|
||||
local backup_dir server_uuid state_dir state_file remote_dir
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
backup_validate_dir "${backup_dir}"
|
||||
backup_lock_or_exit "${backup_dir}/.binlog.lock"
|
||||
server_uuid="$(backup_server_uuid)"
|
||||
state_dir="${backup_dir}/binlog-state/${server_uuid}"
|
||||
[[ -d "${state_dir}" ]] || { backup_error '尚未产生 binlog 归档状态'; exit 1; }
|
||||
while IFS= read -r -d '' state_file; do
|
||||
remote_dir="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${state_file}")"
|
||||
[[ -n "${remote_dir}" ]] || { backup_error "归档状态不完整:${state_file}"; exit 1; }
|
||||
ossutil stat "${remote_dir}/binlog.enc" >/dev/null
|
||||
ossutil stat "${remote_dir}/binlog.enc.sha256" >/dev/null
|
||||
ossutil stat "${remote_dir}/complete.env" >/dev/null
|
||||
done < <(find "${state_dir}" -maxdepth 1 -name '*.state' -print0)
|
||||
backup_log '全部本地 binlog 归档状态均有远端完整对象'
|
||||
}
|
||||
|
||||
case "${1:-archive}" in
|
||||
archive) archive "${2:-}" ;;
|
||||
verify) verify ;;
|
||||
*)
|
||||
printf '用法: %s {archive|verify}\n' "$0" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 在线备份脚本共用函数。仅供 scripts/backup-online.sh、archive-binlog.sh 和
|
||||
# restore-online.sh source,不要单独执行。
|
||||
|
||||
BACKUP_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BACKUP_COMPOSE_FILE="${BACKUP_ROOT_DIR}/deploy/docker-compose.prod.yml"
|
||||
BACKUP_ENV_FILE="${BACKUP_ROOT_DIR}/backend/.env"
|
||||
BACKUP_XTRABACKUP_IMAGE="${XTRABACKUP_IMAGE:-percona/percona-xtrabackup:8.4}"
|
||||
|
||||
backup_log() {
|
||||
printf '[backup] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
backup_error() {
|
||||
printf '[backup] ERROR: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
backup_require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
backup_error "缺少命令:$1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_env_value() {
|
||||
local key="$1"
|
||||
awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKUP_ENV_FILE}"
|
||||
}
|
||||
|
||||
backup_require_env() {
|
||||
local key="$1"
|
||||
local value
|
||||
value="$(backup_env_value "${key}")"
|
||||
if [[ -z "${value}" ]]; then
|
||||
backup_error "${BACKUP_ENV_FILE} 缺少或未填写 ${key}"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
backup_compose() {
|
||||
docker compose --env-file "${BACKUP_ENV_FILE}" -f "${BACKUP_COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
backup_mysql_container_id() {
|
||||
local cid
|
||||
cid="$(backup_compose ps -q mysql 2>/dev/null || true)"
|
||||
if [[ -z "${cid}" ]]; then
|
||||
backup_error "MySQL 容器未运行"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${cid}"
|
||||
}
|
||||
|
||||
backup_mysql_network() {
|
||||
local network
|
||||
network="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' "$1")"
|
||||
if [[ -z "${network}" ]]; then
|
||||
backup_error "无法读取 MySQL Docker 网络"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${network}"
|
||||
}
|
||||
|
||||
backup_mysql_root_query() {
|
||||
backup_compose exec -T mysql sh -c \
|
||||
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B "$MYSQL_DATABASE" -e "$1"' \
|
||||
sh "$1"
|
||||
}
|
||||
|
||||
backup_validate_dir() {
|
||||
local dir="$1"
|
||||
if [[ "${dir}" != /data/* || "${dir}" == "/data" ]]; then
|
||||
backup_error "备份目录必须是 /data 之下的具体绝对路径,当前值:${dir}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_lock_or_exit() {
|
||||
local path="$1"
|
||||
local wait_seconds="${2:-${BACKUP_LOCK_WAIT_SECONDS:-0}}"
|
||||
backup_require_cmd flock
|
||||
mkdir -p "$(dirname "${path}")"
|
||||
exec 9>"${path}"
|
||||
if [[ ! "${wait_seconds}" =~ ^[0-9]+$ ]]; then
|
||||
backup_error "锁等待时间必须是非负整数:${wait_seconds}"
|
||||
exit 1
|
||||
fi
|
||||
if ! flock -w "${wait_seconds}" 9; then
|
||||
backup_error "已有同类备份任务在执行,拒绝并发运行"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_server_uuid() {
|
||||
local value
|
||||
value="$(backup_mysql_root_query 'SELECT @@server_uuid;' 2>/dev/null || true)"
|
||||
if [[ ! "${value}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then
|
||||
backup_error "无法读取 MySQL server_uuid"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${value,,}"
|
||||
}
|
||||
|
||||
backup_binlog_index() {
|
||||
local filename="$1"
|
||||
if [[ "${filename}" =~ ^mysql-bin\.([0-9]{6,})$ ]]; then
|
||||
printf '%d' "$((10#${BASH_REMATCH[1]}))"
|
||||
return
|
||||
fi
|
||||
backup_error "不支持的 binlog 文件名:${filename}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
backup_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
return
|
||||
fi
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
backup_file_size() {
|
||||
if [[ "$(uname)" == 'Darwin' ]]; then
|
||||
stat -f%z "$1"
|
||||
else
|
||||
stat -c%s "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
backup_encrypt_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local passphrase
|
||||
passphrase="$(backup_require_env BACKUP_PASSPHRASE)"
|
||||
BACKUP_PASSPHRASE="${passphrase}" openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt \
|
||||
-pass env:BACKUP_PASSPHRASE \
|
||||
-in "${source}" \
|
||||
-out "${destination}"
|
||||
unset passphrase
|
||||
}
|
||||
|
||||
backup_decrypt_file() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local passphrase
|
||||
passphrase="$(backup_require_env BACKUP_PASSPHRASE)"
|
||||
BACKUP_PASSPHRASE="${passphrase}" openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \
|
||||
-pass env:BACKUP_PASSPHRASE \
|
||||
-in "${source}" \
|
||||
-out "${destination}"
|
||||
unset passphrase
|
||||
}
|
||||
|
||||
backup_oss_upload() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
local kms_key_id
|
||||
kms_key_id="$(backup_env_value BACKUP_KMS_KEY_ID)"
|
||||
if [[ -n "${kms_key_id}" ]]; then
|
||||
ossutil cp "${source}" "${destination}" \
|
||||
--encryption KMS --kms-master-key-id "${kms_key_id}"
|
||||
return
|
||||
fi
|
||||
# 未配置 KMS 时依赖 Bucket 已启用的 OSS 托管服务器端加密;备份载荷仍会先经过客户端加密。
|
||||
ossutil cp "${source}" "${destination}"
|
||||
}
|
||||
|
||||
backup_validate_oss_uri() {
|
||||
local uri="$1"
|
||||
if [[ ! "${uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
backup_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${uri}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_oss_download() {
|
||||
local source="$1"
|
||||
local destination="$2"
|
||||
ossutil cp "${source}" "${destination}"
|
||||
}
|
||||
|
||||
backup_write_complete_marker() {
|
||||
local path="$1"
|
||||
local payload_name="$2"
|
||||
local payload_sha256="$3"
|
||||
local payload_size="$4"
|
||||
local server_uuid="$5"
|
||||
{
|
||||
printf 'format_version=1\n'
|
||||
printf 'created_at=%s\n' "$(date -Iseconds)"
|
||||
printf 'server_uuid=%s\n' "${server_uuid}"
|
||||
printf 'payload=%s\n' "${payload_name}"
|
||||
printf 'payload_sha256=%s\n' "${payload_sha256}"
|
||||
printf 'payload_size_bytes=%s\n' "${payload_size}"
|
||||
} > "${path}"
|
||||
}
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# MySQL 在线全量物理备份。XtraBackup 在业务读写期间运行,备份只在
|
||||
# 上传 payload、manifest、checksum 后最后写入 complete.env,恢复程序只接受
|
||||
# 带 complete.env 的备份集。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=backup-common.sh
|
||||
source "${SCRIPT_DIR}/backup-common.sh"
|
||||
|
||||
cleanup_dir=''
|
||||
cleanup() {
|
||||
if [[ -n "${cleanup_dir}" && -d "${cleanup_dir}" ]]; then
|
||||
# XtraBackup 会保留源文件属主;失败时先在容器内归还临时目录属主,
|
||||
# 再由运行备份的业务用户删除,避免残留无法清理的大文件。
|
||||
docker run --rm --user 0:0 \
|
||||
-v "${cleanup_dir}:/cleanup" \
|
||||
--entrypoint chown \
|
||||
"${BACKUP_XTRABACKUP_IMAGE}" \
|
||||
-R "$(id -u):$(id -g)" /cleanup >/dev/null 2>&1 || true
|
||||
rm -rf -- "${cleanup_dir}" || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
require_backup_config() {
|
||||
backup_require_env BACKUP_DIR >/dev/null
|
||||
backup_require_env BACKUP_OSS_URI >/dev/null
|
||||
backup_require_env BACKUP_PASSPHRASE >/dev/null
|
||||
backup_require_env BACKUP_MYSQL_USER >/dev/null
|
||||
backup_require_env BACKUP_MYSQL_PASSWORD >/dev/null
|
||||
}
|
||||
|
||||
upload_backup_set() {
|
||||
local local_dir="$1"
|
||||
local level="$2"
|
||||
local ts="$3"
|
||||
local server_uuid="$4"
|
||||
local oss_uri="$5"
|
||||
local remote_dir="${oss_uri}/mysql/full/${level}/${server_uuid}/${ts}"
|
||||
|
||||
backup_log "上传 ${level} 全量备份到 ${remote_dir}"
|
||||
backup_oss_upload "${local_dir}/mysql-full.tgz.enc" "${remote_dir}/mysql-full.tgz.enc"
|
||||
backup_oss_upload "${local_dir}/manifest.env" "${remote_dir}/manifest.env"
|
||||
backup_oss_upload "${local_dir}/mysql-full.tgz.enc.sha256" "${remote_dir}/mysql-full.tgz.enc.sha256"
|
||||
# complete.env 必须最后上传,作为远端原子完成标识。
|
||||
backup_oss_upload "${local_dir}/complete.env" "${remote_dir}/complete.env"
|
||||
}
|
||||
|
||||
full_backup() {
|
||||
backup_require_cmd docker
|
||||
backup_require_cmd openssl
|
||||
backup_require_cmd ossutil
|
||||
require_backup_config
|
||||
|
||||
local backup_dir oss_uri mysql_cid network server_uuid backup_user backup_password ts tmp_dir target_dir
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
backup_validate_dir "${backup_dir}"
|
||||
oss_uri="$(backup_require_env BACKUP_OSS_URI)"
|
||||
backup_validate_oss_uri "${oss_uri}"
|
||||
backup_lock_or_exit "${backup_dir}/.full.lock"
|
||||
mysql_cid="$(backup_mysql_container_id)"
|
||||
network="$(backup_mysql_network "${mysql_cid}")"
|
||||
server_uuid="$(backup_server_uuid)"
|
||||
backup_user="$(backup_require_env BACKUP_MYSQL_USER)"
|
||||
backup_password="$(backup_require_env BACKUP_MYSQL_PASSWORD)"
|
||||
ts="$(date +%Y%m%d_%H%M%S)"
|
||||
tmp_dir="${backup_dir}/.tmp-full-${ts}-$$"
|
||||
target_dir="${backup_dir}/full/daily/${ts}"
|
||||
cleanup_dir="${tmp_dir}"
|
||||
mkdir -p "${tmp_dir}/xtrabackup"
|
||||
|
||||
cat > "${tmp_dir}/xtrabackup-client.cnf" <<EOF
|
||||
[client]
|
||||
user=${backup_user}
|
||||
password=${backup_password}
|
||||
host=mysql
|
||||
EOF
|
||||
chmod 600 "${tmp_dir}/xtrabackup-client.cnf"
|
||||
|
||||
backup_log "开始全量热备,实例 ${server_uuid}"
|
||||
# MySQL 数据卷中的 redo 文件仅 MySQL 用户和 root 可读,因此备份容器需以 root 读取。
|
||||
docker run --rm --user 0:0 \
|
||||
--network "${network}" \
|
||||
--volumes-from "${mysql_cid}:ro" \
|
||||
-v "${tmp_dir}/xtrabackup:/backup" \
|
||||
-v "${tmp_dir}/xtrabackup-client.cnf:/etc/xtrabackup-client.cnf:ro" \
|
||||
"${BACKUP_XTRABACKUP_IMAGE}" \
|
||||
xtrabackup \
|
||||
--defaults-extra-file=/etc/xtrabackup-client.cnf \
|
||||
--backup \
|
||||
--target-dir=/backup/data
|
||||
# XtraBackup 会保留源文件属主;在隔离容器内转回业务用户,供后续打包和加密使用。
|
||||
docker run --rm --user 0:0 \
|
||||
-v "${tmp_dir}/xtrabackup:/backup" \
|
||||
--entrypoint chown \
|
||||
"${BACKUP_XTRABACKUP_IMAGE}" \
|
||||
-R "$(id -u):$(id -g)" /backup/data
|
||||
rm -f "${tmp_dir}/xtrabackup-client.cnf"
|
||||
unset backup_password
|
||||
|
||||
local binlog_file binlog_pos
|
||||
binlog_file="$(awk -F$'\t' 'NR == 1 { print $1 }' "${tmp_dir}/xtrabackup/data/xtrabackup_binlog_info")"
|
||||
binlog_pos="$(awk -F$'\t' 'NR == 1 { print $2 }' "${tmp_dir}/xtrabackup/data/xtrabackup_binlog_info")"
|
||||
if [[ -z "${binlog_file}" || ! "${binlog_pos}" =~ ^[0-9]+$ ]]; then
|
||||
backup_error "未读取到有效 xtrabackup_binlog_info"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'format_version=1\n'
|
||||
printf 'created_at=%s\n' "$(date -Iseconds)"
|
||||
printf 'host=%s\n' "$(hostname)"
|
||||
printf 'server_uuid=%s\n' "${server_uuid}"
|
||||
printf 'mysql_version=%s\n' "$(backup_mysql_root_query 'SELECT VERSION();')"
|
||||
printf 'goose_version=%s\n' "$(backup_mysql_root_query 'SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version;')"
|
||||
printf 'binlog_file=%s\n' "${binlog_file}"
|
||||
printf 'binlog_pos=%s\n' "${binlog_pos}"
|
||||
printf 'git_commit=%s\n' "$(git -C "${BACKUP_ROOT_DIR}" rev-parse HEAD 2>/dev/null || true)"
|
||||
} > "${tmp_dir}/manifest.env"
|
||||
|
||||
# 关闭包含 XtraBackup 位点的当前 binlog 并归档。全量备份只有在该文件
|
||||
# 已有远端 complete 标识后才会被标记完成。
|
||||
BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive --force
|
||||
if ! find "${backup_dir}/binlog-state/${server_uuid}" -maxdepth 1 -type f \
|
||||
-name "${binlog_file}.*.state" -print -quit | grep -q .; then
|
||||
backup_error "XtraBackup 位点 ${binlog_file}:${binlog_pos} 尚未完成 binlog 归档"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar czf "${tmp_dir}/mysql-full.tgz" \
|
||||
-C "${tmp_dir}/xtrabackup/data" . \
|
||||
-C "${tmp_dir}" manifest.env
|
||||
backup_encrypt_file "${tmp_dir}/mysql-full.tgz" "${tmp_dir}/mysql-full.tgz.enc"
|
||||
rm -f "${tmp_dir}/mysql-full.tgz"
|
||||
|
||||
local payload_sha payload_size
|
||||
payload_sha="$(backup_sha256 "${tmp_dir}/mysql-full.tgz.enc")"
|
||||
payload_size="$(backup_file_size "${tmp_dir}/mysql-full.tgz.enc")"
|
||||
printf '%s %s\n' "${payload_sha}" 'mysql-full.tgz.enc' > "${tmp_dir}/mysql-full.tgz.enc.sha256"
|
||||
backup_write_complete_marker "${tmp_dir}/complete.env" 'mysql-full.tgz.enc' "${payload_sha}" "${payload_size}" "${server_uuid}"
|
||||
|
||||
# complete.env 会作为远端最后上传的原子完成标识。仅当日备已上传成功后,
|
||||
# 才将该备份移入本地正式保留目录,避免失败备份被 status 误判为完整。
|
||||
upload_backup_set "${tmp_dir}" daily "${ts}" "${server_uuid}" "${oss_uri}"
|
||||
|
||||
mkdir -p "${target_dir}"
|
||||
mv "${tmp_dir}/mysql-full.tgz.enc" "${target_dir}/"
|
||||
mv "${tmp_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/"
|
||||
mv "${tmp_dir}/manifest.env" "${target_dir}/"
|
||||
mv "${tmp_dir}/complete.env" "${target_dir}/"
|
||||
rm -rf "${tmp_dir}/xtrabackup"
|
||||
cleanup_dir=''
|
||||
rmdir "${tmp_dir}"
|
||||
|
||||
if [[ "$(date +%u)" == '7' ]]; then
|
||||
mkdir -p "${backup_dir}/full/weekly/${ts}"
|
||||
ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/weekly/${ts}/mysql-full.tgz.enc"
|
||||
cp "${target_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/manifest.env" "${target_dir}/complete.env" "${backup_dir}/full/weekly/${ts}/"
|
||||
upload_backup_set "${target_dir}" weekly "${ts}" "${server_uuid}" "${oss_uri}"
|
||||
fi
|
||||
if [[ "$(date +%d)" == '01' ]]; then
|
||||
mkdir -p "${backup_dir}/full/monthly/${ts}"
|
||||
ln "${target_dir}/mysql-full.tgz.enc" "${backup_dir}/full/monthly/${ts}/mysql-full.tgz.enc"
|
||||
cp "${target_dir}/mysql-full.tgz.enc.sha256" "${target_dir}/manifest.env" "${target_dir}/complete.env" "${backup_dir}/full/monthly/${ts}/"
|
||||
upload_backup_set "${target_dir}" monthly "${ts}" "${server_uuid}" "${oss_uri}"
|
||||
fi
|
||||
backup_log "全量热备完成:${target_dir},位点 ${binlog_file}:${binlog_pos}"
|
||||
}
|
||||
|
||||
prune_level() {
|
||||
local base="$1"
|
||||
local days="$2"
|
||||
[[ -d "${base}" ]] || return
|
||||
find "${base}" -mindepth 1 -maxdepth 1 -type d -mtime "+${days}" -print0 |
|
||||
while IFS= read -r -d '' dir; do
|
||||
backup_log "清理过期本地备份:${dir}"
|
||||
rm -rf -- "${dir}"
|
||||
done
|
||||
}
|
||||
|
||||
prune_backups() {
|
||||
local backup_dir daily weekly monthly
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
backup_validate_dir "${backup_dir}"
|
||||
backup_lock_or_exit "${backup_dir}/.full.lock"
|
||||
daily="$(backup_env_value BACKUP_KEEP_DAILY)"; daily="${daily:-14}"
|
||||
weekly="$(backup_env_value BACKUP_KEEP_WEEKLY)"; weekly="${weekly:-8}"
|
||||
monthly="$(backup_env_value BACKUP_KEEP_MONTHLY)"; monthly="${monthly:-12}"
|
||||
[[ "${daily}" =~ ^[0-9]+$ && "${weekly}" =~ ^[0-9]+$ && "${monthly}" =~ ^[0-9]+$ ]] || {
|
||||
backup_error 'BACKUP_KEEP_* 必须是非负整数'
|
||||
exit 1
|
||||
}
|
||||
prune_level "${backup_dir}/full/daily" "${daily}"
|
||||
prune_level "${backup_dir}/full/weekly" "$((weekly * 7))"
|
||||
prune_level "${backup_dir}/full/monthly" "$((monthly * 31))"
|
||||
}
|
||||
|
||||
status() {
|
||||
local backup_dir oss_uri
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
oss_uri="$(backup_require_env BACKUP_OSS_URI)"
|
||||
backup_validate_oss_uri "${oss_uri}"
|
||||
backup_log '本地最近完整全量备份:'
|
||||
find "${backup_dir}/full" -name complete.env -print 2>/dev/null | sort | tail -8 || true
|
||||
backup_log 'OSS 最近完整全量备份:'
|
||||
ossutil ls "${oss_uri}/mysql/full/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true
|
||||
backup_log 'OSS 最近 binlog 完成标识:'
|
||||
ossutil ls "${oss_uri}/mysql/binlog/" -r 2>/dev/null | grep 'complete.env' | tail -8 || true
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
full) full_backup ;;
|
||||
prune) prune_backups ;;
|
||||
status) status ;;
|
||||
*)
|
||||
printf '用法: %s {full|prune|status}\n' "$0" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# 由宿主机 cron 每分钟调用。调度配置来自后台 system_configs,实际执行仍在宿主机,
|
||||
# Web 后端不接触 Docker socket、备份载荷或客户端加密密码。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=backup-common.sh
|
||||
source "${SCRIPT_DIR}/backup-common.sh"
|
||||
|
||||
config_value() {
|
||||
local key="$1" fallback="$2" value
|
||||
value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)"
|
||||
value="${value//$'\n'/}"
|
||||
printf '%s' "${value:-${fallback}}"
|
||||
}
|
||||
|
||||
write_job_state() {
|
||||
local status_dir="$1" type="$2" status="$3" message="$4"
|
||||
mkdir -p "${status_dir}"
|
||||
{
|
||||
printf 'job_type=%q\n' "${type}"
|
||||
printf 'job_status=%q\n' "${status}"
|
||||
printf 'job_at=%q\n' "$(date -Iseconds)"
|
||||
printf 'job_message=%q\n' "${message}"
|
||||
} > "${status_dir}/last-job.env"
|
||||
chmod 600 "${status_dir}/last-job.env"
|
||||
}
|
||||
|
||||
run_job() {
|
||||
local status_dir="$1" type="$2" script="$3"
|
||||
shift 3
|
||||
if "${script}" "$@"; then
|
||||
write_job_state "${status_dir}" "${type}" success '执行成功'
|
||||
"${SCRIPT_DIR}/backup-status.sh" write || true
|
||||
return
|
||||
else
|
||||
# 必须在失败分支中立即读取退出码;if 语句本身在无 else 时会返回 0。
|
||||
local code=$?
|
||||
write_job_state "${status_dir}" "${type}" failed "执行失败(退出码 ${code})"
|
||||
"${SCRIPT_DIR}/backup-status.sh" write || true
|
||||
return "${code}"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
backup_require_cmd docker
|
||||
backup_require_cmd flock
|
||||
local backup_dir status_dir enabled hour minute interval now_hour now_minute
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}"
|
||||
backup_validate_dir "${status_dir}"
|
||||
backup_lock_or_exit "${backup_dir}/.scheduler.lock"
|
||||
|
||||
enabled="$(config_value 'backup.schedule_enabled' 'true')"
|
||||
hour="$(config_value 'backup.full_hour' '4')"
|
||||
minute="$(config_value 'backup.full_minute' '30')"
|
||||
interval="$(config_value 'backup.binlog_interval_minutes' '1')"
|
||||
[[ "${enabled}" == true || "${enabled}" == false ]] || { backup_error 'backup.schedule_enabled 必须为 true 或 false'; exit 1; }
|
||||
[[ "${hour}" =~ ^[0-9]+$ && "${hour}" -le 23 && "${minute}" =~ ^[0-9]+$ && "${minute}" -le 59 ]] || {
|
||||
backup_error '备份全量时间配置无效'
|
||||
exit 1
|
||||
}
|
||||
[[ "${interval}" =~ ^[0-9]+$ && "${interval}" -ge 1 && "${interval}" -le 5 ]] || {
|
||||
backup_error 'binlog 归档间隔必须为 1-5 分钟'
|
||||
exit 1
|
||||
}
|
||||
if [[ "${enabled}" == false ]]; then
|
||||
"${SCRIPT_DIR}/backup-status.sh" write || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
now_hour="$((10#$(date +%H)))"
|
||||
now_minute="$((10#$(date +%M)))"
|
||||
if ((now_minute % interval == 0)); then
|
||||
run_job "${status_dir}" binlog "${SCRIPT_DIR}/archive-binlog.sh" archive
|
||||
fi
|
||||
if ((now_hour == hour && now_minute == minute)); then
|
||||
run_job "${status_dir}" full "${SCRIPT_DIR}/backup-online.sh" full
|
||||
fi
|
||||
"${SCRIPT_DIR}/backup-status.sh" write || true
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# 生成供后台只读展示的备份状态摘要。内容不包含密钥、备份载荷或下载地址。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=backup-common.sh
|
||||
source "${SCRIPT_DIR}/backup-common.sh"
|
||||
|
||||
json_escape() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//$'\n'/ }"
|
||||
value="${value//$'\r'/ }"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
config_value() {
|
||||
local key="$1" fallback="$2" value
|
||||
value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)"
|
||||
value="${value//$'\n'/}"
|
||||
printf '%s' "${value:-${fallback}}"
|
||||
}
|
||||
|
||||
write_status() {
|
||||
backup_require_cmd docker
|
||||
backup_require_cmd ossutil
|
||||
|
||||
local backup_dir oss_uri status_dir status_file temp_file enabled hour minute interval
|
||||
local local_complete remote_complete binlog_state binlog_file binlog_remote job_type job_status job_at job_message health
|
||||
backup_dir="$(backup_require_env BACKUP_DIR)"
|
||||
oss_uri="$(backup_require_env BACKUP_OSS_URI)"
|
||||
status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}"
|
||||
backup_validate_dir "${status_dir}"
|
||||
mkdir -p "${status_dir}"
|
||||
chmod 700 "${status_dir}"
|
||||
status_file="${status_dir}/status.json"
|
||||
temp_file="${status_dir}/.status-$$.json"
|
||||
|
||||
enabled="$(config_value 'backup.schedule_enabled' 'true')"
|
||||
hour="$(config_value 'backup.full_hour' '4')"
|
||||
minute="$(config_value 'backup.full_minute' '30')"
|
||||
interval="$(config_value 'backup.binlog_interval_minutes' '1')"
|
||||
local_complete="$(find "${backup_dir}/full/daily" -name complete.env -type f -print 2>/dev/null | sort | tail -1 || true)"
|
||||
remote_complete="$(ossutil ls "${oss_uri}/mysql/full/daily/" -r 2>/dev/null | awk '$NF ~ /\/complete\.env$/ { latest = $NF } END { print latest }')"
|
||||
binlog_state="$(find "${backup_dir}/binlog-state" -name last-archived.env -type f -print 2>/dev/null | sort | tail -1 || true)"
|
||||
binlog_file=''
|
||||
binlog_remote=''
|
||||
if [[ -n "${binlog_state}" ]]; then
|
||||
binlog_file="$(awk -F= '$1 == "filename" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")"
|
||||
binlog_remote="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")"
|
||||
fi
|
||||
|
||||
job_type=''
|
||||
job_status=''
|
||||
job_at=''
|
||||
job_message=''
|
||||
if [[ -f "${status_dir}/last-job.env" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${status_dir}/last-job.env"
|
||||
fi
|
||||
if [[ "${job_status}" == 'failed' ]]; then
|
||||
health='warning'
|
||||
elif [[ -n "${local_complete}" && -n "${remote_complete}" && -n "${binlog_file}" ]]; then
|
||||
health='healthy'
|
||||
else
|
||||
health='warning'
|
||||
fi
|
||||
|
||||
cat > "${temp_file}" <<EOF
|
||||
{
|
||||
"generated_at": "$(date -Iseconds)",
|
||||
"health": "$(json_escape "${health}")",
|
||||
"schedule": {
|
||||
"enabled": "$(json_escape "${enabled}")",
|
||||
"full_hour": "$(json_escape "${hour}")",
|
||||
"full_minute": "$(json_escape "${minute}")",
|
||||
"binlog_interval_minutes": "$(json_escape "${interval}")"
|
||||
},
|
||||
"last_full": {
|
||||
"local_complete": "$(json_escape "${local_complete}")",
|
||||
"remote_complete": "$(json_escape "${remote_complete}")"
|
||||
},
|
||||
"last_binlog": {
|
||||
"file": "$(json_escape "${binlog_file}")",
|
||||
"remote_dir": "$(json_escape "${binlog_remote}")"
|
||||
},
|
||||
"last_job": {
|
||||
"type": "$(json_escape "${job_type}")",
|
||||
"status": "$(json_escape "${job_status}")",
|
||||
"at": "$(json_escape "${job_at}")",
|
||||
"message": "$(json_escape "${job_message}")"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
chmod 640 "${temp_file}"
|
||||
mv "${temp_file}" "${status_file}"
|
||||
}
|
||||
|
||||
case "${1:-write}" in
|
||||
write) write_status ;;
|
||||
*)
|
||||
printf '用法: %s {write}\n' "$0" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BACKEND_DIR="${ROOT_DIR}/backend"
|
||||
PROFILE="$(mktemp -t hfb-backend-cover.XXXXXX)"
|
||||
OUTPUT="$(mktemp -t hfb-backend-cover-output.XXXXXX)"
|
||||
trap 'rm -f "${PROFILE}" "${OUTPUT}"' EXIT
|
||||
|
||||
cd "${BACKEND_DIR}"
|
||||
# 模板库只迁移一次,各包克隆后可并发;指纹未变则跨次运行复用。
|
||||
go test -count=1 -coverprofile="${PROFILE}" ./... | tee "${OUTPUT}"
|
||||
|
||||
coverage_for_package() {
|
||||
local package="$1"
|
||||
awk -v package="${package}" '
|
||||
$1 == "ok" && $2 == package {
|
||||
for (i = 1; i <= NF; i++) {
|
||||
if ($i == "coverage:") {
|
||||
value = $(i + 1)
|
||||
gsub(/%/, "", value)
|
||||
print value
|
||||
exit
|
||||
}
|
||||
}
|
||||
}
|
||||
' "${OUTPUT}"
|
||||
}
|
||||
|
||||
require_at_least() {
|
||||
local label="$1"
|
||||
local actual="$2"
|
||||
local minimum="$3"
|
||||
if [[ -z "${actual}" ]]; then
|
||||
printf '[coverage] 无法读取 %s 覆盖率\n' "${label}" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! awk -v actual="${actual}" -v minimum="${minimum}" 'BEGIN { exit !(actual + 0 >= minimum + 0) }'; then
|
||||
printf '[coverage] %s 覆盖率 %s%%,低于门禁 %s%%\n' "${label}" "${actual}" "${minimum}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '[coverage] %s %s%%(门禁 %s%%)\n' "${label}" "${actual}" "${minimum}"
|
||||
}
|
||||
|
||||
total="$(go tool cover -func="${PROFILE}" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')"
|
||||
require_at_least "后端总语句" "${total}" "30.0"
|
||||
require_at_least "order" "$(coverage_for_package hfb_sys/backend/internal/modules/order)" "40.0"
|
||||
require_at_least "payment" "$(coverage_for_package hfb_sys/backend/internal/modules/payment)" "20.0"
|
||||
require_at_least "wallet" "$(coverage_for_package hfb_sys/backend/internal/modules/wallet)" "34.0"
|
||||
require_at_least "withdrawal" "$(coverage_for_package hfb_sys/backend/internal/modules/withdrawal)" "30.0"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# 检查关键 SQL 防线是否仍存在;真实数据量下的执行计划需要在 MySQL 上单独验证。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations"
|
||||
failures=()
|
||||
|
||||
require_pattern() {
|
||||
local file="$1" pattern="$2" name="$3"
|
||||
if [[ ! -f "${ROOT_DIR}/${file}" ]]; then
|
||||
failures+=("${name}: 文件不存在 ${file}")
|
||||
elif ! grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then
|
||||
failures+=("${name}: 缺少 ${pattern} (${file})")
|
||||
fi
|
||||
}
|
||||
|
||||
forbid_pattern() {
|
||||
local file="$1" pattern="$2" name="$3"
|
||||
if [[ -f "${ROOT_DIR}/${file}" ]] && grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then
|
||||
failures+=("${name}: 存在被禁止的跨表全扫描反模式 ${pattern} (${file})")
|
||||
fi
|
||||
}
|
||||
|
||||
# 数据库运行时配置和参数脱敏不能被重构删除。
|
||||
require_pattern "backend/internal/config/config.go" "DATABASE_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置"
|
||||
require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置"
|
||||
require_pattern "backend/internal/database/gorm_logger.go" "ParamsFilter" "SQL 参数脱敏"
|
||||
require_pattern "backend/internal/database/mysql.go" "SetMaxOpenConns" "连接池应用"
|
||||
|
||||
# 当前财务仪表盘的时间范围聚合和索引是高频查询防线。
|
||||
require_pattern "backend/internal/modules/adminfinance/subquery.go" "settled_at >= ? AND scoped_order.settled_at <= ?" "结算聚合时间范围"
|
||||
require_pattern "backend/internal/modules/adminfinance/dashboard.go" "po.created_at >= ? AND po.created_at <= ?" "支付聚合时间范围"
|
||||
require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_completed_at" "财务完成索引"
|
||||
require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_cancelled_at" "财务取消索引"
|
||||
|
||||
# 分页核心路径必须保留上限;配置型全量列表在运维文档中登记为白名单。
|
||||
require_pattern "backend/internal/modules/listing/public_query.go" "if pageSize > 50" "公开商品分页上限"
|
||||
require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限"
|
||||
|
||||
# 智能意图路由与单表唯一索引直达防线(防止多表跨表 OR/LIKE 导致 MySQL 放弃索引而退化为全表扫描)
|
||||
require_pattern "backend/internal/modules/order/queries.go" "o.order_no = ?" "订单列表订单号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/order/queries.go" "l.listing_no = ?" "订单列表商品号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/order/queries.go" "owner.phone = ? OR renter.phone = ?" "订单列表手机号索引直达"
|
||||
require_pattern "backend/internal/modules/order/queries.go" "page == 1 && len(rows) < pageSize" "订单列表首页面单扫描短路"
|
||||
|
||||
require_pattern "backend/internal/modules/pickup/repository.go" "p.pickup_no = ?" "提号列表提号单号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/pickup/repository.go" "owner.phone = ?" "提号列表号主手机号索引直达"
|
||||
require_pattern "backend/internal/modules/pickup/repository.go" "page == 1 && len(rows) < pageSize" "提号列表首页面单扫描短路"
|
||||
|
||||
require_pattern "backend/internal/modules/dispute/query.go" "o.order_no = ?" "纠纷列表订单号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/dispute/query.go" "page == 1 && len(rows) < pageSize" "纠纷列表首页面单扫描短路"
|
||||
|
||||
require_pattern "backend/internal/modules/adminuser/repository.go" "u.phone = ?" "用户管理手机号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/adminuser/repository.go" "page == 1 && len(rows) < pageSize" "用户管理首页面单扫描短路"
|
||||
|
||||
require_pattern "backend/internal/modules/listing/query.go" "l.listing_no = ?" "商品列表商品号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/listing/query.go" "page == 1 && len(rows) < pageSize" "商品列表首页面单扫描短路"
|
||||
|
||||
require_pattern "backend/internal/modules/chat/support.go" "l.listing_no = ?" "客服会话商品号唯一索引直达"
|
||||
require_pattern "backend/internal/modules/chat/support.go" "renter.phone = ? OR owner.phone = ?" "客服会话手机号唯一索引直达"
|
||||
|
||||
# 禁止核心列表重新引入跨表 6~10 路全扫描反模式
|
||||
forbid_pattern "backend/internal/modules/dispute/query.go" "o.order_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR renter.phone LIKE ? OR d.id = ?" "纠纷列表禁止6表无意图全表扫描"
|
||||
|
||||
# 已合入 main 的迁移只允许新增,不允许修改或删除。
|
||||
# 当前分支尚未合入 main 的迁移可以整段重写。
|
||||
# 新仓库尚未初始化 Git 时跳过该历史检查;初始化 Git 后自动恢复。
|
||||
if ! git -C "${ROOT_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
printf '[sql-guard] 未初始化 Git,跳过已发布迁移变更检查\n' >&2
|
||||
else
|
||||
published_ref=""
|
||||
if git -C "${ROOT_DIR}" rev-parse --verify main >/dev/null 2>&1; then
|
||||
published_ref="main"
|
||||
elif git -C "${ROOT_DIR}" rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||
published_ref="origin/main"
|
||||
fi
|
||||
if [[ -z "${published_ref}" ]]; then
|
||||
failures+=("无法确定 main 分支,无法检查已发布迁移")
|
||||
elif changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status "${published_ref}" -- backend/migrations)"; then
|
||||
while read -r status file _; do
|
||||
[[ -z "${status:-}" ]] && continue
|
||||
if [[ "${status}" != "A" ]]; then
|
||||
failures+=("已发布迁移文件不可修改或删除: ${status} ${file}")
|
||||
fi
|
||||
done <<< "${changed_migrations}"
|
||||
else
|
||||
failures+=("无法检查迁移文件变更")
|
||||
fi
|
||||
fi
|
||||
|
||||
# 初始合并迁移没有 Down,其余迁移必须提供可回滚段。
|
||||
while IFS= read -r file; do
|
||||
[[ "$(basename "${file}")" == "000001_init.sql" ]] && continue
|
||||
if ! grep -Fq -- '-- +goose Up' "${file}" || ! grep -Fq -- '-- +goose Down' "${file}"; then
|
||||
failures+=("迁移缺少 Goose Up/Down: ${file#"${ROOT_DIR}/"}")
|
||||
fi
|
||||
done < <(find "${MIGRATIONS_DIR}" -maxdepth 1 -type f -name '*.sql' | sort)
|
||||
|
||||
if (( ${#failures[@]} > 0 )); then
|
||||
printf '[sql-guard] SQL 约束检查失败\n' >&2
|
||||
printf '[sql-guard] %s\n' "${failures[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '[sql-guard] SQL 约束检查通过\n'
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAX_LINES="${VUE_MAX_LINES:-1800}"
|
||||
failed=0
|
||||
while IFS= read -r -d '' file; do
|
||||
lines="$(wc -l < "${file}")"
|
||||
if (( lines > MAX_LINES )); then
|
||||
printf '[vue-size] %s %s 行,超过上限 %s\n' "${file#${ROOT_DIR}/}" "${lines}" "${MAX_LINES}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0)
|
||||
|
||||
if (( failed != 0 )); then
|
||||
exit 1
|
||||
fi
|
||||
printf '[vue-size] 所有 Vue 页面均不超过 %s 行\n' "${MAX_LINES}"
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# 全量代码门禁:部署前在本地开发机执行,确保后端 + 前端检查全绿。
|
||||
#
|
||||
# 用途:
|
||||
# - 部署门禁:scripts/deploy-prod.sh 在 build 镜像前自动调用本脚本(--skip-check 可跳过)
|
||||
# - 手动校验:开发时手动跑 ./scripts/check.sh
|
||||
#
|
||||
# 包含:
|
||||
# 数据库:SQL 约束与迁移完整性检查
|
||||
# 后端:go vet + go build + go test
|
||||
# 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build
|
||||
#
|
||||
# 任一步骤失败即退出非零,阻止部署。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BACKEND_DIR="${ROOT_DIR}/backend"
|
||||
FRONTEND_DIR="${ROOT_DIR}/frontend"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[1;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { printf "${BLUE}[check]${NC} %s\n" "$*"; }
|
||||
log_ok() { printf "${GREEN}[check]${NC} %s\n" "$*"; }
|
||||
log_warn() { printf "${YELLOW}[check]${NC} %s\n" "$*"; }
|
||||
log_error() { printf "${RED}[check]${NC} %s\n" "$*" >&2; }
|
||||
|
||||
SKIP_BACKEND=0
|
||||
SKIP_FRONTEND=0
|
||||
SKIP_TESTS=0
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
用法: ./scripts/check.sh [选项]
|
||||
|
||||
全量代码门禁:后端 go vet/build/test + 前端 check/test/build。
|
||||
|
||||
选项:
|
||||
--skip-backend 跳过后端检查
|
||||
--skip-frontend 跳过前端检查
|
||||
--skip-tests 跳过测试(仅做 vet/build + lint/typecheck/build)
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./scripts/check.sh
|
||||
./scripts/check.sh --skip-frontend
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-backend) SKIP_BACKEND=1; shift ;;
|
||||
--skip-frontend) SKIP_FRONTEND=1; shift ;;
|
||||
--skip-tests) SKIP_TESTS=1; shift ;;
|
||||
-h|--help) show_help; exit 0 ;;
|
||||
*)
|
||||
log_error "未知参数: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
step_start() { printf "\n${BLUE}[check]${NC} \033[1m▶ %s${NC}\n" "$1"; }
|
||||
step_ok() { log_ok "✓ $1 ($2)"; }
|
||||
step_fail() { log_error "✗ $1 失败"; }
|
||||
|
||||
run_step() {
|
||||
# run_step "步骤名" 命令...
|
||||
local name="$1"; shift
|
||||
local start
|
||||
start=$(date +%s)
|
||||
step_start "$name"
|
||||
if "$@"; then
|
||||
local elapsed=$(( $(date +%s) - start ))
|
||||
step_ok "$name" "${elapsed}s"
|
||||
else
|
||||
step_fail "$name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
FAILED=0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 后端检查
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${SKIP_BACKEND}" == "0" ]]; then
|
||||
log "后端检查目录: ${BACKEND_DIR}"
|
||||
cd "${BACKEND_DIR}"
|
||||
|
||||
run_step "SQL 约束检查" bash "${ROOT_DIR}/scripts/check-sql-guard.sh" || FAILED=1
|
||||
run_step "go vet ./..." go vet ./... || FAILED=1
|
||||
run_step "go build ./..." go build ./... || FAILED=1
|
||||
if [[ "${SKIP_TESTS}" == "0" ]]; then
|
||||
# 后端测试跑在真实 MySQL 上:模板库只迁移一次,各包克隆后可并发。
|
||||
run_step "准备 MySQL 测试库" bash "${ROOT_DIR}/scripts/prepare-test-db.sh" || FAILED=1
|
||||
run_step "go test + coverage" bash "${ROOT_DIR}/scripts/check-backend-coverage.sh" || FAILED=1
|
||||
else
|
||||
log_warn "跳过后端测试 (--skip-tests)"
|
||||
fi
|
||||
else
|
||||
log_warn "跳过后端检查 (--skip-backend)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前端检查
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${SKIP_FRONTEND}" == "0" ]]; then
|
||||
log "前端检查目录: ${FRONTEND_DIR}"
|
||||
cd "${FRONTEND_DIR}"
|
||||
|
||||
run_step "Vue 页面大小门禁" bash "${ROOT_DIR}/scripts/check-vue-size.sh" || FAILED=1
|
||||
|
||||
if [[ ! -d node_modules ]]; then
|
||||
run_step "npm ci (首次安装依赖)" npm ci || FAILED=1
|
||||
fi
|
||||
|
||||
# npm run check = prettier --check + eslint --max-warnings=0 + vue-tsc
|
||||
run_step "npm run check (prettier + eslint + typecheck)" npm run check || FAILED=1
|
||||
if [[ "${SKIP_TESTS}" == "0" ]]; then
|
||||
run_step "npm run test + coverage (vitest)" npm run test:coverage || FAILED=1
|
||||
else
|
||||
log_warn "跳过前端测试 (--skip-tests)"
|
||||
fi
|
||||
run_step "npm run build (vite 生产构建)" npm run build || FAILED=1
|
||||
else
|
||||
log_warn "跳过前端检查 (--skip-frontend)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 汇总
|
||||
# ---------------------------------------------------------------------------
|
||||
printf "\n"
|
||||
if [[ "${FAILED}" == "0" ]]; then
|
||||
log_ok "全部检查通过 ✓"
|
||||
exit 0
|
||||
else
|
||||
log_error "检查未通过,已阻止本次部署。可用 --skip-check 强制跳过(不推荐)。"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 将当前 MySQL 数据库复制到独立测试库。源库只读,导入不会修改源库。
|
||||
# 可通过环境变量覆盖连接:SOURCE_MYSQL_HOST/PORT/USER/PASSWORD/DATABASE、TEST_MYSQL_*。
|
||||
|
||||
source_host="${SOURCE_MYSQL_HOST:-127.0.0.1}"
|
||||
source_port="${SOURCE_MYSQL_PORT:-23306}"
|
||||
source_user="${SOURCE_MYSQL_USER:-hfb}"
|
||||
source_password="${SOURCE_MYSQL_PASSWORD:-secret}"
|
||||
source_database="${SOURCE_MYSQL_DATABASE:-hfb_jysh}"
|
||||
test_host="${TEST_MYSQL_HOST:-127.0.0.1}"
|
||||
test_port="${TEST_MYSQL_PORT:-23306}"
|
||||
test_user="${TEST_MYSQL_USER:-hfb}"
|
||||
test_password="${TEST_MYSQL_PASSWORD:-secret}"
|
||||
test_database="${TEST_MYSQL_DATABASE:-hfb_jysh_distribution_test}"
|
||||
dump_file="${DISTRIBUTION_TEST_DUMP:-${TMPDIR:-/tmp}/jieyou-distribution-${source_database}.sql}"
|
||||
|
||||
for command in mysqldump mysql; do
|
||||
command -v "${command}" >/dev/null || { printf '缺少命令: %s\n' "${command}" >&2; exit 127; }
|
||||
done
|
||||
[[ "${test_database}" =~ ^[A-Za-z0-9_]+$ ]] || { printf '测试库名称不合法\n' >&2; exit 2; }
|
||||
|
||||
printf '导出源库 %s:%s/%s -> %s\n' "${source_host}" "${source_port}" "${source_database}" "${dump_file}"
|
||||
MYSQL_PWD="${source_password}" mysqldump \
|
||||
--host="${source_host}" --port="${source_port}" --user="${source_user}" \
|
||||
--single-transaction --routines --triggers --events --hex-blob \
|
||||
"${source_database}" >"${dump_file}"
|
||||
|
||||
printf '重建独立测试库 %s:%s/%s\n' "${test_host}" "${test_port}" "${test_database}"
|
||||
MYSQL_PWD="${test_password}" mysql --host="${test_host}" --port="${test_port}" --user="${test_user}" \
|
||||
-e "DROP DATABASE IF EXISTS \`${test_database}\`; CREATE DATABASE \`${test_database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
printf '导入测试库并完成校验\n'
|
||||
MYSQL_PWD="${test_password}" mysql --host="${test_host}" --port="${test_port}" --user="${test_user}" "${test_database}" <"${dump_file}"
|
||||
MYSQL_PWD="${test_password}" mysql --host="${test_host}" --port="${test_port}" --user="${test_user}" "${test_database}" \
|
||||
-N -B -e "SELECT COUNT(*) AS table_count FROM information_schema.tables WHERE table_schema='${test_database}';"
|
||||
printf '测试库已就绪: %s\n' "${test_database}"
|
||||
Executable
+732
@@ -0,0 +1,732 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml"
|
||||
BACKEND_ENV="${ROOT_DIR}/backend/.env"
|
||||
BACKEND_LOG_DIR="${ROOT_DIR}/backend/logs"
|
||||
HEALTH_URL="${PROD_HEALTH_URL:-}"
|
||||
PUBLIC_URL="${PROD_PUBLIC_URL:-}"
|
||||
READY_TIMEOUT="${PROD_READY_TIMEOUT:-120}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[1;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
printf "${BLUE}[deploy]${NC} %s\n" "$*"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
printf "${GREEN}[deploy]${NC} %s\n" "$*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
printf "${YELLOW}[deploy]${NC} %s\n" "$*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
printf "${RED}[deploy]${NC} %s\n" "$*" >&2
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
log_error "缺少命令:$1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local key="$1"
|
||||
awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKEND_ENV}"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value
|
||||
value="$(env_value "${key}")"
|
||||
if [[ -z "${value}" ]]; then
|
||||
log_error "backend/.env 缺少或未填写 ${key}"
|
||||
exit 1
|
||||
fi
|
||||
printf "%s" "${value}"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
用法: ./scripts/deploy-prod.sh [选项]
|
||||
|
||||
选项:
|
||||
--no-build 不重新构建镜像,仅启动已有镜像
|
||||
--no-migrate 不执行数据库迁移
|
||||
--reset-db 重置数据库(删除并重建)
|
||||
--logs 部署完成后跟随查看后端日志
|
||||
--skip-check 跳过部署前代码门禁(不推荐)
|
||||
--no-clean 跳过部署后的 Docker 垃圾清理
|
||||
-h, --help 显示帮助信息
|
||||
|
||||
环境变量:
|
||||
PROD_HEALTH_URL=https://你的域名/api/health
|
||||
部署后公网健康检查地址;设置后使用外部 curl 检查
|
||||
PROD_PUBLIC_URL=https://你的域名
|
||||
输出给用户看的访问地址
|
||||
PROD_READY_TIMEOUT=120
|
||||
等待服务就绪秒数
|
||||
|
||||
选号网(hfb_show,可选,写在 backend/.env):
|
||||
CADDY_SHOW_DOMAIN=show.example.com
|
||||
选号网独立域名,A 记录指向本机;留空则不启用
|
||||
HFB_SHOW_DIR=../hfb_show
|
||||
选号网源码目录(相对 hfb_jysh 根目录或绝对路径)
|
||||
构建优先用本机 npm;没有 npm 时自动用 Docker node 镜像,服务器不必装 Node
|
||||
|
||||
说明:
|
||||
npm 仅用于构建前端静态资源,运行已构建的 Docker 镜像不需要 npm。
|
||||
已完成一次完整部署后,日常启动可使用 --no-build。
|
||||
|
||||
示例:
|
||||
./scripts/deploy-prod.sh
|
||||
./scripts/deploy-prod.sh --no-build
|
||||
./scripts/deploy-prod.sh --reset-db
|
||||
./scripts/deploy-prod.sh --logs
|
||||
EOF
|
||||
}
|
||||
|
||||
NO_BUILD=0
|
||||
NO_MIGRATE=0
|
||||
RESET_DB=0
|
||||
FOLLOW_LOGS=0
|
||||
SKIP_CHECK=0
|
||||
NO_CLEAN=0
|
||||
SHOW_DIST_BACKUP_DIR=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-build)
|
||||
NO_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--no-migrate)
|
||||
NO_MIGRATE=1
|
||||
shift
|
||||
;;
|
||||
--reset-db)
|
||||
RESET_DB=1
|
||||
shift
|
||||
;;
|
||||
--logs)
|
||||
FOLLOW_LOGS=1
|
||||
shift
|
||||
;;
|
||||
--skip-check)
|
||||
SKIP_CHECK=1
|
||||
shift
|
||||
;;
|
||||
--no-clean)
|
||||
NO_CLEAN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "未知选项:$1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${RESET_DB}" == "1" && "${NO_MIGRATE}" == "1" ]]; then
|
||||
log_error "--reset-db 会清空数据库,不能和 --no-migrate 同时使用"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_env() {
|
||||
if [[ ! -f "${BACKEND_ENV}" ]]; then
|
||||
log_error "未找到 backend/.env,请先执行:cp backend/.env.prod.example backend/.env 并填写生产配置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_status_dir backup_restore_dir backup_oss_uri backup_user
|
||||
app_env="$(require_env APP_ENV)"
|
||||
caddy_domain="$(require_env CADDY_DOMAIN)"
|
||||
caddy_email="$(require_env CADDY_EMAIL)"
|
||||
mysql_dsn="$(require_env MYSQL_DSN)"
|
||||
redis_addr="$(require_env REDIS_ADDR)"
|
||||
storage_endpoint="$(require_env STORAGE_ENDPOINT)"
|
||||
local mysql_database
|
||||
mysql_database="$(require_env MYSQL_DATABASE)"
|
||||
require_env MYSQL_ROOT_PASSWORD >/dev/null
|
||||
require_env MYSQL_USER >/dev/null
|
||||
require_env MYSQL_PASSWORD >/dev/null
|
||||
require_env JWT_SECRET >/dev/null
|
||||
require_env STORAGE_ACCESS_KEY_ID >/dev/null
|
||||
require_env STORAGE_SECRET_ACCESS_KEY >/dev/null
|
||||
backup_dir="$(require_env BACKUP_DIR)"
|
||||
backup_status_dir="$(require_env BACKUP_STATUS_DIR)"
|
||||
backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)"
|
||||
backup_oss_uri="$(require_env BACKUP_OSS_URI)"
|
||||
require_env BACKUP_PASSPHRASE >/dev/null
|
||||
backup_user="$(require_env BACKUP_MYSQL_USER)"
|
||||
require_env BACKUP_MYSQL_PASSWORD >/dev/null
|
||||
backend_uid="$(require_env BACKEND_UID)"
|
||||
backend_gid="$(require_env BACKEND_GID)"
|
||||
|
||||
if [[ ! "${backend_uid}" =~ ^[1-9][0-9]*$ || ! "${backend_gid}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
log_error "BACKEND_UID 和 BACKEND_GID 必须是大于 0 的数字,禁止后端容器使用 root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${app_env}" != "production" ]]; then
|
||||
log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production"
|
||||
fi
|
||||
|
||||
if [[ "${caddy_domain}" == http://* || "${caddy_domain}" == https://* || "${caddy_domain}" == */* ]]; then
|
||||
log_error "CADDY_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${caddy_domain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${caddy_domain}" == *"127.0.0.1"* || "${caddy_domain}" == *"localhost"* || "${caddy_domain}" != *"."* ]]; then
|
||||
log_error "CADDY_DOMAIN 必须填写可公网解析到本机的正式域名,当前值:${caddy_domain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${caddy_email}" == "admin@example.com" || "${caddy_email}" != *"@"* ]]; then
|
||||
log_error "CADDY_EMAIL 需要填写真实邮箱,用于 ACME 证书通知"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${mysql_dsn}" == *"127.0.0.1"* || "${mysql_dsn}" == *"localhost"* ]]; then
|
||||
log_error "MYSQL_DSN 仍指向本机,请改为 tcp(mysql:3306)"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${redis_addr}" == "127.0.0.1:"* || "${redis_addr}" == "localhost:"* ]]; then
|
||||
log_error "REDIS_ADDR 仍指向本机,请改为 redis:6379"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${storage_endpoint}" == *"127.0.0.1"* || "${storage_endpoint}" == *"localhost"* ]]; then
|
||||
log_error "STORAGE_ENDPOINT 仍指向本机,请改为阿里云 OSS Endpoint(如 https://oss-cn-hangzhou.aliyuncs.com)"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${storage_endpoint}" == *"minio"* ]]; then
|
||||
log_error "STORAGE_ENDPOINT 仍指向已下线的 MinIO,请改为阿里云 OSS Endpoint"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${storage_endpoint}" =~ ^https://oss-[A-Za-z0-9-]+\.aliyuncs\.com/?$ ]]; then
|
||||
log_error "STORAGE_ENDPOINT 必须是阿里云 OSS HTTPS Endpoint,当前值:${storage_endpoint}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${backup_dir}" != /data/* || "${backup_dir}" == "/data" ]]; then
|
||||
log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${backup_status_dir}" != "${backup_dir}/"* ]]; then
|
||||
log_error "BACKUP_STATUS_DIR 必须位于 BACKUP_DIR 之下,当前值:${backup_status_dir}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${backup_restore_dir}" != /data/* || "${backup_restore_dir}" == "/data" || "${backup_restore_dir}" == "${backup_dir}" ]]; then
|
||||
log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${backup_oss_uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
log_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${backup_oss_uri}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${backup_user}" =~ ^[A-Za-z0-9_]{1,32}$ ]]; then
|
||||
log_error "BACKUP_MYSQL_USER 只能包含字母、数字和下划线,长度不超过 32"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${mysql_database}" =~ ^[A-Za-z0-9_]+$ ]]; then
|
||||
log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${mysql_database}"
|
||||
exit 1
|
||||
fi
|
||||
if awk -F= '/^[A-Z0-9_]+=/{ if ($0 ~ /=(change-|保持你的|你的)/) found=1 } END { exit found ? 0 : 1 }' "${BACKEND_ENV}"; then
|
||||
log_error "backend/.env 里还有占位值,请先替换后再部署"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
compose_user_cmd() {
|
||||
printf "docker compose --env-file backend/.env -f deploy/docker-compose.prod.yml"
|
||||
}
|
||||
|
||||
export_caddy_env() {
|
||||
export CADDY_DOMAIN
|
||||
export CADDY_EMAIL
|
||||
export CADDY_SHOW_DOMAIN
|
||||
CADDY_DOMAIN="$(require_env CADDY_DOMAIN)"
|
||||
CADDY_EMAIL="$(require_env CADDY_EMAIL)"
|
||||
CADDY_SHOW_DOMAIN="$(env_value CADDY_SHOW_DOMAIN || true)"
|
||||
export CADDY_SHOW_DOMAIN
|
||||
|
||||
# 分销后台地址:未在 backend/.env 显式配置时,按 Caddyfile 的 distribution.<CADDY_DOMAIN>
|
||||
# 推导,供 Caddy 镜像构建期注入主站前端的「打开独立分销后台」入口与开通凭证。
|
||||
VITE_DISTRIBUTION_ADMIN_URL="$(env_value VITE_DISTRIBUTION_ADMIN_URL || true)"
|
||||
if [[ -z "${VITE_DISTRIBUTION_ADMIN_URL}" ]]; then
|
||||
VITE_DISTRIBUTION_ADMIN_URL="https://distribution.${CADDY_DOMAIN}"
|
||||
fi
|
||||
export VITE_DISTRIBUTION_ADMIN_URL
|
||||
}
|
||||
|
||||
restore_show_dist_placeholders() {
|
||||
if [[ -z "${SHOW_DIST_BACKUP_DIR}" || ! -d "${SHOW_DIST_BACKUP_DIR}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local show_dist="${ROOT_DIR}/deploy/caddy/show-dist"
|
||||
local git_restore_dir
|
||||
git_restore_dir="$(mktemp -d "${TMPDIR:-/tmp}/hfb-show-dist-restore.XXXXXX")"
|
||||
rm -rf "${show_dist}"
|
||||
mkdir -p "${show_dist}"
|
||||
if git -C "${ROOT_DIR}" show HEAD:deploy/caddy/show-dist/.gitkeep >"${git_restore_dir}/.gitkeep" 2>/dev/null \
|
||||
&& git -C "${ROOT_DIR}" show HEAD:deploy/caddy/show-dist/index.html >"${git_restore_dir}/index.html" 2>/dev/null; then
|
||||
cp -a "${git_restore_dir}/." "${show_dist}/"
|
||||
else
|
||||
cp -a "${SHOW_DIST_BACKUP_DIR}/." "${show_dist}/"
|
||||
fi
|
||||
rm -rf "${git_restore_dir}"
|
||||
rm -rf "${SHOW_DIST_BACKUP_DIR}"
|
||||
SHOW_DIST_BACKUP_DIR=""
|
||||
}
|
||||
|
||||
# 准备选号网静态资源与 Caddy 站点配置(可选)
|
||||
prepare_show_site() {
|
||||
local conf_dir="${ROOT_DIR}/deploy/caddy/conf.d"
|
||||
local show_dist="${ROOT_DIR}/deploy/caddy/show-dist"
|
||||
local show_tmpl="${ROOT_DIR}/deploy/caddy/show.caddy.tmpl"
|
||||
local show_conf="${conf_dir}/show.caddy"
|
||||
local show_dir show_domain
|
||||
|
||||
mkdir -p "${conf_dir}"
|
||||
rm -f "${show_conf}"
|
||||
|
||||
show_domain="$(env_value CADDY_SHOW_DOMAIN || true)"
|
||||
if [[ -z "${show_domain}" ]]; then
|
||||
log "未配置 CADDY_SHOW_DOMAIN,跳过选号网站点"
|
||||
# 保留占位页,避免 Caddy 镜像 COPY 目录为空时异常
|
||||
if [[ ! -f "${show_dist}/index.html" ]]; then
|
||||
mkdir -p "${show_dist}"
|
||||
printf '%s\n' '<!doctype html><title>show disabled</title>' >"${show_dist}/index.html"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${NO_BUILD}" == "1" ]]; then
|
||||
# 运行已有镜像不需要 Node/npm;仅重新生成绑定挂载的 Caddy 站点配置。
|
||||
log_warn "--no-build:复用已有选号网静态资源,不执行 npm 构建"
|
||||
if [[ "${show_domain}" == http://* || "${show_domain}" == https://* || "${show_domain}" == */* || "${show_domain}" == "${CADDY_DOMAIN}" || ! "${show_domain}" =~ ^[A-Za-z0-9.-]+$ ]]; then
|
||||
log_error "CADDY_SHOW_DOMAIN 配置不正确:${show_domain}"
|
||||
exit 1
|
||||
fi
|
||||
sed "s/__CADDY_SHOW_DOMAIN__/${show_domain}/g" "${show_tmpl}" >"${show_conf}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${show_domain}" == http://* || "${show_domain}" == https://* || "${show_domain}" == */* ]]; then
|
||||
log_error "CADDY_SHOW_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${show_domain}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${show_domain}" == "${CADDY_DOMAIN}" ]]; then
|
||||
log_error "CADDY_SHOW_DOMAIN 不能与 CADDY_DOMAIN 相同"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_dir="$(env_value HFB_SHOW_DIR || true)"
|
||||
if [[ -z "${show_dir}" ]]; then
|
||||
show_dir="${ROOT_DIR}/../hfb_show"
|
||||
elif [[ "${show_dir}" != /* ]]; then
|
||||
show_dir="${ROOT_DIR}/${show_dir}"
|
||||
fi
|
||||
|
||||
if [[ ! -d "${show_dir}" ]]; then
|
||||
log_error "已配置 CADDY_SHOW_DOMAIN,但找不到选号网目录:${show_dir}"
|
||||
log_error "请设置 HFB_SHOW_DIR,或把仓库放在与 hfb_jysh 同级的 hfb_show"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "${show_dir}/package.json" ]]; then
|
||||
log_error "选号网目录无效(缺少 package.json):${show_dir}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${NO_BUILD}" != "1" ]]; then
|
||||
# 服务器通常无本机 Node:优先本机 npm,否则用 Docker 内 node 镜像构建。
|
||||
# 国内机访问 registry.npmjs.org 易 ECONNRESET,默认走 npmmirror,可用 NPM_REGISTRY 覆盖。
|
||||
local npm_registry npm_install
|
||||
npm_registry="$(env_value NPM_REGISTRY || true)"
|
||||
if [[ -z "${npm_registry}" ]]; then
|
||||
npm_registry="${NPM_REGISTRY:-https://registry.npmmirror.com}"
|
||||
fi
|
||||
if [[ -f "${show_dir}/package-lock.json" ]]; then
|
||||
npm_install="npm ci"
|
||||
else
|
||||
npm_install="npm install"
|
||||
fi
|
||||
log "构建选号网静态资源:${show_dir}(registry=${npm_registry})"
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
(
|
||||
cd "${show_dir}"
|
||||
deps_hash="$( { sha256sum package.json; if [[ -f package-lock.json ]]; then sha256sum package-lock.json; fi; } | sha256sum | awk '{print $1}')"
|
||||
if [[ ! -d node_modules || ! -f .hfb-deps-hash || "$(cat .hfb-deps-hash)" != "${deps_hash}" ]]; then
|
||||
${npm_install} \
|
||||
--registry="${npm_registry}" \
|
||||
--fetch-retries=5 \
|
||||
--fetch-retry-mintimeout=20000 \
|
||||
--fetch-retry-maxtimeout=120000
|
||||
printf '%s\n' "${deps_hash}" > .hfb-deps-hash
|
||||
else
|
||||
log "选号网依赖未变化,跳过 npm 安装"
|
||||
fi
|
||||
npm run build
|
||||
)
|
||||
else
|
||||
log_warn "本机无 npm,使用 Docker 镜像 node:24-alpine 构建选号网(无需安装 Node)"
|
||||
need_cmd docker
|
||||
# 挂载源码目录;node_modules/dist 写在挂载卷上,与本机 npm 行为一致
|
||||
docker run --rm \
|
||||
-e npm_config_registry="${npm_registry}" \
|
||||
-e HFB_NPM_INSTALL="${npm_install}" \
|
||||
-v "${show_dir}:/app" \
|
||||
-w /app \
|
||||
node:24-alpine \
|
||||
sh -c '
|
||||
set -eu
|
||||
deps_hash="$( { sha256sum package.json; if [ -f package-lock.json ]; then sha256sum package-lock.json; fi; } | sha256sum | awk '\''{print $1}'\'')"
|
||||
if [ ! -d node_modules ] || [ ! -f .hfb-deps-hash ] || [ "$(cat .hfb-deps-hash)" != "$deps_hash" ]; then
|
||||
if [ "$HFB_NPM_INSTALL" = "npm ci" ]; then
|
||||
npm ci --registry="$npm_config_registry" --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000
|
||||
else
|
||||
npm install --registry="$npm_config_registry" --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000
|
||||
fi
|
||||
printf "%s\\n" "$deps_hash" > .hfb-deps-hash
|
||||
else
|
||||
echo "选号网依赖未变化,跳过 npm 安装"
|
||||
fi
|
||||
npm run build
|
||||
'
|
||||
fi
|
||||
if [[ ! -d "${show_dir}/dist" ]]; then
|
||||
log_error "选号网构建失败:未生成 dist/"
|
||||
log_error "若仍网络失败,可在 backend/.env 设置 NPM_REGISTRY=https://registry.npmmirror.com 后重试"
|
||||
exit 1
|
||||
fi
|
||||
# show-dist 中的 .gitkeep/index.html 是仓库占位文件。备份后再写入构建产物,
|
||||
# 退出时由 restore_show_dist_placeholders 恢复,避免每次部署污染工作区。
|
||||
SHOW_DIST_BACKUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hfb-show-dist.XXXXXX")"
|
||||
cp -a "${show_dist}/." "${SHOW_DIST_BACKUP_DIR}/"
|
||||
rm -rf "${show_dist}"
|
||||
mkdir -p "${show_dist}"
|
||||
cp -a "${show_dir}/dist/." "${show_dist}/"
|
||||
log_success "选号网 dist 已同步到 deploy/caddy/show-dist"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${show_tmpl}" ]]; then
|
||||
log_error "缺少 ${show_tmpl}"
|
||||
exit 1
|
||||
fi
|
||||
# 域名仅允许主机名字符,避免注入 Caddyfile
|
||||
if [[ ! "${show_domain}" =~ ^[A-Za-z0-9.-]+$ ]]; then
|
||||
log_error "CADDY_SHOW_DOMAIN 含非法字符:${show_domain}"
|
||||
exit 1
|
||||
fi
|
||||
sed "s/__CADDY_SHOW_DOMAIN__/${show_domain}/g" "${show_tmpl}" >"${show_conf}"
|
||||
log_success "已启用选号网站点:https://${show_domain}"
|
||||
}
|
||||
|
||||
public_url() {
|
||||
if [[ -n "${PUBLIC_URL}" ]]; then
|
||||
printf "%s" "${PUBLIC_URL}"
|
||||
return
|
||||
fi
|
||||
printf "https://%s" "$(require_env CADDY_DOMAIN)"
|
||||
}
|
||||
|
||||
health_url() {
|
||||
if [[ -n "${HEALTH_URL}" ]]; then
|
||||
printf "%s" "${HEALTH_URL}"
|
||||
return
|
||||
fi
|
||||
printf "%s/api/health" "$(public_url)"
|
||||
}
|
||||
|
||||
prepare_log_dir() {
|
||||
local backend_uid backend_gid owner_mismatch
|
||||
backend_uid="$(require_env BACKEND_UID)"
|
||||
backend_gid="$(require_env BACKEND_GID)"
|
||||
mkdir -p "${BACKEND_LOG_DIR}"
|
||||
owner_mismatch="$(find "${BACKEND_LOG_DIR}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)"
|
||||
if [[ -n "${owner_mismatch}" ]]; then
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
log_error "日志目录存在属主不匹配的文件:${owner_mismatch}"
|
||||
log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${BACKEND_LOG_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
log "修正日志目录属主为 ${backend_uid}:${backend_gid}..."
|
||||
chown -R "${backend_uid}:${backend_gid}" "${BACKEND_LOG_DIR}"
|
||||
fi
|
||||
chmod 700 "${BACKEND_LOG_DIR}"
|
||||
}
|
||||
|
||||
prepare_backup_status_dir() {
|
||||
local backend_uid backend_gid status_dir owner_mismatch
|
||||
backend_uid="$(require_env BACKEND_UID)"
|
||||
backend_gid="$(require_env BACKEND_GID)"
|
||||
status_dir="$(require_env BACKUP_STATUS_DIR)"
|
||||
mkdir -p "${status_dir}"
|
||||
owner_mismatch="$(find "${status_dir}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)"
|
||||
if [[ -n "${owner_mismatch}" ]]; then
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
log_error "备份状态目录存在属主不匹配的文件:${owner_mismatch}"
|
||||
log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${status_dir}"
|
||||
exit 1
|
||||
fi
|
||||
chown -R "${backend_uid}:${backend_gid}" "${status_dir}"
|
||||
fi
|
||||
chmod 700 "${status_dir}"
|
||||
}
|
||||
|
||||
wait_service_healthy() {
|
||||
local service="$1"
|
||||
local label="$2"
|
||||
local attempt
|
||||
|
||||
log "等待 ${label} 就绪..."
|
||||
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
||||
local container_id status
|
||||
container_id="$(compose ps -q "${service}" 2>/dev/null || true)"
|
||||
if [[ -n "${container_id}" ]]; then
|
||||
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_id}" 2>/dev/null || true)"
|
||||
if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then
|
||||
log_success "${label} 已就绪"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_error "${label} 启动超时"
|
||||
log_warn "最近 ${service} 日志:"
|
||||
compose logs --tail=120 "${service}" || true
|
||||
log_error "可继续执行:$(compose_user_cmd) logs ${service}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
mysql_root_exec() {
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$@"' sh "$@"
|
||||
}
|
||||
|
||||
sql_string_literal() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\'/\'\'}"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
ensure_mysql_backup_user() {
|
||||
local database backup_user backup_password escaped_password
|
||||
database="$(require_env MYSQL_DATABASE)"
|
||||
backup_user="$(require_env BACKUP_MYSQL_USER)"
|
||||
backup_password="$(require_env BACKUP_MYSQL_PASSWORD)"
|
||||
escaped_password="$(sql_string_literal "${backup_password}")"
|
||||
unset backup_password
|
||||
|
||||
log "配置 MySQL 在线备份账号 ${backup_user}"
|
||||
printf "CREATE USER IF NOT EXISTS \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
printf "ALTER USER \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
printf "GRANT BACKUP_ADMIN, PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO \`%s\`@'%%';\n" "${backup_user}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
printf "GRANT SELECT ON performance_schema.log_status TO \`%s\`@'%%';\n" "${backup_user}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
# MySQL 8.4 的 XtraBackup 会读取以下只读状态表,即使未启用对应组件也会探测。
|
||||
printf "GRANT SELECT ON performance_schema.replication_group_members TO \`%s\`@'%%';\n" "${backup_user}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
printf "GRANT SELECT ON performance_schema.keyring_component_status TO \`%s\`@'%%';\n" "${backup_user}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
printf "GRANT SELECT ON \`%s\`.* TO \`%s\`@'%%';\n" "${database}" "${backup_user}" |
|
||||
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
}
|
||||
|
||||
goose_dsn() {
|
||||
local dsn="$1"
|
||||
if [[ "${dsn}" != *"multiStatements="* ]]; then
|
||||
if [[ "${dsn}" == *"?"* ]]; then
|
||||
dsn="${dsn}&multiStatements=true"
|
||||
else
|
||||
dsn="${dsn}?multiStatements=true"
|
||||
fi
|
||||
fi
|
||||
printf "%s" "${dsn}"
|
||||
}
|
||||
|
||||
goose_cmd() {
|
||||
compose run --rm --no-deps backend /app/goose -dir /app/migrations mysql "$(goose_dsn "$(require_env MYSQL_DSN)")" "$@"
|
||||
}
|
||||
|
||||
reset_database() {
|
||||
if [[ "${RESET_DB}" != "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local database
|
||||
database="$(require_env MYSQL_DATABASE)"
|
||||
if [[ ! "${database}" =~ ^[A-Za-z0-9_]+$ ]]; then
|
||||
log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${database}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_warn "重置数据库 ${database}..."
|
||||
mysql_root_exec -e \
|
||||
"DROP DATABASE IF EXISTS \`${database}\`; CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
log_success "数据库已重建"
|
||||
}
|
||||
|
||||
run_migrations() {
|
||||
if [[ "${NO_MIGRATE}" == "1" ]]; then
|
||||
log_warn "已跳过数据库迁移"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "使用 goose 执行数据库迁移..."
|
||||
goose_cmd up
|
||||
log_success "数据库迁移完成"
|
||||
}
|
||||
|
||||
wait_http_ready() {
|
||||
local attempt
|
||||
local url="${HEALTH_URL}"
|
||||
|
||||
if [[ -n "${url}" ]]; then
|
||||
log "等待公网接口健康检查通过..."
|
||||
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
||||
if curl -fsS --max-time 5 "${url}" >/dev/null 2>&1; then
|
||||
log_success "公网健康检查通过:${url}"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_error "公网健康检查超时:${url}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
url="http://127.0.0.1:8080/api/health"
|
||||
log "等待后端容器内健康检查通过..."
|
||||
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
||||
if compose exec -T backend wget -q -O /dev/null -T 5 "${url}" >/dev/null 2>&1; then
|
||||
log_success "容器内健康检查通过:${url}"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_error "后端容器内健康检查超时:${url}"
|
||||
log_warn "最近 backend 日志:"
|
||||
compose logs --tail=120 backend || true
|
||||
log_error "可继续执行:$(compose_user_cmd) logs backend"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 清理部署过程中产生的 Docker 垃圾(旧镜像、构建缓存)
|
||||
# 每次 docker compose build 会生成新镜像,旧镜像变为 <none>:<none>
|
||||
# 构建缓存(BuildKit)也会持续增长,不清理会轻松堆积几十 GB。
|
||||
# 关键:用 --filter until=168h 只清超过 7 天的旧缓存,保留近期缓存(基础镜像层、
|
||||
# Go module / go-build 缓存),让下次构建复用,避免每次重新下载 node 镜像、重拉依赖。
|
||||
docker_cleanup() {
|
||||
log "清理 Docker 垃圾..."
|
||||
local before after
|
||||
|
||||
before=$(docker system df --format '{{.TotalCount}}' 2>/dev/null || echo "?")
|
||||
|
||||
docker image prune -f 2>/dev/null || true
|
||||
docker builder prune -f --filter until=168h 2>/dev/null || true
|
||||
|
||||
after=$(docker system df --format '{{.TotalCount}}' 2>/dev/null || echo "?")
|
||||
|
||||
log_success "Docker 清理完成(对象数: ${before} -> ${after})"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "${ROOT_DIR}"
|
||||
need_cmd docker
|
||||
need_cmd curl
|
||||
need_cmd openssl
|
||||
need_cmd ossutil
|
||||
need_cmd flock
|
||||
validate_env
|
||||
export_caddy_env
|
||||
trap restore_show_dist_placeholders EXIT
|
||||
prepare_log_dir
|
||||
prepare_backup_status_dir
|
||||
# 选号网需在 build caddy 之前准备 show-dist 与 conf.d
|
||||
prepare_show_site
|
||||
|
||||
log "检查 Docker Compose 配置..."
|
||||
compose config >/dev/null
|
||||
|
||||
# 部署前代码门禁:本地有 go + node 时跑全量检查,挡住未通过的提交进镜像。
|
||||
# 部署机无 go/node 或 --skip-check 时跳过并告警,不阻断(避免环境耦合)。
|
||||
if [[ "${NO_BUILD}" == "0" && "${SKIP_CHECK}" == "0" ]]; then
|
||||
if command -v go >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
log "执行部署前代码门禁 (scripts/check.sh)..."
|
||||
if ! bash "${ROOT_DIR}/scripts/check.sh"; then
|
||||
log_error "代码门禁未通过,已中止部署。确认修复后重试,或用 --skip-check 强制跳过。"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_warn "本地缺少 go 或 npm,跳过代码门禁。建议在开发机执行 ./scripts/check.sh 后再部署。"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${NO_BUILD}" == "1" ]]; then
|
||||
log "启动生产服务..."
|
||||
compose up -d
|
||||
else
|
||||
log "清理旧镜像以释放空间..."
|
||||
docker image prune -f 2>/dev/null || true
|
||||
|
||||
log "构建后端镜像..."
|
||||
compose build backend
|
||||
|
||||
log "构建 Caddy 镜像(含主站 + 选号网静态资源)..."
|
||||
compose build caddy
|
||||
|
||||
log "启动生产服务..."
|
||||
compose up -d --no-build --remove-orphans
|
||||
fi
|
||||
|
||||
wait_service_healthy mysql "MySQL"
|
||||
wait_service_healthy redis "Redis"
|
||||
wait_service_healthy caddy "Caddy"
|
||||
ensure_mysql_backup_user
|
||||
reset_database
|
||||
run_migrations
|
||||
|
||||
log "重启后端以加载最新数据库状态..."
|
||||
compose up -d --no-deps --force-recreate backend
|
||||
wait_service_healthy backend "后端"
|
||||
compose up -d --no-deps --force-recreate caddy
|
||||
wait_service_healthy caddy "Caddy"
|
||||
wait_http_ready
|
||||
|
||||
if [[ "${NO_CLEAN}" != "1" ]]; then
|
||||
docker_cleanup
|
||||
fi
|
||||
|
||||
log_success "部署完成:$(public_url)"
|
||||
if [[ -n "$(env_value CADDY_SHOW_DOMAIN || true)" ]]; then
|
||||
log_success "选号网:https://$(env_value CADDY_SHOW_DOMAIN)"
|
||||
fi
|
||||
log "常用命令:$(compose_user_cmd) logs -f caddy backend"
|
||||
|
||||
if [[ "${FOLLOW_LOGS}" == "1" ]]; then
|
||||
compose logs -f backend
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+764
@@ -0,0 +1,764 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.dev.yml"
|
||||
BACKEND_ENV="${ROOT_DIR}/backend/.env"
|
||||
BACKEND_ENV_EXAMPLE="${ROOT_DIR}/backend/.env.example"
|
||||
FRONTEND_DIR="${ROOT_DIR}/frontend"
|
||||
FRONTEND_PACKAGE_JSON="${FRONTEND_DIR}/package.json"
|
||||
FRONTEND_PACKAGE_LOCK="${FRONTEND_DIR}/package-lock.json"
|
||||
FRONTEND_NODE_MODULES="${FRONTEND_DIR}/node_modules"
|
||||
FRONTEND_NODE_MODULES_LOCK="${FRONTEND_NODE_MODULES}/.package-lock.json"
|
||||
DISTRIBUTION_DIR="${ROOT_DIR}/distribution-admin"
|
||||
DISTRIBUTION_PACKAGE_JSON="${DISTRIBUTION_DIR}/package.json"
|
||||
DISTRIBUTION_PACKAGE_LOCK="${DISTRIBUTION_DIR}/package-lock.json"
|
||||
DISTRIBUTION_NODE_MODULES="${DISTRIBUTION_DIR}/node_modules"
|
||||
DISTRIBUTION_NODE_MODULES_LOCK="${DISTRIBUTION_NODE_MODULES}/.package-lock.json"
|
||||
MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations"
|
||||
MYSQL_DATABASE="${DEV_MYSQL_DATABASE:-hfb_jysh}"
|
||||
BACKEND_PORT="${DEV_BACKEND_PORT:-18089}"
|
||||
FRONTEND_PORT="${DEV_FRONTEND_PORT:-5289}"
|
||||
DISTRIBUTION_PORT="${DEV_DISTRIBUTION_PORT:-5290}"
|
||||
DISTRIBUTION_STOREFRONT_PORT="${DEV_DISTRIBUTION_STOREFRONT_PORT:-5291}"
|
||||
DISTRIBUTION_STOREFRONT_SITE_CODE="${DEV_DISTRIBUTION_STOREFRONT_SITE_CODE:-test_1}"
|
||||
MYSQL_PORT="${DEV_MYSQL_PORT:-23306}"
|
||||
REDIS_PORT="${DEV_REDIS_PORT:-26379}"
|
||||
MINIO_API_PORT="${DEV_MINIO_API_PORT:-29090}"
|
||||
MINIO_CONSOLE_PORT="${DEV_MINIO_CONSOLE_PORT:-29091}"
|
||||
BACKEND_HEALTH_URL="${DEV_BACKEND_HEALTH_URL:-http://127.0.0.1:${BACKEND_PORT}/health}"
|
||||
FRONTEND_HEALTH_URL="${DEV_FRONTEND_HEALTH_URL:-http://127.0.0.1:${FRONTEND_PORT}/}"
|
||||
DISTRIBUTION_HEALTH_URL="${DEV_DISTRIBUTION_HEALTH_URL:-http://127.0.0.1:${DISTRIBUTION_PORT}/}"
|
||||
DISTRIBUTION_STOREFRONT_HEALTH_URL="${DEV_DISTRIBUTION_STOREFRONT_HEALTH_URL:-http://127.0.0.1:${DISTRIBUTION_STOREFRONT_PORT}/}"
|
||||
HTTP_READY_TIMEOUT="${DEV_HTTP_READY_TIMEOUT:-120}"
|
||||
AUTO_KILL_PORTS="${DEV_AUTO_KILL_PORTS:-1}"
|
||||
FORCE_NPM_CI="${DEV_FORCE_NPM_CI:-0}"
|
||||
GOOSE_VERSION="${GOOSE_VERSION:-v3.27.1}"
|
||||
GOOSE_BIN="${GOOSE_BIN:-}"
|
||||
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
DISTRIBUTION_PID=""
|
||||
DISTRIBUTION_STOREFRONT_PID=""
|
||||
CLEANED_UP=0
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[1;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
printf "${BLUE}[dev]${NC} %s\n" "$*"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
printf "${GREEN}[dev]${NC} %s\n" "$*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
printf "${YELLOW}[dev]${NC} %s\n" "$*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
printf "${RED}[dev]${NC} %s\n" "$*" >&2
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
log_error "缺少命令:$1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
用法: ./scripts/dev.sh [选项]
|
||||
|
||||
选项:
|
||||
--reset-db 重置数据库(删除并重建)
|
||||
--no-migrate 不执行数据库迁移
|
||||
--no-frontend 不启动主站前端
|
||||
--no-backend 不启动后端
|
||||
--no-distribution 不启动分销后台
|
||||
--no-distribution-storefront
|
||||
不启动分销前台商城
|
||||
-h, --help 显示帮助信息
|
||||
|
||||
环境变量:
|
||||
DEV_RESET_DB=1 等同于 --reset-db
|
||||
DEV_MYSQL_DATABASE=hfb_jysh
|
||||
开发数据库名
|
||||
DEV_BACKEND_PORT=18089
|
||||
后端启动端口
|
||||
DEV_FRONTEND_PORT=5289
|
||||
主站前端启动端口
|
||||
DEV_DISTRIBUTION_PORT=5290
|
||||
分销后台启动端口
|
||||
DEV_MYSQL_PORT=23306
|
||||
MySQL 宿主机映射端口
|
||||
DEV_REDIS_PORT=26379
|
||||
Redis 宿主机映射端口
|
||||
DEV_MINIO_API_PORT=29090
|
||||
MinIO API 宿主机映射端口
|
||||
DEV_MINIO_CONSOLE_PORT=29091
|
||||
MinIO 控制台宿主机映射端口
|
||||
DEV_BACKEND_HEALTH_URL=http://127.0.0.1:18089/health
|
||||
后端健康检查地址
|
||||
DEV_FRONTEND_HEALTH_URL=http://127.0.0.1:5289/
|
||||
主站前端健康检查地址
|
||||
DEV_DISTRIBUTION_HEALTH_URL=http://127.0.0.1:5290/
|
||||
分销后台健康检查地址
|
||||
DEV_HTTP_READY_TIMEOUT=120
|
||||
HTTP 服务就绪等待秒数
|
||||
DEV_AUTO_KILL_PORTS=1
|
||||
前后端端口被占用时自动停止占用的 Docker 容器或本机进程
|
||||
DEV_FORCE_NPM_CI=0
|
||||
强制重新执行前端 npm ci
|
||||
|
||||
示例:
|
||||
./scripts/dev.sh # 正常启动(后端 + 主前后台 + 分销前台)
|
||||
./scripts/dev.sh --reset-db # 重置数据库后启动(推荐迁移后使用)
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 参数解析
|
||||
RESET_DB="${DEV_RESET_DB:-0}"
|
||||
NO_MIGRATE=0
|
||||
NO_FRONTEND=0
|
||||
NO_BACKEND=0
|
||||
NO_DISTRIBUTION=0
|
||||
NO_DISTRIBUTION_STOREFRONT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--reset-db)
|
||||
RESET_DB=1
|
||||
shift
|
||||
;;
|
||||
--no-migrate)
|
||||
NO_MIGRATE=1
|
||||
shift
|
||||
;;
|
||||
--no-frontend)
|
||||
NO_FRONTEND=1
|
||||
shift
|
||||
;;
|
||||
--no-backend)
|
||||
NO_BACKEND=1
|
||||
shift
|
||||
;;
|
||||
--no-distribution)
|
||||
NO_DISTRIBUTION=1
|
||||
shift
|
||||
;;
|
||||
--no-distribution-storefront)
|
||||
NO_DISTRIBUTION_STOREFRONT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "未知选项: $1"
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
if [[ "${CLEANED_UP}" == "1" ]]; then
|
||||
return
|
||||
fi
|
||||
CLEANED_UP=1
|
||||
|
||||
if [[ -z "${FRONTEND_PID}" && -z "${BACKEND_PID}" && -z "${DISTRIBUTION_PID}" && -z "${DISTRIBUTION_STOREFRONT_PID}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "正在停止前后端开发进程..."
|
||||
if [[ -n "${DISTRIBUTION_STOREFRONT_PID}" ]] && kill -0 "${DISTRIBUTION_STOREFRONT_PID}" >/dev/null 2>&1; then
|
||||
kill "${DISTRIBUTION_STOREFRONT_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${DISTRIBUTION_PID}" ]] && kill -0 "${DISTRIBUTION_PID}" >/dev/null 2>&1; then
|
||||
kill "${DISTRIBUTION_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
|
||||
kill "${FRONTEND_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${BACKEND_PID}" ]] && kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
|
||||
kill "${BACKEND_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
wait_container_healthy() {
|
||||
local container_name="$1"
|
||||
local label="$2"
|
||||
local attempt
|
||||
|
||||
log "等待 ${label} 就绪..."
|
||||
for attempt in {1..60}; do
|
||||
local status
|
||||
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_name}" 2>/dev/null || true)"
|
||||
if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then
|
||||
log "${label} 已就绪"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf "%s 启动超时,请检查:docker logs %s\n" "${label}" "${container_name}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_http_ready() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
local pid="${3:-}"
|
||||
local attempt
|
||||
|
||||
log "等待 ${label} HTTP 服务就绪..."
|
||||
for ((attempt = 1; attempt <= HTTP_READY_TIMEOUT; attempt++)); do
|
||||
if [[ -n "${pid}" ]] && ! kill -0 "${pid}" >/dev/null 2>&1; then
|
||||
wait "${pid}" || true
|
||||
log_error "${label} 进程已退出,请检查上方日志"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if curl -fsS --max-time 2 "${url}" >/dev/null 2>&1; then
|
||||
log_success "${label} 已就绪:${url}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_error "${label} HTTP 服务启动超时:${url}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local port="$1"
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z 127.0.0.1 "${port}" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_port_released() {
|
||||
local port="$1"
|
||||
local timeout="${2:-5}"
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= timeout; attempt++)); do
|
||||
if ! port_in_use "${port}"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
is_allowed_container() {
|
||||
local container_name="$1"
|
||||
shift
|
||||
|
||||
local allowed_name
|
||||
for allowed_name in "$@"; do
|
||||
if [[ "${container_name}" == "${allowed_name}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
docker_containers_on_host_port() {
|
||||
local port="$1"
|
||||
|
||||
docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}' 2>/dev/null |
|
||||
awk -F '\t' -v port="${port}" 'index($3, ":" port "->") || $3 ~ ("(^|[ ,])" port "->") { print $1 "\t" $2 }'
|
||||
}
|
||||
|
||||
stop_docker_containers_on_port() {
|
||||
local port="$1"
|
||||
local label="$2"
|
||||
shift 2
|
||||
|
||||
local containers container_id container_name allowed_found=0 stopped_found=0
|
||||
containers="$(docker_containers_on_host_port "${port}" || true)"
|
||||
if [[ -z "${containers}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r container_id container_name; do
|
||||
[[ -z "${container_id}" ]] && continue
|
||||
|
||||
if is_allowed_container "${container_name}" "$@"; then
|
||||
allowed_found=1
|
||||
log "${label}端口 ${port} 已由本项目容器 ${container_name} 使用,保留运行"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "${AUTO_KILL_PORTS}" != "1" ]]; then
|
||||
log_error "${label}端口 ${port} 被 Docker 容器 ${container_name} 占用"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_warn "${label}端口 ${port} 被 Docker 容器 ${container_name} 占用,正在停止..."
|
||||
docker stop "${container_id}" >/dev/null
|
||||
stopped_found=1
|
||||
log_success "已停止容器 ${container_name}"
|
||||
done <<< "${containers}"
|
||||
|
||||
if [[ "${allowed_found}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${stopped_found}" == "1" ]]; then
|
||||
wait_port_released "${port}" 5 || true
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
process_args() {
|
||||
local pid="$1"
|
||||
ps -p "${pid}" -o args= 2>/dev/null || true
|
||||
}
|
||||
|
||||
is_docker_proxy_process() {
|
||||
local args="$1"
|
||||
|
||||
[[ "${args}" == *"com.docker"* || "${args}" == *"Docker Desktop"* || "${args}" == *"vpnkit"* || "${args}" == *"OrbStack"* ]]
|
||||
}
|
||||
|
||||
ensure_port_free() {
|
||||
local port="$1"
|
||||
local label="$2"
|
||||
shift 2
|
||||
|
||||
if stop_docker_containers_on_port "${port}" "${label}" "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
local pids pid args
|
||||
pids="$(lsof -nP -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u || true)"
|
||||
if [[ -z "${pids}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${AUTO_KILL_PORTS}" != "1" ]]; then
|
||||
log_error "${label}端口 ${port} 已被占用,请先停止旧进程或调整端口"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_warn "${label}端口 ${port} 已被占用,正在停止占用进程..."
|
||||
while IFS= read -r pid; do
|
||||
[[ -z "${pid}" ]] && continue
|
||||
args="$(process_args "${pid}")"
|
||||
|
||||
if is_docker_proxy_process "${args}"; then
|
||||
log_error "${label}端口 ${port} 仍由 Docker 代理进程占用,但未找到可自动停止的容器,请执行 docker ps 检查"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_warn "停止 PID ${pid}: ${args:-未知进程}"
|
||||
kill "${pid}" >/dev/null 2>&1 || true
|
||||
done <<< "${pids}"
|
||||
|
||||
if ! wait_port_released "${port}" 5; then
|
||||
log_warn "${label}端口 ${port} 未及时释放,尝试强制停止..."
|
||||
pids="$(lsof -nP -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u || true)"
|
||||
while IFS= read -r pid; do
|
||||
[[ -z "${pid}" ]] && continue
|
||||
args="$(process_args "${pid}")"
|
||||
|
||||
if is_docker_proxy_process "${args}"; then
|
||||
log_error "${label}端口 ${port} 仍由 Docker 代理进程占用,但未找到可自动停止的容器,请执行 docker ps 检查"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_warn "强制停止 PID ${pid}: ${args:-未知进程}"
|
||||
kill -9 "${pid}" >/dev/null 2>&1 || true
|
||||
done <<< "${pids}"
|
||||
|
||||
if ! wait_port_released "${port}" 5; then
|
||||
log_error "${label}端口 ${port} 释放失败,请手动检查占用进程"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "${label}端口 ${port} 已释放"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if nc -z 127.0.0.1 "${port}" >/dev/null 2>&1; then
|
||||
log_error "${label}端口 ${port} 已被占用,但未找到 lsof,无法自动定位并停止占用进程"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "未找到 lsof 或 nc,跳过 ${label}端口 ${port} 占用检查"
|
||||
}
|
||||
|
||||
mysql_root() {
|
||||
docker exec -i -e MYSQL_PWD=rootsecret jysh-mysql mysql -uroot "$@"
|
||||
}
|
||||
|
||||
validate_database_name() {
|
||||
if [[ ! "${MYSQL_DATABASE}" =~ ^[A-Za-z0-9_]+$ ]]; then
|
||||
log_error "DEV_MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${MYSQL_DATABASE}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
reset_database() {
|
||||
if [[ "${RESET_DB}" != "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "重置数据库 ${MYSQL_DATABASE}..."
|
||||
mysql_root -e \
|
||||
"DROP DATABASE IF EXISTS \`${MYSQL_DATABASE}\`; CREATE DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
log_success "数据库已重建"
|
||||
}
|
||||
|
||||
ensure_goose() {
|
||||
if [[ -z "${GOOSE_BIN}" ]]; then
|
||||
GOOSE_BIN="$(command -v goose || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "${GOOSE_BIN}" ]]; then
|
||||
need_cmd go
|
||||
log "未找到 goose,正在安装 ${GOOSE_VERSION}..."
|
||||
(cd "${ROOT_DIR}/backend" && go install "github.com/pressly/goose/v3/cmd/goose@${GOOSE_VERSION}")
|
||||
GOOSE_BIN="$(go env GOPATH)/bin/goose"
|
||||
fi
|
||||
|
||||
if [[ ! -x "${GOOSE_BIN}" ]]; then
|
||||
log_error "goose 不可执行:${GOOSE_BIN}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
goose_dsn() {
|
||||
local dsn="$1"
|
||||
if [[ "${dsn}" != *"multiStatements="* ]]; then
|
||||
if [[ "${dsn}" == *"?"* ]]; then
|
||||
dsn="${dsn}&multiStatements=true"
|
||||
else
|
||||
dsn="${dsn}?multiStatements=true"
|
||||
fi
|
||||
fi
|
||||
printf "%s" "${dsn}"
|
||||
}
|
||||
|
||||
dev_goose_dsn() {
|
||||
goose_dsn "hfb:secret@tcp(127.0.0.1:${MYSQL_PORT})/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
}
|
||||
|
||||
goose_cmd() {
|
||||
"${GOOSE_BIN}" -dir "${MIGRATIONS_DIR}" mysql "$(dev_goose_dsn)" "$@"
|
||||
}
|
||||
|
||||
run_migrations() {
|
||||
if [[ "${NO_MIGRATE}" == "1" ]]; then
|
||||
log_warn "已跳过数据库迁移"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_goose
|
||||
log "使用 goose 执行数据库迁移..."
|
||||
goose_cmd up
|
||||
log_success "数据库迁移完成"
|
||||
}
|
||||
|
||||
load_env_file() {
|
||||
local env_file="$1"
|
||||
local line
|
||||
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
|
||||
line="${line#export }"
|
||||
export "${line}"
|
||||
done < "${env_file}"
|
||||
}
|
||||
|
||||
start_backend() {
|
||||
if [[ ! -f "${BACKEND_ENV}" ]]; then
|
||||
log "创建 backend/.env"
|
||||
cp "${BACKEND_ENV_EXAMPLE}" "${BACKEND_ENV}"
|
||||
fi
|
||||
|
||||
log "启动后端:http://localhost:${BACKEND_PORT}"
|
||||
(
|
||||
cd "${ROOT_DIR}/backend"
|
||||
load_env_file "${BACKEND_ENV}"
|
||||
export APP_ADDR=":${BACKEND_PORT}"
|
||||
export MYSQL_DSN="hfb:secret@tcp(127.0.0.1:${MYSQL_PORT})/${MYSQL_DATABASE}?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
export REDIS_ADDR="127.0.0.1:${REDIS_PORT}"
|
||||
export STORAGE_ENDPOINT="http://localhost:${MINIO_API_PORT}"
|
||||
go run ./cmd/api
|
||||
) &
|
||||
BACKEND_PID="$!"
|
||||
}
|
||||
|
||||
frontend_dependencies_need_ci() {
|
||||
if [[ "${FORCE_NPM_CI}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "${FRONTEND_NODE_MODULES}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "${FRONTEND_NODE_MODULES_LOCK}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${FRONTEND_PACKAGE_JSON}" -nt "${FRONTEND_NODE_MODULES_LOCK}" || "${FRONTEND_PACKAGE_LOCK}" -nt "${FRONTEND_NODE_MODULES_LOCK}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${FRONTEND_DIR}"
|
||||
npm ls --depth=0 >/dev/null 2>&1
|
||||
) || return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_frontend_dependencies() {
|
||||
if [[ ! -f "${FRONTEND_PACKAGE_LOCK}" ]]; then
|
||||
log_error "缺少 frontend/package-lock.json,无法执行 npm ci"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if frontend_dependencies_need_ci; then
|
||||
log "同步前端依赖(npm ci)..."
|
||||
(
|
||||
cd "${FRONTEND_DIR}"
|
||||
npm ci
|
||||
)
|
||||
log_success "前端依赖已同步"
|
||||
else
|
||||
log "前端依赖已同步,跳过 npm ci"
|
||||
fi
|
||||
}
|
||||
|
||||
start_frontend() {
|
||||
ensure_frontend_dependencies
|
||||
|
||||
log "启动前端:http://localhost:${FRONTEND_PORT}"
|
||||
(
|
||||
cd "${FRONTEND_DIR}"
|
||||
export DEV_FRONTEND_PORT="${FRONTEND_PORT}"
|
||||
export DEV_BACKEND_PORT="${BACKEND_PORT}"
|
||||
export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||
npm run dev
|
||||
) &
|
||||
FRONTEND_PID="$!"
|
||||
}
|
||||
|
||||
distribution_dependencies_need_ci() {
|
||||
if [[ "${FORCE_NPM_CI}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "${DISTRIBUTION_NODE_MODULES}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "${DISTRIBUTION_NODE_MODULES_LOCK}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${DISTRIBUTION_PACKAGE_JSON}" -nt "${DISTRIBUTION_NODE_MODULES_LOCK}" || "${DISTRIBUTION_PACKAGE_LOCK}" -nt "${DISTRIBUTION_NODE_MODULES_LOCK}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${DISTRIBUTION_DIR}"
|
||||
npm ls --depth=0 >/dev/null 2>&1
|
||||
) || return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_distribution_dependencies() {
|
||||
if [[ ! -f "${DISTRIBUTION_PACKAGE_LOCK}" ]]; then
|
||||
log_error "缺少 distribution-admin/package-lock.json,无法执行 npm ci"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if distribution_dependencies_need_ci; then
|
||||
log "同步分销后台依赖(npm ci)..."
|
||||
(
|
||||
cd "${DISTRIBUTION_DIR}"
|
||||
npm ci
|
||||
)
|
||||
log_success "分销后台依赖已同步"
|
||||
else
|
||||
log "分销后台依赖已同步,跳过 npm ci"
|
||||
fi
|
||||
}
|
||||
|
||||
start_distribution() {
|
||||
ensure_distribution_dependencies
|
||||
|
||||
log "启动分销后台:http://localhost:${DISTRIBUTION_PORT}"
|
||||
(
|
||||
cd "${DISTRIBUTION_DIR}"
|
||||
export PORT="${DISTRIBUTION_PORT}"
|
||||
export DEV_DISTRIBUTION_PORT="${DISTRIBUTION_PORT}"
|
||||
export DEV_BACKEND_PORT="${BACKEND_PORT}"
|
||||
export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||
npm run dev
|
||||
) &
|
||||
DISTRIBUTION_PID="$!"
|
||||
}
|
||||
|
||||
start_distribution_storefront() {
|
||||
ensure_frontend_dependencies
|
||||
|
||||
log "启动分销前台:http://localhost:${DISTRIBUTION_STOREFRONT_PORT} (分销站: ${DISTRIBUTION_STOREFRONT_SITE_CODE})"
|
||||
(
|
||||
cd "${FRONTEND_DIR}"
|
||||
export DEV_FRONTEND_PORT="${DISTRIBUTION_STOREFRONT_PORT}"
|
||||
export DEV_BACKEND_PORT="${BACKEND_PORT}"
|
||||
export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||
export VITE_DISTRIBUTION_SITE_CODE="${DISTRIBUTION_STOREFRONT_SITE_CODE}"
|
||||
npm run dev
|
||||
) &
|
||||
DISTRIBUTION_STOREFRONT_PID="$!"
|
||||
}
|
||||
|
||||
watch_processes() {
|
||||
while true; do
|
||||
if [[ -n "${BACKEND_PID}" ]] && ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
|
||||
wait "${BACKEND_PID}" || true
|
||||
printf "后端进程已退出\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${FRONTEND_PID}" ]] && ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
|
||||
wait "${FRONTEND_PID}" || true
|
||||
printf "主站前端进程已退出\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${DISTRIBUTION_PID}" ]] && ! kill -0 "${DISTRIBUTION_PID}" >/dev/null 2>&1; then
|
||||
wait "${DISTRIBUTION_PID}" || true
|
||||
printf "分销后台进程已退出\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${DISTRIBUTION_STOREFRONT_PID}" ]] && ! kill -0 "${DISTRIBUTION_STOREFRONT_PID}" >/dev/null 2>&1; then
|
||||
wait "${DISTRIBUTION_STOREFRONT_PID}" || true
|
||||
printf "分销前台商城进程已退出\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
need_cmd docker
|
||||
if [[ "${NO_BACKEND}" == "0" ]]; then
|
||||
need_cmd go
|
||||
fi
|
||||
if [[ "${NO_FRONTEND}" == "0" || "${NO_DISTRIBUTION}" == "0" ]]; then
|
||||
need_cmd npm
|
||||
fi
|
||||
if [[ "${NO_BACKEND}" == "0" || "${NO_FRONTEND}" == "0" || "${NO_DISTRIBUTION}" == "0" ]]; then
|
||||
need_cmd curl
|
||||
fi
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT TERM
|
||||
|
||||
validate_database_name
|
||||
if [[ "${RESET_DB}" == "1" && "${NO_MIGRATE}" == "1" ]]; then
|
||||
log_error "--reset-db 会清空数据库,不能和 --no-migrate 同时使用"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_port_free "${MYSQL_PORT}" "MySQL" jysh-mysql
|
||||
ensure_port_free "${REDIS_PORT}" "Redis" jysh-redis
|
||||
ensure_port_free "${MINIO_API_PORT}" "MinIO API" jysh-minio
|
||||
ensure_port_free "${MINIO_CONSOLE_PORT}" "MinIO 控制台" jysh-minio
|
||||
|
||||
log "启动开发依赖:MySQL、Redis、MinIO"
|
||||
export DEV_MYSQL_PORT="${MYSQL_PORT}"
|
||||
export DEV_REDIS_PORT="${REDIS_PORT}"
|
||||
export DEV_MINIO_API_PORT="${MINIO_API_PORT}"
|
||||
export DEV_MINIO_CONSOLE_PORT="${MINIO_CONSOLE_PORT}"
|
||||
docker compose -f "${COMPOSE_FILE}" up -d
|
||||
|
||||
wait_container_healthy jysh-mysql "MySQL"
|
||||
wait_container_healthy jysh-redis "Redis"
|
||||
wait_container_healthy jysh-minio "MinIO"
|
||||
|
||||
reset_database
|
||||
run_migrations
|
||||
|
||||
if [[ "${NO_BACKEND}" == "0" ]]; then
|
||||
ensure_port_free "${BACKEND_PORT}" "后端"
|
||||
start_backend
|
||||
wait_http_ready "${BACKEND_HEALTH_URL}" "后端" "${BACKEND_PID}"
|
||||
fi
|
||||
|
||||
if [[ "${NO_FRONTEND}" == "0" ]]; then
|
||||
ensure_port_free "${FRONTEND_PORT}" "主站前端"
|
||||
start_frontend
|
||||
wait_http_ready "${FRONTEND_HEALTH_URL}" "主站前端" "${FRONTEND_PID}"
|
||||
fi
|
||||
|
||||
if [[ "${NO_DISTRIBUTION}" == "0" && -d "${DISTRIBUTION_DIR}" ]]; then
|
||||
ensure_port_free "${DISTRIBUTION_PORT}" "分销后台"
|
||||
start_distribution
|
||||
wait_http_ready "${DISTRIBUTION_HEALTH_URL}" "分销后台" "${DISTRIBUTION_PID}"
|
||||
fi
|
||||
|
||||
if [[ "${NO_DISTRIBUTION_STOREFRONT}" == "0" && "${NO_FRONTEND}" == "0" ]]; then
|
||||
ensure_port_free "${DISTRIBUTION_STOREFRONT_PORT}" "分销前台"
|
||||
start_distribution_storefront
|
||||
wait_http_ready "${DISTRIBUTION_STOREFRONT_HEALTH_URL}" "分销前台" "${DISTRIBUTION_STOREFRONT_PID}"
|
||||
fi
|
||||
|
||||
if [[ -z "${BACKEND_PID}" && -z "${FRONTEND_PID}" && -z "${DISTRIBUTION_PID}" && -z "${DISTRIBUTION_STOREFRONT_PID}" ]]; then
|
||||
log "仅启动 Docker 开发依赖,前后端与分销端未启动"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_success "开发环境已就绪"
|
||||
if [[ "${NO_FRONTEND}" == "0" ]]; then
|
||||
local admin_base_path="${VITE_ADMIN_BASE_PATH:-/admin}"
|
||||
log "主站前台:http://localhost:${FRONTEND_PORT}"
|
||||
log "主站后台:http://localhost:${FRONTEND_PORT}${admin_base_path%/}/login"
|
||||
fi
|
||||
if [[ "${NO_DISTRIBUTION}" == "0" && -d "${DISTRIBUTION_DIR}" ]]; then
|
||||
log "分销后台:http://localhost:${DISTRIBUTION_PORT}"
|
||||
fi
|
||||
if [[ "${NO_DISTRIBUTION_STOREFRONT}" == "0" && "${NO_FRONTEND}" == "0" ]]; then
|
||||
log "分销前台:http://localhost:${DISTRIBUTION_STOREFRONT_PORT} (直连分销站: ${DISTRIBUTION_STOREFRONT_SITE_CODE})"
|
||||
fi
|
||||
|
||||
log "按 Ctrl+C 停止所有服务;Docker 依赖会保留运行"
|
||||
|
||||
watch_processes
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# 将大型 SFC 的内联 scoped CSS 外置,保留 <style scoped src="..."> 的编译作用域。
|
||||
while IFS= read -r -d '' file; do
|
||||
if (( $(wc -l < "${file}") <= 900 )); then
|
||||
continue
|
||||
fi
|
||||
if ! rg -q '<style scoped>' "${file}"; then
|
||||
continue
|
||||
fi
|
||||
base="${file%.vue}"
|
||||
css="${base}.css"
|
||||
awk '
|
||||
/<style scoped>/ { inside = 1; next }
|
||||
/<\/style>/ { inside = 0; next }
|
||||
inside { print }
|
||||
' "${file}" > "${css}"
|
||||
name="$(basename "${css}")"
|
||||
perl -0pi -e "s#<style scoped>.*?</style>#<style scoped src=\"./${name}\"></style>#s" "${file}"
|
||||
done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0)
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
git -C "$ROOT_DIR" config core.hooksPath .githooks
|
||||
echo "已启用 .githooks(推送前执行 scripts/check.sh;SKIP_CHECKS=1 可跳过)"
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml"
|
||||
BACKEND_ENV="${ROOT_DIR}/backend/.env"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: ./scripts/migrate-minio-to-oss.sh [--verify|--dry-run] [--prefix 前缀]
|
||||
|
||||
迁移配置写入 backend/.env:
|
||||
STORAGE_* 当前 MinIO 源端配置
|
||||
STORAGE_MIRROR_* 杭州 OSS 目标端配置
|
||||
|
||||
命令会复制同名 object key,不删除 MinIO 数据;可重复执行。
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ ! -f "${BACKEND_ENV}" ]]; then
|
||||
echo "未找到 backend/.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
exec docker compose --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" \
|
||||
run --rm --no-deps backend /app/hfb-storage-migrate "$@"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 准备后端测试所需的 MySQL 库与授权。
|
||||
#
|
||||
# 测试按包使用独立库(默认 hfb_jysh_test_<包名>),这里用一条通配授权覆盖全部包,
|
||||
# 因此只需在搭建环境时执行一次。可重复执行。
|
||||
#
|
||||
# 可用环境变量覆盖:
|
||||
# TEST_MYSQL_HOST/PORT 应用账号连接地址(默认 127.0.0.1:23306)
|
||||
# MYSQL_ADMIN_USER/PASSWORD 管理员账号(默认 root/rootsecret)
|
||||
# TEST_MYSQL_USER/PASSWORD 应用账号(默认 hfb/secret)
|
||||
# HFB_TEST_DATABASE 测试库基础名(默认 hfb_jysh_test)
|
||||
# TEST_MYSQL_CONTAINER 优先使用的容器名(默认 jysh-mysql)
|
||||
|
||||
host="${TEST_MYSQL_HOST:-127.0.0.1}"
|
||||
port="${TEST_MYSQL_PORT:-23306}"
|
||||
admin_user="${MYSQL_ADMIN_USER:-root}"
|
||||
admin_password="${MYSQL_ADMIN_PASSWORD:-rootsecret}"
|
||||
app_user="${TEST_MYSQL_USER:-hfb}"
|
||||
app_password="${TEST_MYSQL_PASSWORD:-secret}"
|
||||
base_database="${HFB_TEST_DATABASE:-hfb_jysh_test}"
|
||||
container="${TEST_MYSQL_CONTAINER:-jysh-mysql}"
|
||||
|
||||
[[ "${base_database}" =~ ^[A-Za-z0-9_]+$ ]] || { printf '测试库基础名不合法: %s\n' "${base_database}" >&2; exit 2; }
|
||||
|
||||
# MySQL 授权库名支持 % 通配;这里按测试库前缀授权,覆盖 __template 与各包克隆库。
|
||||
pattern="${base_database}%"
|
||||
|
||||
run_admin_sql() {
|
||||
if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "${container}"; then
|
||||
docker exec -i "${container}" mysql -u"${admin_user}" -p"${admin_password}"
|
||||
else
|
||||
MYSQL_PWD="${admin_password}" mysql --host="${host}" --port="${port}" --user="${admin_user}"
|
||||
fi
|
||||
}
|
||||
|
||||
printf '准备 MySQL 测试库授权:%s_%% -> %s@%%\n' "${base_database}" "${app_user}"
|
||||
|
||||
run_admin_sql <<SQL
|
||||
CREATE USER IF NOT EXISTS '${app_user}'@'%' IDENTIFIED BY '${app_password}';
|
||||
GRANT ALL PRIVILEGES ON \`${pattern}\`.* TO '${app_user}'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
|
||||
printf '测试库授权已就绪。测试运行时会维护模板库 %s__template,并按包克隆为 %s_<包名>\n' "${base_database}" "${base_database}"
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# 只用于隔离恢复演练或灾难恢复准备。它从不触碰生产 mysql 容器:prepare
|
||||
# 在 BACKUP_RESTORE_DIR 下生成数据目录,start 仅启动 --network none 的临时容器。
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=backup-common.sh
|
||||
source "${SCRIPT_DIR}/backup-common.sh"
|
||||
|
||||
field_value() {
|
||||
local file="$1" key="$2"
|
||||
awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${file}"
|
||||
}
|
||||
|
||||
require_restore_dir() {
|
||||
local dir
|
||||
dir="$(backup_require_env BACKUP_RESTORE_DIR)"
|
||||
backup_validate_dir "${dir}"
|
||||
printf '%s' "${dir}"
|
||||
}
|
||||
|
||||
validate_sha256() {
|
||||
[[ "$1" =~ ^[0-9a-f]{64}$ ]] || { backup_error "非法 SHA-256:$1"; exit 1; }
|
||||
}
|
||||
|
||||
validate_server_uuid() {
|
||||
[[ "$1" =~ ^[0-9a-f-]{36}$ ]] || { backup_error "非法 server_uuid:$1"; exit 1; }
|
||||
}
|
||||
|
||||
verify_downloaded_payload() {
|
||||
local payload="$1" checksum_file="$2" complete_file="$3"
|
||||
local expected actual expected_size actual_size
|
||||
expected="$(field_value "${complete_file}" payload_sha256)"
|
||||
expected_size="$(field_value "${complete_file}" payload_size_bytes)"
|
||||
validate_sha256 "${expected}"
|
||||
[[ "${expected_size}" =~ ^[0-9]+$ ]] || { backup_error '完成标识中的文件大小不合法'; exit 1; }
|
||||
actual="$(backup_sha256 "${payload}")"
|
||||
actual_size="$(backup_file_size "${payload}")"
|
||||
[[ "${expected}" == "${actual}" && "${expected_size}" == "${actual_size}" ]] || {
|
||||
backup_error "下载文件校验失败:${payload}"
|
||||
exit 1
|
||||
}
|
||||
grep -Fqx "${expected} $(basename "${payload}")" "${checksum_file}" || {
|
||||
backup_error "校验文件内容不匹配:${checksum_file}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
prepare_full() {
|
||||
backup_require_cmd docker
|
||||
backup_require_cmd ossutil
|
||||
backup_require_cmd openssl
|
||||
local complete_uri="$1"
|
||||
[[ "${complete_uri}" == oss://*/complete.env ]] || {
|
||||
backup_error 'prepare 参数必须是完整全量备份的 complete.env OSS URI'
|
||||
exit 1
|
||||
}
|
||||
local restore_dir remote_dir ts server_uuid work_dir download_dir
|
||||
restore_dir="$(require_restore_dir)"
|
||||
remote_dir="${complete_uri%/complete.env}"
|
||||
ts="${remote_dir##*/}"
|
||||
server_uuid="$(basename "$(dirname "${remote_dir}")")"
|
||||
validate_server_uuid "${server_uuid}"
|
||||
work_dir="${restore_dir}/${server_uuid}/${ts}"
|
||||
download_dir="${work_dir}/.download"
|
||||
[[ ! -e "${work_dir}" ]] || { backup_error "恢复目录已存在:${work_dir}"; exit 1; }
|
||||
mkdir -p "${download_dir}" "${work_dir}/data"
|
||||
trap 'rm -rf -- "${download_dir}"' EXIT
|
||||
|
||||
backup_oss_download "${complete_uri}" "${download_dir}/complete.env"
|
||||
[[ "$(field_value "${download_dir}/complete.env" payload)" == 'mysql-full.tgz.enc' ]] || {
|
||||
backup_error '不是受支持的全量备份完成标识'
|
||||
exit 1
|
||||
}
|
||||
[[ "$(field_value "${download_dir}/complete.env" server_uuid)" == "${server_uuid}" ]] || {
|
||||
backup_error '备份路径与完成标识的 server_uuid 不一致'
|
||||
exit 1
|
||||
}
|
||||
backup_oss_download "${remote_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz.enc"
|
||||
backup_oss_download "${remote_dir}/mysql-full.tgz.enc.sha256" "${download_dir}/mysql-full.tgz.enc.sha256"
|
||||
verify_downloaded_payload "${download_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz.enc.sha256" "${download_dir}/complete.env"
|
||||
backup_decrypt_file "${download_dir}/mysql-full.tgz.enc" "${download_dir}/mysql-full.tgz"
|
||||
tar xzf "${download_dir}/mysql-full.tgz" -C "${work_dir}/data"
|
||||
local manifest_uuid binlog_file binlog_pos
|
||||
manifest_uuid="$(field_value "${work_dir}/data/manifest.env" server_uuid)"
|
||||
binlog_file="$(field_value "${work_dir}/data/manifest.env" binlog_file)"
|
||||
binlog_pos="$(field_value "${work_dir}/data/manifest.env" binlog_pos)"
|
||||
[[ "${manifest_uuid}" == "${server_uuid}" ]] || { backup_error '归档内 manifest 与完成标识不一致'; exit 1; }
|
||||
backup_binlog_index "${binlog_file}" >/dev/null
|
||||
[[ "${binlog_pos}" =~ ^[0-9]+$ ]] || { backup_error 'manifest 中 binlog 位点非法'; exit 1; }
|
||||
docker run --rm -v "${work_dir}/data:/restore" "${BACKUP_XTRABACKUP_IMAGE}" \
|
||||
xtrabackup --prepare --target-dir=/restore
|
||||
{
|
||||
printf 'server_uuid=%s\n' "${server_uuid}"
|
||||
printf 'binlog_file=%s\n' "${binlog_file}"
|
||||
printf 'binlog_pos=%s\n' "${binlog_pos}"
|
||||
printf 'source_complete_uri=%s\n' "${complete_uri}"
|
||||
printf 'prepared_at=%s\n' "$(date -Iseconds)"
|
||||
} > "${work_dir}/prepared.env"
|
||||
rm -rf -- "${download_dir}"
|
||||
trap - EXIT
|
||||
backup_log "全量备份已 prepare:${work_dir}"
|
||||
backup_log "下一步执行:$0 fetch-all ${work_dir}"
|
||||
}
|
||||
|
||||
fetch_binlog() {
|
||||
backup_require_cmd docker
|
||||
backup_require_cmd ossutil
|
||||
backup_require_cmd openssl
|
||||
local prepared_dir="$1" complete_uri="$2"
|
||||
[[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; }
|
||||
[[ "${complete_uri}" == oss://*/complete.env ]] || { backup_error 'binlog 参数必须是 complete.env OSS URI'; exit 1; }
|
||||
local remote_dir temp_dir complete source_file raw_sha payload server_uuid expected_uuid target_base
|
||||
remote_dir="${complete_uri%/complete.env}"
|
||||
temp_dir="${prepared_dir}/.binlog-download-$$"
|
||||
mkdir -p "${temp_dir}" "${prepared_dir}/binlogs"
|
||||
trap 'rm -rf -- "${temp_dir}"' EXIT
|
||||
backup_oss_download "${complete_uri}" "${temp_dir}/complete.env"
|
||||
source_file="$(field_value "${temp_dir}/complete.env" source_binlog)"
|
||||
raw_sha="$(field_value "${temp_dir}/complete.env" raw_sha256)"
|
||||
payload="$(field_value "${temp_dir}/complete.env" payload)"
|
||||
server_uuid="$(field_value "${temp_dir}/complete.env" server_uuid)"
|
||||
expected_uuid="$(field_value "${prepared_dir}/prepared.env" server_uuid)"
|
||||
backup_binlog_index "${source_file}" >/dev/null
|
||||
validate_sha256 "${raw_sha}"
|
||||
validate_server_uuid "${server_uuid}"
|
||||
[[ "${payload}" == 'binlog.enc' && "${server_uuid}" == "${expected_uuid}" ]] || {
|
||||
backup_error 'binlog 完成标识与恢复目标不匹配'
|
||||
exit 1
|
||||
}
|
||||
target_base="${prepared_dir}/binlogs/${source_file}.${raw_sha}"
|
||||
if [[ -f "${target_base}.meta.env" && -f "${target_base}" ]]; then
|
||||
rm -rf -- "${temp_dir}"
|
||||
trap - EXIT
|
||||
return
|
||||
fi
|
||||
backup_oss_download "${remote_dir}/binlog.enc" "${temp_dir}/binlog.enc"
|
||||
backup_oss_download "${remote_dir}/binlog.enc.sha256" "${temp_dir}/binlog.enc.sha256"
|
||||
verify_downloaded_payload "${temp_dir}/binlog.enc" "${temp_dir}/binlog.enc.sha256" "${temp_dir}/complete.env"
|
||||
backup_decrypt_file "${temp_dir}/binlog.enc" "${target_base}"
|
||||
docker run --rm -v "${prepared_dir}/binlogs:/binlogs:ro" mysql:8.4 \
|
||||
mysqlbinlog --verify-binlog-checksum "/binlogs/$(basename "${target_base}")" >/dev/null
|
||||
mv "${temp_dir}/complete.env" "${target_base}.meta.env"
|
||||
rm -rf -- "${temp_dir}"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
fetch_all() {
|
||||
backup_require_cmd ossutil
|
||||
local prepared_dir="$1" server_uuid oss_uri complete_uri
|
||||
[[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; }
|
||||
server_uuid="$(field_value "${prepared_dir}/prepared.env" server_uuid)"
|
||||
oss_uri="$(backup_require_env BACKUP_OSS_URI)"
|
||||
backup_validate_oss_uri "${oss_uri}"
|
||||
while IFS= read -r complete_uri; do
|
||||
[[ -n "${complete_uri}" ]] || continue
|
||||
fetch_binlog "${prepared_dir}" "${complete_uri}"
|
||||
done < <(ossutil ls "${oss_uri}/mysql/binlog/${server_uuid}/" -r | awk '$NF ~ /\/complete\.env$/ { print $NF }')
|
||||
backup_log "已下载实例 ${server_uuid} 的全部完整 binlog"
|
||||
}
|
||||
|
||||
start_isolated_mysql() {
|
||||
backup_require_cmd docker
|
||||
local prepared_dir="$1" name="${2:-}"
|
||||
[[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; }
|
||||
if [[ -z "${name}" ]]; then
|
||||
name="hfb-restore-$(basename "${prepared_dir}")"
|
||||
fi
|
||||
[[ "${name}" =~ ^hfb-restore-[A-Za-z0-9_-]+$ ]] || { backup_error '恢复容器名必须以 hfb-restore- 开头'; exit 1; }
|
||||
[[ -z "$(docker ps -aq -f "name=^/${name}$")" ]] || { backup_error "恢复容器已存在:${name}"; exit 1; }
|
||||
docker run -d --name "${name}" --network none \
|
||||
-e "MYSQL_ROOT_PASSWORD=$(backup_require_env MYSQL_ROOT_PASSWORD)" \
|
||||
-v "${prepared_dir}/data:/var/lib/mysql" mysql:8.4 \
|
||||
--skip-log-bin --skip-networking=1 >/dev/null
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= 60; attempt++)); do
|
||||
if docker exec "${name}" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqladmin -uroot --protocol=socket ping' >/dev/null 2>&1; then
|
||||
backup_log "隔离 MySQL 已启动:${name}"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
backup_error "隔离 MySQL 启动失败,请执行 docker logs ${name}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
replay_binlogs() {
|
||||
backup_require_cmd docker
|
||||
local prepared_dir="$1" target_container="$2" stop_datetime="${3:-}"
|
||||
[[ -f "${prepared_dir}/prepared.env" ]] || { backup_error "未 prepare 的恢复目录:${prepared_dir}"; exit 1; }
|
||||
local production_cid
|
||||
production_cid="$(backup_mysql_container_id)"
|
||||
[[ "${target_container}" != "${production_cid}" ]] || { backup_error '禁止向生产 MySQL 回放 binlog'; exit 1; }
|
||||
docker inspect "${target_container}" >/dev/null
|
||||
[[ -d "${prepared_dir}/binlogs" ]] || { backup_error '尚未下载 binlog'; exit 1; }
|
||||
local start_file start_pos metadata index source previous_index found=0
|
||||
start_file="$(field_value "${prepared_dir}/prepared.env" binlog_file)"
|
||||
start_pos="$(field_value "${prepared_dir}/prepared.env" binlog_pos)"
|
||||
mapfile -t metadata < <(
|
||||
for metadata in "${prepared_dir}"/binlogs/*.meta.env; do
|
||||
[[ -f "${metadata}" ]] || continue
|
||||
index="$(field_value "${metadata}" binlog_index)"
|
||||
source="$(field_value "${metadata}" source_binlog)"
|
||||
printf '%010d\t%s\t%s\n' "${index}" "${source}" "${metadata}"
|
||||
done | sort -n
|
||||
)
|
||||
[[ "${#metadata[@]}" -gt 0 ]] || { backup_error '没有可回放的 binlog'; exit 1; }
|
||||
local binlog_paths=()
|
||||
previous_index=''
|
||||
local row path
|
||||
for row in "${metadata[@]}"; do
|
||||
IFS=$'\t' read -r index source metadata <<< "${row}"
|
||||
if [[ "${source}" == "${start_file}" ]]; then
|
||||
found=1
|
||||
fi
|
||||
[[ "${found}" == '1' ]] || continue
|
||||
if [[ -n "${previous_index}" && "${index}" -ne $((previous_index + 1)) ]]; then
|
||||
backup_error "恢复 binlog 不连续:${previous_index} -> ${index}"
|
||||
exit 1
|
||||
fi
|
||||
path="/binlogs/$(basename "${metadata%.meta.env}")"
|
||||
binlog_paths+=("${path}")
|
||||
previous_index="${index}"
|
||||
done
|
||||
[[ "${found}" == '1' ]] || { backup_error "缺少全量备份起始 binlog:${start_file}"; exit 1; }
|
||||
local mysqlbinlog_args=(mysqlbinlog "--start-position=${start_pos}")
|
||||
if [[ -n "${stop_datetime}" ]]; then
|
||||
mysqlbinlog_args+=("--stop-datetime=${stop_datetime}")
|
||||
fi
|
||||
mysqlbinlog_args+=("${binlog_paths[@]}")
|
||||
docker run --rm -i -v "${prepared_dir}/binlogs:/binlogs:ro" mysql:8.4 "${mysqlbinlog_args[@]}" |
|
||||
docker exec -i "${target_container}" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
|
||||
backup_log "binlog 已回放至隔离 MySQL:${target_container}"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
prepare) [[ $# -eq 2 ]] || { backup_error '用法:prepare <full-complete-uri>'; exit 1; }; prepare_full "$2" ;;
|
||||
fetch-all) [[ $# -eq 2 ]] || { backup_error '用法:fetch-all <prepared-dir>'; exit 1; }; fetch_all "$2" ;;
|
||||
fetch-binlog) [[ $# -eq 3 ]] || { backup_error '用法:fetch-binlog <prepared-dir> <binlog-complete-uri>'; exit 1; }; fetch_binlog "$2" "$3" ;;
|
||||
start) [[ $# -le 3 && $# -ge 2 ]] || { backup_error '用法:start <prepared-dir> [container-name]'; exit 1; }; start_isolated_mysql "$2" "${3:-}" ;;
|
||||
replay) [[ $# -ge 3 && $# -le 4 ]] || { backup_error '用法:replay <prepared-dir> <container> [YYYY-MM-DD HH:MM:SS]'; exit 1; }; replay_binlogs "$2" "$3" "${4:-}" ;;
|
||||
*)
|
||||
printf '用法: %s {prepare|fetch-all|fetch-binlog|start|replay} ...\n' "$0" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="${ROOT_DIR}/backend/.env"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
printf '缺少 %s,请先复制 backend/.env.example 并填写开发配置\n' "${ENV_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 逐行 export,避免 MYSQL_DSN 中的 & 和括号被 shell 当成语法。
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
|
||||
line="${line#export }"
|
||||
export "${line}"
|
||||
done < "${ENV_FILE}"
|
||||
|
||||
cd "${ROOT_DIR}/backend"
|
||||
exec go run ./cmd/api
|
||||
Reference in New Issue
Block a user