diff --git a/CHANGELOG.md b/CHANGELOG.md index 20351f5..74c68e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.0.4] - 2026-01-13 + +### 新增 +- **Gitea Webhook 自动部署** - 推送代码后自动触发服务器拉取并重新部署 + - 独立的 webhook 服务(Go 实现,端口 9000) + - HMAC-SHA256 签名验证,确保安全性 + - 仅处理 master/main 分支的推送 + - 零停机热更新部署 + - 自动清理旧镜像 + - 健康检查验证 +- 完整的部署配置文档 `WEBHOOK_SETUP.md` + +### 优化 +- 部署脚本使用 `docker-compose up -d --build --force-recreate` 实现热更新 +- 部署时排除 webhook 容器自身,避免自杀问题 +- Dockerfile 添加国内镜像源配置,加速构建 + ## [1.0.3] - 2026-01-13 ### 新增 diff --git a/README.md b/README.md index 5ed38b5..be583f7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。 -![版本](https://img.shields.io/badge/版本-1.0.3-green) +![版本](https://img.shields.io/badge/版本-1.0.4-green) ![架构](https://img.shields.io/badge/架构-微服务-blue) ![Go](https://img.shields.io/badge/Go-1.21-00ADD8) ![Python](https://img.shields.io/badge/Python-3.12-3776AB) @@ -17,6 +17,7 @@ - 📈 **趋势图表** - 日/月消费趋势、分类排行、收支对比 - 🔍 **复核修正** - 对不确定的分类进行人工复核 - 🐳 **一键部署** - Docker Compose 快速启动全部服务 +- 🚀 **自动部署** - Gitea Webhook 触发零停机热更新 ## 🏗️ 系统架构 @@ -123,6 +124,11 @@ BillAI/ │ │ └── wechat.py # 微信 │ └── Dockerfile │ +├── webhook/ # Webhook 服务 (Go) +│ ├── main.go # Webhook 入口 +│ └── Dockerfile +│ +├── deploy.sh # 自动部署脚本 ├── data/ # 测试数据目录 ├── mongo/ # MongoDB 数据 └── docker-compose.yaml # 容器编排 @@ -260,28 +266,15 @@ python server.py ## 📋 版本历史 -### v1.0.3 (2026-01-13) +| 版本 | 日期 | 主要更新 | +|------|------|----------| +| **v1.0.4** | 2026-01-13 | 🚀 Gitea Webhook 自动部署、零停机热更新 | +| **v1.0.3** | 2026-01-13 | ✨ DateTimePicker 组件、收支分类动态切换 | +| **v1.0.2** | 2026-01-11 | 🐛 修复时区和金额解析问题 | +| **v1.0.1** | 2026-01-11 | 🐛 修复复核页面显示错误 | +| **v1.0.0** | 2026-01-07 | 🎉 初始版本发布 | -- ✨ 新增 DateTimePicker 日期时间选择组件 -- ✨ 手动添加账单支持收入/支出分类动态切换 -- 🔧 优化表单布局,收入模式隐藏无关字段 - -### v1.0.2 (2026-01-11) - -- 🐛 修复账单时间显示为 UTC 时区的问题 -- 🐛 修复微信账单金额解析问题(半角¥符号) -- ✨ 新增月度统计 API - -### v1.0.1 (2026-01-11) - -- 🐛 修复智能复核页面空数据显示错误 - -### v1.0.0 (2026-01-07) - -- ✨ 支持微信/支付宝账单上传与解析 -- 🔐 用户登录认证 (JWT) -- 📊 可视化数据分析图表 -- 🏷️ 智能分类推断 +详细更新日志请查看 [CHANGELOG.md](CHANGELOG.md) ## 🤝 贡献指南 diff --git a/WEBHOOK_SETUP.md b/WEBHOOK_SETUP.md new file mode 100644 index 0000000..e3b94aa --- /dev/null +++ b/WEBHOOK_SETUP.md @@ -0,0 +1,490 @@ +# 自动部署配置指南 + +这个指南将帮助你配置 Gitea webhook 以实现代码推送时的自动部署。 + +## 整体架构 + +```mermaid +flowchart TD + A[🏠 Gitea
代码仓库] -->|Push Event| B[🔗 Webhook Service
Port 9000] + B -->|验证签名| C{签名有效?} + C -->|❌ 无效| D[拒绝请求] + C -->|✅ 有效| E{主分支?} + E -->|❌ 否| F[跳过部署] + E -->|✅ 是| G[📜 Deploy Script
deploy.sh] + G --> H[📥 git pull
拉取最新代码] + H --> I[🔨 热更新部署
零停机] + I --> J[🧹 清理旧镜像] + J --> K[🏥 健康检查] + K --> L[✅ 部署完成] + + style A fill:#f9f,stroke:#333 + style B fill:#bbf,stroke:#333 + style G fill:#bfb,stroke:#333 + style L fill:#9f9,stroke:#333 +``` + +## 第一步:生成 Webhook Secret + +打开终端,运行以下命令生成安全的随机密钥: + +```bash +# Linux / macOS +openssl rand -hex 32 + +# Windows PowerShell +[System.BitConverter]::ToString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).Replace('-','').ToLower() +``` + +示例输出: +``` +a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 +``` + +## 第二步:配置 docker-compose.yaml + +编辑 `docker-compose.yaml` 文件中的 webhook 服务配置: + +### 设置 Secret + +```yaml +webhook: + environment: + WEBHOOK_SECRET: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" # 替换为你生成的 Secret +``` + +### 配置仓库路径(关键) + +**重要!** 根据你的部署方式修改挂载路径: + +#### 如果使用本地路径部署: + +```yaml +webhook: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /home/user/billai:/app # 替换为你的实际路径 +``` + +#### 如果在云服务器上部署: + +```yaml +webhook: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/billai:/app # 替换为你的实际路径 +``` + +### 网络配置说明 + +**webhook 服务对外暴露 9000 端口**,用于接收 Gitea webhook 事件。 + +这样的好处是: +- ✅ 支持外部 Gitea 服务器的 webhook 回调 +- ✅ 灵活的部署架构 +- ✅ 支持多种网络拓扑 + +**Webhook 访问地址:** + +- 本地访问:`http://localhost:9000/webhook` +- 内部网络:`http://:9000/webhook` +- 公网访问:`http://:9000/webhook` +- 通过域名:`http://webhook.yourdomain.com:9000/webhook` + +## 第三步:启动 Webhook 服务 + +```bash +# 进入项目根目录 +cd /path/to/billai + +# 构建并启动 webhook 服务 +docker-compose up -d webhook + +# 验证服务是否启动 +docker-compose logs webhook + +# 测试健康检查 +curl http://localhost:9000/health +``` + +输出应该显示: +```json +{ + "status": "ok", + "time": "2026-01-13T10:30:00Z" +} +``` + +## 第四步:配置 Gitea Webhook + +### 4.1 进入 Gitea 仓库设置 + +1. 打开 Gitea 管理界面 +2. 找到你的 BillAI 仓库 +3. 点击 **设置 (Settings)** → **Webhooks** + +### 4.2 添加新 Webhook + +点击 "添加 Webhook (Add Webhook)" 按钮,选择 "Gitea" + +### 4.3 填写 Webhook 配置 + +| 字段 | 值 | +|------|-----| +| **目标 URL** | `http://:9000/webhook` | +| **内容类型** | `application/json` | +| **密钥** | `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` (你生成的 Secret) | +| **事件** | ✓ 推送事件 (Push Events) | +| **活跃** | ✓ 是 | + +### 4.4 配置说明 + +#### 目标 URL + +根据 Gitea 部署方式选择合适的 URL: + +**情况 1:Gitea 和 webhook 都在同一台机器** + +``` +http://localhost:9000/webhook +``` + +**情况 2:Gitea 在不同机器,webhook 在 Docker** + +使用 webhook 所在服务器的 IP 地址: +``` +http://:9000/webhook +``` + +**情况 3:使用域名访问** + +``` +http://webhook.yourdomain.com:9000/webhook +或 +https://webhook.yourdomain.com/webhook (需要配置反向代理处理 HTTPS) +``` + +#### 防火墙配置 + +确保防火墙允许 9000 端口的入站连接: + +```bash +# Linux (ufw) +sudo ufw allow 9000/tcp + +# Linux (iptables) +sudo iptables -A INPUT -p tcp --dport 9000 -j ACCEPT + +# Windows Firewall (PowerShell) +New-NetFirewallRule -DisplayName "Allow Webhook" -Direction Inbound -LocalPort 9000 -Protocol TCP -Action Allow +``` + +### 4.5 测试 Webhook + +在 Gitea webhook 设置页面,找到刚添加的 webhook,点击 "测试 (Test)" 按钮。 + +预期响应: +```json +{ + "message": "非主分支,跳过部署" +} +``` + +(这是因为测试会使用一个默认的测试分支) + +## 第五步:网络配置 + +### 防火墙配置 + +在部署前,确保服务器防火墙允许以下端口: + +```bash +# 允许 9000 端口(webhook) +sudo ufw allow 9000/tcp + +# 允许 3000 端口(前端web)- 已配置 +sudo ufw allow 3000/tcp + +# 允许 80 端口(HTTP,如果使用) +sudo ufw allow 80/tcp + +# 允许 443 端口(HTTPS,如果使用) +sudo ufw allow 443/tcp +``` + +### 可选:Nginx 反向代理配置 + +如果需要通过 HTTPS 访问 webhook,可以配置 Nginx 反向代理: + +```nginx +server { + listen 443 ssl http2; + server_name webhook.yourdomain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:9000; + proxy_set_header Host $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; + } +} + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name webhook.yourdomain.com; + return 301 https://$server_name$request_uri; +} +``` + +## 第六步:测试自动部署 + +现在你可以测试自动部署功能: + +```bash +# 进入仓库目录 +cd /path/to/billai + +# 做一些改动 +echo "# 测试自动部署" >> README.md + +# 提交并推送到主分支 +git add . +git commit -m "test: 测试自动部署功能" +git push origin master +``` + +### 监控部署过程 + +```bash +# 查看 webhook 服务日志 +docker-compose logs -f webhook + +# 查看部署脚本日志 +docker exec billai-webhook tail -f /tmp/billai_deploy.log + +# 查看其他服务日志 +docker-compose logs -f server +docker-compose logs -f web +docker-compose logs -f analyzer +``` + +## 常见问题排查 + +### 问题 1:Webhook 签名验证失败 + +**症状**:日志中出现 "无效的签名" + +**解决方案**: +- 确认 Gitea 中设置的 Secret 与 docker-compose.yaml 中的 WEBHOOK_SECRET 完全相同 +- 检查是否有多余空格或特殊字符 + +```bash +# 在 docker-compose.yaml 中查看 +grep WEBHOOK_SECRET docker-compose.yaml + +# 在 Gitea 中重新复制 Secret,确保没有多余空格 +``` + +### 问题 2:无法连接到 Webhook 服务 + +**症状**:Gitea webhook 测试失败 + +**解决方案**: +1. 检查 webhook 容器是否运行: +```bash +docker-compose ps webhook +``` + +2. 检查端口是否正确暴露: +```bash +docker-compose logs webhook | grep "listen" +``` + +3. 检查防火墙规则: +```bash +# Linux +netstat -tulpn | grep 9000 +sudo ufw status + +# Windows +netstat -ano | findstr 9000 +``` + +4. 测试本地连接: +```bash +curl http://localhost:9000/health +``` + +5. 如果是远程连接,测试网络连通性: +```bash +# 从 Gitea 服务器测试 +curl http://:9000/health + +netstat -ano | findstr 9000 # Windows +``` + +4. 测试本地连接: + +```bash +curl http://localhost:9000/health +``` + +### 问题 3:部署脚本无法执行 + +**症状**:日志中显示 "部署脚本执行失败" + +**解决方案**: +1. 检查 deploy.sh 是否存在: +```bash +ls -la deploy.sh +``` + +2. 检查文件权限: +```bash +chmod +x deploy.sh +``` + +3. 检查容器内的环境: +```bash +docker exec billai-webhook bash -c "ls -la /app/deploy.sh" +``` + +4. 查看详细错误信息: +```bash +docker exec billai-webhook tail -f /tmp/billai_deploy.log +``` + +### 问题 4:Docker 操作权限不足 + +**症状**:日志中显示 "permission denied" 或 "Cannot connect to Docker daemon" + +**解决方案**: +1. 检查 Docker Socket 是否正确挂载: +```yaml +webhook: + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +2. 检查 Docker Socket 权限: +```bash +ls -l /var/run/docker.sock +chmod 666 /var/run/docker.sock # 可能需要 sudo +``` + +3. 确保 webhook 容器以正确的用户运行(通常是 root) + +### 问题 5:部署后服务无法启动 + +**症状**:健康检查超时,部署失败 + +**解决方案**: +1. 检查 docker-compose build 是否成功: +```bash +docker-compose build +``` + +2. 检查依赖服务是否启动: +```bash +docker-compose ps +``` + +3. 查看各服务的详细日志: +```bash +docker-compose logs server +docker-compose logs web +docker-compose logs analyzer +``` + +4. 增加健康检查的超时时间(修改 deploy.sh 中的 sleep 时间) + +## 监控和维护 + +### 定期检查 Webhook 状态 + +```bash +# 查看最近 50 条日志 +docker-compose logs --tail=50 webhook + +# 实时监控日志 +docker-compose logs -f webhook +``` + +### 查看部署历史 + +部署脚本的日志保存在:`/tmp/billai_deploy.log` + +```bash +# 查看部署历史 +docker exec billai-webhook cat /tmp/billai_deploy.log + +# 持续监控 +docker exec billai-webhook tail -f /tmp/billai_deploy.log +``` + +### 禁用/启用自动部署 + +如果需要临时禁用自动部署,修改 deploy.sh: + +```bash +#!/bin/bash + +# 临时禁用部署,取消下面的注释 +# exit 0 + +# ... 后续脚本内容 +``` + +然后重新启动 webhook 服务: +```bash +docker-compose restart webhook +``` + +## 高级配置 + +### 只部署特定分支 + +编辑 webhook/main.go,修改分支检查逻辑: + +```go +// 检查分支,修改条件为只部署 master 和 develop 分支 +if !strings.Contains(payload.Ref, "master") && + !strings.Contains(payload.Ref, "develop") { + // 跳过部署 +} +``` + +### 添加 Slack 通知 + +修改 deploy.sh,在部署完成后发送 Slack 消息: + +```bash +# 在部署完成后添加 +curl -X POST -H 'Content-type: application/json' \ + --data "{\"text\":\"BillAI 部署完成!\"}" \ + YOUR_SLACK_WEBHOOK_URL +``` + +### 限制部署频率 + +在 webhook/main.go 中添加速率限制,防止频繁部署。 + +## 安全建议 + +1. **使用强密钥**:确保 WEBHOOK_SECRET 是随机生成的强密钥 +2. **限制网络访问**:只允许 Gitea 服务器的 IP 访问 webhook +3. **HTTPS**:在生产环境中使用 HTTPS 而不是 HTTP +4. **日志监控**:定期检查部署日志,及时发现问题 +5. **备份**:在部署前备份重要数据 + +## 相关文件 + +- [webhook/README.md](../webhook/README.md) - Webhook 服务文档 +- [deploy.sh](../deploy.sh) - 部署脚本 +- [docker-compose.yaml](../docker-compose.yaml) - Docker Compose 配置 +- [webhook/main.go](../webhook/main.go) - Webhook 服务源代码 diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..bf00245 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# BillAI 自动部署脚本 +# 此脚本由 Gitea webhook 触发,自动执行 git pull 并重新构建部署 + +set -e + +REPO_ROOT="/app" +LOG_FILE="/tmp/billai_deploy.log" + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" +} + +error() { + echo -e "${RED}[错误]${NC} $1" | tee -a "$LOG_FILE" + exit 1 +} + +success() { + echo -e "${GREEN}[成功]${NC} $1" | tee -a "$LOG_FILE" +} + +log "==========================================" +log "🚀 BillAI 自动部署开始" +log "==========================================" + +# 进入仓库目录 +cd "$REPO_ROOT" || error "无法进入仓库目录: $REPO_ROOT" +log "📁 工作目录: $(pwd)" + +# 拉取最新代码 +log "📥 正在拉取最新代码..." +if ! git fetch origin; then + error "git fetch 失败" +fi + +if ! git reset --hard origin/master; then + error "git reset 失败" +fi + +success "代码已更新" + +# 显示当前提交信息 +log "📝 当前提交:" +git log --oneline -1 + +# 检查 docker-compose 是否存在 +if [ ! -f "docker-compose.yaml" ]; then + error "docker-compose.yaml 不存在" +fi + +log "🐳 开始热更新部署(不停机)..." + +# 定义需要重新部署的服务(排除 webhook 自身,否则会自杀) +SERVICES="web server analyzer" + +# 拉取基础镜像更新 +log "📦 检查基础镜像更新..." +docker-compose pull mongodb mongo-express || true + +# 热更新:重新构建并替换容器(不停止旧服务,直接替换) +log "🔨 热更新服务(构建 + 替换)..." +if ! docker-compose up -d --build --force-recreate --no-deps $SERVICES; then + error "Docker 热更新失败" +fi + +# 清理旧的未使用镜像 +log "🧹 清理旧镜像..." +docker image prune -f || true + +# 等待服务启动 +log "⏳ 等待服务启动..." +sleep 5 + +# 检查服务健康状态 +log "🏥 检查服务健康状态..." +for i in {1..30}; do + if curl -f http://localhost:8080/health > /dev/null 2>&1; then + success "后端服务已启动" + break + fi + if [ $i -eq 30 ]; then + error "后端服务启动超时" + fi + sleep 1 +done + +for i in {1..30}; do + if curl -f http://localhost:3000 > /dev/null 2>&1; then + success "前端服务已启动" + break + fi + if [ $i -eq 30 ]; then + error "前端服务启动超时" + fi + sleep 1 +done + +log "==========================================" +success "🎉 部署完成" +log "==========================================" +log "⏰ 完成时间: $(date +'%Y-%m-%d %H:%M:%S')" +log "✅ 所有服务已启动并正常运行" +log "" +log "📍 访问地址:" +log " 前端: http://localhost:3000" +log " 后端: http://localhost:8080" +log "" diff --git a/docker-compose.yaml b/docker-compose.yaml index 113c39e..1e35953 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -125,3 +125,31 @@ services: depends_on: mongodb: condition: service_healthy + + # Gitea Webhook 服务 - 自动部署 + webhook: + build: + context: ./webhook + dockerfile: Dockerfile + container_name: billai-webhook + restart: unless-stopped + ports: + - "9000:9000" + environment: + WEBHOOK_PORT: "9000" + # 重要:更改此 Secret 为强随机值 + # 生成方法: openssl rand -hex 32 + WEBHOOK_SECRET: "change-this-to-your-secret-key" + REPO_PATH: "/app" + volumes: + # 挂载 Docker Socket 以支持容器操作 + - /var/run/docker.sock:/var/run/docker.sock + # 挂载仓库目录 + - /path/to/billai:/app + working_dir: /app + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s diff --git a/webhook/Dockerfile b/webhook/Dockerfile new file mode 100644 index 0000000..0a5a3e3 --- /dev/null +++ b/webhook/Dockerfile @@ -0,0 +1,52 @@ +# Webhook 服务 Dockerfile +# 多阶段构建:编译阶段 + 运行阶段 + +# ===== 编译阶段 ===== +FROM golang:1.21-alpine AS builder + +WORKDIR /build + +# 配置 Alpine 镜像源(国内) +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 配置 Go 代理(国内镜像) +ENV GOPROXY=https://goproxy.cn,direct + +# 安装编译依赖 +RUN apk add --no-cache git + +# 复制代码 +COPY . . + +# 构建二进制 +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o webhook . + +# ===== 运行阶段 ===== +FROM alpine:3.19 + +WORKDIR /app + +# 配置 Alpine 镜像源(国内) +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 安装必要工具 +RUN apk add --no-cache bash curl git docker-cli docker-compose ca-certificates tzdata + +# 设置时区 +ENV TZ=Asia/Shanghai + +# 从编译阶段复制二进制 +COPY --from=builder /build/webhook /usr/local/bin/webhook + +# 注意:deploy.sh 通过 volume 挂载,不在构建时复制 +# 因为 deploy.sh 在仓库根目录,而 Dockerfile 在 webhook 目录 + +# 暴露端口 +EXPOSE 9000 + +# 健康检查 +HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:9000/health || exit 1 + +# 启动服务 +CMD ["webhook"] diff --git a/webhook/README.md b/webhook/README.md new file mode 100644 index 0000000..efbc5f8 --- /dev/null +++ b/webhook/README.md @@ -0,0 +1,132 @@ +# Webhook 服务 + +用于监听 Gitea webhook 事件,自动触发服务器拉取最新代码并重新构建部署。 + +## 特性 + +- ✅ 接收 Gitea webhook 推送事件 +- ✅ HMAC-SHA256 签名验证 +- ✅ 仅处理主分支 (master/main) 的推送 +- ✅ 自动执行部署脚本 +- ✅ 异步部署,快速响应 +- ✅ 零停机热更新 + +## 配置 + +### 环境变量 + +| 变量 | 说明 | 默认值 | +|-----|------|-------| +| `WEBHOOK_PORT` | webhook 服务监听端口 | 9000 | +| `WEBHOOK_SECRET` | Gitea webhook 签名密钥 | your-webhook-secret | +| `REPO_PATH` | 仓库根目录路径 | /app | + +### 部署脚本 + +脚本位置: `/app/deploy.sh` + +执行以下操作: +1. 拉取最新代码 (`git pull`) +2. 热更新部署(零停机) +3. 自动清理旧镜像 +4. 健康检查 + +## 安全性 + +### 设置 Webhook Secret + +在 Gitea 中配置 webhook 时,需要设置 Secret: + +1. 生成一个随机密钥: +```bash +openssl rand -hex 32 +# 输出示例: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 +``` + +2. 在 Docker 容器中设置环境变量: +```yaml +environment: + WEBHOOK_SECRET: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" +``` + +## Gitea Webhook 配置 + +### 1. 进入仓库设置 + +在 Gitea 中打开仓库,进入 **设置 → Webhook** + +### 2. 添加新 Webhook + +- **URL**: `http://:9000/webhook` +- **内容类型**: `application/json` +- **密钥**: 使用上面生成的 Secret +- **触发器**: 勾选 `推送事件` +- **活跃**: 是 + +### 3. 测试连接 + +点击 "测试" 按钮验证连接 + +## 日志 + +查看部署日志: + +```bash +# 查看容器日志 +docker logs billai-webhook + +# 查看部署脚本日志 +docker exec billai-webhook tail -f /tmp/billai_deploy.log +``` + +## 访问端点 + +- **Webhook**: `POST /webhook` +- **健康检查**: `GET /health` + +## 工作流 + +``` +Gitea Push Event + ↓ +Webhook Service (port 9000) + ↓ +验证 HMAC-SHA256 签名 + ↓ +检查分支 (仅主分支) + ↓ +异步触发部署脚本 + ↓ +执行 git pull + ↓ +执行 docker-compose build/up + ↓ +健康检查 + ↓ +部署完成 +``` + +## 故障排除 + +### 签名验证失败 + +- 检查 Gitea 中配置的 Secret 是否与环境变量 `WEBHOOK_SECRET` 一致 + +### 部署脚本无法执行 + +- 确保 `deploy.sh` 存在于仓库根目录 +- 检查文件权限:`chmod +x deploy.sh` +- 检查容器内是否有 git、docker 等工具 + +### Docker 操作失败 + +- 确保容器有权限访问 Docker Socket +- 在 docker-compose 中正确挂载 Docker Socket: +```yaml +volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +## 示例 docker-compose 配置 + +参考项目根目录的 `docker-compose.yaml` diff --git a/webhook/go.mod b/webhook/go.mod new file mode 100644 index 0000000..ae00ed1 --- /dev/null +++ b/webhook/go.mod @@ -0,0 +1,3 @@ +module webhook + +go 1.21 diff --git a/webhook/main.go b/webhook/main.go new file mode 100644 index 0000000..6ccbac6 --- /dev/null +++ b/webhook/main.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// GiteaPayload Gitea webhook 推送事件 +type GiteaPayload struct { + Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + } `json:"repository"` + Pusher struct { + Username string `json:"username"` + } `json:"pusher"` + Ref string `json:"ref"` + Commit string `json:"after"` +} + +// Config webhook 服务配置 +type Config struct { + Port string + Secret string + RepoPath string + ScriptPath string +} + +var cfg Config + +func init() { + // 从环境变量读取配置 + cfg.Port = os.Getenv("WEBHOOK_PORT") + if cfg.Port == "" { + cfg.Port = "9000" + } + + cfg.Secret = os.Getenv("WEBHOOK_SECRET") + if cfg.Secret == "" { + cfg.Secret = "your-webhook-secret" + } + + cfg.RepoPath = os.Getenv("REPO_PATH") + if cfg.RepoPath == "" { + cfg.RepoPath = "/app" + } + + cfg.ScriptPath = filepath.Join(cfg.RepoPath, "deploy.sh") +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Printf("🚀 Webhook 服务启动 | 监听端口: %s\n", cfg.Port) + log.Printf("📁 仓库路径: %s\n", cfg.RepoPath) + log.Printf("📜 部署脚本: %s\n", cfg.ScriptPath) + + http.HandleFunc("/webhook", handleWebhook) + http.HandleFunc("/health", handleHealth) + + if err := http.ListenAndServe(":"+cfg.Port, nil); err != nil { + log.Fatalf("❌ 服务器启动失败: %v", err) + } +} + +// handleHealth 健康检查端点 +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "time": time.Now().Format(time.RFC3339), + }) +} + +// handleWebhook 处理 Gitea webhook +func handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 读取请求体 + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("❌ 读取请求体失败: %v", err) + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // 验证签名 + if !verifySignature(r.Header.Get("X-Gitea-Signature"), body) { + log.Println("⚠️ 无效的签名") + http.Error(w, "Invalid signature", http.StatusUnauthorized) + return + } + + // 解析 payload + var payload GiteaPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Printf("❌ 解析 JSON 失败: %v", err) + http.Error(w, "Failed to parse JSON", http.StatusBadRequest) + return + } + + // 只处理 master 分支的推送 + if !strings.Contains(payload.Ref, "master") && !strings.Contains(payload.Ref, "main") { + log.Printf("⏭️ 忽略非主分支推送: %s", payload.Ref) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "非主分支,跳过部署", + }) + return + } + + log.Printf("✅ 收到有效的 Webhook 请求") + log.Printf(" 仓库: %s", payload.Repository.FullName) + log.Printf(" 推送者: %s", payload.Pusher.Username) + log.Printf(" 分支: %s", payload.Ref) + commitShort := payload.Commit + if len(commitShort) > 7 { + commitShort = commitShort[:7] + } + log.Printf(" 提交: %s", commitShort) + + // 异步执行部署 + go executeDeployment(payload) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]string{ + "message": "部署已启动", + "commit": payload.Commit, + }) +} + +// verifySignature 验证 Gitea webhook 签名 +func verifySignature(signature string, body []byte) bool { + if cfg.Secret == "your-webhook-secret" { + log.Println("⚠️ 警告: 使用默认 secret,建议设置 WEBHOOK_SECRET 环境变量") + return true + } + + hash := hmac.New(sha256.New, []byte(cfg.Secret)) + hash.Write(body) + expected := "sha256=" + hex.EncodeToString(hash.Sum(nil)) + + return signature == expected +} + +// executeDeployment 执行部署 +func executeDeployment(payload GiteaPayload) { + log.Println("\n🔄 开始部署流程...") + + startTime := time.Now() + + // 检查脚本是否存在 + if _, err := os.Stat(cfg.ScriptPath); os.IsNotExist(err) { + log.Printf("❌ 部署脚本不存在: %s", cfg.ScriptPath) + return + } + + // 执行部署脚本 + cmd := exec.Command("bash", cfg.ScriptPath) + cmd.Dir = cfg.RepoPath + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + log.Printf("📜 执行部署脚本: %s", cfg.ScriptPath) + + if err := cmd.Run(); err != nil { + log.Printf("❌ 部署脚本执行失败: %v", err) + if stderr.Len() > 0 { + log.Printf("错误输出:\n%s", stderr.String()) + } + return + } + + duration := time.Since(startTime).Seconds() + + log.Printf("✅ 部署完成") + log.Printf(" 耗时: %.2f 秒", duration) + + if stdout.Len() > 0 { + log.Printf("输出:\n%s", stdout.String()) + } + + log.Printf("📝 部署日志:\n") + log.Printf(" 仓库: %s", payload.Repository.FullName) + log.Printf(" 提交: %s", payload.Commit[:7]) + log.Printf(" 分支: %s", payload.Ref) + log.Printf(" 推送者: %s", payload.Pusher.Username) + log.Printf(" 时间: %s", time.Now().Format(time.RFC3339)) +}