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 实测可逆且数据无损恢复
This commit is contained in:
2026-09-15 13:55:54 +08:00
parent bba5bcf59e
commit e4351c5062
34 changed files with 3204 additions and 65 deletions
+647
View File
@@ -0,0 +1,647 @@
#!/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