451 lines
15 KiB
Bash
451 lines
15 KiB
Bash
#!/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" <<EOF
|
||
APP_NAME=CaiWuXiTong
|
||
APP_ENV=local
|
||
APP_DEBUG=true
|
||
APP_URL=http://127.0.0.1:18088
|
||
APP_KEY=$app_key
|
||
APP_INSTALLED=false
|
||
APP_LOCALE=zh-CN
|
||
APP_FALLBACK_LOCALE=en-GB
|
||
APP_TIMEZONE=Asia/Shanghai
|
||
LOG_CHANNEL=stack
|
||
|
||
DB_CONNECTION=mysql
|
||
DB_HOST=mysql
|
||
DB_PORT=3306
|
||
DB_DATABASE=akaunting
|
||
DB_USERNAME=akaunting
|
||
DB_PASSWORD=akaunting123
|
||
DB_PREFIX=ak_
|
||
|
||
CACHE_DRIVER=file
|
||
SESSION_DRIVER=file
|
||
QUEUE_CONNECTION=sync
|
||
|
||
SYSTEM_DATABASE_MODE=bootstrap
|
||
UPSTREAM_STORED_CONFIG_ENABLED=false
|
||
UPSTREAM_LOCAL_CONFIG_ENABLED=false
|
||
EOF
|
||
|
||
chmod 600 "$environment_file" 2>/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" <<EOF
|
||
BOOTSTRAP_EMAIL=local-bootstrap@caiwu.test
|
||
BOOTSTRAP_PASSWORD=$bootstrap_password
|
||
EOF
|
||
chmod 600 "$credential_file" 2>/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 "====================================================="
|