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 @@
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
-
+



@@ -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))
+}