#!/bin/bash # ============================================================ # OpenDance Studio Web 服务端 —— deploy/.env 初始化引导 # # 作用:把「cp deploy/.env.example deploy/.env && vi deploy/.env」这一步 # 变成可交互的问答,并在写盘前完成必填项 / 端口范围 / 数据库连通性自检。 # # 用法: # bash deploy/init-env.sh # 交互引导(终端里逐项询问) # bash deploy/init-env.sh --force # 已有 .env 也重新逐项询问 # bash deploy/init-env.sh --check # 只校验,不改动、不提问 # bash deploy/init-env.sh --non-interactive # 非交互:只检查缺字段,绝不阻塞 # # 选项: # --check 只校验,不修改文件、不提问(缺失必填 / 端口非法时非 0 退出) # --force 已有 .env 也重新询问一遍(默认:已有且完整则跳过询问) # -y, --yes 非交互(等同 --non-interactive) # --non-interactive CI / 自动化:跳过全部提问,仅做缺字段与端口校验 # -i, --interactive 即使 stdin 不是 TTY 也强制提问(读取超时仍然生效) # --skip-db-check 跳过数据库连通性自检 # --dir DIR 以 DIR/.env 为目标、DIR/.env.example 为模板 # --env-file PATH 指定 .env 路径(默认 <脚本目录>/.env) # --example PATH 指定模板路径(默认 .env 同目录的 .env.example) # --timeout SECONDS 非 TTY 下每次读取的超时秒数(默认 10) # -h, --help 显示本帮助 # # 环境变量: # OPENDANCE_NONINTERACTIVE=1 同 --non-interactive # OPENDANCE_INTERACTIVE=1 同 --interactive # OPENDANCE_SKIP_DB_CHECK=1 同 --skip-db-check # OPENDANCE_ENV_FILE=PATH 同 --env-file # OPENDANCE_ENV_EXAMPLE=PATH 同 --example # OPENDANCE_PROMPT_TIMEOUT=SEC 同 --timeout # # 说明: # * 数据库口令以 `DB_PASS=值` 原样写入(**不加引号**)。请勿使用会破坏 # shell 语法的字符(& $ ` \ 空格 ; | < >),否则 install.sh 的 # `set -a; . .env` 会解析失败。脚本检测到这类字符会给出警告。 # * 非交互判定:--yes / -y / --non-interactive / OPENDANCE_NONINTERACTIVE=1 # 一定跳过提问;否则 stdin 是 TTY、管道、socket 或普通文件时才提问 # (因此 `printf '...\n' | bash deploy/init-env.sh` 与自动化测试可用)。 # 每次 read 都带超时,EOF 立即报错退出——CI 与自动化下绝不挂住。 # * 写回时只替换/追加被填写的键,模板注释与其它字段原样保留。 # ============================================================ set -uo pipefail SELF="${BASH_SOURCE[0]}" SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)" ENV_FILE="${OPENDANCE_ENV_FILE:-$SCRIPT_DIR/.env}" EXAMPLE_FILE="${OPENDANCE_ENV_EXAMPLE:-}" MODE_CHECK=0 FORCE=0 NONINTERACTIVE="${OPENDANCE_NONINTERACTIVE:-0}" FORCE_INTERACTIVE="${OPENDANCE_INTERACTIVE:-0}" SKIP_DB_CHECK="${OPENDANCE_SKIP_DB_CHECK:-0}" PROMPT_TIMEOUT="${OPENDANCE_PROMPT_TIMEOUT:-10}" # 颜色:仅在真正的终端上启用,CI / 测试里输出纯文本便于断言 if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_RED=$'\033[1;31m'; C_YEL=$'\033[1;33m'; C_GRN=$'\033[1;32m' C_BLU=$'\033[1;34m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' else C_RED=''; C_YEL=''; C_GRN=''; C_BLU=''; C_DIM=''; C_RST='' fi log() { printf '%s[init-env]%s %s\n' "$C_BLU" "$C_RST" "$*"; } ok() { printf '%s[init-env]%s %s\n' "$C_GRN" "$C_RST" "$*"; } warn() { printf '%s[init-env]%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } err() { printf '%s[init-env]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; } die() { err "$*"; exit 1; } usage() { cat <<'EOF' 用法:bash deploy/init-env.sh [选项] --check 只校验,不修改文件、不提问 --force 已有 .env 也重新逐项询问 -y, --yes 非交互(等同 --non-interactive) --non-interactive CI / 自动化:跳过全部提问,仅做缺字段校验 -i, --interactive 即使 stdin 不是 TTY 也强制提问(读取超时仍生效) --skip-db-check 跳过数据库连通性自检 --dir DIR 等价于 --env-file DIR/.env --example DIR/.env.example --env-file PATH 指定 .env 路径(默认 <脚本目录>/.env) --example PATH 指定模板路径(默认 .env 同目录的 .env.example) --timeout SECONDS 非 TTY 下每次读取的超时秒数(默认 10) -h, --help 显示本帮助 环境变量:OPENDANCE_NONINTERACTIVE / OPENDANCE_INTERACTIVE / OPENDANCE_SKIP_DB_CHECK / OPENDANCE_ENV_FILE / OPENDANCE_ENV_EXAMPLE / OPENDANCE_PROMPT_TIMEOUT EOF } # ---------- 参数 ---------- DIR_OPT="" while [ $# -gt 0 ]; do case "$1" in --check) MODE_CHECK=1 ;; --force|-f) FORCE=1 ;; -y|--yes|--non-interactive) NONINTERACTIVE=1 ;; -i|--interactive) FORCE_INTERACTIVE=1 ;; --skip-db-check) SKIP_DB_CHECK=1 ;; --dir) shift; [ $# -gt 0 ] || die "--dir 需要一个目录参数"; DIR_OPT="$1" ;; --dir=*) DIR_OPT="${1#*=}" ;; --env-file) shift; [ $# -gt 0 ] || die "--env-file 需要一个路径参数"; ENV_FILE="$1" ;; --env-file=*) ENV_FILE="${1#*=}" ;; --example) shift; [ $# -gt 0 ] || die "--example 需要一个路径参数"; EXAMPLE_FILE="$1" ;; --example=*) EXAMPLE_FILE="${1#*=}" ;; --timeout) shift; [ $# -gt 0 ] || die "--timeout 需要一个秒数"; PROMPT_TIMEOUT="$1" ;; --timeout=*) PROMPT_TIMEOUT="${1#*=}" ;; -h|--help) usage; exit 0 ;; *) die "未知参数:$1(用 --help 查看用法)" ;; esac shift done if [ -n "$DIR_OPT" ]; then ENV_FILE="$DIR_OPT/.env" EXAMPLE_FILE="$DIR_OPT/.env.example" fi [ -n "$ENV_FILE" ] || die "--env-file 路径不能为空" case "$PROMPT_TIMEOUT" in ''|*[!0-9]*) die "--timeout 必须是整数秒(当前:$PROMPT_TIMEOUT)" ;; esac # 模板默认:优先 .env 同目录的 .env.example,其次脚本所在目录 if [ -z "$EXAMPLE_FILE" ]; then if [ -f "$(dirname "$ENV_FILE")/.env.example" ]; then EXAMPLE_FILE="$(dirname "$ENV_FILE")/.env.example" else EXAMPLE_FILE="$SCRIPT_DIR/.env.example" fi fi # ---------- 小工具 ---------- # 1..65535 的整数端口 is_port() { local v="${1:-}" case "$v" in ''|*[!0-9]*) return 1 ;; esac [ "${#v}" -le 5 ] || return 1 [ "$v" -ge 1 ] 2>/dev/null && [ "$v" -le 65535 ] 2>/dev/null } # 模板占位符(如 / <部署方提供>)视为“未填写” is_placeholder() { case "${1:-}" in '<'*'>') return 0 ;; *) return 1 ;; esac } # 读取 .env 中某个键的当前值(最后一次出现的、未注释的那行) # 只做文本解析,不 source 文件,避免 .env 内容被执行 get_env_value() { local key="$1" file="${2:-$ENV_FILE}" raw="" [ -f "$file" ] || { printf ''; return 0; } raw="$(awk -v k="$key" ' $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { line = $0 sub(/^[[:space:]]*/, "", line) sub("^" k "[[:space:]]*=[[:space:]]*", "", line) sub(/[[:space:]]+$/, "", line) val = line } END { if (val != "") print val } ' "$file")" case "$raw" in \"*\") raw="${raw#\"}"; raw="${raw%\"}" ;; \'*\') raw="${raw#\'}"; raw="${raw%\'}" ;; esac printf '%s' "$raw" } # 取默认值:先看 .env(已填写),再看模板 default_for() { local key="$1" v="" v="$(get_env_value "$key" "$ENV_FILE")" if [ -z "$v" ] || is_placeholder "$v"; then v="$(get_env_value "$key" "$EXAMPLE_FILE")" fi printf '%s' "$v" } # 替换或追加单个键,保留文件中的注释与其它行 # 值通过环境变量传入 awk(ENVIRON 不做转义处理),因此 $ * % 等原样落盘 set_env_value() { local key="$1" value="$2" file="$3" tmp="" rc=0 local dir dir="$(dirname "$file")" [ -d "$dir" ] || mkdir -p "$dir" || return 1 tmp="$(mktemp "$dir/.env.tmp.XXXXXX")" || return 1 KEY_NAME="$key" KEY_VALUE="$value" awk ' BEGIN { k = ENVIRON["KEY_NAME"]; v = ENVIRON["KEY_VALUE"]; done = 0 } $0 ~ "^[[:space:]]*" k "[[:space:]]*=" { print k "=" v; done = 1; next } { print } END { if (!done) print k "=" v } ' "$file" > "$tmp" || rc=$? if [ "$rc" -ne 0 ]; then rm -f "$tmp"; return 1; fi chmod 600 "$tmp" 2>/dev/null || true mv "$tmp" "$file" || { rm -f "$tmp"; return 1; } return 0 } # 单字段校验:通过则输出空串,否则输出错误信息 validate_field() { local key="$1" val="${2:-}" required="$3" if [ "$required" = 1 ] && { [ -z "$val" ] || is_placeholder "$val"; }; then printf '%s 不能为空或仍是模板占位符(必填)' "$key"; return 0 fi case "$key" in DB_PORT|PORT|ADMIN_PORT) if [ -n "$val" ] && ! is_port "$val"; then printf '%s 必须是 1..65535 的整数(当前:%s)' "$key" "$val"; return 0 fi ;; esac return 0 } # 口令里会破坏 `set -a; . .env` 解析的字符(脚本按要求不加引号写盘) unsafe_for_unquoted() { case "${1:-}" in *'&'*|*'$'*|*'`'*|*'\'*|*' '*|*' '*|*';'*|*'|'*|*'<'*|*'>'*|*'('*|*')'*|*'!'*) return 0 ;; esac return 1 } # ---------- 交互判定 ---------- # 管道 / socket / 普通文件也算“有输入源”,这样 `printf ... | bash init-env.sh` # 以及 Node spawnSync({input}) 都能交互;/dev/null、已关闭的 stdin 则判为非交互。 # 每次 read 都带超时,绝不无限阻塞。 detect_interactive() { [ "$MODE_CHECK" = 1 ] && return 1 [ "$NONINTERACTIVE" = 1 ] && return 1 [ "$FORCE_INTERACTIVE" = 1 ] && return 0 [ -t 0 ] && return 0 [ -p /dev/stdin ] && return 0 [ -S /dev/stdin ] && return 0 [ -f /dev/stdin ] && return 0 return 1 } INTERACTIVE=0 IS_TTY=0 [ -t 0 ] && IS_TTY=1 READ_OPTS=() if [ "$IS_TTY" != 1 ]; then READ_OPTS=(-t "$PROMPT_TIMEOUT") fi prompt_read_failed() { local key="$1" printf '\n' err "读取 $key 失败:stdin 已结束或超过 ${PROMPT_TIMEOUT}s 无输入。" err "非交互环境请改用:bash deploy/init-env.sh --non-interactive" err "(或设置 OPENDANCE_NONINTERACTIVE=1,并提前把 $ENV_FILE 填好)" exit 1 } # 逐项询问,结果放入全局 ANS ANS="" prompt_field() { local key="$1" usage_text="$2" dflt="$3" required="$4" silent="$5" is_placeholder "$dflt" && dflt="" local shown="$dflt" val="" rc=0 msg="" if [ "$silent" = 1 ] && [ -n "$dflt" ]; then shown="***(已设置,直接回车保留)"; fi while :; do printf '\n%s──── %s ────%s\n' "$C_BLU" "$key" "$C_RST" printf ' 用途:%s\n' "$usage_text" if [ "$required" = 1 ]; then printf ' 必填:%s是%s\n' "$C_YEL" "$C_RST" else printf ' 必填:否(直接回车用默认值)\n'; fi if [ -n "$shown" ]; then printf ' 当前/默认:%s\n' "$shown" else printf ' 当前/默认:%s(空)%s\n' "$C_YEL" "$C_RST"; fi if [ "$silent" = 1 ]; then printf ' 说明:输入不回显;口令不要加引号\n' printf '请输入 %s:' "$key" rc=0 IFS= read -r -s ${READ_OPTS[@]+"${READ_OPTS[@]}"} val || rc=$? printf '\n' else printf '请输入 %s(回车保留默认):' "$key" rc=0 IFS= read -r ${READ_OPTS[@]+"${READ_OPTS[@]}"} val || rc=$? # 管道输入时回车不会被回显,补一个换行让输出可读 [ "$IS_TTY" = 1 ] || printf '\n' fi if [ "$rc" -ne 0 ]; then prompt_read_failed "$key"; fi [ -z "$val" ] && val="$dflt" msg="$(validate_field "$key" "$val" "$required")" if [ -z "$msg" ]; then ANS="$val"; return 0; fi printf '%s ✗ %s%s\n' "$C_RED" "$msg" "$C_RST" printf ' 请重新输入 %s。\n' "$key" done } # ---------- 数据库连通性自检 ---------- # 用项目自带的 mysql2(node -e 动态 require),凭据只经环境变量传给子进程, # 不出现在命令行参数里;失败只警告,不影响写盘。 DB_CHECK_JS=' try { var mysql = require(process.env.MYSQL2_PATH); var conn = mysql.createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT || 3306), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, connectTimeout: 5000, charset: "utf8mb4" }); conn.query("SELECT 1", function (err) { if (err) { console.error("db-check: " + err.message); process.exit(1); } conn.end(function () { process.exit(0); }); }); } catch (e) { console.error("db-check: " + (e && e.message ? e.message : String(e))); process.exit(1); } ' find_mysql2() { local c="" local candidates=() if [ -n "${OPENDANCE_MYSQL2_PATH:-}" ]; then candidates+=("$OPENDANCE_MYSQL2_PATH"); fi candidates+=( "$SCRIPT_DIR/../web-server/node_modules/mysql2" "$PWD/web-server/node_modules/mysql2" "$PWD/node_modules/mysql2" "$SCRIPT_DIR/../node_modules/mysql2" ) if [ -n "${APP_DIR:-}" ]; then candidates+=("$APP_DIR/web-server/node_modules/mysql2"); fi for c in "${candidates[@]}"; do if [ -d "$c" ]; then printf '%s' "$c"; return 0; fi done return 1 } db_check() { local host="$1" port="$2" user="$3" pass="$4" name="$5" if [ "$SKIP_DB_CHECK" = 1 ]; then log "已跳过数据库连通性检查(--skip-db-check / OPENDANCE_SKIP_DB_CHECK)" return 2 fi local node_bin="" node_bin="$(command -v node 2>/dev/null || true)" if [ -z "$node_bin" ]; then warn "未找到 node,跳过数据库连通性检查"; return 2; fi local mysql2="" mysql2="$(find_mysql2 || true)" if [ -z "$mysql2" ]; then warn "未找到 web-server/node_modules/mysql2,跳过数据库连通性检查" return 2 fi log "尝试连接数据库 ${host}:${port}/${name}(5 秒超时,口令不出现在命令行/日志)…" local out="" rc=0 out="$(DB_HOST="$host" DB_PORT="$port" DB_USER="$user" DB_PASS="$pass" DB_NAME="$name" \ MYSQL2_PATH="$mysql2" "$node_bin" -e "$DB_CHECK_JS" 2>&1)" || rc=$? # 兜底脱敏:万一驱动把口令回显进错误信息 if [ -n "$pass" ]; then out="${out//"$pass"/***}"; fi if [ "$rc" -eq 0 ]; then ok "数据库连接成功:${host}:${port}/${name}" return 0 fi warn "数据库连接失败(口令已隐藏):" printf '%s\n' "$out" | tail -n 3 | sed 's/^/ /' >&2 warn "可先完成部署,稍后用 bash deploy/init-env.sh --check 复核。" return 1 } # ---------- 建文件 ---------- if [ ! -f "$ENV_FILE" ]; then if [ "$MODE_CHECK" = 1 ]; then err "缺少 $ENV_FILE(--check 只校验,不会创建文件)" exit 1 fi if [ ! -f "$EXAMPLE_FILE" ]; then die "缺少 $ENV_FILE,且找不到模板 $EXAMPLE_FILE。 请从发布包中确认 deploy/.env.example 存在,或手动创建 $ENV_FILE。" fi mkdir -p "$(dirname "$ENV_FILE")" || die "无法创建目录:$(dirname "$ENV_FILE")" cp "$EXAMPLE_FILE" "$ENV_FILE" || die "无法从模板复制:$EXAMPLE_FILE → $ENV_FILE" chmod 600 "$ENV_FILE" 2>/dev/null || true log "已从模板创建 $ENV_FILE(来源:$EXAMPLE_FILE)" fi # ---------- 读取当前值 ---------- CUR_PORT="$(get_env_value PORT "$ENV_FILE")" [ -n "$CUR_PORT" ] || CUR_PORT="$(get_env_value API_PORT "$ENV_FILE")" # 兼容旧键名 CUR_ADMIN="$(get_env_value ADMIN_PORT "$ENV_FILE")" CUR_DB_HOST="$(get_env_value DB_HOST "$ENV_FILE")" CUR_DB_PORT="$(get_env_value DB_PORT "$ENV_FILE")" CUR_DB_USER="$(get_env_value DB_USER "$ENV_FILE")" CUR_DB_PASS="$(get_env_value DB_PASS "$ENV_FILE")" CUR_DB_NAME="$(get_env_value DB_NAME "$ENV_FILE")" CUR_HOST="$(get_env_value HOST "$ENV_FILE")" # 必填是否已齐全(用于“已有 .env 且完整就不再询问”);端口已写但非法时 # 不算“完整”,这样交互模式会重新询问而不是直接报错退出。 current_complete() { local k v p for k in DB_HOST DB_USER DB_PASS DB_NAME; do v="$(get_env_value "$k" "$ENV_FILE")" if [ -z "$v" ] || is_placeholder "$v"; then return 1; fi done p="$(get_env_value DB_PORT "$ENV_FILE")" if [ -n "$p" ] && ! is_port "$p"; then return 1; fi p="$(get_env_value PORT "$ENV_FILE")" [ -n "$p" ] || p="$(get_env_value API_PORT "$ENV_FILE")" if [ -n "$p" ] && ! is_port "$p"; then return 1; fi p="$(get_env_value ADMIN_PORT "$ENV_FILE")" if [ -n "$p" ] && ! is_port "$p"; then return 1; fi return 0 } INTERACTIVE=0 detect_interactive && INTERACTIVE=1 # ---------- 交互式引导 ---------- if [ "$INTERACTIVE" = 1 ]; then if [ "$FORCE" = 1 ]; then log "交互式引导(--force:重新询问全部字段,回车保留当前值)" elif current_complete; then log "$ENV_FILE 已存在且必填项齐全,跳过询问(如需重新填写请加 --force)" else log "交互式引导:逐项填写(回车保留方括号中的当前/默认值)" fi need_prompt=1 if [ "$FORCE" != 1 ] && current_complete; then need_prompt=0; fi if [ "$need_prompt" = 1 ]; then # DB_HOST prompt_field DB_HOST "现有 MySQL / MariaDB 实例地址(本脚本不安装数据库)" \ "$(default_for DB_HOST)" 1 0 DB_HOST_V="$ANS" # DB_PORT prompt_field DB_PORT "数据库端口(1..65535)" \ "$(default_for DB_PORT)" 0 0 [ -n "$ANS" ] || ANS="3306" DB_PORT_V="$ANS" # DB_USER prompt_field DB_USER "数据库账号" "$(default_for DB_USER)" 1 0 DB_USER_V="$ANS" # DB_PASS(不回显) prompt_field DB_PASS "数据库口令(输入不回显,写盘不加引号)" \ "$(default_for DB_PASS)" 1 1 DB_PASS_V="$ANS" # DB_NAME prompt_field DB_NAME "数据库名" "$(default_for DB_NAME)" 1 0 DB_NAME_V="$ANS" # PORT prompt_field PORT "软件 API 端口(Windows/Godot/Android 客户端调用)" \ "${CUR_PORT:-3000}" 0 0 [ -n "$ANS" ] || ANS="3000" PORT_V="$ANS" # ADMIN_PORT:默认 PORT+1;原值为“旧 PORT+1/旧 PORT”时跟随新 PORT, # 显式自定义过的值则原样保留 old_port="${CUR_PORT:-3000}" is_port "$old_port" || old_port=3000 # 旧 .env 里 PORT 非法时按默认算 if [ -z "$CUR_ADMIN" ] || ! is_port "$CUR_ADMIN"; then adm_default=$((PORT_V + 1)) elif [ "$CUR_ADMIN" = "$old_port" ]; then adm_default="$PORT_V" elif [ "$CUR_ADMIN" = "$((old_port + 1))" ]; then adm_default=$((PORT_V + 1)) else adm_default="$CUR_ADMIN"; fi prompt_field ADMIN_PORT "管理端端口(浏览器管理界面,默认 PORT+1;与 PORT 相同=单端口模式)" \ "$adm_default" 0 0 [ -n "$ANS" ] || ANS="$adm_default" ADMIN_PORT_V="$ANS" # HOST prompt_field HOST "软件 API 监听地址(0.0.0.0 对外 / 127.0.0.1 仅本机)" \ "${CUR_HOST:-0.0.0.0}" 0 0 [ -n "$ANS" ] || ANS="0.0.0.0" HOST_V="$ANS" else # 已有且完整:用现值,不写盘 DB_HOST_V="$CUR_DB_HOST"; DB_PORT_V="${CUR_DB_PORT:-3306}" DB_USER_V="$CUR_DB_USER"; DB_PASS_V="$CUR_DB_PASS" DB_NAME_V="$CUR_DB_NAME"; PORT_V="${CUR_PORT:-3000}" ADMIN_PORT_V="$CUR_ADMIN" if [ -z "$ADMIN_PORT_V" ] && is_port "$PORT_V"; then ADMIN_PORT_V="$((PORT_V + 1))"; fi HOST_V="${CUR_HOST:-0.0.0.0}" if [ "$SKIP_DB_CHECK" = 1 ]; then log "已跳过数据库连通性检查(--skip-db-check / OPENDANCE_SKIP_DB_CHECK)" else db_check "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_PASS_V" "$DB_NAME_V" || true fi log "未做任何修改。" exit 0 fi else # ---------- 非交互 / --check:只校验,不提问 ---------- # 非交互只校验「当前 .env 实际有什么」,绝不从模板补默认值, # 否则模板里的 DB_USER 会被当成已填写,缺字段就报不出来了。 # 端口/HOST 属于有内置默认值的项,缺省不算错误。 DB_HOST_V="$(get_env_value DB_HOST "$ENV_FILE")" DB_PORT_V="$(get_env_value DB_PORT "$ENV_FILE")"; [ -n "$DB_PORT_V" ] || DB_PORT_V="3306" DB_USER_V="$(get_env_value DB_USER "$ENV_FILE")" DB_PASS_V="$(get_env_value DB_PASS "$ENV_FILE")" DB_NAME_V="$(get_env_value DB_NAME "$ENV_FILE")" PORT_V="$(get_env_value PORT "$ENV_FILE")" [ -n "$PORT_V" ] || PORT_V="$(get_env_value API_PORT "$ENV_FILE")" [ -n "$PORT_V" ] || PORT_V="3000" ADMIN_PORT_V="$(get_env_value ADMIN_PORT "$ENV_FILE")" # PORT 非法时不做算术,交给下面的端口校验报错,避免 set -u 下报 caculate 错误 if [ -z "$ADMIN_PORT_V" ] && is_port "$PORT_V"; then ADMIN_PORT_V="$((PORT_V + 1))"; fi HOST_V="$(get_env_value HOST "$ENV_FILE")"; [ -n "$HOST_V" ] || HOST_V="0.0.0.0" fi # ---------- 汇总校验(两种模式共用) ---------- ERRORS=() add_error() { ERRORS+=("$1"); } for pair in "DB_HOST:$DB_HOST_V" "DB_USER:$DB_USER_V" "DB_PASS:$DB_PASS_V" "DB_NAME:$DB_NAME_V"; do k="${pair%%:*}"; v="${pair#*:}" m="$(validate_field "$k" "$v" 1)" [ -n "$m" ] && add_error "$m" done for pair in "DB_PORT:$DB_PORT_V" "PORT:$PORT_V" "ADMIN_PORT:$ADMIN_PORT_V"; do k="${pair%%:*}"; v="${pair#*:}" m="$(validate_field "$k" "$v" 0)" [ -n "$m" ] && add_error "$m" done if [ "${#ERRORS[@]}" -gt 0 ]; then if [ "$MODE_CHECK" = 1 ]; then err "$ENV_FILE 校验未通过(--check 未修改任何文件):" else err "$ENV_FILE 校验未通过:" fi for m in "${ERRORS[@]}"; do err " ✗ $m"; done err "请填写后重试:bash deploy/init-env.sh" exit 1 fi # 端口相同 → 单端口模式(允许,仅提示) if [ "$ADMIN_PORT_V" = "$PORT_V" ]; then warn "ADMIN_PORT 与 PORT 相同($PORT_V):单端口模式,管理页面与软件接口共用同一端口。" fi # 口令不安全的字符提示(按需求不加引号写盘) if unsafe_for_unquoted "$DB_PASS_V"; then warn "DB_PASS 含 shell 特殊字符(& \$ \` \\ 空格 ; | < > ( ) !)。" warn "本脚本按约定不加引号写盘,但 install.sh 的 \`set -a; . .env\` 可能解析失败," warn "建议改用仅含字母/数字/*/%/-/_/. 的口令。" fi # ---------- --check:到此为止 ---------- if [ "$MODE_CHECK" = 1 ]; then ok "$ENV_FILE 校验通过(未做任何修改)" printf ' DB_HOST=%s DB_PORT=%s DB_USER=%s DB_PASS=*** DB_NAME=%s\n' \ "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_NAME_V" printf ' PORT=%s ADMIN_PORT=%s HOST=%s\n' "$PORT_V" "$ADMIN_PORT_V" "$HOST_V" exit 0 fi # ---------- 非交互:只报“校验通过”,不写盘 ---------- if [ "$INTERACTIVE" != 1 ]; then ok "$ENV_FILE 校验通过(非交互模式,未做任何修改)" printf ' DB_HOST=%s DB_PORT=%s DB_USER=%s DB_PASS=*** DB_NAME=%s\n' \ "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_NAME_V" printf ' PORT=%s ADMIN_PORT=%s HOST=%s\n' "$PORT_V" "$ADMIN_PORT_V" "$HOST_V" exit 0 fi # ---------- 交互:数据库自检 ---------- db_check "$DB_HOST_V" "$DB_PORT_V" "$DB_USER_V" "$DB_PASS_V" "$DB_NAME_V" || true # ---------- 写回(保留注释,只改这些键) ---------- log "写回 $ENV_FILE(保留模板注释与其它字段)…" write_key() { local k="$1" v="$2" set_env_value "$k" "$v" "$ENV_FILE" || die "写入 $ENV_FILE 失败(键:$k)" local back="" back="$(get_env_value "$k" "$ENV_FILE")" [ "$back" = "$v" ] || warn "$k 写回后校验不一致(写入:$v,读回:$back)" } CHANGED=() record_change() { local k="$1" old="$2" new="$3" [ "$old" = "$new" ] && return 0 if [ "$k" = DB_PASS ]; then if [ -z "$old" ]; then CHANGED+=("DB_PASS: (空)→ ***") else CHANGED+=("DB_PASS: *** → ***(已更新)"); fi else CHANGED+=("$k: ${old:-(空)} → ${new:-(空)}") fi return 0 } record_change DB_HOST "$CUR_DB_HOST" "$DB_HOST_V" record_change DB_PORT "$CUR_DB_PORT" "$DB_PORT_V" record_change DB_USER "$CUR_DB_USER" "$DB_USER_V" record_change DB_PASS "$CUR_DB_PASS" "$DB_PASS_V" record_change DB_NAME "$CUR_DB_NAME" "$DB_NAME_V" record_change PORT "$CUR_PORT" "$PORT_V" record_change ADMIN_PORT "$CUR_ADMIN" "$ADMIN_PORT_V" record_change HOST "$CUR_HOST" "$HOST_V" write_key DB_HOST "$DB_HOST_V" write_key DB_PORT "$DB_PORT_V" write_key DB_USER "$DB_USER_V" write_key DB_PASS "$DB_PASS_V" write_key DB_NAME "$DB_NAME_V" write_key PORT "$PORT_V" write_key ADMIN_PORT "$ADMIN_PORT_V" write_key HOST "$HOST_V" chmod 600 "$ENV_FILE" 2>/dev/null || warn "无法将 $ENV_FILE 权限设为 600" # ---------- 摘要 ---------- cat <