diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5255426..13e683b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: CI/CD on: pull_request: @@ -59,3 +59,78 @@ jobs: - name: Build run: npm run build + + build-and-push: + name: Build & Push + runs-on: ubuntu-latest + needs: [backend, frontend] + if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Gitea Container Registry + run: | + REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build & Push Backend API + run: | + REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') + REPO="${{ gitea.repository }}" + docker build -f backend/Dockerfile --target api \ + -t "$REGISTRY_HOST/$REPO/backend-api:${{ gitea.sha }}" \ + -t "$REGISTRY_HOST/$REPO/backend-api:latest" \ + . + docker push "$REGISTRY_HOST/$REPO/backend-api" --all-tags + + - name: Build & Push Backend Worker + run: | + REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') + REPO="${{ gitea.repository }}" + docker build -f backend/Dockerfile --target worker \ + -t "$REGISTRY_HOST/$REPO/backend-worker:${{ gitea.sha }}" \ + -t "$REGISTRY_HOST/$REPO/backend-worker:latest" \ + . + docker push "$REGISTRY_HOST/$REPO/backend-worker" --all-tags + + - name: Build & Push Frontend + run: | + REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') + REPO="${{ gitea.repository }}" + docker build -f frontend/Dockerfile \ + -t "$REGISTRY_HOST/$REPO/frontend:${{ gitea.sha }}" \ + -t "$REGISTRY_HOST/$REPO/frontend:latest" \ + . + docker push "$REGISTRY_HOST/$REPO/frontend" --all-tags + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: [build-and-push] + if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/vloop-deploy + chmod 600 ~/.ssh/vloop-deploy + + - name: Copy nginx.prod.conf to server + run: | + scp -o StrictHostKeyChecking=no -i ~/.ssh/vloop-deploy \ + frontend/nginx.prod.conf \ + ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/opt/vloop/nginx.prod.conf + + - name: Pull images & restart services + run: | + ssh -o StrictHostKeyChecking=no -i ~/.ssh/vloop-deploy \ + ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \ + "cd /opt/vloop && \ + sed -i 's/^TAG=.*/TAG=${{ gitea.sha }}/' .env && \ + docker compose -f docker-compose.prod.yml pull && \ + docker compose -f docker-compose.prod.yml up -d --remove-orphans && \ + docker image prune -f" diff --git a/backend/internal/worker/notificationworker.go b/backend/internal/worker/notificationworker.go index 8dbfcca..f439569 100644 --- a/backend/internal/worker/notificationworker.go +++ b/backend/internal/worker/notificationworker.go @@ -95,7 +95,7 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error if len(body) == 0 { return nil } - routingKey := d.RoutingKey + routingKey := d.RoutingKey // 复用路由键充当事件类型标识 var notif *Notification diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7d44596 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,124 @@ +services: + mysql: + image: mysql:8.0 + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + TZ: "Asia/Shanghai" + volumes: + - mysql_data:/var/lib/mysql + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: always + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"] + interval: 5s + timeout: 3s + retries: 20 + + rabbitmq: + image: rabbitmq:3-management + restart: always + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + image: ${REGISTRY}/${OWNER}/${REPO}/backend-api:${TAG} + restart: always + environment: + CONFIG_PATH: /app/configs/config.yaml + # Docker 内部服务发现 —— 覆盖 baked-in config.yaml 中的 localhost + MYSQL_HOST: mysql + REDIS_HOST: redis + RABBITMQ_HOST: rabbitmq + # 密码和密钥 —— 从 .env 注入 + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + REDIS_PASSWORD: ${REDIS_PASSWORD} + RABBITMQ_USER: ${RABBITMQ_USER} + RABBITMQ_PASS: ${RABBITMQ_PASS} + JWT_SECRET: ${JWT_SECRET} + volumes: + - backend_uploads:/app/.run/uploads + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + + worker: + image: ${REGISTRY}/${OWNER}/${REPO}/backend-worker:${TAG} + restart: always + environment: + CONFIG_PATH: /app/configs/config.yaml + MYSQL_HOST: mysql + REDIS_HOST: redis + RABBITMQ_HOST: rabbitmq + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + REDIS_PASSWORD: ${REDIS_PASSWORD} + RABBITMQ_USER: ${RABBITMQ_USER} + RABBITMQ_PASS: ${RABBITMQ_PASS} + JWT_SECRET: ${JWT_SECRET} + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pgrep worker || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + + frontend: + image: ${REGISTRY}/${OWNER}/${REPO}/frontend:${TAG} + restart: always + ports: + - "127.0.0.1:9001:80" + volumes: + - ./nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + mysql_data: + redis_data: + rabbitmq_data: + backend_uploads: diff --git a/frontend/nginx.prod.conf b/frontend/nginx.prod.conf new file mode 100644 index 0000000..a993eea --- /dev/null +++ b/frontend/nginx.prod.conf @@ -0,0 +1,61 @@ +server { + listen 80; + server_name _; + + # Allow large uploads (e.g. videos) + client_max_body_size 300m; + + root /usr/share/nginx/html; + index index.html; + + # SSE notification stream — must disable buffering for real-time push + location /notification/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Health check + location /healthz { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA routing (Vue Router history mode) + location / { + try_files $uri $uri/ /index.html; + } + + # Reverse proxy to backend (strip /api prefix) + location /api/ { + proxy_pass http://backend:8080/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Serve uploaded files via backend static route + location /static/ { + proxy_pass http://backend:8080/static/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + } +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8a67f62..3c8e87b 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -2,18 +2,20 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2023", - "lib": ["ES2023"], + "lib": [ + "ES2023" + ], "module": "ESNext", - "types": ["node"], + "types": [ + "node" + ], "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - /* Linting */ "strict": true, "noUnusedLocals": true, @@ -22,5 +24,7 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] -} + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file