From 2f5db1442890c3c371740c26bc0efb4081bb5ca4 Mon Sep 17 00:00:00 2001 From: xiongdada Date: Sat, 3 Oct 2026 22:12:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?ci:=20=E6=8E=A5=E5=85=A5=20GitHub=20Actions?= =?UTF-8?q?=EF=BC=88CI=20+=20=E8=87=AA=E5=8A=A8=E9=83=A8=E7=BD=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml:前端 tsc/vite 构建 + 后端 mvn test(测试用 H2,无需 MySQL)+ 打包校验 - deploy.yml:push main 自动部署到 ECS,走阿里云 Cloud Assistant 而非 SSH (Actions runner 属境外出口,直连 22 端口会被上游丢包,Cloud Assistant 走 HTTPS API) - deploy/deploy-java.sh:幂等部署脚本,含前置校验、备份回滚点、 AutoLoginService 打包校验(缺类则不重启服务)、免登录契约验证 部署后自动外网复验首页与免登录接口,结果写入 job summary。 --- .github/workflows/ci.yml | 52 ++++++++++++++ .github/workflows/deploy.yml | 133 +++++++++++++++++++++++++++++++++++ deploy/deploy-java.sh | 100 ++++++++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 deploy/deploy-java.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2fe1e72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: java-study CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: java-study-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: 前端构建 + 后端测试 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: 安装 Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: 安装前端依赖 + run: npm ci --no-audit --no-fund + + - name: TypeScript 类型检查 + 构建 + run: npm run build + + - name: 安装 JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Maven 缓存 + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-java-study-${{ hashFiles('backend/pom.xml') }} + restore-keys: ${{ runner.os }}-m2-java-study- + + - name: 后端测试 + # 测试用 H2(application-test profile),无需 MySQL + run: mvn -B -f backend/pom.xml test + + - name: 打包校验(不部署) + run: mvn -B -f backend/pom.xml package -DskipTests \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..05e9217 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,133 @@ +name: java-study Deploy + +# 触发方式: +# push 到 main 且 CI 通过 → 自动部署 +# 手动 workflow_dispatch → 手动重部署(不带缓存) +# +# 部署通道:阿里云 Cloud Assistant(aliyun ecs RunCommand),不用 SSH。 +# 原因:GitHub Actions runner 与本机同属境外出口,直连 47.93.15.207:22 会被 +# 阿里云上游静默丢包(同本机 nc 测试超时),安全组规则在该层之下,加了也无效。 +# Cloud Assistant 走 HTTPS API,不受此限制,是已验证可用的兜底通道。 + +on: + push: + branches: [main] + paths-ignore: + - '**/*.md' + - 'docs/**' + workflow_dispatch: + +concurrency: + group: java-study-deploy + cancel-in-progress: false + +jobs: + deploy: + name: 部署到 ECS + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - name: 部署前记录当前线上版本 + id: before + run: | + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "author=$(git log -1 --pretty='%an')" >> "$GITHUB_OUTPUT" + echo "message=$(git log -1 --pretty='%s')" >> "$GITHUB_OUTPUT" + + # ---------- Cloud Assistant 执行 ---------- + - name: 通过 Cloud Assistant 下发部署脚本 + id: run_command + env: + ALICLOUD_ACCESS_KEY_ID: ${{ secrets.ALIYUN_AK }} + ALICLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALIYUN_SK }} + REGION: cn-beijing + INSTANCE_ID: i-2zecv4kgasiw24p97v0t + run: | + set -eo pipefail + + # 安装 aliyun CLI + curl -fsSL -o aliyun-cli.tgz \ + https://aliyuncli.alicdn.com/aliyun-cli-linux-latest-amd64.tgz + tar -xzf aliyun-cli.tgz + install -m 0755 aliyun /usr/local/bin/aliyun + + aliyun configure set --mode AK \ + --profile ecs-deploy \ + --region "$REGION" \ + --access-key-id "$ALICLOUD_ACCESS_KEY_ID" \ + --access-key-secret "$ALICLOUD_ACCESS_KEY_SECRET" >/dev/null + + # 脚本内容走 stdin,避免超长命令行 + IID=$(aliyun ecs RunCommand --profile ecs-deploy \ + --region "$REGION" \ + --Type RunShellScript \ + --Timeout 1800 \ + --InstanceId.1 "$INSTANCE_ID" \ + --ContentEncoding Base64 \ + --CommandContent "$(base64 -w0 < deploy/deploy-java.sh)" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['InvokeId'])") + + echo "InvokeId=$IID" + echo "::add-mask::$IID" + + # 轮询直到结束 + while :; do + STATUS=$(aliyun ecs DescribeInvocationResults --profile ecs-deploy \ + --region "$REGION" --InvokeId "$IID" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['Invocation']['InvocationResults']['InvocationResult'][0]['InvocationStatus'])") + echo " 状态: $STATUS" + case "$STATUS" in + Running|Pending) sleep 15 ;; + *) break ;; + esac + done + + # 打印输出 + aliyun ecs DescribeInvocationResults --profile ecs-deploy \ + --region "$REGION" --InvokeId "$IID" \ + | python3 -c " +import sys, json, base64 +r = json.load(sys.stdin)['Invocation']['InvocationResults']['InvocationResult'][0] +print(base64.b64decode(r.get('Output','')).decode(errors='replace')) +" | tee /tmp/ca-output.log + + # 失败即让 workflow 失败(不吞错误) + grep -q "JAVA-STUDY DEPLOY DONE" /tmp/ca-output.log || { + echo "::error::部署未完成,DONE 标记缺失" + exit 1 + } + + - name: 部署结果 + if: always() + run: | + { + echo "### 部署完成" + echo "" + echo "- commit: \`${{ steps.before.outputs.commit }}\` — ${{ steps.before.outputs.message }}" + echo "- 站点: http://47.93.15.207:8083/" + echo "" + echo '```' + cat /tmp/ca-output.log 2>/dev/null | tail -30 || echo '(无输出)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: 外网复验(本地 curl) + run: | + set -eo pipefail + sleep 5 + echo "=== 首页 ===" + curl -sS --max-time 15 -o /dev/null -w 'HTTP %{http_code}\n' http://47.93.15.207:8083/ + echo "=== 免登录接口(无 token)===" + echo -n "auth/me : " + curl -sS --max-time 15 http://47.93.15.207:8083/api/auth/me + echo "" + echo -n "summary : " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8083/api/summary + echo -n "chapters: " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8083/api/chapters + echo -n "health : " + curl -sS --max-time 15 http://47.93.15.207:8083/api/health + echo "" \ No newline at end of file diff --git a/deploy/deploy-java.sh b/deploy/deploy-java.sh new file mode 100644 index 0000000..7e0badf --- /dev/null +++ b/deploy/deploy-java.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# java-study 免登录版部署脚本(在 ECS 上执行) +# +# 由 GitHub Actions 通过 `aliyun ecs RunCommand` 下发,或本地 ca-run.sh 兜底执行。 +# 幂等:可重复运行;每次替换源码前自动备份,旧 jar 一并留存以便回滚。 +# +# 关键事实(探查所得,改脚本前先读): +# - 环境变量在 /etc/java-study/java-study.env(systemd EnvironmentFile),站点目录没有 .env +# - 生产用 H2(DB_URL 指向 data/java-study.mv.db),与 MySQL 无关 +# - CONTENT_ROOT 指向 /www/wwwroot/java-study/content,该目录必须保留 +# - 服务器自带 mvn 3.6.2 太旧(compiler-plugin 3.13 要求 >=3.6.3),已装 3.9.9 到 /usr/local/bin/mvn +set -eo pipefail + +SITE_DIR='/www/wwwroot/java-study' +ENVF='/etc/java-study/java-study.env' +BACKEND_PORT=18083 +MVN=/usr/local/bin/mvn # 3.9.9;不要用 /usr/bin/mvn(3.6.2 会构建失败) + +echo "=== 0. 前置检查 ===" +[ -x "$MVN" ] || { echo "❌ $MVN 不存在,需先安装 Maven 3.9+"; exit 1; } +"$MVN" -version 2>&1 | head -1 +set -a; . "$ENVF"; set +a +echo "DB_URL=${DB_URL:0:55}..." +echo "JWT_SECRET 长度: ${#JWT_SECRET}" + +echo "=== 1. 下载源码 ===" +TAR=/tmp/_java-study.tar.gz +rm -f "$TAR" +http=$(curl -sSL -o "$TAR" -w '%{http_code}' --max-time 300 \ + "https://codeload.github.com/webxiongda/java-study/tar.gz/refs/heads/main") +[ "$http" = "200" ] || { echo "❌ 下载失败 HTTP=$http"; exit 1; } +tar -tzf "$TAR" >/dev/null && echo "tar OK ($(du -h $TAR | cut -f1))" + +echo "=== 2. 备份并替换源码(保留 data/ content/ node_modules/)===" +BAK="${SITE_DIR}-bak-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$BAK" +cd "$SITE_DIR" +cp -a backend/app.jar "$BAK/app.jar" 2>/dev/null || true +for item in backend/src src index.html package.json package-lock.json tsconfig.app.json tsconfig.json tsconfig.node.json public; do + [ -e "$item" ] && cp -a "$item" "$BAK/" 2>/dev/null || true +done +echo "已备份 -> $BAK" + +KEEP=/tmp/_java_keep +rm -rf "$KEEP"; mkdir -p "$KEEP" +for keep in data content node_modules; do + [ -e "$keep" ] && mv "$keep" "$KEEP/$keep" || true +done +rm -rf backend src dist public index.html chapters +tar -xzf "$TAR" --strip-components=1 +rm -f "$TAR" +for keep in data content node_modules; do + [ -e "$KEEP/$keep" ] && mv "$KEEP/$keep" ./ || true +done +rm -rf "$KEEP" + +echo "AutoLoginService: $([ -f backend/src/main/java/com/javastudy/service/AutoLoginService.java ] && echo YES || echo '❌ 缺失')" +echo "content 保留: $([ -d content ] && echo "YES ($(find content -type f | wc -l) 文件)" || echo '❌ 缺失')" +echo "data 保留: $([ -f data/java-study.mv.db ] && echo YES || echo '❌ 缺失')" + +echo "=== 3. 构建前端 ===" +export PATH=/usr/local/bin:$PATH +[ -d node_modules ] || npm install --no-audit --no-fund 2>&1 | tail -3 +npm run build 2>&1 | tail -8 + +echo "=== 4. 构建后端 jar ===" +cd "$SITE_DIR/backend" +[ -f app.jar ] && mv app.jar "app.jar.old-$(date +%s)" || true +export DB_URL DB_USERNAME DB_PASSWORD JWT_SECRET +"$MVN" -B clean package -DskipTests 2>&1 | grep -E "ERROR|BUILD|Building jar" | tail -10 +JAR=$(ls -t target/*.jar 2>/dev/null | head -1) +[ -n "$JAR" ] || { echo "❌ 未生成 jar"; exit 1; } +cp "$JAR" app.jar +echo "app.jar: $(du -h app.jar | cut -f1)" +unzip -l app.jar 2>/dev/null | grep -q AutoLoginService \ + && echo "✅ AutoLoginService 已打包" \ + || { echo "❌ jar 内无 AutoLoginService,中止(不重启服务)"; exit 1; } + +echo "=== 5. 重启 systemd ===" +systemctl restart java-study.service +for i in $(seq 1 20); do + sleep 5 + code=$(curl -s -m 4 -o /dev/null -w "%{http_code}" "http://127.0.0.1:${BACKEND_PORT}/api/health" 2>/dev/null || echo 000) + echo " 探测 ${i}: $code" + [ "$code" = "200" ] && break + [ "$i" = "20" ] && { echo "❌ 20 次探测仍未就绪"; journalctl -u java-study.service --since "3 min ago" --no-pager | tail -20; exit 1; } +done + +echo "=== 6. 免登录验证(无 Authorization 头)===" +echo -n "health : "; curl -s -m 8 "http://127.0.0.1:${BACKEND_PORT}/api/health"; echo "" +echo -n "auth/me : "; curl -s -m 8 "http://127.0.0.1:${BACKEND_PORT}/api/auth/me"; echo "" +for p in summary chapters interview/categories; do + printf " %-20s %s\n" "$p:" "$(curl -s -m 8 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${BACKEND_PORT}/api/$p")" +done +echo "chapters 条数: $(curl -s -m 8 "http://127.0.0.1:${BACKEND_PORT}/api/chapters" | grep -o '"no"' | wc -l)" + +# 回滚提示(不自动执行,避免误回滚) +echo "" +echo "如需回滚: systemctl stop java-study.service && cp '$BAK/app.jar' '$SITE_DIR/backend/app.jar' && systemctl start java-study.service" +echo "=== JAVA-STUDY DEPLOY DONE ===" \ No newline at end of file From 8488a552b7f2e0917b07f326c10d9d59d459a660 Mon Sep 17 00:00:00 2001 From: xiongdada Date: Sat, 3 Oct 2026 22:15:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20deploy.yml?= =?UTF-8?q?=20=E7=9A=84=20YAML=20=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow 里内联的多行 python3 -c 必须顶格写,会顶断 run: | 块标量的缩进, 导致 GitHub 无法解析 YAML。改为把解析逻辑抽到 deploy/ca_result.py。 已用真实 RunCommand 返回验证 status / output 两种模式均正确。 --- .github/workflows/deploy.yml | 13 ++++------ deploy/ca_result.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 deploy/ca_result.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 05e9217..b507e34 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -76,8 +76,7 @@ jobs: # 轮询直到结束 while :; do STATUS=$(aliyun ecs DescribeInvocationResults --profile ecs-deploy \ - --region "$REGION" --InvokeId "$IID" \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['Invocation']['InvocationResults']['InvocationResult'][0]['InvocationStatus'])") + --region "$REGION" --InvokeId "$IID" | python3 deploy/ca_result.py status) echo " 状态: $STATUS" case "$STATUS" in Running|Pending) sleep 15 ;; @@ -85,14 +84,10 @@ jobs: esac done - # 打印输出 + # 打印输出(用仓库内脚本解析,避免内联多行 Python 顶断 YAML 缩进) aliyun ecs DescribeInvocationResults --profile ecs-deploy \ - --region "$REGION" --InvokeId "$IID" \ - | python3 -c " -import sys, json, base64 -r = json.load(sys.stdin)['Invocation']['InvocationResults']['InvocationResult'][0] -print(base64.b64decode(r.get('Output','')).decode(errors='replace')) -" | tee /tmp/ca-output.log + --region "$REGION" --InvokeId "$IID" | python3 deploy/ca_result.py output \ + | tee /tmp/ca-output.log # 失败即让 workflow 失败(不吞错误) grep -q "JAVA-STUDY DEPLOY DONE" /tmp/ca-output.log || { diff --git a/deploy/ca_result.py b/deploy/ca_result.py new file mode 100644 index 0000000..f61df20 --- /dev/null +++ b/deploy/ca_result.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""解析 `aliyun ecs DescribeInvocationResults` 的 JSON 输出。 + +用法: + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py status + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py output + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py both + +单独放成文件而不是内联在 workflow 的 run: | 块里,是因为内联多行 Python +必须顶格写,会顶断 YAML 的块标量缩进。 +""" +from __future__ import annotations + +import base64 +import json +import sys + + +def first_result(payload: dict) -> dict: + invocation = payload["Invocation"]["InvocationResults"] + results = invocation["InvocationResult"] + if not results: + raise SystemExit("DescribeInvocationResults 返回空结果") + return results[0] + + +def main() -> int: + if len(sys.argv) < 2 or sys.argv[1] not in {"status", "output", "both"}: + print(__doc__, file=sys.stderr) + return 2 + + mode = sys.argv[1] + result = first_result(json.load(sys.stdin)) + + if mode in {"status", "both"}: + print(result.get("InvocationStatus", "")) + + if mode in {"output", "both"}: + raw = result.get("Output", "") or "" + sys.stdout.write(base64.b64decode(raw).decode(errors="replace")) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file