#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")/.." compose_file="docker-compose.local.yml" environment_file="runtime/.env" credential_file="runtime/bootstrap-credentials.env" app_image="jiangling_akaunting:local" build_mode="auto" for argument in "$@"; do case "$argument" in --build) build_mode="build" ;; --skip-build) build_mode="skip" ;; --help|-h) echo "用法:bash deploy/local-init.sh [--build|--skip-build]" echo " --build 强制重新构建本地开发镜像" echo " --skip-build 只启动已有镜像;镜像不存在时停止" exit 0 ;; *) echo "错误:未知参数 $argument" >&2 exit 1 ;; esac done read_env_value() { local key="$1" sed -n "s/^${key}=//p" "$environment_file" \ | tail -n 1 \ | tr -d '\r' \ | sed 's/^"//; s/"$//' } set_env_value() { local key="$1" local value="$2" local temporary temporary="${environment_file}.tmp.$$" awk -v key="$key" -v value="$value" ' BEGIN { found = 0 } index($0, key "=") == 1 { print key "=" value found = 1 next } { print } END { if (!found) { print key "=" value } } ' "$environment_file" > "$temporary" cp "$temporary" "$environment_file" rm -f "$temporary" chmod 600 "$environment_file" 2>/dev/null || true } calculate_local_image_fingerprint() { { printf '%s\0' \ .dockerignore \ composer.json \ composer.lock \ package.json \ package-lock.json \ webpack.mix.js \ tailwind.config.js \ presets.js \ safelist.txt \ manifest.json \ serviceworker.js \ deploy/Dockerfile.local \ deploy/apache-vhost.conf \ deploy/php.ini \ deploy/php-local.ini \ deploy/entrypoint.sh \ deploy/local-healthcheck.sh find resources/assets resources/views resources/lang -type f -print0 find public -type f \ ! -path 'public/js/*' \ ! -path 'public/vendor/*' \ ! -path 'public/css/app.css' \ ! -name 'mix-manifest.json' \ -print0 } \ | sort -z \ | xargs -0 sha256sum \ | sha256sum \ | awk '{ print $1 }' } image_has_required_assets() { docker run --rm --entrypoint sh "$app_image" -lc ' for required_asset in \ /var/www/html/public/js/manifest.js \ /var/www/html/public/js/vendor.js \ /var/www/html/public/css/app.css \ /var/www/html/public/mix-manifest.json \ /var/www/html/public/vendor/apexcharts/apexcharts.min.js \ /var/www/html/public/vendor/js-cookie/js.cookie.js do test -s "$required_asset" || exit 1 done ' >/dev/null 2>&1 } echo "==> 工作目录: $(pwd)" if ! command -v docker >/dev/null 2>&1; then echo "错误:未找到 Docker,请先启动 Docker Desktop。" >&2 exit 1 fi if ! docker version >/dev/null 2>&1; then echo "错误:Docker 尚未就绪,请先启动 Docker Desktop。" >&2 exit 1 fi if [ ! -f "$environment_file" ]; then mkdir -p runtime app_key="base64:$(head -c 32 /dev/urandom | base64 | tr -d '\r\n')" cat > "$environment_file" </dev/null || true echo "==> 已生成本机私有 runtime/.env(不会提交到 Git)。" fi image_exists=0 if docker image inspect "$app_image" >/dev/null 2>&1; then image_exists=1 fi local_image_fingerprint="$(calculate_local_image_fingerprint)" stored_image_fingerprint="" image_contract_ok=0 if [ "$image_exists" -eq 1 ]; then stored_image_fingerprint="$( docker image inspect "$app_image" \ --format '{{ index .Config.Labels "com.caiwu.local-build-fingerprint" }}' \ 2>/dev/null \ || true )" if image_has_required_assets; then image_contract_ok=1 fi fi build_reason="" if [ "$build_mode" = "build" ]; then build_reason="已要求强制构建" elif [ "$image_exists" -eq 0 ]; then build_reason="本地镜像不存在" elif [ "$image_contract_ok" -ne 1 ]; then build_reason="现有镜像缺少必要静态资源" elif [ "$stored_image_fingerprint" != "$local_image_fingerprint" ]; then build_reason="前端、依赖或运行镜像配置已经变化" fi if [ "$build_mode" = "skip" ] && [ -n "$build_reason" ]; then echo "错误:$build_reason,不能安全使用 --skip-build。" >&2 echo "请不带参数重新运行本脚本,由系统自动更新镜像。" >&2 exit 1 fi if [ -n "$build_reason" ]; then build_attempt=1 while [ "$build_attempt" -le 2 ]; do local_image_fingerprint="$(calculate_local_image_fingerprint)" echo "==> [1/5] 构建本地开发镜像:${build_reason}。" docker compose -f "$compose_file" build \ --build-arg "LOCAL_BUILD_FINGERPRINT=$local_image_fingerprint" \ app built_image_fingerprint="$( docker image inspect "$app_image" \ --format '{{ index .Config.Labels "com.caiwu.local-build-fingerprint" }}' )" current_image_fingerprint="$(calculate_local_image_fingerprint)" if [ "$current_image_fingerprint" != "$local_image_fingerprint" ]; then if [ "$build_attempt" -lt 2 ]; then echo " 构建期间检测到源码变化,自动按最新源码再构建一次。" build_reason="源码在构建期间发生变化" build_attempt=$((build_attempt + 1)) continue fi echo "错误:构建期间源码持续变化,请等待当前文件修改完成后重试。" >&2 exit 1 fi if [ "$built_image_fingerprint" != "$local_image_fingerprint" ]; then echo "错误:本地镜像版本标识与当前源码不一致。" >&2 exit 1 fi if ! image_has_required_assets; then echo "错误:本地镜像缺少必要静态资源,请查看上方构建日志。" >&2 exit 1 fi break done else echo "==> [1/5] 本地开发镜像与当前源码一致,直接复用。" fi # 共享快照只由生产中央节点刷新。先清理旧版 worker,避免 Compose 报孤儿 # 容器,也避免不同分支继续重复访问上游。 docker rm -f jiangling_snapshot_worker >/dev/null 2>&1 || true echo "==> [2/5] 启动本地容器..." # 先只启动数据库并确认 bootstrap 状态,再启动网页容器,避免旧的 # APP_INSTALLED=true 让 entrypoint 与首次安装命令并发迁移空数据库。 docker compose -f "$compose_file" up -d --no-build mysql echo "==> [3/5] 等待临时 MySQL 就绪..." mysql_ready=0 for i in $(seq 1 60); do if docker compose -f "$compose_file" exec -T mysql \ mysqladmin ping -h 127.0.0.1 -u akaunting -pakaunting123 >/dev/null 2>&1; then mysql_ready=1 echo " 临时 MySQL 已就绪。" break fi echo " 等待中... ($i/60)" sleep 2 done if [ "$mysql_ready" -ne 1 ]; then echo "错误:临时 MySQL 未能在规定时间内启动。" >&2 exit 1 fi db_host="$(read_env_value DB_HOST)" app_installed="$(read_env_value APP_INSTALLED | tr '[:upper:]' '[:lower:]')" if [ "$db_host" = "mysql" ]; then set_env_value SYSTEM_DATABASE_MODE bootstrap set_env_value UPSTREAM_STORED_CONFIG_ENABLED false set_env_value UPSTREAM_LOCAL_CONFIG_ENABLED false db_prefix="$(read_env_value DB_PREFIX)" if ! [[ "$db_prefix" =~ ^[A-Za-z0-9_]{0,50}$ ]]; then echo "错误:本机引导库表前缀无效,已停止初始化。" >&2 exit 1 fi bootstrap_table_count="$( docker compose -f "$compose_file" exec -T mysql \ mysql -N -h 127.0.0.1 -u akaunting -pakaunting123 akaunting \ -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'akaunting' AND table_name IN ('${db_prefix}users', '${db_prefix}settings', '${db_prefix}companies')" \ 2>/dev/null \ | tr -d '\r' \ || true )" bootstrap_any_table_count="$( docker compose -f "$compose_file" exec -T mysql \ mysql -N -h 127.0.0.1 -u akaunting -pakaunting123 akaunting \ -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'akaunting'" \ 2>/dev/null \ | tr -d '\r' \ || true )" if [ "$bootstrap_table_count" != "3" ]; then app_installed="false" set_env_value APP_INSTALLED false echo "==> 检测到本机引导库尚未安装或数据卷已重建,将自动执行 bootstrap 初始化。" fi if [ "$app_installed" = "false" ] && [ "${bootstrap_any_table_count:-0}" -gt 0 ]; then echo "==> 清理上次中断后留下的本机 bootstrap 半安装表..." docker compose -f "$compose_file" exec -T mysql \ mysql -h 127.0.0.1 -u root -prootpass \ -e "DROP DATABASE IF EXISTS akaunting; CREATE DATABASE akaunting CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON akaunting.* TO 'akaunting'@'%'; FLUSH PRIVILEGES;" fi fi # 已保存的团队密码可解锁最新端点;服务器迁移不应要求每台开发机手改 .env。 # 无有效团队密码的首次引导保持原状,仍通过网页完成一次接入。 echo "==> 自动对齐本机团队共享连接..." if docker compose -f "$compose_file" run --rm --no-deps -T --entrypoint sh app -c \ 'jiangling-entrypoint true && exec runuser -u www-data -- php artisan system-database:connect-team --no-interaction --no-ansi'; then team_connect_status=0 else team_connect_status=$? fi if [ "$team_connect_status" -ne 0 ] && [ "$team_connect_status" -ne 13 ]; then echo " 最新端点自动对齐未完成,验证当前已保存的共享连接..." if docker compose -f "$compose_file" run --rm --no-deps -T --entrypoint sh app -c \ 'jiangling-entrypoint true && exec runuser -u www-data -- php artisan system-database:verify --no-interaction --no-ansi'; then echo " 当前共享连接仍可用,保留原配置并继续初始化。" else echo "错误:最新端点和当前共享连接均不可用;原配置已保留,请核对网络或首次输入团队数据库密码。" >&2 exit "$team_connect_status" fi fi db_host="$(read_env_value DB_HOST)" app_installed="$(read_env_value APP_INSTALLED | tr '[:upper:]' '[:lower:]')" docker compose -f "$compose_file" up -d --no-build app frontend # 容器可能已经存在而没有重新执行 entrypoint。显式复用同一权限初始化逻辑, # 修复历史 root CLI 遗留目录;该命令本身不运行 Artisan。 docker compose -f "$compose_file" exec -T -u root app jiangling-entrypoint true if [ "$db_host" = "mysql" ] && [ "$app_installed" != "true" ] && [ "$app_installed" != "1" ]; then echo "==> [4/5] 初始化仅供首次登录使用的本机引导库..." if [ ! -f "$credential_file" ]; then umask 077 bootstrap_password="Cw!$(od -An -N16 -tx1 /dev/urandom | tr -d ' \r\n')" cat > "$credential_file" </dev/null || true fi # 文件由脚本生成,值只包含固定邮箱和受限字符集的随机密码。 # shellcheck disable=SC1090 source "$credential_file" docker compose -f "$compose_file" exec -T -u www-data app \ php artisan install --no-interaction \ --db-host=mysql \ --db-port=3306 \ --db-name=akaunting \ --db-username=akaunting \ --db-password=akaunting123 \ --db-prefix=ak_ \ --company-name="本地引导环境" \ --company-email="$BOOTSTRAP_EMAIL" \ --admin-email="$BOOTSTRAP_EMAIL" \ --admin-password="$BOOTSTRAP_PASSWORD" \ --locale=zh-CN else echo "==> [4/5] 当前环境已经初始化,跳过本机引导库安装。" fi echo "==> 自动检查本机临时引导库结构..." docker compose -f "$compose_file" exec -T -u www-data app php artisan optimize:clear >/dev/null echo "==> 自动检查供应商应付本地维护库结构..." docker compose -f "$compose_file" exec -T -u www-data app \ php artisan migrate --database=supplier_payable_maintenance \ --path=database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php \ --force --no-ansi docker compose -f "$compose_file" exec -T -u www-data app \ php artisan local-bootstrap:update --no-ansi echo "==> [5/5] 验证安装状态、数据库和管理员权限..." ready=0 for i in $(seq 1 30); do if docker compose -f "$compose_file" exec -T -u www-data app php artisan app:ready --quiet; then ready=1 break fi sleep 2 done if [ "$ready" -ne 1 ]; then echo "错误:应用未通过就绪检查,请运行以下命令查看日志:" >&2 echo "docker compose -f $compose_file logs --tail=100 app" >&2 exit 1 fi http_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \ http://127.0.0.1:18088/auth/login || true)" if [ "$http_code" != "200" ]; then echo "错误:登录页返回 HTTP ${http_code:-000}。" >&2 exit 1 fi echo "" echo "=====================================================" echo "本地网页可访问:http://127.0.0.1:18088" # Docker healthy 只表示当前连接可用;共享接入必须由新 Laravel 进程实测, # 不能根据 DB_HOST 不是 mysql 就宣称团队数据已连接。 if docker compose -f "$compose_file" exec -T -u www-data app \ php artisan system-database:verify --no-ansi; then shared_status=0 else shared_status=$? fi case "$shared_status" in 0) echo "团队共享系统数据库验证通过,请使用其中属于自己的正式账号登录。" ;; 13) echo "团队数据接入尚未完成;后台 worker 启动不代表数据已经同步。" echo "临时登录凭据保存在本机私有文件:$credential_file" echo "请登录后输入一次系统数据库密码,连接成功后改用团队正式账号登录。" ;; *) echo "团队共享系统数据库未通过验证;不要将 Docker healthy 当作接入成功。" >&2 echo "请让 Codex 根据上方失败阶段检查本机配置,保留现有配置和未提交代码。" >&2 exit "$shared_status" ;; esac echo "====================================================="