Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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 deploy/ca_result.py status)
echo " 状态: $STATUS"
case "$STATUS" in
Running|Pending) sleep 15 ;;
*) break ;;
esac
done

# 打印输出(用仓库内脚本解析,避免内联多行 Python 顶断 YAML 缩进)
aliyun ecs DescribeInvocationResults --profile ecs-deploy \
--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 || {
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 ""
46 changes: 46 additions & 0 deletions deploy/ca_result.py
Original file line number Diff line number Diff line change
@@ -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())
100 changes: 100 additions & 0 deletions deploy/deploy-java.sh
Original file line number Diff line number Diff line change
@@ -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 ==="
Loading