diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..ca3da1f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,164 @@ +name: 构建并部署 VoiceHub +run-name: ${{ gitea.actor }} 触发部署 #${{{{ gitea.run_number }}}} + +on: + push: + branches: + - main + - master + # 允许在 Gitea 页面手动点「运行工作流」 + workflow_dispatch: + inputs: + deploy_target: + description: 是否在构建后自动部署到服务器 + type: boolean + default: true + required: false + +# 同一时刻只跑一个部署,避免部署竞态 +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + # ====== 按需修改(改成你的 Gitea 域名,不要 http:// 前缀) ====== + GITEA_REGISTRY: gitea.example.com + # ====== 一般不改 ====== + IMAGE_NAME: byw-voicehub + DEPLOY_PATH: /opt/byw-voicehub + NODE_VERSION: "22" + +jobs: + # ============================================================ + # Job 1: 构建 & 推送 Docker 镜像到 Gitea 自带容器仓库 + # ============================================================ + build-image: + name: 构建 Docker 镜像 + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.tag }} + image_full: ${{ steps.meta.outputs.full }} + steps: + - name: 📥 拉取代码 + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: 🔧 设置 Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmmirror.com # 国内加速,不需要可删 + + - name: 🧪 环境校验 + run: | + set -euxo pipefail + node -v + npm -v + npm ci --no-audit --no-fund + + - name: 🔐 登录 Gitea 容器仓库 + uses: docker/login-action@v3 + with: + registry: ${{ env.GITEA_REGISTRY }} + username: ${{ gitea.repository_owner }} + password: ${{ secrets.REGISTRY_TOKEN || gitea.token }} + + - name: 🏷️ 生成镜像标签 + id: meta + run: | + set -euo pipefail + SHORT_SHA="${GITEA_SHA:0:7}" + DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}" + FULL="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:${DATE_TAG}" + LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest" + echo "tag=${DATE_TAG}" >> "$GITEA_OUTPUT" + echo "full=${FULL}" >> "$GITEA_OUTPUT" + echo "latest=${LATEST}" >> "$GITEA_OUTPUT" + echo "✅ 镜像 tag: ${DATE_TAG}" + echo "✅ 镜像完整: ${FULL}" + + - name: 🐳 构建并推送镜像(BuildKit 多阶段 + 缓存) + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.full }} + ${{ steps.meta.outputs.latest }} + cache-from: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 + labels: | + org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }} + org.opencontainers.image.revision=${{ gitea.sha }} + + # ============================================================ + # Job 2: SSH 到服务器拉取新镜像并重启 + # ============================================================ + deploy: + name: 部署到服务器 + needs: build-image + if: ${{ !cancelled() && !failure() && (inputs.deploy_target == true || github.event_name == 'push') }} + runs-on: ubuntu-latest + environment: production + steps: + - name: 🌐 SSH 部署到生产服务器 + uses: https://github.com/appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || 22 }} + script_stop: true + command_timeout: 30m + script: | + set -euxo pipefail + + # 1) 首次部署时自动创建目录 & 空的 docker-compose.yml/.env + mkdir -p "${DEPLOY_PATH}" + cd "${DEPLOY_PATH}" + + # 2) 登录 Gitea 镜像仓库(用一个仅 pull 权限的 token 更安全) + echo "${{ secrets.REGISTRY_PULL_TOKEN || gitea.token }}" \ + | docker login "${GITEA_REGISTRY}" -u "${{ secrets.REGISTRY_PULL_USER || gitea.repository_owner }}" --password-stdin 2>/dev/null || true + + # 3) 拉最新镜像 + IMAGE_LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest" + docker pull "$IMAGE_LATEST" + + # 4) 写一个最小的 docker-compose.override.yml,指向刚拉的镜像 + # (避免改动服务器上已配置好的 docker-compose.yml 和 .env) + cat > docker-compose.override.yml <<'YAML' + services: + voicehub: + image: __REPLACE_WITH_IMAGE__ + YAML + sed -i "s|__REPLACE_WITH_IMAGE__|${IMAGE_LATEST}|g" docker-compose.override.yml + + # 5) 启动(如果没有 docker-compose.yml 就自动从你的仓库默认模板里下载一份) + if [ ! -f docker-compose.yml ]; then + echo "ℹ️ 首次部署:写入默认 docker-compose.yml 模板(请稍后在服务器上编辑 .env)" + curl -fsSL "${{ gitea.server_url }}/${{ gitea.repository }}/raw/branch/main/docker-compose.yml" \ + -o docker-compose.yml || echo "下载失败,跳过(你需要手动上传 docker-compose.yml)" + fi + + # 6) 健康拉取 + 滚动重启(先拉好再 up,downtime 最小) + docker compose pull --quiet voicehub 2>/dev/null || true + docker compose up -d --remove-orphans + + # 7) 等健康检查通过,最多等 120s + for i in $(seq 1 24); do + if docker compose ps --format json 2>/dev/null | grep -q '"Health":"healthy"'; then + echo "✅ 容器健康" + break + fi + sleep 5 + done + + # 8) 清理旧镜像(只留最近 2 个 tag,省磁盘) + docker image prune -af --filter "until=72h" || true + + echo "🎉 部署完成!镜像=${{ needs.build-image.outputs.image_full }}" diff --git a/docker-compose.yml b/docker-compose.yml index c1ed74d..f89810e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,57 +1,159 @@ +name: BYW VoiceHub (1.7.0.0) services: + # ======================================================================== + # 应用本体 - Nuxt 4 (Node SSR) + # - 使用两阶段构建 Dockerfile,构建阶段内存友好 + # - PREBUILT=true:告诉 scripts/deploy.js 跳过 npm install 和 nuxt build + # (因为第一阶段已经 build 好了,产物在 .output/ 里) + # ======================================================================== voicehub: - build: . + image: byw-voicehub:1.7.0.0 + build: + context: . + dockerfile: Dockerfile + args: + BUILDKIT_INLINE_CACHE: "1" + container_name: voicehub-app + restart: unless-stopped ports: - - '4456:3000' - environment: - - DATABASE_URL=postgresql://user:password@postgres:5432/voicehub - - JWT_SECRET=your-jwt-secret-here - - NODE_ENV=production - - NODE_OPTIONS=--max-old-space-size=512 - - SKIP_BUILD=true - mem_limit: 1200m - mem_reservation: 256m - cpus: '1.0' - deploy: - resources: - limits: - cpus: '1' - memory: 1200M - reservations: - cpus: '0.25' - memory: 256M + # 左边是宿主机端口(对外),右边是容器内端口(固定 3000) + # 如果要套 Nginx,就只监听 127.0.0.1:4456:3000 不直接暴露外网 + - "4456:3000" depends_on: postgres: condition: service_healthy - restart: unless-stopped - - postgres: - image: postgres:15-alpine + redis: + condition: service_healthy + env_file: + # 把密码、密钥都写在项目根的 .env 里,不要直接写进这个 yaml + - .env environment: - - POSTGRES_USER=user - - POSTGRES_PASSWORD=password - - POSTGRES_DB=voicehub - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - '5433:5432' - mem_limit: 1200m - mem_reservation: 256m - cpus: '1.0' + # ===== 运行时基础(可以被 .env 覆盖) ===== + NODE_ENV: production + PORT: 3000 + HOST: 0.0.0.0 + PREBUILT: "true" + SKIP_INSTALL: "true" + SKIP_BUILD: "true" + NITRO_PRESET: node-server + + # ===== 数据库连接串(.env 写了就用 .env,没写就自动拼 compose 内部 postgres 服务) ===== + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-voicehub}:${POSTGRES_PASSWORD:-voicehub}@postgres:5432/${POSTGRES_DB:-voicehub}?sslmode=disable} + + # ===== Redis 连接串(同上,自动拼内部 redis 服务) ===== + REDIS_URL: ${REDIS_URL:-redis://${REDIS_PASSWORD:+:${REDIS_PASSWORD}@}redis:6379/0} + + # ===== 会话/Token(必须在 .env 里设,这里给一个占位提醒) ===== + JWT_SECRET: ${JWT_SECRET:-请在.env里设置JWT_SECRET用openssl rand -hex 32生成} + + # ===== 应用默认(可在 .env 覆盖,不会覆盖 .env 里同名字段) ===== + NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://127.0.0.1:4456} + NUXT_PUBLIC_SITE_TITLE: ${NUXT_PUBLIC_SITE_TITLE:-校园广播站点歌系统} + NUXT_PUBLIC_DEFAULT_THEME: ${NUXT_PUBLIC_DEFAULT_THEME:-dark} + NUXT_PUBLIC_ENABLED_THEMES: ${NUXT_PUBLIC_ENABLED_THEMES:-dark,light} + + # ===== 日志/性能 ===== + NODE_OPTIONS: ${NODE_OPTIONS:-"--max-old-space-size=768"} + # 2C4G 机器的推荐配置(.env 可覆盖);小机器 1C2G 也能跑 + mem_limit: ${APP_MEM_LIMIT:-1400m} + mem_reservation: ${APP_MEM_RESERVATION:-384m} + cpus: ${APP_CPUS:-"1.5"} deploy: resources: limits: - cpus: '1' - memory: 1200M + cpus: ${APP_CPUS_LIMIT:-"2"} + memory: ${APP_MEM_LIMIT:-1400M} reservations: - cpus: '0.25' - memory: 256M + cpus: ${APP_CPUS_RESERVATION:-"0.5"} + memory: ${APP_MEM_RESERVATION:-384M} healthcheck: - test: ['CMD-SHELL', 'pg_isready -U user -d voicehub'] + # 30s 后开始检查,每 15s 一次,连续 3 次失败就重启 + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))" + ] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + networks: + - voicehub-net + volumes: + # (可选)把自定义 logo/上传资源挂到宿主机 + - voicehub-uploads:/app/app/public/uploads:rw + + # ======================================================================== + # PostgreSQL 16 - 主数据库 + # ======================================================================== + postgres: + image: postgres:16-alpine + container_name: voicehub-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-voicehub} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-请在.env里改这个密码} + POSTGRES_DB: ${POSTGRES_DB:-voicehub} + PGDATA: /var/lib/postgresql/data/pgdata + # .env 里没设 DATABASE_URL 时,自动拼一份给 app 用 + # (如果 .env 里已经写了外部 Postgres,就以 .env 为准) + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-voicehub} -d ${POSTGRES_DB:-voicehub}"] interval: 5s timeout: 5s - retries: 5 + retries: 10 + volumes: + - postgres-data:/var/lib/postgresql/data + # 如需从宿主机用 Navicat 连,取消下一行注释 + # ports: + # - "127.0.0.1:5433:5432" + mem_limit: ${PG_MEM_LIMIT:-1200m} + mem_reservation: ${PG_MEM_RESERVATION:-256m} + cpus: ${PG_CPUS:-"1.0"} + networks: + - voicehub-net + + # ======================================================================== + # Redis 7 - 缓存 / 会话 / 限流 / 队列 + # ======================================================================== + redis: + image: redis:7-alpine + container_name: voicehub-redis restart: unless-stopped + command: > + sh -c " + if [ -n \"$$REDIS_PASSWORD\" ]; then + redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\" + else + redis-server --appendonly yes + fi + " + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - redis-data:/data + mem_limit: ${REDIS_MEM_LIMIT:-384m} + mem_reservation: ${REDIS_MEM_RESERVATION:-64m} + cpus: ${REDIS_CPUS:-"0.5"} + networks: + - voicehub-net volumes: - postgres_data: + postgres-data: + name: voicehub-postgres-data + redis-data: + name: voicehub-redis-data + voicehub-uploads: + name: voicehub-uploads + +networks: + voicehub-net: + name: voicehub-net + driver: bridge diff --git a/scripts/deploy.js b/scripts/deploy.js index c20219f..9963ca4 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -106,26 +106,26 @@ async function deploy() { logStep('📦', '安装依赖...') let installed = false - // 优先尝试 pnpm install --frozen-lockfile - if (fileExists('pnpm-lock.yaml')) { - log('尝试使用 pnpm install --frozen-lockfile 安装...', 'cyan') - if (safeExec('pnpm install --frozen-lockfile')) { + // 优先使用 npm(用户要求:禁止 pnpm)—— 优先 package-lock.json 冻结安装 + if (fileExists('package-lock.json')) { + log('尝试使用 npm ci(基于 package-lock.json 冻结安装)...', 'cyan') + if (safeExec('npm ci --no-audit --no-fund')) { installed = true - logSuccess('依赖安装完成 (pnpm install --frozen-lockfile)') + logSuccess('依赖安装完成 (npm ci)') } else { - logWarning('pnpm install --frozen-lockfile 安装失败,准备回退到 pnpm install...') + logWarning('npm ci 失败,准备回退到 npm install...') } } else { - logWarning('未检测到 pnpm-lock.yaml,跳过冻结锁文件安装,直接使用 pnpm install...') + logWarning('未检测到 package-lock.json,直接使用 npm install...') } - // 如果冻结锁文件安装没运行或失败,使用 pnpm install + // 兜底用 npm install if (!installed) { - log('正在使用 pnpm install 安装...', 'cyan') - if (!safeExec('pnpm install')) { + log('正在使用 npm install 安装...', 'cyan') + if (!safeExec('npm install --no-audit --no-fund')) { throw new Error('依赖安装失败') } - logSuccess('依赖安装完成 (pnpm install)') + logSuccess('依赖安装完成 (npm install)') } } @@ -166,7 +166,10 @@ async function deploy() { // 4. 创建管理员账户 if (fileExists('scripts/create-admin.js') && dbSyncSuccess) { logStep('👤', '检查管理员账户...') - safeExec('pnpm run create-admin') + // 优先 npm run create-admin,兜底直接用 node_modules/.bin/tsx 调用(用户禁用 pnpm) + if (!safeExec('npm run create-admin')) { + safeExec('./node_modules/.bin/tsx scripts/create-admin.js') + } } // 5. 构建应用 @@ -178,7 +181,8 @@ async function deploy() { ...process.env, NODE_OPTIONS: process.env.NODE_OPTIONS || DEFAULT_NODE_OPTIONS } - if (!safeExec('pnpm exec nuxt build', { env: buildEnv })) { + // 用 npm run build(= nuxt build),避免用 pnpm exec + if (!safeExec('npm run build', { env: buildEnv })) { throw new Error('应用构建失败') } logSuccess('应用构建完成')