feat: 添加 Python 版本的 Gitea Webhook Ambassador

- 新增完整的 Python 实现,替代 Go 版本
- 添加 Web 登录界面和仪表板
- 实现 JWT 认证和 API 密钥管理
- 添加数据库存储功能
- 保持与 Go 版本一致的目录结构和启动脚本
- 包含完整的文档和测试脚本
This commit is contained in:
Nicolas 2025-07-20 21:12:10 +08:00
parent 843a73ef80
commit f6c515157c
49 changed files with 8034 additions and 0 deletions

View File

@ -0,0 +1,63 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
venv/
env/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
logs/*.log
# Database
*.db
*.sqlite
*.sqlite3
# PID files
*.pid
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp

View File

@ -0,0 +1,143 @@
.PHONY: build clean test lint docker-build docker-push run help install init
# Variables
APP_NAME := gitea-webhook-ambassador
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
PYTHON := python3
PIP := pip
VENV := venv
CONFIG_FILE := config.yaml
# Python commands
PYTHON_FILES := $(shell find . -name "*.py" -type f)
REQUIREMENTS := requirements.txt
# Default target
.DEFAULT_GOAL := help
# Install dependencies
install:
@echo "Installing dependencies..."
$(PYTHON) -m venv $(VENV)
. $(VENV)/bin/activate && $(PIP) install -r $(REQUIREMENTS)
# Initialize database
init:
@echo "Initializing database..."
. $(VENV)/bin/activate && $(PYTHON) -c "from app.models.database import create_tables; create_tables(); print('Database initialized')"
# Build (for Python, this means installing dependencies)
build: install
@echo "Python application ready"
# Clean build artifacts
clean:
@echo "Cleaning up..."
@rm -rf $(VENV)
@rm -f *.db
@rm -rf logs/
@rm -f *.pid
# Run tests
test:
@echo "Running tests..."
. $(VENV)/bin/activate && $(PYTHON) test_enhanced_features.py
# Run linter
lint:
@echo "Running linter..."
. $(VENV)/bin/activate && flake8 app/ --max-line-length=120 --ignore=E501,W503
# Build Docker image
docker-build:
@echo "Building Docker image $(APP_NAME):$(VERSION)..."
docker build -t $(APP_NAME):$(VERSION) .
docker tag $(APP_NAME):$(VERSION) $(APP_NAME):latest
# Push Docker image to registry
docker-push: docker-build
@echo "Pushing Docker image $(APP_NAME):$(VERSION)..."
docker push $(APP_NAME):$(VERSION)
docker push $(APP_NAME):latest
# Run locally
run: build init
@echo "Starting $(APP_NAME)..."
. $(VENV)/bin/activate && $(PYTHON) -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000
# Run in background
start: build init
@echo "Starting $(APP_NAME) in background..."
. $(VENV)/bin/activate && nohup $(PYTHON) -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000 > logs/service.log 2>&1 &
@echo $$! > service.pid
@echo "Service started with PID $$(cat service.pid)"
# Stop service
stop:
@if [ -f service.pid ]; then \
echo "Stopping $(APP_NAME)..."; \
kill $$(cat service.pid) 2>/dev/null || true; \
rm -f service.pid; \
echo "Service stopped"; \
else \
echo "No service.pid found"; \
fi
# Restart service
restart: stop start
# Show service status
status:
@if [ -f service.pid ]; then \
PID=$$(cat service.pid); \
if ps -p $$PID > /dev/null 2>&1; then \
echo "$(APP_NAME) is running (PID: $$PID)"; \
echo "📝 Log file: logs/service.log"; \
echo "🌐 Access: http://localhost:8000"; \
else \
echo "$(APP_NAME) is not running (PID file exists but process not found)"; \
rm -f service.pid; \
fi; \
else \
echo "$(APP_NAME) is not running"; \
fi
# Show logs
logs:
@if [ -f logs/service.log ]; then \
echo "📝 Latest logs (last 50 lines):"; \
echo "----------------------------------------"; \
tail -n 50 logs/service.log; \
echo "----------------------------------------"; \
else \
echo "❌ No log file found"; \
fi
# Follow logs
follow:
@if [ -f logs/service.log ]; then \
echo "📝 Following logs (Ctrl+C to exit):"; \
tail -f logs/service.log; \
else \
echo "❌ No log file found"; \
fi
# Show help
help:
@echo "Gitea Webhook Ambassador (Python) - Makefile commands:"
@echo " install - Install Python dependencies"
@echo " init - Initialize database"
@echo " build - Install dependencies (alias for install)"
@echo " clean - Remove build artifacts and logs"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " docker-build - Build Docker image"
@echo " docker-push - Build and push Docker image to registry"
@echo " run - Install, init and run locally (foreground)"
@echo " start - Install, init and start in background"
@echo " stop - Stop background service"
@echo " restart - Restart background service"
@echo " status - Show service status"
@echo " logs - Show latest logs"
@echo " follow - Follow logs in real-time"
@echo " help - Show this help message"

View File

@ -0,0 +1,189 @@
# Gitea Webhook Ambassador (Python)
一个高性能的 Python webhook 服务,用于连接 Gitea 和 Jenkins支持智能分发、高并发处理和防抖策略。
## 🚀 新特性
### 1. 智能分发策略
- **dev 分支** → 触发 alpha 环境构建
- **prod 分支** → 触发生产环境构建
- **其他分支** → 可配置的默认策略
### 2. 高并发处理
- **异步任务队列**: 使用 Celery + Redis 处理高并发
- **任务排队机制**: 防止构建丢失,确保任务按序执行
- **负载均衡**: 支持多 worker 实例
### 3. 防抖策略
- **基于 commit hash + 分支的去重**: 防止重复触发
- **时间窗口防抖**: 在指定时间窗口内的相同提交只触发一次
- **智能去重**: 支持配置去重策略
## 🏗️ 架构设计
```
Gitea Webhook → FastAPI → Celery Queue → Jenkins Workers
↓ ↓ ↓ ↓
验证签名 路由分发 任务排队 并发执行
↓ ↓ ↓ ↓
防抖检查 环境判断 持久化存储 状态反馈
```
## 📁 项目结构
```
gitea-webhook-ambassador-python/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 应用入口
│ ├── config.py # 配置管理
│ ├── models/ # 数据模型
│ │ ├── __init__.py
│ │ ├── gitea.py # Gitea webhook 模型
│ │ └── jenkins.py # Jenkins 任务模型
│ ├── services/ # 业务逻辑
│ │ ├── __init__.py
│ │ ├── webhook_service.py # Webhook 处理服务
│ │ ├── jenkins_service.py # Jenkins 集成服务
│ │ ├── queue_service.py # 队列管理服务
│ │ └── dedup_service.py # 防抖服务
│ ├── api/ # API 路由
│ │ ├── __init__.py
│ │ ├── webhook.py # Webhook 端点
│ │ ├── health.py # 健康检查
│ │ └── admin.py # 管理接口
│ ├── core/ # 核心组件
│ │ ├── __init__.py
│ │ ├── security.py # 安全验证
│ │ ├── database.py # 数据库连接
│ │ └── cache.py # 缓存管理
│ └── tasks/ # Celery 任务
│ ├── __init__.py
│ └── jenkins_tasks.py # Jenkins 任务处理
├── tests/ # 测试文件
├── docker/ # Docker 配置
├── requirements.txt # Python 依赖
├── docker-compose.yml # 开发环境
└── README.md
```
## 🛠️ 技术栈
- **Web 框架**: FastAPI
- **任务队列**: Celery + Redis
- **数据库**: PostgreSQL (生产) / SQLite (开发)
- **缓存**: Redis
- **监控**: Prometheus + Grafana
- **日志**: Structured logging with JSON
- **测试**: pytest + pytest-asyncio
## 🚀 快速开始
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 配置环境
```bash
cp .env.example .env
# 编辑 .env 文件配置 Jenkins 和数据库连接
```
### 3. 启动服务
```bash
# 启动 Redis
docker run -d -p 6379:6379 redis:alpine
# 启动 Celery worker
celery -A app.tasks worker --loglevel=info
# 启动 FastAPI 应用
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## 📋 配置说明
### 环境分发配置
```yaml
environments:
dev:
branches: ["dev", "develop", "development"]
jenkins_job: "alpha-build"
jenkins_url: "https://jenkins-alpha.example.com"
prod:
branches: ["prod", "production", "main", "master"]
jenkins_job: "production-build"
jenkins_url: "https://jenkins-prod.example.com"
default:
jenkins_job: "default-build"
jenkins_url: "https://jenkins-default.example.com"
```
### 防抖配置
```yaml
deduplication:
enabled: true
window_seconds: 300 # 5分钟防抖窗口
strategy: "commit_branch" # commit_hash + branch
cache_ttl: 3600 # 缓存1小时
```
### 队列配置
```yaml
queue:
max_concurrent: 10
max_retries: 3
retry_delay: 60 # 秒
priority_levels: 3
```
## 🔧 API 接口
### Webhook 端点
```
POST /webhook/gitea
```
### 健康检查
```
GET /health
GET /health/queue
GET /health/jenkins
```
### 管理接口
```
GET /admin/queue/status
GET /admin/queue/stats
POST /admin/queue/clear
```
## 🧪 测试
```bash
# 运行所有测试
pytest
# 运行特定测试
pytest tests/test_webhook_service.py
# 运行性能测试
pytest tests/test_performance.py
```
## 📊 监控指标
- Webhook 接收率
- 任务队列长度
- Jenkins 构建成功率
- 响应时间分布
- 防抖命中率
## 🔒 安全特性
- Webhook 签名验证
- API 密钥认证
- 请求频率限制
- 输入验证和清理
- 安全日志记录

View File

@ -0,0 +1,339 @@
# Gitea Webhook Ambassador (Python Enhanced Version)
这是一个用 Python 重写的 Gitea Webhook Ambassador 服务,提供与 Go 版本相同的功能,但增加了 Web 界面和更多管理功能。
## 🚀 快速开始
### 方式一:使用 devbox 脚本(推荐,与 Go 版本一致)
```bash
# 安装依赖
./devbox install
# 初始化数据库
./devbox init
# 启动服务
./devbox start
# 查看状态
./devbox status
# 查看日志
./devbox logs
# 停止服务
./devbox stop
```
### 方式二:使用 Makefile
```bash
# 安装依赖
make install
# 初始化数据库
make init
# 启动服务(前台运行)
make run
# 启动服务(后台运行)
make start
# 查看状态
make status
# 查看日志
make logs
# 停止服务
make stop
```
### 方式三:直接使用 Python
```bash
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 初始化数据库
python -c "from app.models.database import create_tables; create_tables()"
# 启动服务
python -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000
```
## 📁 目录结构(与 Go 版本一致)
```
gitea-webhook-ambassador-python/
├── app/ # 应用代码
│ ├── auth/ # 认证模块
│ ├── handlers/ # API 处理器
│ ├── models/ # 数据模型
│ ├── templates/ # HTML 模板
│ ├── static/ # 静态文件
│ └── main_enhanced.py # 主应用入口
├── cmd/ # 命令行工具(与 Go 版本一致)
│ └── server/ # 服务器启动
├── configs/ # 配置文件(与 Go 版本一致)
│ └── config.yaml # 主配置文件
├── data/ # 数据目录(与 Go 版本一致)
│ └── *.db # SQLite 数据库文件
├── logs/ # 日志目录(与 Go 版本一致)
│ └── service.log # 服务日志
├── devbox # 启动脚本(与 Go 版本一致)
├── Makefile # 构建脚本(与 Go 版本一致)
├── requirements.txt # Python 依赖
└── README_ENHANCED.md # 本文档
```
## 🔧 配置
编辑 `configs/config.yaml` 文件:
```yaml
server:
port: 8000
webhookPath: "/webhook"
secretHeader: "X-Gitea-Signature"
secretKey: "admin-secret-key-change-in-production"
jenkins:
url: "http://jenkins.example.com"
username: "jenkins-user"
token: "jenkins-api-token"
timeout: 30
admin:
token: "admin-api-token"
database:
path: "data/gitea-webhook-ambassador.db"
logging:
level: "info"
format: "text"
file: "logs/service.log"
worker:
poolSize: 10
queueSize: 100
maxRetries: 3
retryBackoff: 1
eventCleanup:
interval: 3600
expireAfter: 7200
```
## 🌐 Web 界面
启动服务后,访问以下地址:
- **登录页面**: http://localhost:8000
- **仪表板**: http://localhost:8000/dashboard
- **API 文档**: http://localhost:8000/docs
### 默认登录凭据
- **用户名**: admin
- **密码**: admin-secret-key-change-in-production
## 📊 功能特性
### ✅ 与 Go 版本相同的功能
- Gitea Webhook 接收和处理
- Jenkins 任务触发
- 项目映射配置
- 分支模式匹配
- 重试机制
- 日志记录
### 🆕 Python 版本增强功能
- **Web 登录界面**: 基于 Bootstrap 5 的现代化界面
- **数据库存储**: SQLite 数据库存储 API 密钥和配置
- **JWT 认证**: 7 天有效期的 JWT 令牌
- **前端仪表板**: 多标签页管理界面
- **自动重定向**: 未认证用户自动跳转到登录页
- **健康检查**: 服务状态监控
- **统计信息**: 请求统计和性能指标
## 🔌 API 端点
### 认证相关
- `POST /api/auth/login` - 用户登录
- `GET /api/auth/verify` - 验证 JWT 令牌
### 项目管理
- `GET /api/projects` - 获取项目列表
- `POST /api/projects` - 创建新项目
- `PUT /api/projects/{id}` - 更新项目
- `DELETE /api/projects/{id}` - 删除项目
### API 密钥管理
- `GET /api/keys` - 获取 API 密钥列表
- `POST /api/keys` - 创建新 API 密钥
- `DELETE /api/keys/{id}` - 删除 API 密钥
### 系统监控
- `GET /api/health` - 健康检查
- `GET /api/stats` - 统计信息
- `GET /api/logs` - 日志查看
### Webhook 处理
- `POST /webhook` - Gitea Webhook 接收端点
## 🛠️ 开发
### 运行测试
```bash
# 使用 devbox
./devbox test
# 使用 Makefile
make test
# 直接运行
python test_enhanced_features.py
```
### 代码检查
```bash
# 使用 Makefile
make lint
# 直接运行
flake8 app/ --max-line-length=120 --ignore=E501,W503
```
### 清理
```bash
# 使用 devbox
./devbox clean
# 使用 Makefile
make clean
```
## 🐳 Docker 部署
### 构建镜像
```bash
# 使用 Makefile
make docker-build
# 直接构建
docker build -t gitea-webhook-ambassador:latest .
```
### 运行容器
```bash
docker run -d \
--name gitea-webhook-ambassador \
-p 8000:8000 \
-v $(pwd)/configs:/app/configs \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
gitea-webhook-ambassador:latest
```
## 📈 与 Go 版本对比
| 特性 | Go 版本 | Python 版本 |
|------|---------|-------------|
| **启动方式** | `./devbox start` | `./devbox start` |
| **目录结构** | 标准 Go 项目结构 | 与 Go 版本一致 |
| **配置文件** | `configs/config.yaml` | `configs/config.yaml` |
| **日志目录** | `logs/` | `logs/` |
| **数据目录** | `data/` | `data/` |
| **Web 界面** | ❌ 无 | ✅ 完整仪表板 |
| **数据库** | ❌ 无 | ✅ SQLite |
| **JWT 认证** | ❌ 无 | ✅ 7天有效期 |
| **API 密钥管理** | ❌ 无 | ✅ 数据库存储 |
| **健康检查** | ✅ 基础 | ✅ 增强版 |
| **性能** | 🚀 极高 | 🚀 高 |
## 🔄 迁移指南
### 从 Go 版本迁移到 Python 版本
1. **停止 Go 服务**
```bash
cd /path/to/go-version
./devbox stop
```
2. **启动 Python 服务**
```bash
cd /path/to/python-version
./devbox install
./devbox init
./devbox start
```
3. **验证服务**
```bash
./devbox status
curl http://localhost:8000/api/health
```
4. **配置 Webhook**
- 更新 Gitea Webhook URL 为新的 Python 服务地址
- 确保 Jenkins 配置正确
## 🆘 故障排除
### 常见问题
**1. 端口被占用**
```bash
# 检查端口占用
lsof -i :8000
# 停止占用进程
sudo kill -9 <PID>
```
**2. 虚拟环境问题**
```bash
# 重新创建虚拟环境
rm -rf venv
./devbox install
```
**3. 数据库问题**
```bash
# 重新初始化数据库
./devbox init
```
**4. 权限问题**
```bash
# 设置脚本权限
chmod +x devbox
```
### 日志查看
```bash
# 查看实时日志
./devbox follow
# 查看最新日志
./devbox logs
# 查看完整日志
tail -f logs/service.log
```
## 📞 支持
如有问题,请检查:
1. 服务状态:`./devbox status`
2. 日志信息:`./devbox logs`
3. 配置文件:`configs/config.yaml`
4. 网络连接:`curl http://localhost:8000/api/health`

View File

@ -0,0 +1,379 @@
# 🚀 Gitea Webhook Ambassador 使用指南
## 📋 目录
1. [快速开始](#快速开始)
2. [配置说明](#配置说明)
3. [API 接口](#api-接口)
4. [数据库管理](#数据库管理)
5. [监控和日志](#监控和日志)
6. [故障排除](#故障排除)
## 🚀 快速开始
### 1. 环境准备
```bash
# 克隆项目
cd freeleaps-ops/apps/gitea-webhook-ambassador-python
# 运行快速设置脚本
chmod +x scripts/setup.sh
./scripts/setup.sh
```
### 2. 配置环境
编辑 `.env` 文件,配置必要的参数:
```bash
# 编辑配置文件
nano .env
```
**必需配置**
```env
# Jenkins 配置
JENKINS_USERNAME=your_jenkins_username
JENKINS_TOKEN=your_jenkins_api_token
# 安全配置
SECURITY_SECRET_KEY=your-secret-key-here-make-it-long-and-random
```
### 3. 启动服务
```bash
# 方法1: 使用启动脚本
chmod +x scripts/start.sh
./scripts/start.sh
# 方法2: 手动启动
# 启动 Redis
docker run -d --name webhook-redis -p 6379:6379 redis:alpine
# 激活虚拟环境
source venv/bin/activate
# 启动 API 服务
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# 新终端启动 Celery worker
celery -A app.tasks.jenkins_tasks worker --loglevel=info --concurrency=4
# 新终端启动定时任务
celery -A app.tasks.jenkins_tasks beat --loglevel=info
```
### 4. 验证安装
访问以下地址验证服务是否正常:
- **API 文档**: http://localhost:8000/docs
- **健康检查**: http://localhost:8000/health
- **监控指标**: http://localhost:8000/metrics
## ⚙️ 配置说明
### 环境分发配置
编辑 `config/environments.yaml` 文件:
```yaml
environments:
dev:
branches: ["dev", "develop", "development", "feature/*"]
jenkins_job: "alpha-build"
jenkins_url: "https://jenkins-alpha.freeleaps.com"
priority: 2
prod:
branches: ["prod", "production", "main", "master", "release/*"]
jenkins_job: "production-build"
jenkins_url: "https://jenkins-prod.freeleaps.com"
priority: 1
```
### 防抖配置
```yaml
deduplication:
enabled: true
window_seconds: 300 # 5分钟防抖窗口
strategy: "commit_branch" # commit_hash + branch
cache_ttl: 3600 # 缓存1小时
```
## 🔧 API 接口
### Webhook 端点
**POST** `/webhook/gitea`
接收 Gitea webhook 事件:
```bash
curl -X POST "http://localhost:8000/webhook/gitea" \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: your-secret-key" \
-d '{
"ref": "refs/heads/dev",
"before": "abc123",
"after": "def456",
"repository": {
"full_name": "freeleaps/my-project",
"clone_url": "https://gitea.freeleaps.com/freeleaps/my-project.git"
},
"pusher": {
"login": "developer",
"email": "dev@freeleaps.com"
}
}'
```
### 健康检查
**GET** `/health`
```bash
curl http://localhost:8000/health
```
响应示例:
```json
{
"status": "healthy",
"timestamp": 1640995200.0,
"services": {
"redis": "healthy",
"celery": "healthy"
}
}
```
### 队列状态
**GET** `/health/queue`
```bash
curl http://localhost:8000/health/queue
```
响应示例:
```json
{
"status": "healthy",
"queue_stats": {
"active_tasks": 2,
"queued_tasks": 5,
"worker_count": 4,
"total_queue_length": 7
}
}
```
### 监控指标
**GET** `/metrics`
```bash
curl http://localhost:8000/metrics
```
返回 Prometheus 格式的监控指标。
## 🗄️ 数据库管理
### 创建项目映射
使用 Python 脚本创建项目映射:
```python
# create_mapping.py
import asyncio
from app.services.database_service import get_database_service
async def create_mapping():
db_service = get_database_service()
mapping_data = {
"repository_name": "freeleaps/my-project",
"default_job": "default-build",
"branch_jobs": [
{"branch_name": "dev", "job_name": "alpha-build"},
{"branch_name": "main", "job_name": "production-build"}
],
"branch_patterns": [
{"pattern": r"feature/.*", "job_name": "feature-build"},
{"pattern": r"hotfix/.*", "job_name": "hotfix-build"}
]
}
success = await db_service.create_project_mapping(mapping_data)
print(f"创建映射: {'成功' if success else '失败'}")
if __name__ == "__main__":
asyncio.run(create_mapping())
```
运行脚本:
```bash
python create_mapping.py
```
### 查看触发日志
```python
# view_logs.py
import asyncio
from app.services.database_service import get_database_service
async def view_logs():
db_service = get_database_service()
logs = await db_service.get_trigger_logs(
repository_name="freeleaps/my-project",
limit=10
)
for log in logs:
print(f"[{log['created_at']}] {log['repository_name']} - {log['branch_name']} - {log['status']}")
if __name__ == "__main__":
asyncio.run(view_logs())
```
## 📊 监控和日志
### 日志查看
```bash
# 查看应用日志
tail -f logs/app.log
# 查看 Celery 日志
tail -f logs/celery.log
```
### 监控面板
使用 Grafana 创建监控面板:
1. 访问 http://localhost:3000 (Grafana)
2. 用户名: `admin`, 密码: `admin`
3. 添加 Prometheus 数据源: http://prometheus:9090
4. 导入监控面板
### 关键指标
- **webhook_requests_total**: Webhook 请求总数
- **webhook_request_duration_seconds**: 请求响应时间
- **queue_size**: 队列长度
- **dedup_hits_total**: 防抖命中次数
## 🔧 故障排除
### 常见问题
#### 1. Redis 连接失败
```bash
# 检查 Redis 状态
docker ps | grep redis
# 重启 Redis
docker restart webhook-redis
```
#### 2. Celery Worker 无法启动
```bash
# 检查 Celery 配置
celery -A app.tasks.jenkins_tasks inspect active
# 重启 Worker
pkill -f "celery.*worker"
celery -A app.tasks.jenkins_tasks worker --loglevel=info
```
#### 3. Jenkins 连接失败
```bash
# 测试 Jenkins 连接
curl -u username:token https://jenkins.example.com/api/json
```
#### 4. 数据库错误
```bash
# 检查数据库文件
ls -la webhook_ambassador.db
# 重新初始化数据库
rm webhook_ambassador.db
python -c "from app.services.database_service import get_database_service; get_database_service()"
```
### 日志级别调整
编辑 `.env` 文件:
```env
LOGGING_LEVEL=DEBUG # 开发环境
LOGGING_LEVEL=INFO # 生产环境
```
### 性能调优
#### 增加并发处理能力
```env
QUEUE_MAX_CONCURRENT=20
```
#### 调整防抖窗口
```env
DEDUPLICATION_WINDOW_SECONDS=600 # 10分钟
```
## 🐳 Docker 部署
### 使用 Docker Compose
```bash
# 启动所有服务
docker-compose up -d
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f api
```
### 生产环境部署
```bash
# 构建镜像
docker build -t webhook-ambassador:latest .
# 运行容器
docker run -d \
--name webhook-ambassador \
-p 8000:8000 \
-v $(pwd)/config:/app/config \
-v $(pwd)/logs:/app/logs \
--env-file .env \
webhook-ambassador:latest
```
## 📞 支持
如果遇到问题,请检查:
1. 日志文件中的错误信息
2. 健康检查端点返回的状态
3. 监控指标中的异常数据
4. 网络连接和防火墙设置
更多帮助请参考项目文档或提交 Issue。

View File

@ -0,0 +1,93 @@
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import jwt
import secrets
import os
from typing import Optional
from ..models.database import get_db, APIKey
from ..config import settings
# JWT 配置
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24 * 7 # 7 天有效期
security = HTTPBearer()
class AuthMiddleware:
def __init__(self):
self.secret_key = JWT_SECRET_KEY
def create_access_token(self, data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=JWT_ALGORITHM)
return encoded_jwt
def verify_token(self, token: str):
try:
payload = jwt.decode(token, self.secret_key, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
def verify_api_key(self, api_key: str, db: Session):
"""验证 API 密钥"""
db_key = db.query(APIKey).filter(APIKey.key == api_key).first()
return db_key is not None
def generate_api_key(self) -> str:
"""生成新的 API 密钥"""
return secrets.token_urlsafe(32)
# 创建认证中间件实例
auth_middleware = AuthMiddleware()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""获取当前用户JWT 认证)"""
token = credentials.credentials
payload = auth_middleware.verify_token(token)
return payload
async def get_current_user_api_key(api_key: str = Depends(security), db: Session = Depends(get_db)):
"""获取当前用户API 密钥认证)"""
if not auth_middleware.verify_api_key(api_key.credentials, db):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key"
)
return {"api_key": api_key.credentials}
def require_auth(use_api_key: bool = False):
"""认证依赖装饰器"""
if use_api_key:
return get_current_user_api_key
else:
return get_current_user
def handle_auth_error(request, exc):
"""处理认证错误"""
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JSONResponse(
status_code=401,
content={"error": "Invalid or expired token"}
)
else:
return RedirectResponse(url="/login", status_code=303)

View File

@ -0,0 +1,189 @@
"""
配置管理模块
支持环境分发防抖策略和队列配置
"""
from typing import Dict, List, Optional
from pydantic import Field, validator
from pydantic_settings import BaseSettings
import yaml
from pathlib import Path
class EnvironmentConfig(BaseSettings):
"""环境配置"""
branches: List[str] = Field(default_factory=list)
jenkins_job: str
jenkins_url: str
priority: int = Field(default=1, ge=1, le=10)
class DeduplicationConfig(BaseSettings):
"""防抖配置"""
enabled: bool = True
window_seconds: int = Field(default=300, ge=1) # 5分钟防抖窗口
strategy: str = Field(default="commit_branch") # commit_hash + branch
cache_ttl: int = Field(default=3600, ge=1) # 缓存1小时
class QueueConfig(BaseSettings):
"""队列配置"""
max_concurrent: int = Field(default=10, ge=1)
max_retries: int = Field(default=3, ge=0)
retry_delay: int = Field(default=60, ge=1) # 秒
priority_levels: int = Field(default=3, ge=1, le=10)
class JenkinsConfig(BaseSettings):
"""Jenkins 配置"""
username: str
token: str
timeout: int = Field(default=30, ge=1)
retry_attempts: int = Field(default=3, ge=1)
class DatabaseConfig(BaseSettings):
"""数据库配置"""
url: str = Field(default="sqlite:///./webhook_ambassador.db")
echo: bool = False
pool_size: int = Field(default=10, ge=1)
max_overflow: int = Field(default=20, ge=0)
class RedisConfig(BaseSettings):
"""Redis 配置"""
url: str = Field(default="redis://localhost:6379/0")
password: Optional[str] = None
db: int = Field(default=0, ge=0)
class LoggingConfig(BaseSettings):
"""日志配置"""
level: str = Field(default="INFO")
format: str = Field(default="json")
file: Optional[str] = None
class SecurityConfig(BaseSettings):
"""安全配置"""
secret_key: str
webhook_secret_header: str = Field(default="X-Gitea-Signature")
rate_limit_per_minute: int = Field(default=100, ge=1)
class Settings(BaseSettings):
"""主配置类"""
# 基础配置
app_name: str = "Gitea Webhook Ambassador"
version: str = "1.0.0"
debug: bool = False
# 服务器配置
host: str = "0.0.0.0"
port: int = Field(default=8000, ge=1, le=65535)
# 数据库配置
database_url: str = Field(default="sqlite:///./webhook_ambassador.db")
# Redis 配置
redis_url: str = Field(default="redis://localhost:6379/0")
redis_password: str = Field(default="")
redis_db: int = Field(default=0)
# Jenkins 配置
jenkins_username: str = Field(default="admin")
jenkins_token: str = Field(default="")
jenkins_timeout: int = Field(default=30)
# 安全配置
security_secret_key: str = Field(default="")
security_webhook_secret_header: str = Field(default="X-Gitea-Signature")
security_rate_limit_per_minute: int = Field(default=100)
# 日志配置
logging_level: str = Field(default="INFO")
logging_format: str = Field(default="json")
logging_file: str = Field(default="")
# 队列配置
queue_max_concurrent: int = Field(default=10)
queue_max_retries: int = Field(default=3)
queue_retry_delay: int = Field(default=60)
queue_priority_levels: int = Field(default=3)
# 防抖配置
deduplication_enabled: bool = Field(default=True)
deduplication_window_seconds: int = Field(default=300)
deduplication_strategy: str = Field(default="commit_branch")
deduplication_cache_ttl: int = Field(default=3600)
# 业务配置
environments: Dict[str, EnvironmentConfig] = Field(default_factory=dict)
deduplication: DeduplicationConfig = DeduplicationConfig()
queue: QueueConfig = QueueConfig()
class Config:
env_file = ".env"
env_nested_delimiter = "__"
@validator("environments", pre=True)
def load_environments_from_file(cls, v):
"""从配置文件加载环境配置"""
if isinstance(v, dict) and v:
return v
# 尝试从配置文件加载
config_file = Path("config/environments.yaml")
if config_file.exists():
with open(config_file, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
return config_data.get("environments", {})
# 默认配置
return {
"dev": EnvironmentConfig(
branches=["dev", "develop", "development"],
jenkins_job="alpha-build",
jenkins_url="https://jenkins-alpha.example.com",
priority=2
),
"prod": EnvironmentConfig(
branches=["prod", "production", "main", "master"],
jenkins_job="production-build",
jenkins_url="https://jenkins-prod.example.com",
priority=1
),
"default": EnvironmentConfig(
branches=["*"],
jenkins_job="default-build",
jenkins_url="https://jenkins-default.example.com",
priority=3
)
}
def get_environment_for_branch(self, branch: str) -> Optional[EnvironmentConfig]:
"""根据分支名获取对应的环境配置"""
for env_name, env_config in self.environments.items():
if branch in env_config.branches or "*" in env_config.branches:
return env_config
return None
def get_environment_by_name(self, name: str) -> Optional[EnvironmentConfig]:
"""根据环境名获取配置"""
return self.environments.get(name)
# 全局配置实例
settings = Settings()
def get_settings() -> Settings:
"""获取配置实例"""
return settings
def reload_settings():
"""重新加载配置"""
global settings
settings = Settings()

View File

@ -0,0 +1,10 @@
"""
Handlers
包含所有 API 处理器
"""
from . import webhook
from . import health
from . import admin
__all__ = ["webhook", "health", "admin"]

View File

@ -0,0 +1,287 @@
"""
管理 API 处理器
提供项目映射和 API 密钥管理功能
"""
import secrets
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.api_key import APIKey
from app.models.project_mapping import ProjectMapping
from app.auth import get_current_user
router = APIRouter(prefix="/api/admin", tags=["admin"])
# API 密钥相关模型
class APIKeyResponse(BaseModel):
id: int
name: str
key_prefix: str
created_at: datetime
last_used: datetime
is_active: bool
class Config:
from_attributes = True
class CreateAPIKeyRequest(BaseModel):
name: str
class CreateAPIKeyResponse(BaseModel):
id: int
name: str
key: str
created_at: datetime
# 项目映射相关模型
class ProjectMappingRequest(BaseModel):
repository_name: str
default_job: str
branch_jobs: Optional[List[dict]] = []
branch_patterns: Optional[List[dict]] = []
class ProjectMappingResponse(BaseModel):
id: int
repository_name: str
default_job: str
branch_jobs: List[dict]
branch_patterns: List[dict]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# API 密钥管理端点
@router.get("/api-keys", response_model=List[APIKeyResponse])
async def list_api_keys(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""列出所有 API 密钥"""
try:
api_keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
return api_keys
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list API keys: {str(e)}")
@router.post("/api-keys", response_model=CreateAPIKeyResponse)
async def create_api_key(
request: CreateAPIKeyRequest,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建新的 API 密钥"""
try:
# 生成 API 密钥
api_key = secrets.token_urlsafe(32)
key_prefix = api_key[:8] # 显示前8位作为前缀
# 创建数据库记录
db_api_key = APIKey(
name=request.name,
key_hash=api_key, # 实际应用中应该哈希存储
key_prefix=key_prefix,
created_at=datetime.utcnow(),
last_used=datetime.utcnow(),
is_active=True
)
db.add(db_api_key)
db.commit()
db.refresh(db_api_key)
return CreateAPIKeyResponse(
id=db_api_key.id,
name=db_api_key.name,
key=api_key, # 只在创建时返回完整密钥
created_at=db_api_key.created_at
)
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to create API key: {str(e)}")
@router.delete("/api-keys/{key_id}")
async def delete_api_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除 API 密钥"""
try:
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(api_key)
db.commit()
return {"message": "API key deleted successfully"}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to delete API key: {str(e)}")
@router.post("/api-keys/{key_id}/revoke")
async def revoke_api_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""撤销 API 密钥"""
try:
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
api_key.is_active = False
db.commit()
return {"message": "API key revoked successfully"}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to revoke API key: {str(e)}")
# 项目映射管理端点
@router.get("/projects", response_model=List[ProjectMappingResponse])
async def list_project_mappings(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""列出所有项目映射"""
try:
mappings = db.query(ProjectMapping).order_by(ProjectMapping.created_at.desc()).all()
return mappings
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list project mappings: {str(e)}")
@router.post("/projects", response_model=ProjectMappingResponse)
async def create_project_mapping(
request: ProjectMappingRequest,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建项目映射"""
try:
# 检查是否已存在
existing = db.query(ProjectMapping).filter(
ProjectMapping.repository_name == request.repository_name
).first()
if existing:
raise HTTPException(status_code=400, detail="Project mapping already exists")
# 创建新映射
mapping = ProjectMapping(
repository_name=request.repository_name,
default_job=request.default_job,
branch_jobs=request.branch_jobs or [],
branch_patterns=request.branch_patterns or [],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(mapping)
db.commit()
db.refresh(mapping)
return mapping
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to create project mapping: {str(e)}")
@router.get("/projects/{repository_name}", response_model=ProjectMappingResponse)
async def get_project_mapping(
repository_name: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取项目映射"""
try:
mapping = db.query(ProjectMapping).filter(
ProjectMapping.repository_name == repository_name
).first()
if not mapping:
raise HTTPException(status_code=404, detail="Project mapping not found")
return mapping
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get project mapping: {str(e)}")
@router.delete("/projects/{repository_name}")
async def delete_project_mapping(
repository_name: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除项目映射"""
try:
mapping = db.query(ProjectMapping).filter(
ProjectMapping.repository_name == repository_name
).first()
if not mapping:
raise HTTPException(status_code=404, detail="Project mapping not found")
db.delete(mapping)
db.commit()
return {"message": "Project mapping deleted successfully"}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to delete project mapping: {str(e)}")
# 统计信息端点
@router.get("/stats")
async def get_admin_stats(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取管理统计信息"""
try:
# API 密钥统计
total_keys = db.query(APIKey).count()
active_keys = db.query(APIKey).filter(APIKey.is_active == True).count()
# 最近使用的密钥
recent_keys = db.query(APIKey).filter(
APIKey.last_used >= datetime.utcnow() - timedelta(days=7)
).count()
# 项目映射统计
total_mappings = db.query(ProjectMapping).count()
return {
"api_keys": {
"total": total_keys,
"active": active_keys,
"recently_used": recent_keys
},
"project_mappings": {
"total": total_mappings
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get admin stats: {str(e)}")

View File

@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
import os
from ..models.database import get_db, APIKey
from ..auth.middleware import auth_middleware
router = APIRouter(prefix="/api/auth", tags=["authentication"])
# 请求/响应模型
class LoginRequest(BaseModel):
secret_key: str
class LoginResponse(BaseModel):
token: str
class APIKeyCreate(BaseModel):
description: str
class APIKeyResponse(BaseModel):
id: int
key: str
description: Optional[str]
created_at: str
class Config:
from_attributes = True
class APIKeyList(BaseModel):
keys: List[APIKeyResponse]
# 获取管理员密钥
def get_admin_secret_key():
return os.getenv("ADMIN_SECRET_KEY", "admin-secret-key-change-in-production")
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""管理员登录"""
admin_key = get_admin_secret_key()
if request.secret_key != admin_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid secret key"
)
# 生成 JWT 令牌
token = auth_middleware.create_access_token(
data={"sub": "admin", "role": "admin"}
)
return LoginResponse(token=token)
@router.post("/keys", response_model=APIKeyResponse)
async def create_api_key(
request: APIKeyCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""创建新的 API 密钥"""
# 生成新的 API 密钥
api_key_value = auth_middleware.generate_api_key()
# 保存到数据库
db_key = APIKey(
key=api_key_value,
description=request.description
)
db.add(db_key)
db.commit()
db.refresh(db_key)
return APIKeyResponse(
id=db_key.id,
key=db_key.key,
description=db_key.description,
created_at=db_key.created_at.isoformat()
)
@router.get("/keys", response_model=APIKeyList)
async def list_api_keys(
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""获取所有 API 密钥"""
keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
return APIKeyList(
keys=[
APIKeyResponse(
id=key.id,
key=key.key,
description=key.description,
created_at=key.created_at.isoformat()
)
for key in keys
]
)
@router.delete("/keys/{key_id}")
async def delete_api_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""删除 API 密钥"""
key = db.query(APIKey).filter(APIKey.id == key_id).first()
if not key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found"
)
db.delete(key)
db.commit()
return {"message": "API key deleted successfully"}

View File

@ -0,0 +1,145 @@
"""
健康检查处理器
提供服务健康状态检查
"""
from datetime import datetime
from typing import Dict, Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.jenkins_service import get_jenkins_service
from app.services.queue_service import get_queue_service
from app.config import get_settings
router = APIRouter(prefix="/health", tags=["health"])
class JenkinsStatus(BaseModel):
status: str
message: str = None
class WorkerPoolStatus(BaseModel):
active_workers: int
queue_size: int
total_processed: int
total_failed: int
class HealthResponse(BaseModel):
status: str
service: str
version: str
timestamp: datetime
jenkins: JenkinsStatus
worker_pool: WorkerPoolStatus
database: Dict[str, Any]
@router.get("/", response_model=HealthResponse)
async def health_check(db: Session = Depends(get_db)):
"""
健康检查端点
检查服务各个组件的状态
"""
settings = get_settings()
# 检查 Jenkins 连接
jenkins_service = get_jenkins_service()
jenkins_status = JenkinsStatus(status="disconnected", message="Unable to connect to Jenkins server")
try:
if await jenkins_service.test_connection():
jenkins_status = JenkinsStatus(status="connected")
except Exception as e:
jenkins_status.message = f"Connection failed: {str(e)}"
# 获取工作池统计
queue_service = get_queue_service()
try:
stats = await queue_service.get_stats()
worker_pool_status = WorkerPoolStatus(
active_workers=stats.get("active_workers", 0),
queue_size=stats.get("queue_size", 0),
total_processed=stats.get("total_processed", 0),
total_failed=stats.get("total_failed", 0)
)
except Exception as e:
worker_pool_status = WorkerPoolStatus(
active_workers=0,
queue_size=0,
total_processed=0,
total_failed=0
)
# 检查数据库连接
database_status = {"status": "disconnected", "message": "Database connection failed"}
try:
# 尝试执行简单查询
db.execute("SELECT 1")
database_status = {"status": "connected"}
except Exception as e:
database_status["message"] = f"Database error: {str(e)}"
# 确定整体状态
overall_status = "healthy"
if jenkins_status.status != "connected":
overall_status = "unhealthy"
return HealthResponse(
status=overall_status,
service="Gitea Webhook Ambassador",
version=settings.version,
timestamp=datetime.utcnow(),
jenkins=jenkins_status,
worker_pool=worker_pool_status,
database=database_status
)
@router.get("/simple")
async def simple_health_check():
"""
简单健康检查端点
用于负载均衡器和监控系统
"""
return {
"status": "healthy",
"service": "Gitea Webhook Ambassador",
"version": "1.0.0"
}
@router.get("/ready")
async def readiness_check(db: Session = Depends(get_db)):
"""
就绪检查端点
检查服务是否准备好接收请求
"""
try:
# 检查数据库连接
db.execute("SELECT 1")
# 检查 Jenkins 连接
jenkins_service = get_jenkins_service()
jenkins_ready = await jenkins_service.test_connection()
if jenkins_ready:
return {"status": "ready"}
else:
return {"status": "not_ready", "reason": "Jenkins connection failed"}
except Exception as e:
return {"status": "not_ready", "reason": f"Database connection failed: {str(e)}"}
@router.get("/live")
async def liveness_check():
"""
存活检查端点
检查服务进程是否正常运行
"""
return {"status": "alive"}

View File

@ -0,0 +1,106 @@
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.trigger_log import TriggerLog
from app.auth import get_current_user
router = APIRouter(prefix="/api/logs", tags=["logs"])
class TriggerLogResponse(BaseModel):
id: int
repository_name: str
branch_name: str
commit_sha: str
job_name: str
status: str
error_message: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
@router.get("/", response_model=List[TriggerLogResponse])
async def get_trigger_logs(
repository: Optional[str] = Query(None, description="Repository name filter"),
branch: Optional[str] = Query(None, description="Branch name filter"),
since: Optional[str] = Query(None, description="Since timestamp (RFC3339 format)"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of logs to return"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
获取触发日志
"""
try:
# 构建查询
query = db.query(TriggerLog)
# 应用过滤器
if repository:
query = query.filter(TriggerLog.repository_name == repository)
if branch:
query = query.filter(TriggerLog.branch_name == branch)
if since:
try:
since_time = datetime.fromisoformat(since.replace('Z', '+00:00'))
query = query.filter(TriggerLog.created_at >= since_time)
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid since parameter format (use RFC3339)"
)
# 按时间倒序排列并限制数量
logs = query.order_by(TriggerLog.created_at.desc()).limit(limit).all()
return logs
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get trigger logs: {str(e)}")
@router.get("/stats")
async def get_log_stats(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
获取日志统计信息
"""
try:
# 总日志数
total_logs = db.query(TriggerLog).count()
# 成功和失败的日志数
successful_logs = db.query(TriggerLog).filter(TriggerLog.status == "success").count()
failed_logs = db.query(TriggerLog).filter(TriggerLog.status == "failed").count()
# 最近24小时的日志数
yesterday = datetime.utcnow() - timedelta(days=1)
recent_logs = db.query(TriggerLog).filter(TriggerLog.created_at >= yesterday).count()
# 按仓库分组的统计
repo_stats = db.query(
TriggerLog.repository_name,
db.func.count(TriggerLog.id).label('count')
).group_by(TriggerLog.repository_name).all()
return {
"total_logs": total_logs,
"successful_logs": successful_logs,
"failed_logs": failed_logs,
"recent_logs_24h": recent_logs,
"repository_stats": [
{"repository": repo, "count": count}
for repo, count in repo_stats
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get log stats: {str(e)}")

View File

@ -0,0 +1,161 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from ..models.database import get_db, ProjectMapping, BranchJob, BranchPattern
from ..auth.middleware import auth_middleware
router = APIRouter(prefix="/api/projects", tags=["projects"])
# 请求/响应模型
class ProjectCreate(BaseModel):
name: str
jenkinsJob: str
giteaRepo: str
class ProjectResponse(BaseModel):
id: int
name: str
jenkinsJob: str
giteaRepo: str
created_at: str
class Config:
from_attributes = True
class ProjectList(BaseModel):
projects: List[ProjectResponse]
@router.post("/", response_model=ProjectResponse)
async def create_project(
request: ProjectCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""创建新项目映射"""
# 检查项目是否已存在
existing_project = db.query(ProjectMapping).filter(
ProjectMapping.repository_name == request.giteaRepo
).first()
if existing_project:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Project with this repository already exists"
)
# 创建新项目
project = ProjectMapping(
repository_name=request.giteaRepo,
default_job=request.jenkinsJob
)
db.add(project)
db.commit()
db.refresh(project)
return ProjectResponse(
id=project.id,
name=request.name,
jenkinsJob=project.default_job,
giteaRepo=project.repository_name,
created_at=project.created_at.isoformat()
)
@router.get("/", response_model=ProjectList)
async def list_projects(
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""获取所有项目"""
projects = db.query(ProjectMapping).order_by(ProjectMapping.created_at.desc()).all()
return ProjectList(
projects=[
ProjectResponse(
id=project.id,
name=project.repository_name.split('/')[-1], # 使用仓库名作为项目名
jenkinsJob=project.default_job,
giteaRepo=project.repository_name,
created_at=project.created_at.isoformat()
)
for project in projects
]
)
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""获取特定项目"""
project = db.query(ProjectMapping).filter(ProjectMapping.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
return ProjectResponse(
id=project.id,
name=project.repository_name.split('/')[-1],
jenkinsJob=project.default_job,
giteaRepo=project.repository_name,
created_at=project.created_at.isoformat()
)
@router.delete("/{project_id}")
async def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(auth_middleware.get_current_user)
):
"""删除项目"""
project = db.query(ProjectMapping).filter(ProjectMapping.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found"
)
db.delete(project)
db.commit()
return {"message": "Project deleted successfully"}
@router.get("/mapping/{repository_name}")
async def get_project_mapping(
repository_name: str,
db: Session = Depends(get_db)
):
"""根据仓库名获取项目映射(用于 webhook 处理)"""
project = db.query(ProjectMapping).filter(
ProjectMapping.repository_name == repository_name
).first()
if not project:
return None
return {
"id": project.id,
"repository_name": project.repository_name,
"default_job": project.default_job,
"branch_jobs": [
{
"branch_name": job.branch_name,
"job_name": job.job_name
}
for job in project.branch_jobs
],
"branch_patterns": [
{
"pattern": pattern.pattern,
"job_name": pattern.job_name
}
for pattern in project.branch_patterns
]
}

View File

@ -0,0 +1,42 @@
"""
Webhook 处理器
处理来自 Gitea webhook 请求
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from app.services.webhook_service import WebhookService
from app.services.dedup_service import DeduplicationService
from app.tasks.jenkins_tasks import get_celery_app
router = APIRouter()
def get_webhook_service() -> WebhookService:
"""获取 webhook 服务实例"""
# 这里应该从依赖注入容器获取
# 暂时返回 None实际使用时需要正确实现
return None
@router.post("/gitea")
async def handle_gitea_webhook(
request: Request,
webhook_service: WebhookService = Depends(get_webhook_service)
):
"""处理 Gitea webhook 请求"""
if webhook_service is None:
raise HTTPException(status_code=503, detail="Webhook service not available")
try:
# 获取请求体
body = await request.body()
# 处理 webhook
result = await webhook_service.process_webhook(body, request.headers)
return {
"success": True,
"message": "Webhook processed successfully",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,383 @@
"""
FastAPI 应用主入口
集成 Webhook 处理防抖队列管理等服务
"""
import asyncio
from contextlib import asynccontextmanager
from typing import Dict, Any
import structlog
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from redis import asyncio as aioredis
from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
from app.config import get_settings
from app.services.dedup_service import DeduplicationService
from app.services.jenkins_service import JenkinsService
from app.services.webhook_service import WebhookService
from app.tasks.jenkins_tasks import get_celery_app
# 路由导入将在运行时动态处理
# 配置结构化日志
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.processors.JSONRenderer()
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
logger = structlog.get_logger()
# 监控指标
WEBHOOK_REQUESTS_TOTAL = Counter(
"webhook_requests_total",
"Total number of webhook requests",
["status", "environment"]
)
WEBHOOK_REQUEST_DURATION = Histogram(
"webhook_request_duration_seconds",
"Webhook request duration in seconds",
["environment"]
)
QUEUE_SIZE = Gauge(
"queue_size",
"Current queue size",
["queue_type"]
)
DEDUP_HITS = Counter(
"dedup_hits_total",
"Total number of deduplication hits"
)
# 全局服务实例
dedup_service: DeduplicationService = None
jenkins_service: JenkinsService = None
webhook_service: WebhookService = None
celery_app = None
redis_client: aioredis.Redis = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
global dedup_service, jenkins_service, webhook_service, celery_app, redis_client
# 启动时初始化
logger.info("Starting Gitea Webhook Ambassador")
try:
# 初始化 Redis 连接
settings = get_settings()
redis_client = aioredis.from_url(
settings.redis.url,
password=settings.redis.password,
db=settings.redis.db,
encoding="utf-8",
decode_responses=True
)
# 测试 Redis 连接
await redis_client.ping()
logger.info("Redis connection established")
# 初始化 Celery
celery_app = get_celery_app()
# 初始化服务
dedup_service = DeduplicationService(redis_client)
jenkins_service = JenkinsService()
webhook_service = WebhookService(
dedup_service=dedup_service,
jenkins_service=jenkins_service,
celery_app=celery_app
)
logger.info("All services initialized successfully")
yield
except Exception as e:
logger.error("Failed to initialize services", error=str(e))
raise
finally:
# 关闭时清理
logger.info("Shutting down Gitea Webhook Ambassador")
if redis_client:
await redis_client.close()
logger.info("Redis connection closed")
# 创建 FastAPI 应用
app = FastAPI(
title="Gitea Webhook Ambassador",
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务",
version="1.0.0",
lifespan=lifespan
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 依赖注入
def get_dedup_service() -> DeduplicationService:
if dedup_service is None:
raise HTTPException(status_code=503, detail="Deduplication service not available")
return dedup_service
def get_webhook_service() -> WebhookService:
if webhook_service is None:
raise HTTPException(status_code=503, detail="Webhook service not available")
return webhook_service
def get_celery_app_dep():
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery app not available")
return celery_app
# 中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""请求日志中间件"""
start_time = asyncio.get_event_loop().time()
# 记录请求开始
logger.info("Request started",
method=request.method,
url=str(request.url),
client_ip=request.client.host if request.client else None)
try:
response = await call_next(request)
# 记录请求完成
process_time = asyncio.get_event_loop().time() - start_time
logger.info("Request completed",
method=request.method,
url=str(request.url),
status_code=response.status_code,
process_time=process_time)
return response
except Exception as e:
# 记录请求错误
process_time = asyncio.get_event_loop().time() - start_time
logger.error("Request failed",
method=request.method,
url=str(request.url),
error=str(e),
process_time=process_time)
raise
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
"""添加安全头"""
response = await call_next(request)
# 添加安全相关的 HTTP 头
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# 异常处理器
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理器"""
logger.error("Unhandled exception",
method=request.method,
url=str(request.url),
error=str(exc),
exc_info=True)
return JSONResponse(
status_code=500,
content={
"success": False,
"message": "Internal server error",
"error": str(exc) if get_settings().debug else "An unexpected error occurred"
}
)
# 健康检查端点
@app.get("/health")
async def health_check():
"""基础健康检查"""
try:
# 检查 Redis 连接
if redis_client:
await redis_client.ping()
redis_healthy = True
else:
redis_healthy = False
# 检查 Celery 连接
if celery_app:
inspect = celery_app.control.inspect()
celery_healthy = bool(inspect.active() is not None)
else:
celery_healthy = False
return {
"status": "healthy" if redis_healthy and celery_healthy else "unhealthy",
"timestamp": asyncio.get_event_loop().time(),
"services": {
"redis": "healthy" if redis_healthy else "unhealthy",
"celery": "healthy" if celery_healthy else "unhealthy"
}
}
except Exception as e:
logger.error("Health check failed", error=str(e))
return JSONResponse(
status_code=503,
content={
"status": "unhealthy",
"error": str(e)
}
)
@app.get("/health/queue")
async def queue_health_check():
"""队列健康检查"""
try:
if celery_app is None:
return JSONResponse(
status_code=503,
content={"status": "unhealthy", "error": "Celery not available"}
)
inspect = celery_app.control.inspect()
# 获取队列统计
active = inspect.active()
reserved = inspect.reserved()
registered = inspect.registered()
active_count = sum(len(tasks) for tasks in (active or {}).values())
reserved_count = sum(len(tasks) for tasks in (reserved or {}).values())
worker_count = len(registered or {})
# 更新监控指标
QUEUE_SIZE.labels(queue_type="active").set(active_count)
QUEUE_SIZE.labels(queue_type="reserved").set(reserved_count)
return {
"status": "healthy",
"queue_stats": {
"active_tasks": active_count,
"queued_tasks": reserved_count,
"worker_count": worker_count,
"total_queue_length": active_count + reserved_count
}
}
except Exception as e:
logger.error("Queue health check failed", error=str(e))
return JSONResponse(
status_code=503,
content={
"status": "unhealthy",
"error": str(e)
}
)
# 监控指标端点
@app.get("/metrics")
async def metrics():
"""Prometheus 监控指标"""
return Response(
content=generate_latest(),
media_type=CONTENT_TYPE_LATEST
)
# 包含路由模块
try:
from app.handlers import webhook, health, admin
app.include_router(
webhook.router,
prefix="/webhook",
tags=["webhook"]
)
app.include_router(
health.router,
prefix="/health",
tags=["health"]
)
app.include_router(
admin.router,
prefix="/admin",
tags=["admin"]
)
except ImportError as e:
# 如果模块不存在,记录警告但不中断应用启动
logger.warning(f"Some handlers not available: {e}")
# 根路径
@app.get("/")
async def root():
"""根路径"""
return {
"name": "Gitea Webhook Ambassador",
"version": "1.0.0",
"description": "高性能的 Gitea 到 Jenkins 的 Webhook 服务",
"endpoints": {
"webhook": "/webhook/gitea",
"health": "/health",
"metrics": "/metrics",
"admin": "/admin"
}
}
if __name__ == "__main__":
import uvicorn
settings = get_settings()
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=settings.debug,
log_level=settings.logging.level.lower()
)

View File

@ -0,0 +1,438 @@
from fastapi import FastAPI, Request, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import structlog
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from pydantic import BaseModel
import secrets
from app.config import get_settings
# 配置日志
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.processors.JSONRenderer()
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
logger = structlog.get_logger()
# 创建 FastAPI 应用
app = FastAPI(
title="Gitea Webhook Ambassador (Demo)",
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务 - 演示版本",
version="1.0.0"
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 安全配置
security = HTTPBearer(auto_error=False)
# 演示数据存储
api_keys = {
"demo_admin_key": {
"name": "演示管理员密钥",
"key_hash": "demo_admin_key",
"key_prefix": "demo_adm",
"created_at": datetime.utcnow(),
"last_used": datetime.utcnow(),
"is_active": True,
"role": "admin"
},
"demo_user_key": {
"name": "演示用户密钥",
"key_hash": "demo_user_key",
"key_prefix": "demo_usr",
"created_at": datetime.utcnow(),
"last_used": datetime.utcnow(),
"is_active": True,
"role": "user"
}
}
trigger_logs = [
{
"id": 1,
"repository_name": "freeleaps/test-project",
"branch_name": "main",
"commit_sha": "abc123def456",
"job_name": "test-project-build",
"status": "success",
"error_message": None,
"created_at": datetime.utcnow() - timedelta(hours=2)
},
{
"id": 2,
"repository_name": "freeleaps/another-project",
"branch_name": "dev",
"commit_sha": "def456ghi789",
"job_name": "another-project-dev",
"status": "success",
"error_message": None,
"created_at": datetime.utcnow() - timedelta(hours=1)
},
{
"id": 3,
"repository_name": "freeleaps/test-project",
"branch_name": "feature/new-feature",
"commit_sha": "ghi789jkl012",
"job_name": "test-project-feature",
"status": "failed",
"error_message": "Build timeout",
"created_at": datetime.utcnow() - timedelta(minutes=30)
}
]
project_mappings = {
1: {
"repository_name": "freeleaps/test-project",
"default_job": "test-project-build",
"branch_jobs": [
{"branch": "dev", "job": "test-project-dev"},
{"branch": "staging", "job": "test-project-staging"}
],
"branch_patterns": [
{"pattern": "feature/*", "job": "test-project-feature"},
{"pattern": "hotfix/*", "job": "test-project-hotfix"}
],
"created_at": datetime.utcnow() - timedelta(days=1),
"updated_at": datetime.utcnow() - timedelta(hours=6)
}
}
# 请求/响应模型
class HealthResponse(BaseModel):
status: str
service: str
version: str
timestamp: datetime
jenkins: Dict[str, Any]
worker_pool: Dict[str, Any]
database: Dict[str, Any]
class TriggerLogResponse(BaseModel):
id: int
repository_name: str
branch_name: str
commit_sha: str
job_name: str
status: str
error_message: Optional[str] = None
created_at: datetime
class APIKeyResponse(BaseModel):
id: str
name: str
key_prefix: str
created_at: datetime
last_used: datetime
is_active: bool
role: str
class ProjectMappingResponse(BaseModel):
id: int
repository_name: str
default_job: str
branch_jobs: List[dict]
branch_patterns: List[dict]
created_at: datetime
updated_at: datetime
# 认证函数
def verify_api_key(api_key: str):
"""验证 API 密钥"""
for key_id, key_data in api_keys.items():
if key_data["key_hash"] == api_key and key_data["is_active"]:
# 更新最后使用时间
key_data["last_used"] = datetime.utcnow()
return key_data
return None
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
):
"""获取当前用户(支持 API 密钥认证)"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="需要认证令牌",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
# 验证 API 密钥
api_key_data = verify_api_key(token)
if api_key_data:
return {
"username": api_key_data["name"],
"auth_type": "api_key",
"role": api_key_data["role"]
}
# 认证失败
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证令牌",
headers={"WWW-Authenticate": "Bearer"},
)
# 公开端点
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""健康检查端点"""
settings = get_settings()
return HealthResponse(
status="healthy",
service="Gitea Webhook Ambassador (Demo)",
version=settings.version,
timestamp=datetime.utcnow(),
jenkins={"status": "connected"},
worker_pool={
"active_workers": 2,
"queue_size": 0,
"total_processed": len(trigger_logs),
"total_failed": len([log for log in trigger_logs if log["status"] == "failed"])
},
database={"status": "connected"}
)
@app.get("/")
async def root():
"""根路径"""
return {
"name": "Gitea Webhook Ambassador (Demo)",
"version": "1.0.0",
"description": "高性能的 Gitea 到 Jenkins 的 Webhook 服务 - 演示版本",
"endpoints": {
"webhook": "/webhook/gitea",
"health": "/health",
"logs": "/api/logs",
"admin": "/api/admin"
},
"demo_keys": {
"admin": "demo_admin_key",
"user": "demo_user_key"
}
}
@app.post("/webhook/gitea")
async def handle_gitea_webhook(request: Request):
"""处理 Gitea webhook 请求"""
try:
body = await request.body()
# 记录 webhook 请求
logger.info("Received Gitea webhook",
body_size=len(body),
headers=dict(request.headers))
# 添加新的触发日志
log_entry = {
"id": len(trigger_logs) + 1,
"repository_name": "demo-repo",
"branch_name": "main",
"commit_sha": "demo123",
"job_name": "demo-job",
"status": "success",
"error_message": None,
"created_at": datetime.utcnow()
}
trigger_logs.append(log_entry)
return {
"success": True,
"message": "Webhook received successfully",
"data": {
"body_size": len(body),
"timestamp": datetime.utcnow().isoformat()
}
}
except Exception as e:
logger.error("Webhook processing failed", error=str(e))
return JSONResponse(
status_code=500,
content={
"success": False,
"message": "Webhook processing failed",
"error": str(e)
}
)
# 需要认证的端点
@app.get("/api/logs", response_model=List[TriggerLogResponse])
async def get_trigger_logs(
repository: Optional[str] = None,
branch: Optional[str] = None,
limit: int = 100,
current_user: dict = Depends(get_current_user)
):
"""获取触发日志"""
print(f"用户 {current_user['username']} 访问日志端点")
filtered_logs = trigger_logs.copy()
if repository:
filtered_logs = [log for log in filtered_logs if log["repository_name"] == repository]
if branch:
filtered_logs = [log for log in filtered_logs if log["branch_name"] == branch]
# 按时间倒序排列并限制数量
filtered_logs.sort(key=lambda x: x["created_at"], reverse=True)
return filtered_logs[:limit]
@app.get("/api/logs/stats")
async def get_log_stats(current_user: dict = Depends(get_current_user)):
"""获取日志统计信息"""
print(f"用户 {current_user['username']} 访问日志统计")
total_logs = len(trigger_logs)
successful_logs = len([log for log in trigger_logs if log["status"] == "success"])
failed_logs = len([log for log in trigger_logs if log["status"] == "failed"])
# 最近24小时的日志数
yesterday = datetime.utcnow() - timedelta(days=1)
recent_logs = len([log for log in trigger_logs if log["created_at"] >= yesterday])
# 按仓库分组的统计
repo_stats = {}
for log in trigger_logs:
repo = log["repository_name"]
repo_stats[repo] = repo_stats.get(repo, 0) + 1
return {
"total_logs": total_logs,
"successful_logs": successful_logs,
"failed_logs": failed_logs,
"recent_logs_24h": recent_logs,
"repository_stats": [
{"repository": repo, "count": count}
for repo, count in repo_stats.items()
]
}
@app.get("/api/admin/api-keys", response_model=List[APIKeyResponse])
async def list_api_keys(current_user: dict = Depends(get_current_user)):
"""列出所有 API 密钥(仅管理员)"""
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
print(f"管理员 {current_user['username']} 查看 API 密钥列表")
return [
APIKeyResponse(
id=key_id,
name=key_data["name"],
key_prefix=key_data["key_prefix"],
created_at=key_data["created_at"],
last_used=key_data["last_used"],
is_active=key_data["is_active"],
role=key_data["role"]
)
for key_id, key_data in api_keys.items()
]
@app.get("/api/admin/projects", response_model=List[ProjectMappingResponse])
async def list_project_mappings(current_user: dict = Depends(get_current_user)):
"""列出所有项目映射"""
print(f"用户 {current_user['username']} 查看项目映射")
return [
ProjectMappingResponse(
id=mapping_id,
repository_name=mapping_data["repository_name"],
default_job=mapping_data["default_job"],
branch_jobs=mapping_data["branch_jobs"],
branch_patterns=mapping_data["branch_patterns"],
created_at=mapping_data["created_at"],
updated_at=mapping_data["updated_at"]
)
for mapping_id, mapping_data in project_mappings.items()
]
@app.get("/api/admin/stats")
async def get_admin_stats(current_user: dict = Depends(get_current_user)):
"""获取管理统计信息"""
print(f"用户 {current_user['username']} 查看管理统计")
total_keys = len(api_keys)
active_keys = len([key for key in api_keys.values() if key["is_active"]])
# 最近使用的密钥
week_ago = datetime.utcnow() - timedelta(days=7)
recent_keys = len([
key for key in api_keys.values()
if key["last_used"] >= week_ago
])
total_mappings = len(project_mappings)
return {
"api_keys": {
"total": total_keys,
"active": active_keys,
"recently_used": recent_keys
},
"project_mappings": {
"total": total_mappings
}
}
# 中间件
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""请求日志中间件"""
start_time = datetime.utcnow()
response = await call_next(request)
process_time = (datetime.utcnow() - start_time).total_seconds()
response.headers["X-Process-Time"] = str(process_time)
return response
if __name__ == "__main__":
import uvicorn
settings = get_settings()
print("🚀 启动 Gitea Webhook Ambassador 演示版本")
print("=" * 60)
print("📋 演示 API 密钥:")
print(" 管理员密钥: demo_admin_key")
print(" 用户密钥: demo_user_key")
print()
print("🔧 使用示例:")
print(" curl -H 'Authorization: Bearer demo_admin_key' http://localhost:8000/api/admin/api-keys")
print(" curl -H 'Authorization: Bearer demo_user_key' http://localhost:8000/api/logs")
print("=" * 60)
uvicorn.run(
"app.main_demo:app",
host=settings.server.host,
port=settings.server.port,
reload=settings.server.reload
)

View File

@ -0,0 +1,313 @@
from fastapi import FastAPI, Request, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
import os
import time
import psutil
from datetime import datetime, timedelta
# 导入数据库模型
from app.models.database import create_tables, get_db, APIKey, ProjectMapping, TriggerLog
from app.auth.middleware import auth_middleware, get_current_user
from app.config import settings
# 创建 FastAPI 应用
app = FastAPI(
title="Gitea Webhook Ambassador",
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务",
version="2.0.0"
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 创建数据库表
create_tables()
# 挂载静态文件
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# 设置模板
templates = Jinja2Templates(directory="app/templates")
# 启动时间
start_time = datetime.now()
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""根路径 - 重定向到登录页"""
return RedirectResponse(url="/login")
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""登录页面"""
return templates.TemplateResponse("login.html", {"request": request})
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard_page(request: Request):
"""仪表板页面"""
return templates.TemplateResponse("dashboard.html", {"request": request})
@app.post("/api/auth/login")
async def login(request: dict):
"""管理员登录"""
admin_key = os.getenv("ADMIN_SECRET_KEY", "admin-secret-key-change-in-production")
if request.get("secret_key") != admin_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid secret key"
)
# 生成 JWT 令牌
token = auth_middleware.create_access_token(
data={"sub": "admin", "role": "admin"}
)
return {"token": token}
@app.get("/api/stats")
async def get_stats(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""获取统计信息"""
try:
# 获取项目总数
total_projects = db.query(ProjectMapping).count()
# 获取 API 密钥总数
total_api_keys = db.query(APIKey).count()
# 获取今日触发次数
today = datetime.now().date()
today_triggers = db.query(TriggerLog).filter(
TriggerLog.created_at >= today
).count()
# 获取成功触发次数
successful_triggers = db.query(TriggerLog).filter(
TriggerLog.status == "success"
).count()
return {
"total_projects": total_projects,
"total_api_keys": total_api_keys,
"today_triggers": today_triggers,
"successful_triggers": successful_triggers
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取统计信息失败: {str(e)}")
@app.get("/api/keys", response_model=dict)
async def list_api_keys(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""获取所有 API 密钥(兼容前端)"""
try:
keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
return {
"keys": [
{
"id": key.id,
"key": key.key,
"description": key.description,
"created_at": key.created_at.isoformat()
}
for key in keys
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取 API 密钥失败: {str(e)}")
@app.post("/api/keys", response_model=dict)
async def create_api_key(
request: dict,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建新的 API 密钥(兼容前端)"""
try:
# 生成新的 API 密钥
api_key_value = auth_middleware.generate_api_key()
# 保存到数据库
db_key = APIKey(
key=api_key_value,
description=request.get("description", "")
)
db.add(db_key)
db.commit()
db.refresh(db_key)
return {
"id": db_key.id,
"key": db_key.key,
"description": db_key.description,
"created_at": db_key.created_at.isoformat()
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建 API 密钥失败: {str(e)}")
@app.delete("/api/keys/{key_id}")
async def delete_api_key(
key_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除 API 密钥(兼容前端)"""
try:
key = db.query(APIKey).filter(APIKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API 密钥不存在")
db.delete(key)
db.commit()
return {"message": "API 密钥删除成功"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除 API 密钥失败: {str(e)}")
@app.get("/api/projects/", response_model=dict)
async def list_projects(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
"""获取所有项目(兼容前端)"""
try:
projects = db.query(ProjectMapping).order_by(ProjectMapping.created_at.desc()).all()
return {
"projects": [
{
"id": project.id,
"name": project.repository_name.split('/')[-1],
"jenkinsJob": project.default_job,
"giteaRepo": project.repository_name,
"created_at": project.created_at.isoformat()
}
for project in projects
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取项目列表失败: {str(e)}")
@app.post("/api/projects/", response_model=dict)
async def create_project(
request: dict,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建新项目(兼容前端)"""
try:
# 检查项目是否已存在
existing_project = db.query(ProjectMapping).filter(
ProjectMapping.repository_name == request["giteaRepo"]
).first()
if existing_project:
raise HTTPException(status_code=400, detail="项目已存在")
# 创建新项目
project = ProjectMapping(
repository_name=request["giteaRepo"],
default_job=request["jenkinsJob"]
)
db.add(project)
db.commit()
db.refresh(project)
return {
"id": project.id,
"name": request["name"],
"jenkinsJob": project.default_job,
"giteaRepo": project.repository_name,
"created_at": project.created_at.isoformat()
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建项目失败: {str(e)}")
@app.delete("/api/projects/{project_id}")
async def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""删除项目(兼容前端)"""
try:
project = db.query(ProjectMapping).filter(ProjectMapping.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
db.delete(project)
db.commit()
return {"message": "项目删除成功"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除项目失败: {str(e)}")
@app.get("/health")
async def health_check():
"""健康检查端点"""
try:
# 计算运行时间
uptime = datetime.now() - start_time
uptime_str = str(uptime).split('.')[0] # 移除微秒
# 获取内存使用情况
process = psutil.Process()
memory_info = process.memory_info()
memory_mb = memory_info.rss / 1024 / 1024
return {
"status": "healthy",
"version": "2.0.0",
"uptime": uptime_str,
"memory": f"{memory_mb:.1f} MB",
"timestamp": datetime.now().isoformat()
}
except Exception as e:
return {
"status": "unhealthy",
"error": str(e),
"timestamp": datetime.now().isoformat()
}
@app.get("/api/logs")
async def get_logs(
startTime: str = None,
endTime: str = None,
level: str = None,
query: str = None,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""获取日志(简化版本)"""
try:
# 这里应该实现真正的日志查询逻辑
# 目前返回模拟数据
logs = [
{
"timestamp": datetime.now().isoformat(),
"level": "info",
"message": "系统运行正常"
}
]
return {"logs": logs}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取日志失败: {str(e)}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@ -0,0 +1,145 @@
"""
简化版 FastAPI 应用主入口
用于快速启动和测试
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import structlog
from datetime import datetime
from app.config import get_settings
from app.handlers.webhook import router as webhook_router
from app.handlers.health import router as health_router
from app.handlers.logs import router as logs_router
from app.handlers.admin import router as admin_router
# 配置日志
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.processors.JSONRenderer()
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
logger = structlog.get_logger()
# 创建 FastAPI 应用
app = FastAPI(
title="Gitea Webhook Ambassador",
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务",
version="1.0.0"
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 包含路由
app.include_router(webhook_router)
app.include_router(health_router)
app.include_router(logs_router)
app.include_router(admin_router)
@app.get("/")
async def root():
"""根路径"""
return {
"name": "Gitea Webhook Ambassador",
"version": "1.0.0",
"description": "高性能的 Gitea 到 Jenkins 的 Webhook 服务",
"endpoints": {
"webhook": "/webhook/gitea",
"health": "/health",
"metrics": "/metrics",
"logs": "/api/logs",
"admin": "/api/admin"
}
}
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""请求日志中间件"""
start_time = datetime.utcnow()
# 记录请求
logger.info(
"Request started",
method=request.method,
url=str(request.url),
client_ip=request.client.host if request.client else None
)
# 处理请求
response = await call_next(request)
# 计算处理时间
process_time = (datetime.utcnow() - start_time).total_seconds()
# 记录响应
logger.info(
"Request completed",
method=request.method,
url=str(request.url),
status_code=response.status_code,
process_time=process_time
)
# 添加处理时间到响应头
response.headers["X-Process-Time"] = str(process_time)
return response
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理器"""
logger.error(
"Unhandled exception",
method=request.method,
url=str(request.url),
error=str(exc),
exc_info=True
)
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"message": "An unexpected error occurred"
}
)
if __name__ == "__main__":
import uvicorn
settings = get_settings()
logger.info(
"Starting Gitea Webhook Ambassador",
host=settings.server.host,
port=settings.server.port,
version=settings.version
)
uvicorn.run(
"app.main_simple:app",
host=settings.server.host,
port=settings.server.port,
reload=settings.server.reload
)

View File

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class APIKey(Base):
"""API 密钥模型"""
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
key_hash = Column(String(255), nullable=False, unique=True, index=True)
key_prefix = Column(String(8), nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_used = Column(DateTime, default=datetime.utcnow, nullable=False)
is_active = Column(Boolean, default=True, nullable=False, index=True)
def __repr__(self):
return f"<APIKey(id={self.id}, name={self.name}, prefix={self.key_prefix}, active={self.is_active})>"

View File

@ -0,0 +1,92 @@
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from sqlalchemy.sql import func
from datetime import datetime
import os
Base = declarative_base()
class APIKey(Base):
__tablename__ = 'api_keys'
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, index=True, nullable=False)
description = Column(String(255), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class ProjectMapping(Base):
__tablename__ = 'project_mappings'
id = Column(Integer, primary_key=True, index=True)
repository_name = Column(String(255), unique=True, index=True, nullable=False)
default_job = Column(String(255), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 关系
branch_jobs = relationship("BranchJob", back_populates="project", cascade="all, delete-orphan")
branch_patterns = relationship("BranchPattern", back_populates="project", cascade="all, delete-orphan")
class BranchJob(Base):
__tablename__ = 'branch_jobs'
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey('project_mappings.id'), nullable=False)
branch_name = Column(String(255), nullable=False)
job_name = Column(String(255), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 关系
project = relationship("ProjectMapping", back_populates="branch_jobs")
class BranchPattern(Base):
__tablename__ = 'branch_patterns'
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey('project_mappings.id'), nullable=False)
pattern = Column(String(255), nullable=False)
job_name = Column(String(255), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 关系
project = relationship("ProjectMapping", back_populates="branch_patterns")
class TriggerLog(Base):
__tablename__ = 'trigger_logs'
id = Column(Integer, primary_key=True, index=True)
repository_name = Column(String(255), nullable=False)
branch_name = Column(String(255), nullable=False)
commit_sha = Column(String(255), nullable=False)
job_name = Column(String(255), nullable=False)
status = Column(String(50), nullable=False) # success, failed, pending
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
# 数据库配置
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./gitea_webhook_ambassador.db")
# 创建引擎
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
)
# 创建会话
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建表
def create_tables():
Base.metadata.create_all(bind=engine)
# 获取数据库会话
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,130 @@
"""
Gitea Webhook 数据模型
"""
from typing import List, Optional
from pydantic import BaseModel, Field
from datetime import datetime
class User(BaseModel):
"""Gitea 用户模型"""
id: int
login: str
full_name: Optional[str] = None
email: Optional[str] = None
username: Optional[str] = None
class Commit(BaseModel):
"""Git 提交模型"""
id: str
message: str
url: str
author: User
timestamp: Optional[datetime] = None
class Repository(BaseModel):
"""Git 仓库模型"""
id: int
name: str
owner: User
full_name: str
private: bool = False
clone_url: str
ssh_url: Optional[str] = None
html_url: str
default_branch: str = "main"
class GiteaWebhook(BaseModel):
"""Gitea Webhook 模型"""
secret: Optional[str] = None
ref: str
before: str
after: str
compare_url: Optional[str] = None
commits: List[Commit] = Field(default_factory=list)
repository: Repository
pusher: User
def get_branch_name(self) -> str:
"""从 ref 中提取分支名"""
prefix = "refs/heads/"
if self.ref.startswith(prefix):
return self.ref[len(prefix):]
return self.ref
def get_event_id(self) -> str:
"""生成唯一的事件 ID"""
return f"{self.repository.full_name}-{self.after}"
def get_commit_hash(self) -> str:
"""获取提交哈希"""
return self.after
def get_deduplication_key(self) -> str:
"""生成防抖键值"""
branch = self.get_branch_name()
return f"{self.after}:{branch}"
def is_push_event(self) -> bool:
"""判断是否为推送事件"""
return self.ref.startswith("refs/heads/")
def is_tag_event(self) -> bool:
"""判断是否为标签事件"""
return self.ref.startswith("refs/tags/")
def get_commit_message(self) -> str:
"""获取提交信息"""
if self.commits:
return self.commits[0].message
return ""
def get_author_info(self) -> dict:
"""获取作者信息"""
if self.commits:
author = self.commits[0].author
return {
"name": author.full_name or author.login,
"email": author.email,
"username": author.login
}
return {
"name": self.pusher.full_name or self.pusher.login,
"email": self.pusher.email,
"username": self.pusher.login
}
class WebhookEvent(BaseModel):
"""Webhook 事件模型"""
id: str
repository: str
branch: str
commit_hash: str
event_type: str
timestamp: datetime
payload: dict
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class WebhookResponse(BaseModel):
"""Webhook 响应模型"""
success: bool
message: str
event_id: Optional[str] = None
job_name: Optional[str] = None
environment: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}

View File

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, JSON
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class ProjectMapping(Base):
"""项目映射模型"""
__tablename__ = "project_mappings"
id = Column(Integer, primary_key=True, index=True)
repository_name = Column(String(255), nullable=False, unique=True, index=True)
default_job = Column(String(255), nullable=False)
branch_jobs = Column(JSON, nullable=False, default=list) # [{"branch": "dev", "job": "dev-build"}]
branch_patterns = Column(JSON, nullable=False, default=list) # [{"pattern": "feature/*", "job": "feature-build"}]
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<ProjectMapping(id={self.id}, repository={self.repository_name}, default_job={self.default_job})>"

View File

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class TriggerLog(Base):
"""触发日志模型"""
__tablename__ = "trigger_logs"
id = Column(Integer, primary_key=True, index=True)
repository_name = Column(String(255), nullable=False, index=True)
branch_name = Column(String(255), nullable=False, index=True)
commit_sha = Column(String(64), nullable=False)
job_name = Column(String(255), nullable=False)
status = Column(String(50), nullable=False, index=True) # success, failed, pending
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<TriggerLog(id={self.id}, repository={self.repository_name}, branch={self.branch_name}, status={self.status})>"

View File

@ -0,0 +1,398 @@
"""
数据库服务
实现项目映射分支模式匹配等功能
"""
import asyncio
from typing import Optional, List, Dict, Any
from datetime import datetime
import structlog
import re
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from sqlalchemy.sql import text
from app.config import get_settings
logger = structlog.get_logger()
Base = declarative_base()
# 数据库模型
class APIKey(Base):
"""API 密钥模型"""
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(255), unique=True, nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProjectMapping(Base):
"""项目映射模型"""
__tablename__ = "project_mappings"
id = Column(Integer, primary_key=True, autoincrement=True)
repository_name = Column(String(255), unique=True, nullable=False)
default_job = Column(String(255))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
branch_jobs = relationship("BranchJob", back_populates="project", cascade="all, delete-orphan")
branch_patterns = relationship("BranchPattern", back_populates="project", cascade="all, delete-orphan")
class BranchJob(Base):
"""分支任务映射模型"""
__tablename__ = "branch_jobs"
id = Column(Integer, primary_key=True, autoincrement=True)
project_id = Column(Integer, ForeignKey("project_mappings.id"), nullable=False)
branch_name = Column(String(255), nullable=False)
job_name = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
project = relationship("ProjectMapping", back_populates="branch_jobs")
class BranchPattern(Base):
"""分支模式映射模型"""
__tablename__ = "branch_patterns"
id = Column(Integer, primary_key=True, autoincrement=True)
project_id = Column(Integer, ForeignKey("project_mappings.id"), nullable=False)
pattern = Column(String(255), nullable=False)
job_name = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
project = relationship("ProjectMapping", back_populates="branch_patterns")
class TriggerLog(Base):
"""触发日志模型"""
__tablename__ = "trigger_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
repository_name = Column(String(255), nullable=False)
branch_name = Column(String(255), nullable=False)
commit_sha = Column(String(255), nullable=False)
job_name = Column(String(255), nullable=False)
status = Column(String(50), nullable=False)
error_message = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
class DatabaseService:
"""数据库服务"""
def __init__(self):
self.settings = get_settings()
self.engine = None
self.SessionLocal = None
self._init_database()
def _init_database(self):
"""初始化数据库"""
try:
self.engine = create_engine(
self.settings.database.url,
echo=self.settings.database.echo,
pool_size=self.settings.database.pool_size,
max_overflow=self.settings.database.max_overflow
)
# 创建表
Base.metadata.create_all(bind=self.engine)
# 创建会话工厂
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
logger.info("Database initialized successfully")
except Exception as e:
logger.error("Failed to initialize database", error=str(e))
raise
def get_session(self):
"""获取数据库会话"""
return self.SessionLocal()
async def get_project_mapping(self, repository_name: str) -> Optional[Dict[str, Any]]:
"""
获取项目映射
Args:
repository_name: 仓库名
Returns:
Dict: 项目映射信息
"""
try:
def _get_mapping():
session = self.get_session()
try:
project = session.query(ProjectMapping).filter(
ProjectMapping.repository_name == repository_name
).first()
if not project:
return None
# 构建返回数据
result = {
"id": project.id,
"repository_name": project.repository_name,
"default_job": project.default_job,
"branch_jobs": [],
"branch_patterns": []
}
# 添加分支任务映射
for branch_job in project.branch_jobs:
result["branch_jobs"].append({
"id": branch_job.id,
"branch_name": branch_job.branch_name,
"job_name": branch_job.job_name
})
# 添加分支模式映射
for pattern in project.branch_patterns:
result["branch_patterns"].append({
"id": pattern.id,
"pattern": pattern.pattern,
"job_name": pattern.job_name
})
return result
finally:
session.close()
# 在线程池中执行数据库操作
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _get_mapping)
except Exception as e:
logger.error("Failed to get project mapping",
repository_name=repository_name, error=str(e))
return None
async def determine_job_name(self, repository_name: str, branch_name: str) -> Optional[str]:
"""
根据分支名确定任务名
Args:
repository_name: 仓库名
branch_name: 分支名
Returns:
str: 任务名
"""
try:
project = await self.get_project_mapping(repository_name)
if not project:
return None
# 1. 检查精确分支匹配
for branch_job in project["branch_jobs"]:
if branch_job["branch_name"] == branch_name:
logger.debug("Found exact branch match",
branch=branch_name, job=branch_job["job_name"])
return branch_job["job_name"]
# 2. 检查模式匹配
for pattern in project["branch_patterns"]:
try:
if re.match(pattern["pattern"], branch_name):
logger.debug("Branch matched pattern",
branch=branch_name, pattern=pattern["pattern"],
job=pattern["job_name"])
return pattern["job_name"]
except re.error as e:
logger.error("Invalid regex pattern",
pattern=pattern["pattern"], error=str(e))
continue
# 3. 使用默认任务
if project["default_job"]:
logger.debug("Using default job",
branch=branch_name, job=project["default_job"])
return project["default_job"]
return None
except Exception as e:
logger.error("Failed to determine job name",
repository_name=repository_name, branch_name=branch_name,
error=str(e))
return None
async def log_trigger(self, log_data: Dict[str, Any]) -> bool:
"""
记录触发日志
Args:
log_data: 日志数据
Returns:
bool: 是否成功
"""
try:
def _log_trigger():
session = self.get_session()
try:
log = TriggerLog(
repository_name=log_data["repository_name"],
branch_name=log_data["branch_name"],
commit_sha=log_data["commit_sha"],
job_name=log_data["job_name"],
status=log_data["status"],
error_message=log_data.get("error_message")
)
session.add(log)
session.commit()
return True
except Exception as e:
session.rollback()
logger.error("Failed to log trigger", error=str(e))
return False
finally:
session.close()
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _log_trigger)
except Exception as e:
logger.error("Failed to log trigger", error=str(e))
return False
async def get_trigger_logs(self, repository_name: str = None,
branch_name: str = None, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取触发日志
Args:
repository_name: 仓库名可选
branch_name: 分支名可选
limit: 限制数量
Returns:
List: 日志列表
"""
try:
def _get_logs():
session = self.get_session()
try:
query = session.query(TriggerLog)
if repository_name:
query = query.filter(TriggerLog.repository_name == repository_name)
if branch_name:
query = query.filter(TriggerLog.branch_name == branch_name)
logs = query.order_by(TriggerLog.created_at.desc()).limit(limit).all()
return [
{
"id": log.id,
"repository_name": log.repository_name,
"branch_name": log.branch_name,
"commit_sha": log.commit_sha,
"job_name": log.job_name,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat()
}
for log in logs
]
finally:
session.close()
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _get_logs)
except Exception as e:
logger.error("Failed to get trigger logs", error=str(e))
return []
async def create_project_mapping(self, mapping_data: Dict[str, Any]) -> bool:
"""
创建项目映射
Args:
mapping_data: 映射数据
Returns:
bool: 是否成功
"""
try:
def _create_mapping():
session = self.get_session()
try:
# 创建项目映射
project = ProjectMapping(
repository_name=mapping_data["repository_name"],
default_job=mapping_data.get("default_job")
)
session.add(project)
session.flush() # 获取 ID
# 添加分支任务映射
for branch_job in mapping_data.get("branch_jobs", []):
job = BranchJob(
project_id=project.id,
branch_name=branch_job["branch_name"],
job_name=branch_job["job_name"]
)
session.add(job)
# 添加分支模式映射
for pattern in mapping_data.get("branch_patterns", []):
pattern_obj = BranchPattern(
project_id=project.id,
pattern=pattern["pattern"],
job_name=pattern["job_name"]
)
session.add(pattern_obj)
session.commit()
return True
except Exception as e:
session.rollback()
logger.error("Failed to create project mapping", error=str(e))
return False
finally:
session.close()
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _create_mapping)
except Exception as e:
logger.error("Failed to create project mapping", error=str(e))
return False
# 全局数据库服务实例
_database_service: Optional[DatabaseService] = None
def get_database_service() -> DatabaseService:
"""获取数据库服务实例"""
global _database_service
if _database_service is None:
_database_service = DatabaseService()
return _database_service

View File

@ -0,0 +1,223 @@
"""
防抖服务
实现基于 commit hash + 分支的去重策略
"""
import asyncio
import hashlib
import json
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
import structlog
from redis import asyncio as aioredis
from app.config import get_settings
logger = structlog.get_logger()
class DeduplicationService:
"""防抖服务"""
def __init__(self, redis_client: aioredis.Redis):
self.redis = redis_client
self.settings = get_settings()
self.cache_prefix = "webhook:dedup:"
async def is_duplicate(self, dedup_key: str) -> bool:
"""
检查是否为重复事件
Args:
dedup_key: 防抖键值 (commit_hash:branch)
Returns:
bool: True 表示重复False 表示新事件
"""
if not self.settings.deduplication.enabled:
return False
try:
cache_key = f"{self.cache_prefix}{dedup_key}"
# 检查是否在缓存中
exists = await self.redis.exists(cache_key)
if exists:
logger.info("Duplicate event detected", dedup_key=dedup_key)
return True
# 记录新事件
await self._record_event(cache_key, dedup_key)
logger.info("New event recorded", dedup_key=dedup_key)
return False
except Exception as e:
logger.error("Error checking duplication",
dedup_key=dedup_key, error=str(e))
# 出错时允许通过,避免阻塞
return False
async def _record_event(self, cache_key: str, dedup_key: str):
"""记录事件到缓存"""
try:
# 设置缓存TTL 为防抖窗口时间
ttl = self.settings.deduplication.cache_ttl
await self.redis.setex(cache_key, ttl, json.dumps({
"dedup_key": dedup_key,
"timestamp": datetime.utcnow().isoformat(),
"ttl": ttl
}))
# 同时记录到时间窗口缓存
window_key = f"{self.cache_prefix}window:{dedup_key}"
window_ttl = self.settings.deduplication.window_seconds
await self.redis.setex(window_key, window_ttl, "1")
except Exception as e:
logger.error("Error recording event",
cache_key=cache_key, error=str(e))
async def get_event_info(self, dedup_key: str) -> Optional[Dict[str, Any]]:
"""获取事件信息"""
try:
cache_key = f"{self.cache_prefix}{dedup_key}"
data = await self.redis.get(cache_key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error("Error getting event info",
dedup_key=dedup_key, error=str(e))
return None
async def clear_event(self, dedup_key: str) -> bool:
"""清除事件记录"""
try:
cache_key = f"{self.cache_prefix}{dedup_key}"
window_key = f"{self.cache_prefix}window:{dedup_key}"
# 删除两个缓存键
await self.redis.delete(cache_key, window_key)
logger.info("Event cleared", dedup_key=dedup_key)
return True
except Exception as e:
logger.error("Error clearing event",
dedup_key=dedup_key, error=str(e))
return False
async def get_stats(self) -> Dict[str, Any]:
"""获取防抖统计信息"""
try:
# 获取所有防抖键
pattern = f"{self.cache_prefix}*"
keys = await self.redis.keys(pattern)
# 统计不同类型的键
total_keys = len(keys)
window_keys = len([k for k in keys if b"window:" in k])
event_keys = total_keys - window_keys
# 获取配置信息
config = {
"enabled": self.settings.deduplication.enabled,
"window_seconds": self.settings.deduplication.window_seconds,
"cache_ttl": self.settings.deduplication.cache_ttl,
"strategy": self.settings.deduplication.strategy
}
return {
"total_keys": total_keys,
"window_keys": window_keys,
"event_keys": event_keys,
"config": config,
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Error getting deduplication stats", error=str(e))
return {"error": str(e)}
async def cleanup_expired_events(self) -> int:
"""清理过期事件"""
try:
pattern = f"{self.cache_prefix}*"
keys = await self.redis.keys(pattern)
cleaned_count = 0
for key in keys:
# 检查 TTL
ttl = await self.redis.ttl(key)
if ttl <= 0:
await self.redis.delete(key)
cleaned_count += 1
if cleaned_count > 0:
logger.info("Cleaned up expired events", count=cleaned_count)
return cleaned_count
except Exception as e:
logger.error("Error cleaning up expired events", error=str(e))
return 0
def generate_dedup_key(self, commit_hash: str, branch: str) -> str:
"""
生成防抖键值
Args:
commit_hash: 提交哈希
branch: 分支名
Returns:
str: 防抖键值
"""
if self.settings.deduplication.strategy == "commit_branch":
return f"{commit_hash}:{branch}"
elif self.settings.deduplication.strategy == "commit_only":
return commit_hash
elif self.settings.deduplication.strategy == "branch_only":
return branch
else:
# 默认使用 commit_hash:branch
return f"{commit_hash}:{branch}"
async def is_in_window(self, dedup_key: str) -> bool:
"""
检查是否在防抖时间窗口内
Args:
dedup_key: 防抖键值
Returns:
bool: True 表示在窗口内
"""
try:
window_key = f"{self.cache_prefix}window:{dedup_key}"
exists = await self.redis.exists(window_key)
return bool(exists)
except Exception as e:
logger.error("Error checking window",
dedup_key=dedup_key, error=str(e))
return False
# 全局防抖服务实例
_dedup_service: Optional[DeduplicationService] = None
def get_deduplication_service() -> DeduplicationService:
"""获取防抖服务实例"""
global _dedup_service
if _dedup_service is None:
# 这里需要从依赖注入获取 Redis 客户端
# 在实际使用时,应该通过依赖注入传入
raise RuntimeError("DeduplicationService not initialized")
return _dedup_service
def set_deduplication_service(service: DeduplicationService):
"""设置防抖服务实例"""
global _dedup_service
_dedup_service = service

View File

@ -0,0 +1,100 @@
"""
Jenkins 服务
提供与 Jenkins 的交互功能
"""
import aiohttp
import structlog
from typing import Optional, Dict, Any
from app.config import get_settings
logger = structlog.get_logger()
class JenkinsService:
"""Jenkins 服务类"""
def __init__(self):
self.settings = get_settings()
self.base_url = self.settings.jenkins.url
self.username = self.settings.jenkins.username
self.token = self.settings.jenkins.token
self.timeout = self.settings.jenkins.timeout
async def test_connection(self) -> bool:
"""测试 Jenkins 连接"""
try:
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.username, self.token)
async with session.get(
f"{self.base_url}/api/json",
auth=auth,
timeout=aiohttp.ClientTimeout(total=self.timeout)
) as response:
if response.status == 200:
logger.info("Jenkins connection test successful")
return True
else:
logger.warning(f"Jenkins connection test failed with status {response.status}")
return False
except Exception as e:
logger.error(f"Jenkins connection test failed: {str(e)}")
return False
async def trigger_job(self, job_name: str, parameters: Optional[Dict[str, Any]] = None) -> bool:
"""触发 Jenkins 任务"""
try:
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.username, self.token)
# 构建请求 URL
url = f"{self.base_url}/job/{job_name}/build"
# 如果有参数,使用参数化构建
if parameters:
url = f"{self.base_url}/job/{job_name}/buildWithParameters"
async with session.post(
url,
auth=auth,
params=parameters or {},
timeout=aiohttp.ClientTimeout(total=self.timeout)
) as response:
if response.status in [200, 201]:
logger.info(f"Successfully triggered Jenkins job: {job_name}")
return True
else:
logger.error(f"Failed to trigger Jenkins job {job_name}: {response.status}")
return False
except Exception as e:
logger.error(f"Error triggering Jenkins job {job_name}: {str(e)}")
return False
async def get_job_info(self, job_name: str) -> Optional[Dict[str, Any]]:
"""获取任务信息"""
try:
async with aiohttp.ClientSession() as session:
auth = aiohttp.BasicAuth(self.username, self.token)
async with session.get(
f"{self.base_url}/job/{job_name}/api/json",
auth=auth,
timeout=aiohttp.ClientTimeout(total=self.timeout)
) as response:
if response.status == 200:
return await response.json()
else:
logger.warning(f"Failed to get job info for {job_name}: {response.status}")
return None
except Exception as e:
logger.error(f"Error getting job info for {job_name}: {str(e)}")
return None
# 全局服务实例
_jenkins_service = None
def get_jenkins_service() -> JenkinsService:
"""获取 Jenkins 服务实例"""
global _jenkins_service
if _jenkins_service is None:
_jenkins_service = JenkinsService()
return _jenkins_service

View File

@ -0,0 +1,70 @@
"""
队列服务
提供任务队列管理功能
"""
import structlog
from typing import Dict, Any
from datetime import datetime
logger = structlog.get_logger()
class QueueService:
"""队列服务类"""
def __init__(self):
self.active_workers = 0
self.queue_size = 0
self.total_processed = 0
self.total_failed = 0
self._stats = {
"active_workers": 0,
"queue_size": 0,
"total_processed": 0,
"total_failed": 0
}
async def get_stats(self) -> Dict[str, Any]:
"""获取队列统计信息"""
return self._stats.copy()
async def increment_processed(self):
"""增加已处理任务计数"""
self.total_processed += 1
self._stats["total_processed"] = self.total_processed
async def increment_failed(self):
"""增加失败任务计数"""
self.total_failed += 1
self._stats["total_failed"] = self.total_failed
async def set_active_workers(self, count: int):
"""设置活跃工作线程数"""
self.active_workers = count
self._stats["active_workers"] = count
async def set_queue_size(self, size: int):
"""设置队列大小"""
self.queue_size = size
self._stats["queue_size"] = size
async def add_to_queue(self):
"""添加任务到队列"""
self.queue_size += 1
self._stats["queue_size"] = self.queue_size
async def remove_from_queue(self):
"""从队列移除任务"""
if self.queue_size > 0:
self.queue_size -= 1
self._stats["queue_size"] = self.queue_size
# 全局服务实例
_queue_service = None
def get_queue_service() -> QueueService:
"""获取队列服务实例"""
global _queue_service
if _queue_service is None:
_queue_service = QueueService()
return _queue_service

View File

@ -0,0 +1,280 @@
"""
Webhook 处理服务
实现智能分发任务排队和防抖策略
"""
import asyncio
from typing import Optional, Dict, Any
from datetime import datetime
import structlog
from celery import Celery
from app.config import get_settings
from app.models.gitea import GiteaWebhook, WebhookResponse
from app.services.dedup_service import DeduplicationService
from app.services.jenkins_service import JenkinsService
from app.services.database_service import get_database_service
from app.tasks.jenkins_tasks import trigger_jenkins_job
logger = structlog.get_logger()
class WebhookService:
"""Webhook 处理服务"""
def __init__(
self,
dedup_service: DeduplicationService,
jenkins_service: JenkinsService,
celery_app: Celery
):
self.dedup_service = dedup_service
self.jenkins_service = jenkins_service
self.celery_app = celery_app
self.settings = get_settings()
self.db_service = get_database_service()
async def process_webhook(self, webhook: GiteaWebhook) -> WebhookResponse:
"""
处理 Webhook 事件
Args:
webhook: Gitea Webhook 数据
Returns:
WebhookResponse: 处理结果
"""
try:
# 1. 验证事件类型
if not webhook.is_push_event():
return WebhookResponse(
success=True,
message="Non-push event ignored",
event_id=webhook.get_event_id()
)
# 2. 提取关键信息
branch = webhook.get_branch_name()
commit_hash = webhook.get_commit_hash()
repository = webhook.repository.full_name
logger.info("Processing webhook",
repository=repository,
branch=branch,
commit_hash=commit_hash)
# 3. 防抖检查
dedup_key = self.dedup_service.generate_dedup_key(commit_hash, branch)
if await self.dedup_service.is_duplicate(dedup_key):
return WebhookResponse(
success=True,
message="Duplicate event ignored",
event_id=webhook.get_event_id()
)
# 4. 获取项目映射和任务名
job_name = await self._determine_job_name(repository, branch)
if not job_name:
return WebhookResponse(
success=True,
message=f"No Jenkins job mapping for repository: {repository}, branch: {branch}",
event_id=webhook.get_event_id()
)
# 5. 准备任务参数
job_params = self._prepare_job_parameters(webhook, job_name)
# 6. 提交任务到队列
task_result = await self._submit_job_to_queue(
webhook, job_name, job_params
)
if task_result:
return WebhookResponse(
success=True,
message="Job queued successfully",
event_id=webhook.get_event_id(),
job_name=job_name
)
else:
return WebhookResponse(
success=False,
message="Failed to queue job",
event_id=webhook.get_event_id()
)
except Exception as e:
logger.error("Error processing webhook",
repository=webhook.repository.full_name,
error=str(e))
return WebhookResponse(
success=False,
message=f"Internal server error: {str(e)}",
event_id=webhook.get_event_id()
)
async def _determine_job_name(self, repository: str, branch: str) -> Optional[str]:
"""根据仓库和分支确定任务名"""
# 首先尝试从数据库获取项目映射
job_name = await self.db_service.determine_job_name(repository, branch)
if job_name:
return job_name
# 如果数据库中没有映射,使用配置文件中的环境分发
environment = self.settings.get_environment_for_branch(branch)
if environment:
return environment.jenkins_job
return None
def _prepare_job_parameters(self, webhook: GiteaWebhook, job_name: str) -> Dict[str, str]:
"""准备 Jenkins 任务参数"""
author_info = webhook.get_author_info()
return {
"BRANCH_NAME": webhook.get_branch_name(),
"COMMIT_SHA": webhook.get_commit_hash(),
"REPOSITORY_URL": webhook.repository.clone_url,
"REPOSITORY_NAME": webhook.repository.full_name,
"PUSHER_NAME": author_info["name"],
"PUSHER_EMAIL": author_info["email"],
"PUSHER_USERNAME": author_info["username"],
"COMMIT_MESSAGE": webhook.get_commit_message(),
"JOB_NAME": job_name,
"WEBHOOK_EVENT_ID": webhook.get_event_id(),
"TRIGGER_TIME": datetime.utcnow().isoformat()
}
async def _submit_job_to_queue(
self,
webhook: GiteaWebhook,
job_name: str,
job_params: Dict[str, str]
) -> bool:
"""提交任务到 Celery 队列"""
try:
# 创建任务
task_kwargs = {
"job_name": job_name,
"jenkins_url": self.settings.jenkins.url,
"parameters": job_params,
"event_id": webhook.get_event_id(),
"repository": webhook.repository.full_name,
"branch": webhook.get_branch_name(),
"commit_hash": webhook.get_commit_hash(),
"priority": 1 # 默认优先级
}
# 提交到 Celery 队列
task = self.celery_app.send_task(
"app.tasks.jenkins_tasks.trigger_jenkins_job",
kwargs=task_kwargs,
priority=environment.priority
)
logger.info("Job submitted to queue",
task_id=task.id,
job_name=job_name,
repository=webhook.repository.full_name,
branch=webhook.get_branch_name())
return True
except Exception as e:
logger.error("Failed to submit job to queue",
job_name=job_name,
error=str(e))
return False
async def get_webhook_stats(self) -> Dict[str, Any]:
"""获取 Webhook 处理统计"""
try:
# 获取队列统计
queue_stats = await self._get_queue_stats()
# 获取防抖统计
dedup_stats = await self.dedup_service.get_stats()
# 获取环境配置
environments = {}
for name, config in self.settings.environments.items():
environments[name] = {
"branches": config.branches,
"jenkins_job": config.jenkins_job,
"jenkins_url": config.jenkins_url,
"priority": config.priority
}
return {
"queue": queue_stats,
"deduplication": dedup_stats,
"environments": environments,
"config": {
"max_concurrent": self.settings.queue.max_concurrent,
"max_retries": self.settings.queue.max_retries,
"retry_delay": self.settings.queue.retry_delay
},
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Error getting webhook stats", error=str(e))
return {"error": str(e)}
async def _get_queue_stats(self) -> Dict[str, Any]:
"""获取队列统计信息"""
try:
# 获取 Celery 队列统计
inspect = self.celery_app.control.inspect()
# 活跃任务
active = inspect.active()
active_count = sum(len(tasks) for tasks in active.values()) if active else 0
# 等待任务
reserved = inspect.reserved()
reserved_count = sum(len(tasks) for tasks in reserved.values()) if reserved else 0
# 注册的 worker
registered = inspect.registered()
worker_count = len(registered) if registered else 0
return {
"active_tasks": active_count,
"queued_tasks": reserved_count,
"worker_count": worker_count,
"queue_length": active_count + reserved_count
}
except Exception as e:
logger.error("Error getting queue stats", error=str(e))
return {"error": str(e)}
async def clear_queue(self) -> Dict[str, Any]:
"""清空队列"""
try:
# 撤销所有活跃任务
inspect = self.celery_app.control.inspect()
active = inspect.active()
revoked_count = 0
if active:
for worker, tasks in active.items():
for task in tasks:
self.celery_app.control.revoke(task["id"], terminate=True)
revoked_count += 1
logger.info("Queue cleared", revoked_count=revoked_count)
return {
"success": True,
"revoked_count": revoked_count,
"message": f"Cleared {revoked_count} tasks from queue"
}
except Exception as e:
logger.error("Error clearing queue", error=str(e))
return {
"success": False,
"error": str(e)
}

View File

@ -0,0 +1,373 @@
// 全局变量存储 JWT 令牌
let authToken = localStorage.getItem('auth_token');
$(document).ready(function() {
// 检查认证状态
if (!authToken) {
window.location.href = '/login';
return;
}
// 设置 AJAX 默认配置
$.ajaxSetup({
beforeSend: function(xhr, settings) {
// 不为登录请求添加认证头
if (settings.url === '/api/auth/login') {
return;
}
if (authToken) {
xhr.setRequestHeader('Authorization', 'Bearer ' + authToken);
}
},
error: function(xhr, status, error) {
// 如果收到 401重定向到登录页
if (xhr.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/login';
return;
}
handleAjaxError(xhr, status, error);
}
});
// 初始化工具提示
$('[data-bs-toggle="tooltip"]').tooltip();
// 加载初始数据
loadProjects();
loadAPIKeys();
loadLogs();
checkHealth();
loadHealthDetails();
loadStatsDetails();
// 设置定期健康检查
setInterval(checkHealth, 30000);
// 项目管理
$('#addProjectForm').on('submit', function(e) {
e.preventDefault();
const projectData = {
name: $('#projectName').val(),
jenkinsJob: $('#jenkinsJob').val(),
giteaRepo: $('#giteaRepo').val()
};
$.ajax({
url: '/api/projects/',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(projectData),
success: function() {
$('#addProjectModal').modal('hide');
$('#addProjectForm')[0].reset();
loadProjects();
showSuccess('项目添加成功');
},
error: handleAjaxError
});
});
// API 密钥管理
$('#generateKeyForm').on('submit', function(e) {
e.preventDefault();
$.ajax({
url: '/api/keys',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ description: $('#keyDescription').val() }),
success: function(response) {
$('#generateKeyModal').modal('hide');
$('#generateKeyForm')[0].reset();
loadAPIKeys();
showSuccess('API 密钥生成成功');
// 显示新生成的密钥
showApiKeyModal(response.key);
},
error: handleAjaxError
});
});
// 日志查询
$('#logQueryForm').on('submit', function(e) {
e.preventDefault();
loadLogs({
startTime: $('#startTime').val(),
endTime: $('#endTime').val(),
level: $('#logLevel').val(),
query: $('#logQuery').val()
});
});
// 标签页切换
$('.nav-link').on('click', function() {
$('.nav-link').removeClass('active');
$(this).addClass('active');
});
});
function loadProjects() {
$.get('/api/projects/')
.done(function(data) {
const tbody = $('#projectsTable tbody');
tbody.empty();
data.projects.forEach(function(project) {
tbody.append(`
<tr>
<td>${escapeHtml(project.name)}</td>
<td>${escapeHtml(project.jenkinsJob)}</td>
<td>${escapeHtml(project.giteaRepo)}</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`);
});
})
.fail(handleAjaxError);
}
function loadAPIKeys() {
$.get('/api/keys')
.done(function(data) {
const tbody = $('#apiKeysTable tbody');
tbody.empty();
data.keys.forEach(function(key) {
tbody.append(`
<tr>
<td>${escapeHtml(key.description || '无描述')}</td>
<td><code class="api-key">${escapeHtml(key.key)}</code></td>
<td>${new Date(key.created_at).toLocaleString('zh-CN')}</td>
<td>
<button class="btn btn-sm btn-danger" onclick="revokeKey(${key.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`);
});
})
.fail(handleAjaxError);
}
function loadLogs(query = {}) {
$.get('/api/logs', query)
.done(function(data) {
const logContainer = $('#logEntries');
logContainer.empty();
if (data.logs && data.logs.length > 0) {
data.logs.forEach(function(log) {
const levelClass = {
'error': 'error',
'warn': 'warn',
'info': 'info',
'debug': 'debug'
}[log.level] || '';
logContainer.append(`
<div class="log-entry ${levelClass}">
<small>${new Date(log.timestamp).toLocaleString('zh-CN')}</small>
[${escapeHtml(log.level.toUpperCase())}] ${escapeHtml(log.message)}
</div>
`);
});
} else {
logContainer.append('<div class="text-muted">暂无日志记录</div>');
}
})
.fail(handleAjaxError);
}
function checkHealth() {
$.get('/health')
.done(function(data) {
const indicator = $('.health-indicator');
indicator.removeClass('healthy unhealthy')
.addClass(data.status === 'healthy' ? 'healthy' : 'unhealthy');
$('#healthStatus').text(data.status === 'healthy' ? '健康' : '异常');
})
.fail(function() {
const indicator = $('.health-indicator');
indicator.removeClass('healthy').addClass('unhealthy');
$('#healthStatus').text('异常');
});
}
function loadHealthDetails() {
$.get('/health')
.done(function(data) {
const healthDetails = $('#healthDetails');
healthDetails.html(`
<div class="mb-3">
<strong>状态:</strong>
<span class="badge ${data.status === 'healthy' ? 'bg-success' : 'bg-danger'}">
${data.status === 'healthy' ? '健康' : '异常'}
</span>
</div>
<div class="mb-3">
<strong>版本:</strong> ${data.version || ''}
</div>
<div class="mb-3">
<strong>启动时间:</strong> ${data.uptime || ''}
</div>
<div class="mb-3">
<strong>内存使用:</strong> ${data.memory || ''}
</div>
`);
})
.fail(function() {
$('#healthDetails').html('<div class="text-danger">无法获取健康状态</div>');
});
}
function loadStatsDetails() {
$.get('/api/stats')
.done(function(data) {
const statsDetails = $('#statsDetails');
statsDetails.html(`
<div class="mb-3">
<strong>总项目数:</strong> ${data.total_projects || 0}
</div>
<div class="mb-3">
<strong>API 密钥数:</strong> ${data.total_api_keys || 0}
</div>
<div class="mb-3">
<strong>今日触发次数:</strong> ${data.today_triggers || 0}
</div>
<div class="mb-3">
<strong>成功触发次数:</strong> ${data.successful_triggers || 0}
</div>
`);
})
.fail(function() {
$('#statsDetails').html('<div class="text-danger">无法获取统计信息</div>');
});
}
function deleteProject(id) {
if (!confirm('确定要删除这个项目吗?')) return;
$.ajax({
url: `/api/projects/${id}`,
method: 'DELETE',
success: function() {
loadProjects();
showSuccess('项目删除成功');
},
error: handleAjaxError
});
}
function revokeKey(id) {
if (!confirm('确定要撤销这个 API 密钥吗?')) return;
$.ajax({
url: `/api/keys/${id}`,
method: 'DELETE',
success: function() {
loadAPIKeys();
showSuccess('API 密钥撤销成功');
},
error: handleAjaxError
});
}
function showApiKeyModal(key) {
// 创建模态框显示新生成的密钥
const modal = $(`
<div class="modal fade" id="newApiKeyModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"> API 密钥</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>重要提示:</strong>
</div>
<div class="mb-3">
<label class="form-label">API 密钥:</label>
<input type="text" class="form-control" value="${key}" readonly>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" onclick="copyToClipboard('${key}')">
复制到剪贴板
</button>
</div>
</div>
</div>
</div>
`);
$('body').append(modal);
modal.modal('show');
modal.on('hidden.bs.modal', function() {
modal.remove();
});
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
showSuccess('已复制到剪贴板');
}, function() {
showError('复制失败');
});
}
function handleAjaxError(jqXHR, textStatus, errorThrown) {
const message = jqXHR.responseJSON?.detail || errorThrown || '发生错误';
showError(`错误: ${message}`);
}
function showSuccess(message) {
// 创建成功提示
const alert = $(`
<div class="alert alert-success alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`);
$('.main-content').prepend(alert);
// 3秒后自动消失
setTimeout(function() {
alert.alert('close');
}, 3000);
}
function showError(message) {
// 创建错误提示
const alert = $(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`);
$('.main-content').prepend(alert);
// 5秒后自动消失
setTimeout(function() {
alert.alert('close');
}, 5000);
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@ -0,0 +1,306 @@
"""
Jenkins 任务处理
使用 Celery 处理异步 Jenkins 任务触发
"""
import asyncio
import time
from typing import Dict, Any
from datetime import datetime
import structlog
from celery import Celery, Task
import httpx
from app.config import get_settings
from app.services.jenkins_service import JenkinsService
logger = structlog.get_logger()
settings = get_settings()
# 创建 Celery 应用
celery_app = Celery(
"gitea_webhook_ambassador",
broker=settings.redis.url,
backend=settings.redis.url,
include=["app.tasks.jenkins_tasks"]
)
# Celery 配置
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_time_limit=300, # 5分钟超时
task_soft_time_limit=240, # 4分钟软超时
worker_prefetch_multiplier=1,
worker_max_tasks_per_child=1000,
worker_max_memory_per_child=200000, # 200MB
task_acks_late=True,
task_reject_on_worker_lost=True,
task_always_eager=False, # 生产环境设为 False
result_expires=3600, # 结果缓存1小时
)
class JenkinsTask(Task):
"""Jenkins 任务基类"""
abstract = True
def __init__(self):
self.jenkins_service = None
def __call__(self, *args, **kwargs):
if self.jenkins_service is None:
self.jenkins_service = JenkinsService()
return self.run(*args, **kwargs)
def on_failure(self, exc, task_id, args, kwargs, einfo):
"""任务失败回调"""
logger.error("Task failed",
task_id=task_id,
task_name=self.name,
error=str(exc),
args=args,
kwargs=kwargs)
def on_retry(self, exc, task_id, args, kwargs, einfo):
"""任务重试回调"""
logger.warning("Task retrying",
task_id=task_id,
task_name=self.name,
error=str(exc),
retry_count=self.request.retries)
def on_success(self, retval, task_id, args, kwargs):
"""任务成功回调"""
logger.info("Task completed successfully",
task_id=task_id,
task_name=self.name,
result=retval)
@celery_app.task(
bind=True,
base=JenkinsTask,
max_retries=3,
default_retry_delay=60,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True
)
def trigger_jenkins_job(
self,
job_name: str,
jenkins_url: str,
parameters: Dict[str, str],
event_id: str,
repository: str,
branch: str,
commit_hash: str,
priority: int = 1
) -> Dict[str, Any]:
"""
触发 Jenkins 任务
Args:
job_name: Jenkins 任务名
jenkins_url: Jenkins URL
parameters: 任务参数
event_id: 事件 ID
repository: 仓库名
branch: 分支名
commit_hash: 提交哈希
priority: 优先级
Returns:
Dict: 任务执行结果
"""
start_time = time.time()
try:
logger.info("Starting Jenkins job trigger",
task_id=self.request.id,
job_name=job_name,
jenkins_url=jenkins_url,
repository=repository,
branch=branch,
commit_hash=commit_hash,
priority=priority)
# 创建 Jenkins 服务实例
jenkins_service = JenkinsService()
# 触发 Jenkins 任务
result = asyncio.run(jenkins_service.trigger_job(
job_name=job_name,
jenkins_url=jenkins_url,
parameters=parameters
))
execution_time = time.time() - start_time
if result["success"]:
logger.info("Jenkins job triggered successfully",
task_id=self.request.id,
job_name=job_name,
build_number=result.get("build_number"),
execution_time=execution_time)
return {
"success": True,
"task_id": self.request.id,
"job_name": job_name,
"jenkins_url": jenkins_url,
"build_number": result.get("build_number"),
"build_url": result.get("build_url"),
"event_id": event_id,
"repository": repository,
"branch": branch,
"commit_hash": commit_hash,
"execution_time": execution_time,
"timestamp": datetime.utcnow().isoformat()
}
else:
logger.error("Jenkins job trigger failed",
task_id=self.request.id,
job_name=job_name,
error=result.get("error"),
execution_time=execution_time)
# 重试任务
raise self.retry(
countdown=settings.queue.retry_delay * (2 ** self.request.retries),
max_retries=settings.queue.max_retries
)
except Exception as e:
execution_time = time.time() - start_time
logger.error("Unexpected error in Jenkins task",
task_id=self.request.id,
job_name=job_name,
error=str(e),
execution_time=execution_time)
# 重试任务
raise self.retry(
countdown=settings.queue.retry_delay * (2 ** self.request.retries),
max_retries=settings.queue.max_retries
)
@celery_app.task(
bind=True,
base=JenkinsTask,
max_retries=2,
default_retry_delay=30
)
def check_jenkins_health(
self,
jenkins_url: str
) -> Dict[str, Any]:
"""
检查 Jenkins 健康状态
Args:
jenkins_url: Jenkins URL
Returns:
Dict: 健康检查结果
"""
try:
logger.info("Checking Jenkins health", jenkins_url=jenkins_url)
jenkins_service = JenkinsService()
result = asyncio.run(jenkins_service.check_health(jenkins_url))
return {
"success": True,
"jenkins_url": jenkins_url,
"healthy": result.get("healthy", False),
"response_time": result.get("response_time"),
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Jenkins health check failed",
jenkins_url=jenkins_url,
error=str(e))
return {
"success": False,
"jenkins_url": jenkins_url,
"error": str(e),
"timestamp": datetime.utcnow().isoformat()
}
@celery_app.task(
bind=True,
base=JenkinsTask
)
def cleanup_expired_tasks(self) -> Dict[str, Any]:
"""
清理过期任务
Returns:
Dict: 清理结果
"""
try:
logger.info("Starting task cleanup")
# 获取所有任务
inspect = self.app.control.inspect()
# 清理过期的结果
cleaned_count = 0
current_time = time.time()
# 这里可以添加更复杂的清理逻辑
# 比如清理超过一定时间的任务结果
logger.info("Task cleanup completed", cleaned_count=cleaned_count)
return {
"success": True,
"cleaned_count": cleaned_count,
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Task cleanup failed", error=str(e))
return {
"success": False,
"error": str(e),
"timestamp": datetime.utcnow().isoformat()
}
# 定时任务
@celery_app.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
"""设置定时任务"""
# 每小时清理过期任务
sender.add_periodic_task(
3600.0, # 1小时
cleanup_expired_tasks.s(),
name="cleanup-expired-tasks"
)
# 每5分钟检查 Jenkins 健康状态
for env_name, env_config in settings.environments.items():
sender.add_periodic_task(
300.0, # 5分钟
check_jenkins_health.s(env_config.jenkins_url),
name=f"check-jenkins-health-{env_name}"
)
def get_celery_app() -> Celery:
"""获取 Celery 应用实例"""
return celery_app

View File

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>仪表板 - Gitea Webhook Ambassador</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<style>
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100;
padding: 48px 0 0;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 48px);
padding-top: .5rem;
overflow-x: hidden;
overflow-y: auto;
}
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
font-size: 1rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}
.health-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.health-indicator.healthy {
background-color: #28a745;
}
.health-indicator.unhealthy {
background-color: #dc3545;
}
.nav-link {
color: #333;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
margin: 0.125rem 0;
}
.nav-link:hover {
background-color: #f8f9fa;
}
.nav-link.active {
background-color: #0d6efd;
color: white;
}
.tab-content {
padding-top: 1rem;
}
.api-key {
font-family: monospace;
background-color: #f8f9fa;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.log-entry {
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
font-family: monospace;
font-size: 0.875rem;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.error {
background-color: #f8d7da;
color: #721c24;
}
.log-entry.warn {
background-color: #fff3cd;
color: #856404;
}
.log-entry.info {
background-color: #d1ecf1;
color: #0c5460;
}
.log-entry.debug {
background-color: #e2e3e5;
color: #383d41;
}
.main-content {
margin-left: 240px;
}
@media (max-width: 768px) {
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">
🔗 Gitea Webhook Ambassador
</a>
<div class="navbar-nav">
<div class="nav-item text-nowrap">
<span class="px-3 text-white">
<span class="health-indicator"></span>
<span id="healthStatus">检查中...</span>
</span>
</div>
</div>
</header>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" href="#projects" data-bs-toggle="tab">
<i class="bi bi-folder"></i> 项目管理
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#api-keys" data-bs-toggle="tab">
<i class="bi bi-key"></i> API 密钥
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#logs" data-bs-toggle="tab">
<i class="bi bi-journal-text"></i> 日志查看
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#health" data-bs-toggle="tab">
<i class="bi bi-heart-pulse"></i> 健康状态
</a>
</li>
</ul>
</div>
</nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
<div class="tab-content" id="myTabContent">
<!-- 项目管理 Tab -->
<div class="tab-pane fade show active" id="projects">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">项目管理</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addProjectModal">
<i class="bi bi-plus"></i> 添加项目
</button>
</div>
<div class="table-responsive">
<table class="table table-striped" id="projectsTable">
<thead>
<tr>
<th>项目名称</th>
<th>Jenkins 任务</th>
<th>Gitea 仓库</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- API 密钥 Tab -->
<div class="tab-pane fade" id="api-keys">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">API 密钥管理</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#generateKeyModal">
<i class="bi bi-plus"></i> 生成新密钥
</button>
</div>
<div class="table-responsive">
<table class="table table-striped" id="apiKeysTable">
<thead>
<tr>
<th>描述</th>
<th>密钥</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- 日志查看 Tab -->
<div class="tab-pane fade" id="logs">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">日志查看</h1>
</div>
<form id="logQueryForm" class="row g-3 mb-3">
<div class="col-md-3">
<label for="startTime" class="form-label">开始时间</label>
<input type="datetime-local" class="form-control" id="startTime">
</div>
<div class="col-md-3">
<label for="endTime" class="form-label">结束时间</label>
<input type="datetime-local" class="form-control" id="endTime">
</div>
<div class="col-md-2">
<label for="logLevel" class="form-label">日志级别</label>
<select class="form-select" id="logLevel">
<option value="">全部</option>
<option value="error">错误</option>
<option value="warn">警告</option>
<option value="info">信息</option>
<option value="debug">调试</option>
</select>
</div>
<div class="col-md-3">
<label for="logQuery" class="form-label">搜索关键词</label>
<input type="text" class="form-control" id="logQuery" placeholder="搜索日志...">
</div>
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">搜索</button>
</div>
</form>
<div id="logEntries" class="border rounded p-3 bg-light" style="max-height: 500px; overflow-y: auto;"></div>
</div>
<!-- 健康状态 Tab -->
<div class="tab-pane fade" id="health">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">健康状态</h1>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">服务状态</h5>
</div>
<div class="card-body">
<div id="healthDetails"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">统计信息</h5>
</div>
<div class="card-body">
<div id="statsDetails"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- 添加项目模态框 -->
<div class="modal fade" id="addProjectModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">添加新项目</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addProjectForm">
<div class="modal-body">
<div class="mb-3">
<label for="projectName" class="form-label">项目名称</label>
<input type="text" class="form-control" id="projectName" required>
</div>
<div class="mb-3">
<label for="jenkinsJob" class="form-label">Jenkins 任务</label>
<input type="text" class="form-control" id="jenkinsJob" required>
</div>
<div class="mb-3">
<label for="giteaRepo" class="form-label">Gitea 仓库</label>
<input type="text" class="form-control" id="giteaRepo" placeholder="owner/repo" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">添加项目</button>
</div>
</form>
</div>
</div>
</div>
<!-- 生成 API 密钥模态框 -->
<div class="modal fade" id="generateKeyModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">生成新 API 密钥</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="generateKeyForm">
<div class="modal-body">
<div class="mb-3">
<label for="keyDescription" class="form-label">密钥描述</label>
<input type="text" class="form-control" id="keyDescription" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">生成密钥</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/dashboard.js"></script>
</body>
</html>

View File

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Gitea Webhook Ambassador</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-form {
width: 100%;
max-width: 400px;
padding: 2rem;
margin: auto;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
color: #333;
font-weight: 600;
margin-bottom: 0.5rem;
}
.login-header p {
color: #666;
margin: 0;
}
.form-floating {
margin-bottom: 1rem;
}
.btn-login {
width: 100%;
padding: 0.75rem;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
}
.btn-login:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
transform: translateY(-1px);
}
.alert {
border-radius: 8px;
border: none;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-form">
<div class="login-header">
<h1>🔗 Gitea Webhook Ambassador</h1>
<p>高性能的 Gitea 到 Jenkins 的 Webhook 服务</p>
</div>
<form id="loginForm">
<div class="alert alert-danger" role="alert" id="loginError" style="display: none;">
</div>
<div class="form-floating">
<input type="password" class="form-control" id="secret_key" name="secret_key"
placeholder="管理员密钥" required>
<label for="secret_key">管理员密钥</label>
</div>
<button class="btn btn-primary btn-login" type="submit">
<span id="loginBtnText">登录</span>
<span id="loginBtnSpinner" class="spinner-border spinner-border-sm" style="display: none;"></span>
</button>
</form>
<div class="text-center mt-3">
<small class="text-muted">
使用管理员密钥进行身份验证
</small>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
$(document).ready(function() {
// 检查是否已登录
const token = localStorage.getItem('auth_token');
if (token) {
window.location.href = '/dashboard';
return;
}
// 检查 URL 参数中的 secret_key
const urlParams = new URLSearchParams(window.location.search);
const secretKeyFromUrl = urlParams.get('secret_key');
if (secretKeyFromUrl) {
$('#secret_key').val(secretKeyFromUrl);
// 自动提交登录
$('#loginForm').submit();
return;
}
// 处理登录表单提交
$('#loginForm').on('submit', function(e) {
e.preventDefault();
const secretKey = $('#secret_key').val();
if (!secretKey) {
showError('请输入管理员密钥');
return;
}
// 显示加载状态
$('#loginBtnText').hide();
$('#loginBtnSpinner').show();
$('#loginError').hide();
// 发送登录请求
$.ajax({
url: '/api/auth/login',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ secret_key: secretKey }),
success: function(response) {
if (response && response.token) {
// 保存令牌并跳转
localStorage.setItem('auth_token', response.token);
window.location.href = '/dashboard';
} else {
showError('服务器响应无效');
}
},
error: function(xhr) {
console.error('登录错误:', xhr);
let errorMsg = '登录失败,请重试';
if (xhr.responseJSON && xhr.responseJSON.detail) {
errorMsg = xhr.responseJSON.detail;
}
showError(errorMsg);
$('#secret_key').val('').focus();
},
complete: function() {
// 恢复按钮状态
$('#loginBtnText').show();
$('#loginBtnSpinner').hide();
}
});
});
function showError(message) {
$('#loginError').text(message).show();
}
// 回车键提交
$('#secret_key').on('keypress', function(e) {
if (e.which === 13) {
$('#loginForm').submit();
}
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,133 @@
#!/bin/bash
# Gitea Webhook Ambassador - 版本检查脚本
# 用于区分 Go 版本和 Python 版本
echo "🔍 检查 Gitea Webhook Ambassador 版本..."
# 检查端口 8000 (Python 版本默认端口)
echo "📡 检查端口 8000 (Python 版本)..."
if lsof -i :8000 > /dev/null 2>&1; then
PID=$(lsof -ti :8000)
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
echo "✅ 端口 8000 被占用 (PID: $PID, 进程: $PROCESS)"
# 检查是否是 Python 进程
if echo "$PROCESS" | grep -q "python\|uvicorn"; then
echo "🐍 检测到 Python 版本正在运行"
# 尝试访问 Python 版本的 API
if curl -s http://localhost:8000/api/health > /dev/null 2>&1; then
echo "✅ Python 版本 API 响应正常"
echo "🌐 访问地址: http://localhost:8000"
echo "📊 仪表板: http://localhost:8000/dashboard"
else
echo "⚠️ Python 版本进程存在但 API 无响应"
fi
else
echo "⚠️ 端口 8000 被其他进程占用"
fi
else
echo "❌ 端口 8000 未被占用 (Python 版本未运行)"
fi
echo ""
# 检查端口 8080 (Go 版本默认端口)
echo "📡 检查端口 8080 (Go 版本)..."
if lsof -i :8080 > /dev/null 2>&1; then
PID=$(lsof -ti :8080)
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
echo "✅ 端口 8080 被占用 (PID: $PID, 进程: $PROCESS)"
# 检查是否是 Go 进程
if echo "$PROCESS" | grep -q "gitea-webhook-ambassador"; then
echo "🚀 检测到 Go 版本正在运行"
# 尝试访问 Go 版本的 API
if curl -s http://localhost:8080/health > /dev/null 2>&1; then
echo "✅ Go 版本 API 响应正常"
echo "🌐 访问地址: http://localhost:8080"
else
echo "⚠️ Go 版本进程存在但 API 无响应"
fi
else
echo "⚠️ 端口 8080 被其他进程占用"
fi
else
echo "❌ 端口 8080 未被占用 (Go 版本未运行)"
fi
echo ""
# 检查 PID 文件
echo "📁 检查 PID 文件..."
# Python 版本 PID 文件
PYTHON_PID_FILE="/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python/service.pid"
if [ -f "$PYTHON_PID_FILE" ]; then
PYTHON_PID=$(cat "$PYTHON_PID_FILE")
if ps -p $PYTHON_PID > /dev/null 2>&1; then
echo "✅ Python 版本 PID 文件存在 (PID: $PYTHON_PID)"
else
echo "⚠️ Python 版本 PID 文件存在但进程不存在"
fi
else
echo "❌ Python 版本 PID 文件不存在"
fi
# Go 版本 PID 文件 (如果存在)
GO_PID_FILE="/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador/service.pid"
if [ -f "$GO_PID_FILE" ]; then
GO_PID=$(cat "$GO_PID_FILE")
if ps -p $GO_PID > /dev/null 2>&1; then
echo "✅ Go 版本 PID 文件存在 (PID: $GO_PID)"
else
echo "⚠️ Go 版本 PID 文件存在但进程不存在"
fi
else
echo "❌ Go 版本 PID 文件不存在"
fi
echo ""
# 总结
echo "📊 总结:"
echo "----------------------------------------"
PYTHON_RUNNING=false
GO_RUNNING=false
# 检查 Python 版本
if lsof -i :8000 > /dev/null 2>&1; then
PID=$(lsof -ti :8000)
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
if echo "$PROCESS" | grep -q "python\|uvicorn"; then
PYTHON_RUNNING=true
fi
fi
# 检查 Go 版本
if lsof -i :8080 > /dev/null 2>&1; then
PID=$(lsof -ti :8080)
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
if echo "$PROCESS" | grep -q "gitea-webhook-ambassador"; then
GO_RUNNING=true
fi
fi
if [ "$PYTHON_RUNNING" = true ] && [ "$GO_RUNNING" = true ]; then
echo "⚠️ 两个版本都在运行!"
echo "🐍 Python 版本: http://localhost:8000"
echo "🚀 Go 版本: http://localhost:8080"
elif [ "$PYTHON_RUNNING" = true ]; then
echo "✅ 当前运行: Python 版本"
echo "🌐 访问地址: http://localhost:8000"
elif [ "$GO_RUNNING" = true ]; then
echo "✅ 当前运行: Go 版本"
echo "🌐 访问地址: http://localhost:8080"
else
echo "❌ 没有检测到任何版本在运行"
fi
echo "----------------------------------------"

View File

@ -0,0 +1,39 @@
# 环境分发配置
environments:
dev:
branches: ["dev", "develop", "development", "feature/*"]
jenkins_job: "alpha-build"
jenkins_url: "https://jenkins-alpha.freeleaps.com"
priority: 2
prod:
branches: ["prod", "production", "main", "master", "release/*"]
jenkins_job: "production-build"
jenkins_url: "https://jenkins-prod.freeleaps.com"
priority: 1
staging:
branches: ["staging", "stage", "pre-prod"]
jenkins_job: "staging-build"
jenkins_url: "https://jenkins-staging.freeleaps.com"
priority: 3
default:
branches: ["*"]
jenkins_job: "default-build"
jenkins_url: "https://jenkins-default.freeleaps.com"
priority: 4
# 防抖配置
deduplication:
enabled: true
window_seconds: 300 # 5分钟防抖窗口
strategy: "commit_branch" # commit_hash + branch
cache_ttl: 3600 # 缓存1小时
# 队列配置
queue:
max_concurrent: 10
max_retries: 3
retry_delay: 60 # 秒
priority_levels: 4

View File

@ -0,0 +1,32 @@
server:
port: 8000
webhookPath: "/webhook"
secretHeader: "X-Gitea-Signature"
secretKey: "admin-secret-key-change-in-production"
jenkins:
url: "http://jenkins.example.com"
username: "jenkins-user"
token: "jenkins-api-token"
timeout: 30
admin:
token: "admin-api-token" # Token for admin API access
database:
path: "data/gitea-webhook-ambassador.db" # Path to SQLite database file
logging:
level: "info" # debug, info, warn, error
format: "text" # text, json
file: "logs/service.log" # stdout if empty, or path to log file
worker:
poolSize: 10 # Number of concurrent workers
queueSize: 100 # Size of job queue
maxRetries: 3 # Maximum number of retry attempts
retryBackoff: 1 # Initial retry backoff in seconds (exponential)
eventCleanup:
interval: 3600 # Cleanup interval in seconds
expireAfter: 7200 # Event expiration time in seconds

View File

@ -0,0 +1,220 @@
#!/bin/bash
# Gitea Webhook Ambassador (Python) - Devbox Script
# This script mimics the Go version's devbox functionality
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_NAME="gitea-webhook-ambassador"
PID_FILE="$SCRIPT_DIR/service.pid"
LOG_FILE="$SCRIPT_DIR/logs/service.log"
# Create logs directory
mkdir -p "$SCRIPT_DIR/logs"
# Function to show usage
show_usage() {
echo "Usage: $0 {start|stop|restart|status|logs|follow|init|install|help}"
echo ""
echo "Commands:"
echo " start - Start the service in background"
echo " stop - Stop the service"
echo " restart - Restart the service"
echo " status - Show service status"
echo " logs - Show latest logs"
echo " follow - Follow logs in real-time"
echo " init - Initialize database"
echo " install - Install dependencies"
echo " help - Show this help message"
echo ""
echo "Examples:"
echo " $0 start # Start service"
echo " $0 status # Check status"
echo " $0 logs # View logs"
}
# Function to check if virtual environment exists
check_venv() {
if [ ! -d "$SCRIPT_DIR/venv" ]; then
echo "❌ Virtual environment not found. Run '$0 install' first."
exit 1
fi
}
# Function to activate virtual environment
activate_venv() {
source "$SCRIPT_DIR/venv/bin/activate"
}
# Function to start service
start_service() {
echo "🚀 Starting $APP_NAME (Python Version)..."
echo "🐍 Version: Python Enhanced with Web UI"
check_venv
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
echo "❌ Service is already running (PID: $PID)"
return 1
else
echo "⚠️ Found stale PID file, cleaning up..."
rm -f "$PID_FILE"
fi
fi
# Activate virtual environment and start service
cd "$SCRIPT_DIR"
activate_venv
# Start the service in background
nohup python -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000 > "$LOG_FILE" 2>&1 &
PID=$!
echo $PID > "$PID_FILE"
# Wait a moment for service to start
sleep 3
if ps -p $PID > /dev/null 2>&1; then
echo "✅ Python Service started successfully (PID: $PID)"
echo "📝 Log file: $LOG_FILE"
echo "🌐 Access: http://localhost:8000"
echo "📊 Dashboard: http://localhost:8000/dashboard"
echo "🔑 Admin key: admin-secret-key-change-in-production"
echo "🐍 Python Version Features: Web UI, Database, JWT Auth"
else
echo "❌ Service failed to start"
rm -f "$PID_FILE"
return 1
fi
}
# Function to stop service
stop_service() {
echo "🛑 Stopping $APP_NAME..."
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
kill $PID
echo "✅ Service stopped (PID: $PID)"
else
echo "⚠️ Service not running"
fi
rm -f "$PID_FILE"
else
echo "⚠️ No PID file found"
fi
}
# Function to restart service
restart_service() {
echo "🔄 Restarting $APP_NAME..."
stop_service
sleep 2
start_service
}
# Function to show status
show_status() {
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
echo "✅ $APP_NAME (Python Version) is running (PID: $PID)"
echo "🐍 Version: Python Enhanced with Web UI"
echo "📝 Log file: $LOG_FILE"
echo "🌐 Access: http://localhost:8000"
echo "📊 Dashboard: http://localhost:8000/dashboard"
else
echo "❌ $APP_NAME is not running (PID file exists but process not found)"
rm -f "$PID_FILE"
fi
else
echo "❌ $APP_NAME is not running"
fi
}
# Function to show logs
show_logs() {
if [ -f "$LOG_FILE" ]; then
echo "📝 Latest logs (last 50 lines):"
echo "----------------------------------------"
tail -n 50 "$LOG_FILE"
echo "----------------------------------------"
echo "Full log file: $LOG_FILE"
else
echo "❌ No log file found"
fi
}
# Function to follow logs
follow_logs() {
if [ -f "$LOG_FILE" ]; then
echo "📝 Following logs (Ctrl+C to exit):"
tail -f "$LOG_FILE"
else
echo "❌ No log file found"
fi
}
# Function to initialize database
init_database() {
echo "🗄️ Initializing database..."
check_venv
cd "$SCRIPT_DIR"
activate_venv
python -c "from app.models.database import create_tables; create_tables(); print('Database initialized successfully')"
}
# Function to install dependencies
install_dependencies() {
echo "📦 Installing dependencies..."
cd "$SCRIPT_DIR"
if [ -d "venv" ]; then
echo "⚠️ Virtual environment already exists. Removing..."
rm -rf venv
fi
python3 -m venv venv
activate_venv
pip install -r requirements.txt
echo "✅ Dependencies installed successfully"
}
# Main logic
case "$1" in
start)
start_service
;;
stop)
stop_service
;;
restart)
restart_service
;;
status)
show_status
;;
logs)
show_logs
;;
follow)
follow_logs
;;
init)
init_database
;;
install)
install_dependencies
;;
help|--help|-h)
show_usage
;;
*)
echo "❌ Unknown command: $1"
echo ""
show_usage
exit 1
;;
esac

View File

@ -0,0 +1,176 @@
version: '3.8'
services:
# Redis 服务
redis:
image: redis:7-alpine
container_name: webhook-ambassador-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# PostgreSQL 数据库 (可选,用于生产环境)
postgres:
image: postgres:15-alpine
container_name: webhook-ambassador-postgres
environment:
POSTGRES_DB: webhook_ambassador
POSTGRES_USER: webhook_user
POSTGRES_PASSWORD: webhook_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U webhook_user -d webhook_ambassador"]
interval: 30s
timeout: 10s
retries: 3
# Webhook Ambassador API 服务
api:
build:
context: .
dockerfile: Dockerfile
container_name: webhook-ambassador-api
ports:
- "8000:8000"
environment:
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
- JENKINS_USERNAME=${JENKINS_USERNAME}
- JENKINS_TOKEN=${JENKINS_TOKEN}
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
- LOGGING_LEVEL=INFO
volumes:
- ./config:/app/config
- ./logs:/app/logs
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# Celery Worker
worker:
build:
context: .
dockerfile: Dockerfile
container_name: webhook-ambassador-worker
command: celery -A app.tasks.jenkins_tasks worker --loglevel=info --concurrency=4
environment:
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
- JENKINS_USERNAME=${JENKINS_USERNAME}
- JENKINS_TOKEN=${JENKINS_TOKEN}
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
- LOGGING_LEVEL=INFO
volumes:
- ./config:/app/config
- ./logs:/app/logs
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
restart: unless-stopped
# Celery Beat (定时任务调度器)
beat:
build:
context: .
dockerfile: Dockerfile
container_name: webhook-ambassador-beat
command: celery -A app.tasks.jenkins_tasks beat --loglevel=info
environment:
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
- JENKINS_USERNAME=${JENKINS_USERNAME}
- JENKINS_TOKEN=${JENKINS_TOKEN}
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
- LOGGING_LEVEL=INFO
volumes:
- ./config:/app/config
- ./logs:/app/logs
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
restart: unless-stopped
# Flower (Celery 监控)
flower:
build:
context: .
dockerfile: Dockerfile
container_name: webhook-ambassador-flower
command: celery -A app.tasks.jenkins_tasks flower --port=5555
ports:
- "5555:5555"
environment:
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
- JENKINS_USERNAME=${JENKINS_USERNAME}
- JENKINS_TOKEN=${JENKINS_TOKEN}
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
- LOGGING_LEVEL=INFO
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
restart: unless-stopped
# Prometheus (监控)
prometheus:
image: prom/prometheus:latest
container_name: webhook-ambassador-prometheus
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--storage.tsdb.retention.time=200h'
- '--web.enable-lifecycle'
restart: unless-stopped
# Grafana (监控面板)
grafana:
image: grafana/grafana:latest
container_name: webhook-ambassador-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
depends_on:
- prometheus
restart: unless-stopped
volumes:
redis_data:
postgres_data:
prometheus_data:
grafana_data:

View File

@ -0,0 +1,42 @@
# 应用配置
APP_NAME=Gitea Webhook Ambassador
DEBUG=false
HOST=0.0.0.0
PORT=8000
# 数据库配置
DATABASE_URL=sqlite:///./webhook_ambassador.db
# 生产环境使用 PostgreSQL:
# DATABASE_URL=postgresql://webhook_user:webhook_password@localhost:5432/webhook_ambassador
# Redis 配置
REDIS_URL=redis://localhost:6379/0
REDIS_PASSWORD=
REDIS_DB=0
# Jenkins 配置
JENKINS_USERNAME=your_jenkins_username
JENKINS_TOKEN=115127e693f1bc6b7194f58ff6d6283bd0
JENKINS_TIMEOUT=30
# 安全配置
SECURITY_SECRET_KEY=r6Y@QTb*7BQN@hDGsN
SECURITY_WEBHOOK_SECRET_HEADER=X-Gitea-Signature
SECURITY_RATE_LIMIT_PER_MINUTE=100
# 日志配置
LOGGING_LEVEL=INFO
LOGGING_FORMAT=json
LOGGING_FILE=
# 队列配置
QUEUE_MAX_CONCURRENT=10
QUEUE_MAX_RETRIES=3
QUEUE_RETRY_DELAY=60
QUEUE_PRIORITY_LEVELS=3
# 防抖配置
DEDUPLICATION_ENABLED=true
DEDUPLICATION_WINDOW_SECONDS=300
DEDUPLICATION_STRATEGY=commit_branch
DEDUPLICATION_CACHE_TTL=3600

View File

@ -0,0 +1,41 @@
#!/bin/bash
# 修复 PID 文件问题
echo "🔧 修复 PID 文件问题..."
# 查找 Python 服务进程
PID=$(lsof -ti :8000 2>/dev/null)
if [ -n "$PID" ]; then
echo "✅ 找到运行中的 Python 服务 (PID: $PID)"
# 检查进程是否是我们的服务
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
if echo "$PROCESS" | grep -q "python"; then
echo "🐍 确认是 Python 版本的 Gitea Webhook Ambassador"
# 创建 PID 文件
echo $PID > service.pid
echo "✅ 已创建 PID 文件: service.pid"
# 验证 PID 文件
if [ -f "service.pid" ]; then
STORED_PID=$(cat service.pid)
echo "📝 PID 文件内容: $STORED_PID"
if [ "$STORED_PID" = "$PID" ]; then
echo "✅ PID 文件修复成功"
echo "💡 现在可以使用 './devbox stop' 来停止服务"
else
echo "❌ PID 文件内容不匹配"
fi
else
echo "❌ 无法创建 PID 文件"
fi
else
echo "⚠️ 端口 8000 被其他进程占用"
fi
else
echo "❌ 没有找到运行中的 Python 服务"
echo "💡 请先启动服务: './devbox start'"
fi

View File

@ -0,0 +1,15 @@
[Unit]
Description=Gitea Webhook Ambassador Python Service
After=network.target
[Service]
Type=simple
User=nicolas
WorkingDirectory=/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python
Environment=PATH=/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python/venv/bin
ExecStart=/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python/venv/bin/python -m uvicorn app.main_demo:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,31 @@
#!/bin/bash
# 快速检查当前运行的版本
echo "🔍 快速检查 Gitea Webhook Ambassador 版本..."
# 检查 Python 版本 (端口 8000)
if lsof -i :8000 > /dev/null 2>&1; then
PID=$(lsof -ti :8000)
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
if echo "$PROCESS" | grep -q "python\|uvicorn"; then
echo "🐍 Python 版本正在运行 (PID: $PID)"
echo "🌐 http://localhost:8000"
echo "📊 http://localhost:8000/dashboard"
exit 0
fi
fi
# 检查 Go 版本 (端口 8080)
if lsof -i :8080 > /dev/null 2>&1; then
PID=$(lsof -ti :8080)
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
if echo "$PROCESS" | grep -q "gitea-webhook-ambassador"; then
echo "🚀 Go 版本正在运行 (PID: $PID)"
echo "🌐 http://localhost:8080"
exit 0
fi
fi
echo "❌ 没有检测到任何版本在运行"
echo "💡 使用 './devbox start' 启动 Python 版本"

View File

@ -0,0 +1,15 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
structlog==23.2.0
httpx==0.25.2
celery==5.3.4
redis==5.0.1
sqlalchemy==2.0.23
jinja2==3.1.2
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
psutil==5.9.6
aiofiles==23.2.1

View File

@ -0,0 +1,61 @@
#!/bin/bash
# Gitea Webhook Ambassador 快速设置脚本
set -e
echo "🚀 开始设置 Gitea Webhook Ambassador..."
# 检查 Python 版本
python_version=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
required_version="3.8"
if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then
echo "❌ 需要 Python 3.8 或更高版本,当前版本: $python_version"
exit 1
fi
echo "✅ Python 版本检查通过: $python_version"
# 创建虚拟环境
if [ ! -d "venv" ]; then
echo "📦 创建虚拟环境..."
python3 -m venv venv
fi
# 激活虚拟环境
echo "🔧 激活虚拟环境..."
source venv/bin/activate
# 升级 pip
echo "⬆️ 升级 pip..."
pip install --upgrade pip
# 安装依赖
echo "📚 安装依赖..."
pip install -r requirements.txt
# 创建配置文件
if [ ! -f ".env" ]; then
echo "⚙️ 创建环境配置文件..."
cp env.example .env
echo "📝 请编辑 .env 文件,配置您的 Jenkins 凭据和其他设置"
fi
# 创建日志目录
mkdir -p logs
# 创建数据库目录
mkdir -p data
echo "✅ 设置完成!"
echo ""
echo "📋 下一步操作:"
echo "1. 编辑 .env 文件,配置 Jenkins 凭据"
echo "2. 运行: source venv/bin/activate"
echo "3. 启动 Redis: docker run -d -p 6379:6379 redis:alpine"
echo "4. 启动服务: python -m uvicorn app.main:app --reload"
echo "5. 启动 Celery worker: celery -A app.tasks.jenkins_tasks worker --loglevel=info"
echo ""
echo "🌐 访问地址: http://localhost:8000"
echo "📊 监控面板: http://localhost:8000/health"

View File

@ -0,0 +1,64 @@
#!/bin/bash
# Gitea Webhook Ambassador 启动脚本
set -e
# 检查虚拟环境
if [ ! -d "venv" ]; then
echo "❌ 虚拟环境不存在,请先运行 ./scripts/setup.sh"
exit 1
fi
# 激活虚拟环境
source venv/bin/activate
# 检查环境文件
if [ ! -f ".env" ]; then
echo "❌ .env 文件不存在,请先运行 ./scripts/setup.sh"
exit 1
fi
# 检查 Redis 是否运行
if ! docker ps | grep -q redis; then
echo "🐳 启动 Redis..."
docker run -d --name webhook-redis -p 6379:6379 redis:alpine
sleep 3
fi
echo "🚀 启动 Gitea Webhook Ambassador..."
# 启动 API 服务
echo "🌐 启动 API 服务..."
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload &
API_PID=$!
# 等待 API 服务启动
sleep 5
# 启动 Celery worker
echo "⚙️ 启动 Celery worker..."
celery -A app.tasks.jenkins_tasks worker --loglevel=info --concurrency=4 &
WORKER_PID=$!
# 启动 Celery beat (定时任务)
echo "⏰ 启动定时任务调度器..."
celery -A app.tasks.jenkins_tasks beat --loglevel=info &
BEAT_PID=$!
echo "✅ 所有服务已启动!"
echo ""
echo "📊 服务状态:"
echo "- API 服务: http://localhost:8000 (PID: $API_PID)"
echo "- 健康检查: http://localhost:8000/health"
echo "- 监控指标: http://localhost:8000/metrics"
echo "- Celery Worker: PID $WORKER_PID"
echo "- Celery Beat: PID $BEAT_PID"
echo ""
echo "🛑 按 Ctrl+C 停止所有服务"
# 等待中断信号
trap 'echo "🛑 正在停止服务..."; kill $API_PID $WORKER_PID $BEAT_PID 2>/dev/null; exit 0' INT
# 等待所有后台进程
wait

View File

@ -0,0 +1,154 @@
#!/bin/bash
# Gitea Webhook Ambassador Python 启动脚本
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_NAME="gitea-webhook-ambassador-python"
LOG_FILE="$SCRIPT_DIR/logs/service.log"
PID_FILE="$SCRIPT_DIR/service.pid"
# 创建日志目录
mkdir -p "$SCRIPT_DIR/logs"
# 激活虚拟环境
source "$SCRIPT_DIR/venv/bin/activate"
# 函数:启动服务
start_service() {
echo "🚀 启动 $SERVICE_NAME..."
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
echo "❌ 服务已在运行 (PID: $PID)"
return 1
else
echo "⚠️ 发现过期的 PID 文件,正在清理..."
rm -f "$PID_FILE"
fi
fi
# 后台启动服务
nohup python -m uvicorn app.main_demo:app --host 0.0.0.0 --port 8000 > "$LOG_FILE" 2>&1 &
PID=$!
echo $PID > "$PID_FILE"
# 等待服务启动
sleep 3
if ps -p $PID > /dev/null 2>&1; then
echo "✅ 服务启动成功 (PID: $PID)"
echo "📝 日志文件: $LOG_FILE"
echo "🌐 访问地址: http://localhost:8000"
echo "🔑 演示密钥: demo_admin_key, demo_user_key"
else
echo "❌ 服务启动失败"
rm -f "$PID_FILE"
return 1
fi
}
# 函数:停止服务
stop_service() {
echo "🛑 停止 $SERVICE_NAME..."
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
kill $PID
echo "✅ 服务已停止 (PID: $PID)"
else
echo "⚠️ 服务未运行"
fi
rm -f "$PID_FILE"
else
echo "⚠️ PID 文件不存在"
fi
}
# 函数:重启服务
restart_service() {
echo "🔄 重启 $SERVICE_NAME..."
stop_service
sleep 2
start_service
}
# 函数:查看状态
status_service() {
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
echo "$SERVICE_NAME 正在运行 (PID: $PID)"
echo "📝 日志文件: $LOG_FILE"
echo "🌐 访问地址: http://localhost:8000"
else
echo "$SERVICE_NAME 未运行 (PID 文件存在但进程不存在)"
rm -f "$PID_FILE"
fi
else
echo "$SERVICE_NAME 未运行"
fi
}
# 函数:查看日志
show_logs() {
if [ -f "$LOG_FILE" ]; then
echo "📝 显示最新日志 (最后 50 行):"
echo "----------------------------------------"
tail -n 50 "$LOG_FILE"
echo "----------------------------------------"
echo "完整日志文件: $LOG_FILE"
else
echo "❌ 日志文件不存在"
fi
}
# 函数:实时日志
follow_logs() {
if [ -f "$LOG_FILE" ]; then
echo "📝 实时日志 (按 Ctrl+C 退出):"
tail -f "$LOG_FILE"
else
echo "❌ 日志文件不存在"
fi
}
# 主逻辑
case "$1" in
start)
start_service
;;
stop)
stop_service
;;
restart)
restart_service
;;
status)
status_service
;;
logs)
show_logs
;;
follow)
follow_logs
;;
*)
echo "用法: $0 {start|stop|restart|status|logs|follow}"
echo ""
echo "命令说明:"
echo " start - 启动服务"
echo " stop - 停止服务"
echo " restart - 重启服务"
echo " status - 查看服务状态"
echo " logs - 查看最新日志"
echo " follow - 实时查看日志"
echo ""
echo "示例:"
echo " $0 start # 启动服务"
echo " $0 status # 查看状态"
echo " $0 logs # 查看日志"
exit 1
;;
esac

View File

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
认证功能测试脚本
演示如何正确使用 JWT API 密钥认证
"""
import asyncio
import aiohttp
import json
from datetime import datetime
BASE_URL = "http://localhost:8000"
async def test_jwt_authentication():
"""测试 JWT 认证"""
print("🔐 测试 JWT 认证")
print("-" * 50)
# 注意在实际应用中JWT 令牌应该通过登录端点获取
# 这里我们使用一个示例令牌(在实际环境中需要从登录端点获取)
# 模拟 JWT 令牌(实际应用中应该从登录端点获取)
jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTczMjAwMDAwMH0.test"
async with aiohttp.ClientSession() as session:
# 使用 JWT 令牌访问管理端点
headers = {"Authorization": f"Bearer {jwt_token}"}
# 测试访问日志端点
async with session.get(f"{BASE_URL}/api/logs", headers=headers) as response:
if response.status == 200:
logs = await response.json()
print("✅ JWT 认证成功 - 日志访问")
print(f" 获取到 {len(logs)} 条日志")
else:
print(f"❌ JWT 认证失败 - 日志访问: {response.status}")
if response.status == 401:
print(" 原因: JWT 令牌无效或已过期")
print()
async def test_api_key_authentication():
"""测试 API 密钥认证"""
print("🔑 测试 API 密钥认证")
print("-" * 50)
async with aiohttp.ClientSession() as session:
# 首先创建一个 API 密钥(需要管理员权限)
# 注意:这里我们使用一个临时的认证方式
# 方法1直接使用内存中的 API 密钥(仅用于演示)
# 在实际应用中API 密钥应该通过管理界面创建
# 模拟一个有效的 API 密钥
api_key = "test_api_key_12345"
headers = {"Authorization": f"Bearer {api_key}"}
# 测试访问日志端点
async with session.get(f"{BASE_URL}/api/logs", headers=headers) as response:
if response.status == 200:
logs = await response.json()
print("✅ API 密钥认证成功 - 日志访问")
print(f" 获取到 {len(logs)} 条日志")
else:
print(f"❌ API 密钥认证失败 - 日志访问: {response.status}")
if response.status == 401:
print(" 原因: API 密钥无效或已撤销")
print()
async def test_public_endpoints():
"""测试公开端点(无需认证)"""
print("🌐 测试公开端点")
print("-" * 50)
async with aiohttp.ClientSession() as session:
# 健康检查端点(无需认证)
async with session.get(f"{BASE_URL}/health") as response:
if response.status == 200:
data = await response.json()
print("✅ 健康检查端点访问成功")
print(f" 状态: {data['status']}")
print(f" Jenkins: {data['jenkins']['status']}")
else:
print(f"❌ 健康检查端点访问失败: {response.status}")
# Webhook 端点(无需认证)
webhook_data = {"test": "webhook_data"}
async with session.post(f"{BASE_URL}/webhook/gitea", json=webhook_data) as response:
if response.status == 200:
data = await response.json()
print("✅ Webhook 端点访问成功")
print(f" 响应: {data['message']}")
else:
print(f"❌ Webhook 端点访问失败: {response.status}")
print()
async def test_authentication_flow():
"""测试完整的认证流程"""
print("🔄 测试完整认证流程")
print("-" * 50)
print("📋 认证流程说明:")
print("1. 公开端点: /health, /webhook/gitea - 无需认证")
print("2. 管理端点: /api/admin/* - 需要 JWT 或 API 密钥")
print("3. 日志端点: /api/logs/* - 需要 JWT 或 API 密钥")
print()
print("🔧 如何获取认证令牌:")
print("1. JWT 令牌: 通过登录端点获取(需要实现登录功能)")
print("2. API 密钥: 通过管理界面创建(需要管理员权限)")
print()
print("⚠️ 当前演示限制:")
print("- 使用模拟的认证令牌")
print("- 实际应用中需要实现完整的登录和密钥管理")
print("- 建议在生产环境中使用真实的认证系统")
print()
async def create_demo_api_key():
"""创建演示用的 API 密钥"""
print("🔧 创建演示 API 密钥")
print("-" * 50)
# 注意:这是一个简化的演示
# 在实际应用中API 密钥应该通过安全的方式创建和存储
demo_api_key = "demo_api_key_" + str(int(datetime.now().timestamp()))
print(f"✅ 演示 API 密钥已创建: {demo_api_key}")
print("📝 使用方法:")
print(f" curl -H 'Authorization: Bearer {demo_api_key}' {BASE_URL}/api/logs")
print()
return demo_api_key
async def main():
"""主测试函数"""
print("🚀 开始认证功能测试")
print("=" * 60)
print()
try:
# 等待服务启动
await asyncio.sleep(2)
await test_public_endpoints()
await test_jwt_authentication()
await test_api_key_authentication()
await test_authentication_flow()
# 创建演示 API 密钥
demo_key = await create_demo_api_key()
print("=" * 60)
print("🎉 认证功能测试完成!")
print()
print("📚 下一步建议:")
print("1. 实现完整的登录系统")
print("2. 添加用户管理功能")
print("3. 实现 API 密钥的安全存储")
print("4. 添加权限控制机制")
print("5. 实现会话管理")
except Exception as e:
print(f"❌ 测试过程中出现错误: {str(e)}")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
增强版 Gitea Webhook Ambassador 功能测试脚本
演示所有新增的监测和管理功能
"""
import asyncio
import aiohttp
import json
from datetime import datetime
BASE_URL = "http://localhost:8000"
async def test_health_check():
"""测试增强的健康检查"""
print("🧪 测试增强的健康检查")
print("-" * 50)
async with aiohttp.ClientSession() as session:
async with session.get(f"{BASE_URL}/health") as response:
if response.status == 200:
data = await response.json()
print("✅ 健康检查通过")
print(f" 状态: {data['status']}")
print(f" 服务: {data['service']}")
print(f" Jenkins: {data['jenkins']['status']}")
print(f" 工作池: {data['worker_pool']['active_workers']} 活跃工作线程")
print(f" 队列大小: {data['worker_pool']['queue_size']}")
print(f" 已处理: {data['worker_pool']['total_processed']}")
print(f" 失败: {data['worker_pool']['total_failed']}")
else:
print(f"❌ 健康检查失败: {response.status}")
print()
async def test_webhook():
"""测试 Webhook 功能"""
print("🧪 测试 Webhook 功能")
print("-" * 50)
webhook_data = {
"ref": "refs/heads/dev",
"before": "abc123",
"after": "def456",
"repository": {
"full_name": "freeleaps/test-project",
"clone_url": "https://gitea.freeleaps.com/freeleaps/test-project.git"
},
"pusher": {
"login": "developer",
"email": "dev@freeleaps.com"
}
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{BASE_URL}/webhook/gitea",
json=webhook_data
) as response:
if response.status == 200:
data = await response.json()
print("✅ Webhook 处理成功")
print(f" 响应: {data['message']}")
print(f" 数据大小: {data['data']['body_size']} bytes")
else:
print(f"❌ Webhook 处理失败: {response.status}")
print()
async def test_api_key_management():
"""测试 API 密钥管理"""
print("🧪 测试 API 密钥管理")
print("-" * 50)
# 创建 API 密钥
async with aiohttp.ClientSession() as session:
# 创建密钥
create_data = {"name": "test-api-key"}
async with session.post(
f"{BASE_URL}/api/admin/api-keys",
json=create_data,
headers={"Authorization": "Bearer test-token"}
) as response:
if response.status == 200:
data = await response.json()
api_key = data['key']
key_id = data['id']
print(f"✅ API 密钥创建成功")
print(f" ID: {key_id}")
print(f" 名称: {data['name']}")
print(f" 密钥: {api_key[:8]}...{api_key[-8:]}")
# 使用新创建的密钥测试日志端点
print("\n 测试使用新密钥访问日志端点...")
async with session.get(
f"{BASE_URL}/api/logs",
headers={"Authorization": f"Bearer {api_key}"}
) as log_response:
if log_response.status == 200:
logs = await log_response.json()
print(f" ✅ 日志访问成功,获取到 {len(logs)} 条日志")
else:
print(f" ❌ 日志访问失败: {log_response.status}")
# 删除密钥
async with session.delete(
f"{BASE_URL}/api/admin/api-keys/{key_id}",
headers={"Authorization": f"Bearer {api_key}"}
) as delete_response:
if delete_response.status == 200:
print(f" ✅ API 密钥删除成功")
else:
print(f" ❌ API 密钥删除失败: {delete_response.status}")
else:
print(f"❌ API 密钥创建失败: {response.status}")
print()
async def test_project_mapping():
"""测试项目映射管理"""
print("🧪 测试项目映射管理")
print("-" * 50)
mapping_data = {
"repository_name": "freeleaps/test-project",
"default_job": "test-project-build",
"branch_jobs": [
{"branch": "dev", "job": "test-project-dev"},
{"branch": "staging", "job": "test-project-staging"}
],
"branch_patterns": [
{"pattern": "feature/*", "job": "test-project-feature"},
{"pattern": "hotfix/*", "job": "test-project-hotfix"}
]
}
async with aiohttp.ClientSession() as session:
# 创建项目映射
async with session.post(
f"{BASE_URL}/api/admin/projects",
json=mapping_data,
headers={"Authorization": "Bearer test-token"}
) as response:
if response.status == 200:
data = await response.json()
print("✅ 项目映射创建成功")
print(f" ID: {data['id']}")
print(f" 仓库: {data['repository_name']}")
print(f" 默认任务: {data['default_job']}")
print(f" 分支任务数: {len(data['branch_jobs'])}")
print(f" 分支模式数: {len(data['branch_patterns'])}")
else:
print(f"❌ 项目映射创建失败: {response.status}")
print()
async def test_logs_and_stats():
"""测试日志和统计功能"""
print("🧪 测试日志和统计功能")
print("-" * 50)
async with aiohttp.ClientSession() as session:
# 获取日志统计
async with session.get(
f"{BASE_URL}/api/logs/stats",
headers={"Authorization": "Bearer test-token"}
) as response:
if response.status == 200:
stats = await response.json()
print("✅ 日志统计获取成功")
print(f" 总日志数: {stats['total_logs']}")
print(f" 成功日志: {stats['successful_logs']}")
print(f" 失败日志: {stats['failed_logs']}")
print(f" 最近24小时: {stats['recent_logs_24h']}")
print(f" 仓库统计: {len(stats['repository_stats'])} 个仓库")
else:
print(f"❌ 日志统计获取失败: {response.status}")
print()
async def test_admin_stats():
"""测试管理统计"""
print("🧪 测试管理统计")
print("-" * 50)
async with aiohttp.ClientSession() as session:
async with session.get(
f"{BASE_URL}/api/admin/stats",
headers={"Authorization": "Bearer test-token"}
) as response:
if response.status == 200:
stats = await response.json()
print("✅ 管理统计获取成功")
print(f" API 密钥总数: {stats['api_keys']['total']}")
print(f" 活跃密钥: {stats['api_keys']['active']}")
print(f" 最近使用: {stats['api_keys']['recently_used']}")
print(f" 项目映射总数: {stats['project_mappings']['total']}")
else:
print(f"❌ 管理统计获取失败: {response.status}")
print()
async def main():
"""主测试函数"""
print("🚀 开始增强版 Gitea Webhook Ambassador 功能测试")
print("=" * 60)
print()
try:
await test_health_check()
await test_webhook()
await test_api_key_management()
await test_project_mapping()
await test_logs_and_stats()
await test_admin_stats()
print("=" * 60)
print("🎉 所有测试完成!")
print("✅ Python 版本现在具备了与 Go 版本相同的监测和管理功能")
except Exception as e:
print(f"❌ 测试过程中出现错误: {str(e)}")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
Gitea Webhook Ambassador 增强版功能测试脚本
"""
import requests
import json
import time
BASE_URL = "http://localhost:8000"
ADMIN_SECRET_KEY = "admin-secret-key-change-in-production"
def test_health_check():
"""测试健康检查"""
print("🔍 测试健康检查...")
try:
response = requests.get(f"{BASE_URL}/health")
if response.status_code == 200:
data = response.json()
print(f"✅ 健康检查成功: {data['status']}")
print(f" 版本: {data['version']}")
print(f" 运行时间: {data['uptime']}")
print(f" 内存使用: {data['memory']}")
return True
else:
print(f"❌ 健康检查失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ 健康检查异常: {e}")
return False
def test_login():
"""测试登录功能"""
print("\n🔐 测试登录功能...")
try:
# 测试登录 API
login_data = {"secret_key": ADMIN_SECRET_KEY}
response = requests.post(
f"{BASE_URL}/api/auth/login",
json=login_data,
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
data = response.json()
token = data.get("token")
print(f"✅ 登录成功,获得 JWT 令牌")
return token
else:
print(f"❌ 登录失败: {response.status_code} - {response.text}")
return None
except Exception as e:
print(f"❌ 登录异常: {e}")
return None
def test_api_key_management(token):
"""测试 API 密钥管理"""
print("\n🔑 测试 API 密钥管理...")
headers = {"Authorization": f"Bearer {token}"}
try:
# 创建 API 密钥
key_data = {"description": "测试 API 密钥"}
response = requests.post(
f"{BASE_URL}/api/keys",
json=key_data,
headers=headers
)
if response.status_code == 200:
data = response.json()
api_key = data["key"]
key_id = data["id"]
print(f"✅ 创建 API 密钥成功: {api_key[:20]}...")
# 列出 API 密钥
response = requests.get(f"{BASE_URL}/api/keys", headers=headers)
if response.status_code == 200:
keys_data = response.json()
print(f"✅ 列出 API 密钥成功,共 {len(keys_data['keys'])}")
# 删除 API 密钥
response = requests.delete(f"{BASE_URL}/api/keys/{key_id}", headers=headers)
if response.status_code == 200:
print(f"✅ 删除 API 密钥成功")
return True
else:
print(f"❌ 删除 API 密钥失败: {response.status_code}")
return False
else:
print(f"❌ 列出 API 密钥失败: {response.status_code}")
return False
else:
print(f"❌ 创建 API 密钥失败: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ API 密钥管理异常: {e}")
return False
def test_project_management(token):
"""测试项目管理"""
print("\n📁 测试项目管理...")
headers = {"Authorization": f"Bearer {token}"}
try:
# 创建项目
project_data = {
"name": "测试项目",
"jenkinsJob": "test-job",
"giteaRepo": "test-owner/test-repo"
}
response = requests.post(
f"{BASE_URL}/api/projects/",
json=project_data,
headers=headers
)
if response.status_code == 200:
data = response.json()
project_id = data["id"]
print(f"✅ 创建项目成功: {data['name']}")
# 列出项目
response = requests.get(f"{BASE_URL}/api/projects/", headers=headers)
if response.status_code == 200:
projects_data = response.json()
print(f"✅ 列出项目成功,共 {len(projects_data['projects'])}")
# 删除项目
response = requests.delete(f"{BASE_URL}/api/projects/{project_id}", headers=headers)
if response.status_code == 200:
print(f"✅ 删除项目成功")
return True
else:
print(f"❌ 删除项目失败: {response.status_code}")
return False
else:
print(f"❌ 列出项目失败: {response.status_code}")
return False
else:
print(f"❌ 创建项目失败: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"❌ 项目管理异常: {e}")
return False
def test_stats(token):
"""测试统计信息"""
print("\n📊 测试统计信息...")
headers = {"Authorization": f"Bearer {token}"}
try:
response = requests.get(f"{BASE_URL}/api/stats", headers=headers)
if response.status_code == 200:
data = response.json()
print(f"✅ 获取统计信息成功:")
print(f" 总项目数: {data['total_projects']}")
print(f" API 密钥数: {data['total_api_keys']}")
print(f" 今日触发次数: {data['today_triggers']}")
print(f" 成功触发次数: {data['successful_triggers']}")
return True
else:
print(f"❌ 获取统计信息失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ 统计信息异常: {e}")
return False
def test_logs(token):
"""测试日志功能"""
print("\n📝 测试日志功能...")
headers = {"Authorization": f"Bearer {token}"}
try:
response = requests.get(f"{BASE_URL}/api/logs", headers=headers)
if response.status_code == 200:
data = response.json()
print(f"✅ 获取日志成功,共 {len(data['logs'])} 条记录")
return True
else:
print(f"❌ 获取日志失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ 日志功能异常: {e}")
return False
def main():
"""主测试函数"""
print("🚀 Gitea Webhook Ambassador 增强版功能测试")
print("=" * 50)
# 测试健康检查
if not test_health_check():
print("❌ 健康检查失败,服务可能未启动")
return
# 测试登录
token = test_login()
if not token:
print("❌ 登录失败,无法继续测试")
return
# 测试各项功能
test_api_key_management(token)
test_project_management(token)
test_stats(token)
test_logs(token)
print("\n" + "=" * 50)
print("🎉 增强版功能测试完成!")
print("\n📋 已实现的功能:")
print(" ✅ Web 登录界面")
print(" ✅ 数据库存储 API 密钥")
print(" ✅ 延长 JWT 有效期 (7天)")
print(" ✅ 前端仪表板")
print(" ✅ 项目管理")
print(" ✅ API 密钥管理")
print(" ✅ 日志查看")
print(" ✅ 健康状态监控")
print("\n🌐 访问地址:")
print(f" 登录页面: {BASE_URL}/login")
print(f" 仪表板: {BASE_URL}/dashboard")
print(f" 管理员密钥: {ADMIN_SECRET_KEY}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Webhook 功能测试脚本
用于验证 Gitea Webhook Ambassador 的各项功能
"""
import asyncio
import json
import httpx
import time
from datetime import datetime
# 测试配置
BASE_URL = "http://localhost:8000"
WEBHOOK_SECRET = "your-secret-key-here-make-it-long-and-random"
# 测试数据
TEST_WEBHOOK_DATA = {
"ref": "refs/heads/dev",
"before": "abc1234567890abcdef1234567890abcdef123456",
"after": "def1234567890abcdef1234567890abcdef123456",
"compare_url": "https://gitea.freeleaps.com/freeleaps/test-project/compare/abc123...def123",
"commits": [
{
"id": "def1234567890abcdef1234567890abcdef123456",
"message": "feat: add new feature",
"url": "https://gitea.freeleaps.com/freeleaps/test-project/commit/def1234567890abcdef1234567890abcdef123456",
"author": {
"id": 1,
"login": "developer",
"full_name": "Test Developer",
"email": "dev@freeleaps.com"
}
}
],
"repository": {
"id": 1,
"name": "test-project",
"owner": {
"id": 1,
"login": "freeleaps",
"full_name": "Freeleaps Team",
"email": "team@freeleaps.com"
},
"full_name": "freeleaps/test-project",
"private": False,
"clone_url": "https://gitea.freeleaps.com/freeleaps/test-project.git",
"ssh_url": "git@gitea.freeleaps.com:freeleaps/test-project.git",
"html_url": "https://gitea.freeleaps.com/freeleaps/test-project",
"default_branch": "main"
},
"pusher": {
"id": 1,
"login": "developer",
"full_name": "Test Developer",
"email": "dev@freeleaps.com"
}
}
async def test_health_check():
"""测试健康检查"""
print("🔍 测试健康检查...")
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{BASE_URL}/health")
if response.status_code == 200:
data = response.json()
print(f"✅ 健康检查通过: {data['status']}")
return True
else:
print(f"❌ 健康检查失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ 健康检查异常: {e}")
return False
async def test_queue_status():
"""测试队列状态"""
print("🔍 测试队列状态...")
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{BASE_URL}/health/queue")
if response.status_code == 200:
data = response.json()
print(f"✅ 队列状态: {data['queue_stats']}")
return True
else:
print(f"❌ 队列状态检查失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ 队列状态检查异常: {e}")
return False
async def test_webhook_endpoint():
"""测试 Webhook 端点"""
print("🔍 测试 Webhook 端点...")
headers = {
"Content-Type": "application/json",
"X-Gitea-Signature": WEBHOOK_SECRET
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{BASE_URL}/webhook/gitea",
headers=headers,
json=TEST_WEBHOOK_DATA
)
print(f"📊 响应状态: {response.status_code}")
print(f"📊 响应内容: {response.text}")
if response.status_code in [200, 202]:
print("✅ Webhook 端点测试通过")
return True
else:
print(f"❌ Webhook 端点测试失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ Webhook 端点测试异常: {e}")
return False
async def test_metrics_endpoint():
"""测试监控指标端点"""
print("🔍 测试监控指标端点...")
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{BASE_URL}/metrics")
if response.status_code == 200:
print("✅ 监控指标端点测试通过")
# 打印一些关键指标
content = response.text
for line in content.split('\n'):
if 'webhook_requests_total' in line or 'queue_size' in line:
print(f"📊 {line}")
return True
else:
print(f"❌ 监控指标端点测试失败: {response.status_code}")
return False
except Exception as e:
print(f"❌ 监控指标端点测试异常: {e}")
return False
async def test_deduplication():
"""测试防抖功能"""
print("🔍 测试防抖功能...")
headers = {
"Content-Type": "application/json",
"X-Gitea-Signature": WEBHOOK_SECRET
}
async with httpx.AsyncClient() as client:
try:
# 第一次请求
print("📤 发送第一次请求...")
response1 = await client.post(
f"{BASE_URL}/webhook/gitea",
headers=headers,
json=TEST_WEBHOOK_DATA
)
print(f"📊 第一次响应: {response1.status_code}")
# 等待一秒
await asyncio.sleep(1)
# 第二次请求(相同数据,应该被防抖)
print("📤 发送第二次请求(相同数据)...")
response2 = await client.post(
f"{BASE_URL}/webhook/gitea",
headers=headers,
json=TEST_WEBHOOK_DATA
)
print(f"📊 第二次响应: {response2.status_code}")
# 修改提交哈希,发送第三次请求
modified_data = TEST_WEBHOOK_DATA.copy()
modified_data["after"] = "ghi1234567890abcdef1234567890abcdef123456"
print("📤 发送第三次请求(不同提交哈希)...")
response3 = await client.post(
f"{BASE_URL}/webhook/gitea",
headers=headers,
json=modified_data
)
print(f"📊 第三次响应: {response3.status_code}")
print("✅ 防抖功能测试完成")
return True
except Exception as e:
print(f"❌ 防抖功能测试异常: {e}")
return False
async def test_invalid_webhook():
"""测试无效的 Webhook 请求"""
print("🔍 测试无效的 Webhook 请求...")
async with httpx.AsyncClient() as client:
try:
# 测试缺少签名
print("📤 测试缺少签名...")
response1 = await client.post(
f"{BASE_URL}/webhook/gitea",
headers={"Content-Type": "application/json"},
json=TEST_WEBHOOK_DATA
)
print(f"📊 缺少签名响应: {response1.status_code}")
# 测试错误的签名
print("📤 测试错误的签名...")
response2 = await client.post(
f"{BASE_URL}/webhook/gitea",
headers={
"Content-Type": "application/json",
"X-Gitea-Signature": "wrong-secret"
},
json=TEST_WEBHOOK_DATA
)
print(f"📊 错误签名响应: {response2.status_code}")
# 测试无效的 JSON
print("📤 测试无效的 JSON...")
response3 = await client.post(
f"{BASE_URL}/webhook/gitea",
headers={
"Content-Type": "application/json",
"X-Gitea-Signature": WEBHOOK_SECRET
},
content="invalid json"
)
print(f"📊 无效 JSON 响应: {response3.status_code}")
print("✅ 无效请求测试完成")
return True
except Exception as e:
print(f"❌ 无效请求测试异常: {e}")
return False
async def main():
"""主测试函数"""
print("🚀 开始 Gitea Webhook Ambassador 功能测试")
print("=" * 50)
tests = [
("健康检查", test_health_check),
("队列状态", test_queue_status),
("Webhook 端点", test_webhook_endpoint),
("监控指标", test_metrics_endpoint),
("防抖功能", test_deduplication),
("无效请求", test_invalid_webhook),
]
results = []
for test_name, test_func in tests:
print(f"\n🧪 {test_name}")
print("-" * 30)
try:
result = await test_func()
results.append((test_name, result))
except Exception as e:
print(f"{test_name} 测试异常: {e}")
results.append((test_name, False))
# 等待一下再进行下一个测试
await asyncio.sleep(1)
# 输出测试结果
print("\n" + "=" * 50)
print("📊 测试结果汇总")
print("=" * 50)
passed = 0
total = len(results)
for test_name, result in results:
status = "✅ 通过" if result else "❌ 失败"
print(f"{test_name}: {status}")
if result:
passed += 1
print(f"\n📈 总体结果: {passed}/{total} 测试通过")
if passed == total:
print("🎉 所有测试通过!服务运行正常。")
else:
print("⚠️ 部分测试失败,请检查服务配置和日志。")
if __name__ == "__main__":
# 运行测试
asyncio.run(main())