Files
opendance-web/deploy/init-env.sh
T
zijunchiang e4351c5062 feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
2026-09-15 13:55:54 +08:00

648 lines
25 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
}
# 模板占位符(如 <existing-mysql-host> / <部署方提供>)视为“未填写”
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 <<EOF
============================================================
deploy/.env 已写入:$ENV_FILE
============================================================
DB_HOST = $DB_HOST_V
DB_PORT = $DB_PORT_V
DB_USER = $DB_USER_V
DB_PASS = ***
DB_NAME = $DB_NAME_V
PORT = $PORT_V
ADMIN_PORT = $ADMIN_PORT_V$([ "$ADMIN_PORT_V" = "$PORT_V" ] && printf ' (单端口模式)')
HOST = $HOST_V
------------------------------------------------------------
EOF
if [ "${#CHANGED[@]}" -gt 0 ]; then
echo "本次变更:"
for c in "${CHANGED[@]}"; do printf ' %s\n' "$c"; done
else
echo "本次变更:无(与写入前一致)"
fi
cat <<EOF
------------------------------------------------------------
提示:数据库口令不要加引号(当前文件按 \$DB_PASS=值 原样写入)。
如需复核:bash deploy/init-env.sh --check
如需重填:bash deploy/init-env.sh --force
============================================================
EOF
ok "初始化完成,可以继续执行:bash deploy/install.sh"
exit 0