From 57fcd366f3957b9f0e7dc4746c83421944227099 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 21 Jul 2026 16:17:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E5=BC=80=E5=8F=91=E6=9C=BA?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E6=9E=84=E5=BB=BA=E4=B8=8E=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=B5=81=E7=A8=8B=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 8 +- .gitignore | 4 + AGENTS.md | 50 +++++++- compose.jiangling.yml | 45 +++++-- deploy/Dockerfile | 56 ++++++++- deploy/activate-release.sh | 94 ++++++++++++++ deploy/bootstrap-server.sh | 99 +++++++++++++++ deploy/entrypoint.sh | 17 ++- docs/开发与部署SOP.md | 135 ++++++++++++++++++++ scripts/deploy-from-workstation.ps1 | 185 ++++++++++++++++++++++++++++ 10 files changed, 666 insertions(+), 27 deletions(-) create mode 100644 deploy/activate-release.sh create mode 100644 deploy/bootstrap-server.sh create mode 100644 docs/开发与部署SOP.md create mode 100644 scripts/deploy-from-workstation.ps1 diff --git a/.dockerignore b/.dockerignore index db545f77..dacccf38 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,8 @@ .env +*.pem +*.tar +artifacts +runtime .git .gitignore .idea @@ -11,6 +15,8 @@ storage/framework/sessions/* storage/framework/views/* storage/logs/* tests +test-data +docs +scripts npm-debug.log yarn-error.log - diff --git a/.gitignore b/.gitignore index cad4d2d7..a8799781 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,7 @@ modules/* !modules/PaypalStandard/** .laravelstatsrc .DS_Store +*.pem +*.tar +/artifacts/ +/runtime/ diff --git a/AGENTS.md b/AGENTS.md index 0fbc1f0c..b7b1a7b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ ## 2. 分支与提交 -- `main` 是稳定主线,禁止直接推送、禁止强推、禁止直接修改。 +- `main` 是稳定主线,默认禁止直接推送、禁止强推、禁止直接修改。项目负责人 `WuYongJie`,以及得到其明确授权的 Codex,可以直接合并 PR 或直接推送 `main`;任何身份仍禁止强推。 - 每项任务都必须从最新 `main` 新建分支;不得长期共用或复用旧任务分支。 - 分支命名:新功能用 `feature/用户名-任务名`,修复用 `fix/用户名-问题名`,紧急修复用 `hotfix/用户名-问题名`。 - 示例:`feature/dodo-login`、`fix/wangshu-amount-rounding`。 @@ -27,7 +27,7 @@ 4. 要求 AI 先说明改动计划、涉及文件、风险和验证步骤;确认后再修改。 5. 修改后查看 diff,并要求 AI 用普通语言解释“改了什么、为什么、怎样测试”。 6. 在本地或测试环境完成最小有效验证,记录实际结果。 -7. 推送任务分支并发起 PR;不得自行绕过 PR 合并到 `main`。 +7. 推送任务分支并发起 PR;新人不得自行绕过 PR 合并到 `main`。项目负责人和其明确授权的 Codex 按上面的例外执行。 推荐向 AI 提问的模板: @@ -42,9 +42,9 @@ ## 4. PR 与合并 -- 所有任务分支必须通过 PR 合并到 `main`。 +- 新人和普通协作者的任务分支必须通过 PR 合并到 `main`;项目负责人和其明确授权的 Codex 可以作为例外直接合并或推送。 - PR 作者必须填写:做了什么、Codex 改了哪些文件、怎样测试、是否涉及金额/权限/数据库/配置。 -- PR 至少由另一位成员检查后才能批准;作者不得把“AI 已完成”当作测试结果。 +- 新人提交的 PR 至少由另一位成员检查后才能批准;作者不得把“AI 已完成”当作测试结果。项目负责人和其明确授权的 Codex 不受这条审核人数限制,但仍必须完成实际验证并如实记录结果。 - 涉及金额计算、权限、导入、数据库迁移、账户或系统配置时,必须在 PR 中明确标注,并由负责人最终确认。 - 使用仓库默认的 Squash merge(压缩合并)。合并由负责人或指定合并人执行。 - PR 合并后分支自动删除;不要把已合并分支继续用于新任务。 @@ -70,3 +70,45 @@ - 数据库迁移:新人不得自行在共享环境执行;先在 PR 说明影响、验证方式和回滚思路。 - 未完成验证时,PR 必须明确写“未验证”及原因,不能写“应该没问题”。 +## 8. 固定构建与部署规则 + +### 8.1 职责边界 + +- 项目负责人只负责产品方向、验收标准和高风险授权,不负责日常构建、上传、部署命令和服务器维护操作。 +- 新人负责描述任务、检查结果和发起 PR;Codex 负责按本节流程完成环境检查、构建、部署、验证和收口,不得把一串命令丢给项目负责人执行。 +- 正式发布只允许使用已合并的 `main`。非 `main` 只能用于明确授权的测试发布。 +- 生产数据库迁移、数据修复、删除、覆盖配置等高风险动作,仍需项目负责人明确授权。 + +### 8.2 服务器红线 + +- 当前服务器只有 2GB 内存,只负责运行容器、健康检查和镜像切换。 +- 严禁在服务器执行 `docker build`、`docker compose build`、`npm install`、`npm run production`、`composer install` 或其他源码构建命令。 +- `compose.jiangling.yml` 不得出现 `build:`;服务器只能运行开发机已经构建并上传的 `caiwu_akaunting:` 镜像。 +- 服务器保留当前镜像和上一个可回滚镜像,其他本项目旧镜像在发布成功后清理。 +- 服务器发布必须使用互斥锁;同一时间只允许一个人切换版本,禁止并发覆盖。 + +### 8.3 开发机固定流程 + +- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,不得手工拼接构建、打包、上传和启动命令。 +- 镜像标签使用 Git 提交 SHA;工作区有未提交的受控文件时,脚本必须拒绝发布。 +- 使用项目专属 BuildKit 构建器 `caiwu-builder`。禁止日常使用 `--no-cache`、`--pull` 或删除全部 Docker 数据。 +- Dockerfile 必须先复制锁文件再安装依赖:只有 `composer.lock`、`package-lock.json` 或基础镜像定义变化时,才允许对应依赖层失效。 +- 基础镜像、Composer 下载缓存、npm 下载缓存和未变化的构建层应直接复用,不得每次全量下载。 + +### 8.4 磁盘和缓存上限 + +- Docker Desktop 为本项目构建至少分配 8GB 内存,虚拟磁盘上限建议设为 60GB;宿主机至少保留 15GB 可用空间。 +- 发布脚本使用独立缓存并将 BuildKit 缓存控制在 8GB 内,只清理 7 天前的本项目缓存。 +- 开发机和服务器默认各保留 2 个本项目镜像;上传用临时 `image.tar` 无论成功失败都必须删除。 +- 禁止脚本自动执行全局 `docker system prune -a`、删除其他项目镜像、删除卷或重置 Docker Desktop。 +- Docker 虚拟磁盘文件不会因为普通清理立即缩小;日常目标是控制已用空间不再增长,不得让新人自行删除或重建虚拟磁盘。 + +### 8.5 发布安全与验收 + +- SSH 私钥、`runtime/.env`、镜像临时包、数据库密码不得进入 Git 或 Docker 构建上下文。 +- 首次服务器初始化由脚本生成数据库密码和 `APP_KEY`,不在终端、聊天或文档中输出。 +- 数据库迁移默认关闭;检测到新增迁移时发布必须停止,获得负责人授权后才可显式开启。 +- 新镜像必须通过真实 HTTP 页面健康检查后才算发布成功;失败时自动恢复上一个镜像,并保留日志用于排查。 +- 发布完成必须报告:分支、提交 SHA、镜像标签、服务器容器状态、真实页面结果、是否执行迁移、清理结果。 + +详细 SOP 见 `docs/开发与部署SOP.md`。 diff --git a/compose.jiangling.yml b/compose.jiangling.yml index eb1cd13b..7ea79511 100644 --- a/compose.jiangling.yml +++ b/compose.jiangling.yml @@ -1,22 +1,45 @@ +# 服务器只运行本机已加载的镜像,禁止在服务器执行 docker compose build。 services: - app: - build: - context: . - dockerfile: deploy/Dockerfile - image: jiangling_akaunting:local - container_name: jiangling_app + db: + image: mariadb:11.4 + pull_policy: missing + container_name: caiwu_db restart: unless-stopped - network_mode: host + env_file: + - ./runtime/.env + volumes: + - caiwu_database:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + + app: + image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required} + pull_policy: never + container_name: caiwu_app + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0} + ports: + - "80:18088" volumes: - ./runtime/.env:/var/www/html/.env - - jiangling_storage:/var/www/html/storage + - caiwu_storage:/var/www/html/storage healthcheck: - test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/auth/login"] + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/"] interval: 15s timeout: 5s retries: 10 start_period: 45s volumes: - jiangling_storage: - name: jiangling_storage + caiwu_database: + name: caiwu_database + caiwu_storage: + name: caiwu_storage diff --git a/deploy/Dockerfile b/deploy/Dockerfile index fca9cfa9..d3ac9919 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,4 +1,6 @@ -FROM php:8.3-apache-bookworm +# syntax=docker/dockerfile:1.7 + +FROM php:8.3-apache-bookworm AS php-base RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -28,14 +30,57 @@ RUN apt-get update \ && a2enmod expires headers rewrite \ && rm -rf /var/lib/apt/lists/* +FROM php-base AS vendor + +COPY --from=composer:2.8 /usr/bin/composer /usr/local/bin/composer + +WORKDIR /build + +COPY composer.json composer.lock ./ + +RUN --mount=type=cache,target=/tmp/composer-cache \ + COMPOSER_CACHE_DIR=/tmp/composer-cache composer install \ + --prefer-dist \ + --no-dev \ + --no-interaction \ + --no-progress \ + --no-scripts \ + --optimize-autoloader + +FROM node:20-bookworm-slim AS assets + +WORKDIR /build + +COPY package.json package-lock.json ./ + +RUN --mount=type=cache,target=/root/.npm \ + npm ci --no-audit --no-fund + +COPY . . + +RUN NODE_OPTIONS=--max-old-space-size=4096 npm run production + +FROM php-base AS runtime + +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown + +LABEL com.caiwu.project="CaiWuXiTong" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" + WORKDIR /var/www/html COPY . /var/www/html +COPY --from=vendor /build/vendor /var/www/html/vendor +COPY --from=assets /build/public/js /var/www/html/public/js +COPY --from=assets /build/public/css /var/www/html/public/css +COPY --from=assets /build/public/mix-manifest.json /var/www/html/public/mix-manifest.json COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf -COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini -COPY deploy/entrypoint.sh /usr/local/bin/jiangling-entrypoint +COPY deploy/php.ini /usr/local/etc/php/conf.d/caiwu.ini +COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint -RUN chmod +x /usr/local/bin/jiangling-entrypoint \ +RUN chmod +x /usr/local/bin/caiwu-entrypoint \ && mkdir -p \ storage/app/public \ storage/app/temp \ @@ -47,6 +92,5 @@ RUN chmod +x /usr/local/bin/jiangling-entrypoint \ bootstrap/cache \ && chown -R www-data:www-data storage bootstrap/cache -ENTRYPOINT ["jiangling-entrypoint"] +ENTRYPOINT ["caiwu-entrypoint"] CMD ["apache2-foreground"] - diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh new file mode 100644 index 00000000..2f28aac4 --- /dev/null +++ b/deploy/activate-release.sh @@ -0,0 +1,94 @@ +#!/bin/sh +set -eu + +IMAGE_ARCHIVE="${1:?IMAGE_ARCHIVE is required}" +IMAGE_TAG="${2:?IMAGE_TAG is required}" +REMOTE_DIR="${3:-/opt/caiwuxitong}" +DEPLOY_SHA="${4:?DEPLOY_SHA is required}" +RUN_MIGRATIONS="${5:-0}" +KEEP_IMAGES="${6:-2}" +IMAGE_NAME="caiwu_akaunting" +LOCK_FILE="$REMOTE_DIR/.deploy.lock" + +cleanup() { + rm -f "$IMAGE_ARCHIVE" +} + +trap cleanup EXIT INT TERM + +if [ "$(id -u)" -ne 0 ]; then + echo "activate-release.sh must run as root" >&2 + exit 1 +fi + +if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then + echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2 + exit 1 +fi + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "Another CaiWuXiTong deployment is already running" >&2 + exit 1 +fi + +PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)" +PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)" + +docker load --input "$IMAGE_ARCHIVE" +rm -f "$IMAGE_ARCHIVE" + +LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" +if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then + echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2 + exit 1 +fi + +cd "$REMOTE_DIR" +export IMAGE_TAG RUN_MIGRATIONS +docker compose -f compose.jiangling.yml up -d --remove-orphans + +healthy=0 +attempt=1 +while [ "$attempt" -le 30 ]; do + if curl -fsS -o /dev/null http://127.0.0.1/; then + healthy=1 + break + fi + + sleep 5 + attempt=$((attempt + 1)) +done + +if [ "$healthy" -ne 1 ]; then + docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true + + case "$PREVIOUS_IMAGE" in + "$IMAGE_NAME":*) + IMAGE_TAG="${PREVIOUS_IMAGE#*:}" + RUN_MIGRATIONS=0 + export IMAGE_TAG RUN_MIGRATIONS + docker compose -f compose.jiangling.yml up -d app || true + ;; + esac + + echo "Release failed health check; previous image was restored when available" >&2 + exit 1 +fi + +printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp" +mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha" + +docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \ + | awk '!seen[$0]++' \ + | tail -n "+$((KEEP_IMAGES + 1))" \ + | while IFS= read -r old_image; do + [ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true + done + +docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true +docker compose -f compose.jiangling.yml ps +echo "DEPLOYED_SHA=$DEPLOY_SHA" + +# Keep the variable for rollback diagnostics without printing any secret. +: "$PREVIOUS_SHA" diff --git a/deploy/bootstrap-server.sh b/deploy/bootstrap-server.sh new file mode 100644 index 00000000..d1404cf6 --- /dev/null +++ b/deploy/bootstrap-server.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +APP_URL="${1:?APP_URL is required}" +REMOTE_DIR="${2:-/opt/caiwuxitong}" + +if [ "$(id -u)" -ne 0 ]; then + echo "bootstrap-server.sh must run as root" >&2 + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends ca-certificates curl openssl + +install -m 0755 -d /etc/apt/keyrings +if [ ! -f /etc/apt/keyrings/docker.asc ]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc +fi + +. /etc/os-release +ARCH="$(dpkg --print-architecture)" +CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}" + +{ + printf '%s\n' \ + 'Types: deb' \ + 'URIs: https://download.docker.com/linux/ubuntu' \ + "Suites: $CODENAME" \ + 'Components: stable' \ + "Architectures: $ARCH" \ + 'Signed-By: /etc/apt/keyrings/docker.asc' +} > /etc/apt/sources.list.d/docker.sources + +apt-get update +if command -v docker >/dev/null 2>&1; then + apt-get install -y --no-install-recommends docker-buildx-plugin docker-compose-plugin +else + apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi +systemctl enable --now docker + +if ! swapon --show=NAME --noheadings | grep -q .; then + if [ ! -f /swapfile ]; then + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile >/dev/null + fi + + swapon /swapfile + + if ! grep -q '^/swapfile ' /etc/fstab; then + printf '%s\n' '/swapfile none swap sw 0 0' >> /etc/fstab + fi +fi + +install -d -m 700 "$REMOTE_DIR/runtime" + +if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then + DB_PASSWORD="$(openssl rand -hex 24)" + DB_ROOT_PASSWORD="$(openssl rand -hex 24)" + APP_KEY="$(openssl rand -base64 32 | tr -d '\n')" + + umask 077 + { + printf '%s\n' \ + 'APP_NAME=CaiWuXiTong' \ + 'APP_ENV=production' \ + "APP_KEY=base64:$APP_KEY" \ + 'APP_DEBUG=false' \ + 'APP_INSTALLED=false' \ + "APP_URL=$APP_URL" \ + 'APP_LOCALE=zh-CN' \ + 'APP_FALLBACK_LOCALE=en-GB' \ + 'APP_TIMEZONE=Asia/Shanghai' \ + 'LOG_CHANNEL=stack' \ + 'DB_CONNECTION=mysql' \ + 'DB_HOST=db' \ + 'DB_PORT=3306' \ + 'DB_DATABASE=caiwu' \ + 'DB_USERNAME=caiwu' \ + "DB_PASSWORD=$DB_PASSWORD" \ + 'DB_PREFIX=ak_' \ + 'CACHE_DRIVER=file' \ + 'SESSION_DRIVER=file' \ + 'QUEUE_CONNECTION=sync' \ + 'MARIADB_DATABASE=caiwu' \ + 'MARIADB_USER=caiwu' \ + "MARIADB_PASSWORD=$DB_PASSWORD" \ + "MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \ + 'MARIADB_AUTO_UPGRADE=1' + } > "$REMOTE_DIR/runtime/.env" + chmod 600 "$REMOTE_DIR/runtime/.env" +fi + +docker --version +docker compose version +echo "Server runtime is ready: $REMOTE_DIR" diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index dede9119..ab1db11a 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -14,16 +14,23 @@ mkdir -p \ chown -R www-data:www-data storage bootstrap/cache if [ -f .env ]; then - chown root:www-data .env - chmod 640 .env + chown www-data:www-data .env + chmod 660 .env fi if [ "${1:-}" = "apache2-foreground" ]; then sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf php artisan optimize:clear - php artisan migrate --force - php artisan config:cache - php artisan view:cache + php artisan package:discover --ansi + + if grep -Eq '^APP_INSTALLED=(true|1)$' .env; then + if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then + php artisan migrate --force + fi + + php artisan config:cache + php artisan view:cache + fi fi exec "$@" diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md new file mode 100644 index 00000000..7c6751b9 --- /dev/null +++ b/docs/开发与部署SOP.md @@ -0,0 +1,135 @@ +# CaiWuXiTong 开发与部署 SOP + +## 1. 这套流程解决什么问题 + +本项目由 6 位新人使用 Codex 协作开发。项目负责人只确认方向、验收结果和高风险动作,不负责日常命令操作。 + +固定链路如下: + +```text +任务分支开发 -> 本机验证 -> PR -> 合并 main + -> 开发机复用缓存构建镜像 + -> 临时导出并上传镜像 + -> 2GB 服务器只加载和切换镜像 + -> 真实页面健康检查 + -> 保留当前与上一个镜像,清理其余本项目产物 +``` + +服务器永远不构建源码。 + +## 2. 每台开发机只做一次的设置 + +1. 安装并启动 Docker Desktop,使用 WSL2 后端。 +2. 在 Docker Desktop 的 `Settings -> Resources -> Advanced` 中: + - 构建可用内存至少 8GB; + - Docker 虚拟磁盘上限建议 60GB; + - Windows 磁盘至少保留 15GB 可用空间。 +3. SSH 私钥放到个人目录,例如 `C:\Users\你的用户名\.ssh\caiwu.pem`,不要放进 Git。 +4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。 + +说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。 + +## 3. 为什么后续不会反复全量下载 + +项目使用专属构建器 `caiwu-builder`,缓存与其他项目分开: + +- PHP 扩展层只在 Dockerfile 的基础环境变化时重建; +- Composer 只在 `composer.lock` 变化时重新解析依赖,并复用下载缓存; +- npm 只在 `package-lock.json` 变化时重新安装依赖,并复用下载缓存; +- 业务代码变化只重新执行后面的代码复制和资源构建; +- 日常命令不带 `--pull` 和 `--no-cache`。 + +首次构建必然较慢;后续构建应大量显示 `CACHED`。 + +## 4. 首次初始化服务器 + +首次发布由 Codex 或当班开发者在仓库根目录执行: + +```powershell +.\scripts\deploy-from-workstation.ps1 ` + -Server 118.196.84.79 ` + -KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" ` + -BootstrapServer +``` + +初始化脚本会: + +- 安装并启动 Docker 与 Compose; +- 为 2GB 服务器增加 2GB swap(交换空间),仅用于运行时缓冲; +- 创建 `/opt/caiwuxitong/runtime/.env`; +- 在服务器本地生成数据库密码和 Laravel `APP_KEY`,不会打印出来; +- 创建持久化数据库卷和上传文件卷。 + +首次打开 `http://118.196.84.79` 后,由负责人或指定人员在网页安装向导中设置管理员账号和密码。 + +## 5. 日常正式发布 + +前提:PR 已审核并合并,当前为干净的 `main`。 + +```powershell +git switch main +git pull --ff-only + +.\scripts\deploy-from-workstation.ps1 ` + -Server 118.196.84.79 ` + -KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" +``` + +脚本自动完成: + +1. 检查分支和未提交改动; +2. 使用提交 SHA 构建镜像; +3. 复用项目专属缓存; +4. 导出临时镜像包并上传; +5. 服务器加载镜像并启动; +6. 检查真实 HTTP 页面; +7. 失败时恢复上一个镜像; +8. 使用服务器互斥锁阻止两个人同时切换版本; +9. 删除本地和服务器临时镜像包; +10. 两端仅保留当前和上一个本项目镜像; +11. 清理 7 天前的本项目构建缓存,并将总量压到最多 8GB。 + +## 6. 数据库迁移 + +默认禁止自动迁移。脚本发现上次部署后新增了 `database/migrations` 文件时会停止。 + +只有负责人明确批准、PR 已写清影响与回滚思路后,才允许: + +```powershell +.\scripts\deploy-from-workstation.ps1 ` + -Server 118.196.84.79 ` + -KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" ` + -AllowMigrations +``` + +镜像回滚不能自动撤销已经执行的数据库结构变更,因此迁移授权必须单独处理。 + +## 7. 测试分支发布 + +正式服务器默认只允许 `main`。确需临时测试时,必须得到明确授权,并添加: + +```powershell +-AllowNonMain +``` + +测试完成后仍要回到 `main` 再正式发布,不能把测试分支当长期生产版本。 + +## 8. 禁止事项 + +- 禁止登录服务器运行 `docker build` 或 `docker compose build`; +- 禁止在服务器运行 Composer、npm 或前端打包; +- 禁止把 `.pem`、`.env`、密码、数据库文件或 `image.tar` 提交到 Git; +- 禁止为了省空间删除 Docker 卷; +- 禁止全局清空 Docker,因为同一台电脑可能还有其他项目; +- 禁止跳过健康检查后口头说“部署完成”。 + +## 9. 发布完成时应报告 + +- 发布分支和完整 Git SHA; +- 镜像标签; +- 服务器 `caiwu_app`、`caiwu_db` 状态; +- `http://118.196.84.79` 的实际返回结果; +- 是否执行数据库迁移; +- 本地临时包是否删除; +- 本地/服务器保留了几个本项目镜像; +- BuildKit 缓存当前占用。 diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 new file mode 100644 index 00000000..08bdde6b --- /dev/null +++ b/scripts/deploy-from-workstation.ps1 @@ -0,0 +1,185 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Server, + + [Parameter(Mandatory = $true)] + [string]$KeyPath, + + [string]$SshUser = 'root', + [string]$RemotePath = '/opt/caiwuxitong', + [string]$AppUrl, + [string]$BuilderName = 'caiwu-builder', + [int]$KeepImages = 2, + [switch]$BootstrapServer, + [switch]$AllowNonMain, + [switch]$AllowMigrations +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$keyFullPath = (Resolve-Path $KeyPath).Path +$sshTarget = "${SshUser}@${Server}" +$imageName = 'caiwu_akaunting' +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N')) +$localArchive = Join-Path $tempRoot 'image.tar' +$remoteArchive = $null +$bootstrapRemote = $null + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Command, + [Parameter(Mandatory = $true)] + [string]$FailureMessage + ) + + & $Command + if ($LASTEXITCODE -ne 0) { + throw $FailureMessage + } +} + +function Remove-OldProjectImages { + param([string]$CurrentImage) + + $projectImages = @( + docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' | + Where-Object { $_ -and $_ -ne ':' } | + Select-Object -Unique + ) + + $keep = @($CurrentImage) + foreach ($candidate in $projectImages) { + if ($keep.Count -ge $KeepImages) { break } + if ($candidate -notin $keep) { $keep += $candidate } + } + + foreach ($candidate in $projectImages) { + if ($candidate -notin $keep) { + docker image rm $candidate 2>$null | Out-Null + } + } +} + +Push-Location $repoRoot +try { + $branch = (git branch --show-current).Trim() + if (-not $AllowNonMain -and $branch -ne 'main') { + throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。" + } + + $trackedChanges = git status --porcelain --untracked-files=no + if ($trackedChanges) { + throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。' + } + + $deploySha = (git rev-parse HEAD).Trim() + $imageTag = $deploySha.Substring(0, 12) + $fullImage = "${imageName}:$imageTag" + $remoteArchive = "$RemotePath/image-$imageTag.tar" + $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + + if (-not $AppUrl) { + $AppUrl = "http://$Server" + } + + Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。' + + docker buildx inspect $BuilderName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Invoke-Native { + docker buildx create --name $BuilderName --driver docker-container --use | Out-Null + } '创建项目专属 BuildKit 构建器失败。' + } + + Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。' + + Invoke-Native { + docker buildx build ` + --builder $BuilderName ` + --load ` + --tag $fullImage ` + --build-arg "VCS_REF=$deploySha" ` + --build-arg "BUILD_DATE=$buildDate" ` + --label 'com.caiwu.project=CaiWuXiTong' ` + --file deploy/Dockerfile ` + . + } '本机镜像构建失败。服务器未发生任何变更。' + + $previousSha = (& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true").Trim() + if ($LASTEXITCODE -ne 0) { + throw '无法连接服务器。' + } + + $previousShaExists = $false + if ($previousSha) { + git cat-file -e "${previousSha}^{commit}" 2>$null + $previousShaExists = $LASTEXITCODE -eq 0 + } + + if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) { + throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch,或在负责人授权后使用 -AllowMigrations。" + } + + if ($previousSha -and $previousShaExists) { + $migrationChanges = git diff --name-only "$previousSha..$deploySha" -- database/migrations + if ($migrationChanges -and -not $AllowMigrations) { + throw "检测到数据库迁移:$($migrationChanges -join ', ')。需负责人确认后添加 -AllowMigrations。" + } + } + + New-Item -ItemType Directory -Path $tempRoot | Out-Null + + if ($BootstrapServer) { + $bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh" + Invoke-Native { + scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote" + } '上传服务器初始化脚本失败。' + Invoke-Native { + ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'" + } '服务器初始化失败。' + } + + Invoke-Native { + ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'" + } '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。' + + Invoke-Native { docker save --output $localArchive $fullImage } '导出镜像失败。' + Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。' + Invoke-Native { scp -i $keyFullPath -o BatchMode=yes compose.jiangling.yml deploy/activate-release.sh "${sshTarget}:$RemotePath/" } '上传运行配置失败。' + + $migrationFlag = if ($AllowMigrations) { '1' } else { '0' } + Invoke-Native { + ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/activate-release.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'" + } '服务器激活或健康检查失败。' + + Remove-OldProjectImages -CurrentImage $fullImage + Write-Host "发布完成:$fullImage -> $AppUrl" +} +finally { + Pop-Location + + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + + if ($remoteArchive) { + ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null + } + + if ($bootstrapRemote) { + ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null + } + + $pruneHelp = docker buildx prune --help 2>$null | Out-String + docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null + if ($pruneHelp -match '--max-used-space') { + docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null + } + elseif ($pruneHelp -match '--keep-storage') { + docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null + } + + docker system df +}