first commit

This commit is contained in:
hukdoesn
2025-06-12 16:48:37 +08:00
commit 1bb6f0b9a8
44 changed files with 8808 additions and 0 deletions

70
.dockerignore Normal file
View File

@@ -0,0 +1,70 @@
# 通用忽略
.git
.gitignore
.DS_Store
.env
.env.*
.vscode
.idea
*.swp
*.swo
README.md
LICENSE
# 前端忽略
web/node_modules
web/.vscode
web/.idea
web/coverage
web/npm-debug.log
web/yarn-debug.log
web/yarn-error.log
web/pnpm-debug.log
web/.env.local
web/.env.development.local
web/.env.test.local
web/.env.production.local
# 注意不要忽略web/dist目录因为我们需要将其复制到容器中
# 后端忽略
backend/__pycache__/
backend/**/__pycache__/
backend/**/**/__pycache__/
backend/*.py[cod]
backend/*$py.class
backend/*.so
backend/.Python
backend/env/
backend/build/
backend/develop-eggs/
backend/dist/
backend/downloads/
backend/eggs/
backend/.eggs/
backend/lib/
backend/lib64/
backend/parts/
backend/sdist/
backend/var/
backend/*.egg-info/
backend/.installed.cfg
backend/*.egg
backend/db.sqlite3
backend/media
backend/logs/
backend/backups/
# 过滤掉migrations文件但保留__init__.py
backend/*/migrations/*
!backend/*/migrations/__init__.py
# 其他文件
Dockerfile
docker-compose.yml
.dockerignore
start-containers.sh
verify-docker.sh
DOCKER_IN_DOCKER.md
# 不要忽略启动脚本
!docker-entrypoint.sh
!ci-entrypoint-dind.sh

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
*.egg
# Django
*.log
*.pot
*.pyc
/backend/*/migrations/
**/migrations/
db.sqlite3
/backend/backups/
/liteops-www/
package.json
# Node
node_modules/
package-lock.json
**/node_modules/
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.gz
*.xml
# web
/web/

252
Dockerfile Normal file
View File

@@ -0,0 +1,252 @@
# =============================================================================
# LiteOps CI/CD Platform - Docker in Docker Multi-stage Dockerfile
# =============================================================================
# 第一阶段:构建和工具安装阶段
FROM debian:bullseye-slim AS builder
# 设置构建时的环境变量
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
# Java环境变量
JAVA_HOME=/usr/local/java/jdk1.8.0_211 \
MAVEN_HOME=/usr/local/maven/apache-maven-3.8.8 \
# NVM环境变量
NVM_DIR=/root/.nvm \
# Docker版本
DOCKER_VERSION=24.0.7
# =============================================================================
# 系统基础配置和轻量化软件安装
# =============================================================================
RUN set -eux; \
# 配置阿里云镜像源以加速下载
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
# Python
python3.9 \
python3-pip \
curl \
ca-certificates \
# SSH
openssh-client \
# GitGitPython依赖
git \
# 进程管理
procps \
bash \
# Docker安装依赖
apt-transport-https \
gnupg \
lsb-release \
iptables \
&& \
# 创建Python符号链接
ln -sf /usr/bin/python3.9 /usr/bin/python3 && \
ln -sf /usr/bin/python3.9 /usr/bin/python && \
# 配置pip镜像源
pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
pip config set install.trusted-host mirrors.aliyun.com && \
# SSH客户端基础配置
mkdir -p /root/.ssh && \
chmod 700 /root/.ssh && \
# 轻量化安装NVM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash && \
echo 'export NVM_DIR="$HOME/.nvm"' >> /root/.bashrc && \
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> /root/.bashrc && \
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" --no-use' >> /root/.profile && \
# 创建Java和Maven安装目录
mkdir -p /usr/local/java /usr/local/maven && \
# 安装精简版Docker Engine
(curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://mirrors.aliyun.com/docker-ce/linux/debian bullseye stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null) || \
(curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bullseye stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null) && \
apt-get update && \
apt-get install -y --no-install-recommends \
docker-ce-cli \
docker-ce \
&& \
apt-get autoremove -y && \
apt-get autoclean && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/cache/apt/* /root/.cache/*
# =============================================================================
# 精简Java环境安装
# =============================================================================
COPY jdk-8u211-linux-x64.tar.gz apache-maven-3.8.8-bin.tar.gz /tmp/
RUN set -eux; \
# 解压JDK和Maven
tar -xzf /tmp/jdk-8u211-linux-x64.tar.gz -C /usr/local/java && \
tar -xzf /tmp/apache-maven-3.8.8-bin.tar.gz -C /usr/local/maven && \
# 立即清理压缩包
rm -f /tmp/jdk-8u211-linux-x64.tar.gz /tmp/apache-maven-3.8.8-bin.tar.gz && \
# 极度精简JDK - 删除所有不必要的文件
cd /usr/local/java/jdk1.8.0_211 && \
rm -rf src.zip javafx-src.zip man sample demo \
COPYRIGHT LICENSE README.html THIRDPARTYLICENSEREADME.txt \
release ASSEMBLY_EXCEPTION && \
# 删除不常用的JDK工具保留核心编译和运行工具
cd bin && \
rm -f appletviewer extcheck jarsigner java-rmi.cgi \
javadoc javah javap javaws jcmd jconsole jdb jhat \
jinfo jmap jps jrunscript jsadebugd jstack jstat \
jstatd jvisualvm native2ascii orbd policytool \
rmic rmid rmiregistry schemagen serialver servertool \
tnameserv wsgen wsimport xjc && \
# 删除JRE中的不必要文件
cd ../jre && \
rm -rf COPYRIGHT LICENSE README THIRDPARTYLICENSEREADME.txt \
ASSEMBLY_EXCEPTION release && \
cd bin && \
rm -f javaws jvisualvm orbd policytool rmid \
rmiregistry servertool tnameserv && \
# 精简Maven安装删除文档和示例
cd /usr/local/maven/apache-maven-3.8.8 && \
rm -rf LICENSE NOTICE README.txt
# =============================================================================
# 第二阶段:超轻量运行时镜像
# =============================================================================
FROM debian:bullseye-slim
# 设置运行时环境变量
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
# Java环境变量
JAVA_HOME=/usr/local/java/jdk1.8.0_211 \
MAVEN_HOME=/usr/local/maven/apache-maven-3.8.8 \
# NVM环境变量
NVM_DIR=/root/.nvm \
# Docker版本
DOCKER_VERSION=24.0.7 \
# Locale配置 - 使用POSIX避免SSH locale警告
LC_ALL=POSIX \
LANG=POSIX \
# 更新PATH环境变量
PATH=/usr/local/java/jdk1.8.0_211/bin:/usr/local/maven/apache-maven-3.8.8/bin:/usr/local/bin:/usr/local/sbin:$PATH
# =============================================================================
# 运行时最小化系统配置
# =============================================================================
RUN set -eux; \
# 配置阿里云镜像源
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
# 只安装绝对必需的运行时包
apt-get update && \
apt-get install -y --no-install-recommends \
python3.9 \
python3-pip \
curl \
ca-certificates \
# SSH
openssh-client \
# GitGitPython依赖
git \
# 轻量web服务器
nginx-light \
# 进程管理
procps \
bash \
# Docker运行时依赖
apt-transport-https \
gnupg \
lsb-release \
iptables \
&& \
# 创建Python符号链接
ln -sf /usr/bin/python3.9 /usr/bin/python3 && \
ln -sf /usr/bin/python3.9 /usr/bin/python && \
# 配置pip镜像源
pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
pip config set install.trusted-host mirrors.aliyun.com && \
# 安装精简版Docker Engine
(curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://mirrors.aliyun.com/docker-ce/linux/debian bullseye stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null) || \
(curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bullseye stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null) && \
apt-get update && \
apt-get install -y --no-install-recommends \
docker-ce-cli \
docker-ce \
&& \
# 安装kubectl - 使用官方二进制文件
KUBECTL_VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt) && \
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" && \
chmod +x kubectl && \
mv kubectl /usr/local/bin/ && \
# 创建必要的目录
mkdir -p /app/logs && \
rm -rf /var/log/nginx/* /var/lib/nginx/body /var/lib/nginx/fastcgi \
/var/lib/nginx/proxy /var/lib/nginx/scgi /var/lib/nginx/uwsgi \
/etc/nginx/sites-enabled/default && \
apt-get autoremove -y && \
apt-get autoclean && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/cache/apt/* /root/.cache/* \
/var/cache/debconf/* /var/lib/dpkg/info/* /usr/share/doc/* \
/usr/share/man/* /usr/share/locale/* /usr/share/info/*
# =============================================================================
# 从构建阶段复制精简的文件
# =============================================================================
# 复制SSH配置
COPY --from=builder /root/.ssh /root/.ssh
# 复制精简的NVM环境
COPY --from=builder /root/.nvm /root/.nvm
COPY --from=builder /root/.bashrc /root/.bashrc
COPY --from=builder /root/.profile /root/.profile
# 复制精简后的Java环境
COPY --from=builder /usr/local/java /usr/local/java
COPY --from=builder /usr/local/maven /usr/local/maven
# Docker已在运行时阶段安装无需复制
# =============================================================================
# 应用程序配置
# =============================================================================
# 设置工作目录
WORKDIR /app
# 配置Nginx - 复制自定义配置文件
COPY web/nginx.conf /etc/nginx/sites-available/default
RUN ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
# 复制前端构建文件到Nginx静态文件目录
COPY web/dist/ /usr/share/nginx/html/
# 优化Python依赖安装
COPY backend/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt && \
# 清理pip缓存和不必要的文件
rm -rf /root/.cache/pip /tmp/* && \
# 移除pip的缓存目录
pip cache purge 2>/dev/null || true
# 复制后端应用代码
COPY backend/ /app/
# 复制启动脚本并设置执行权限
COPY docker-entrypoint.sh /app/
COPY ci-entrypoint-dind.sh /usr/local/bin/
RUN chmod +x /app/docker-entrypoint.sh /usr/local/bin/ci-entrypoint-dind.sh
# =============================================================================
# 容器配置
# =============================================================================
# 暴露端口
# 80: Nginx Web服务器端口
# 8900: Django后端API端口
EXPOSE 80 8900
# 设置容器入口点和默认命令
ENTRYPOINT ["/usr/local/bin/ci-entrypoint-dind.sh"]
CMD ["/app/docker-entrypoint.sh"]

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
<div align="center">
# 🚀 LiteOps - 轻量级DevOps平台
<img src="liteops-sidebar.png" alt="LiteOps Logo" width="200"/>
**简单、高效的CI/CD解决方案**
</div>
<p align="center">
<img src="https://img.shields.io/badge/Vue.js-3-42b883?style=flat-square&logo=vue.js" alt="Vue 3"/>
<img src="https://img.shields.io/badge/Django-4.2-092e20?style=flat-square&logo=django" alt="Django"/>
<img src="https://img.shields.io/badge/MySQL-8.0-4479a1?style=flat-square&logo=mysql" alt="MySQL"/>
<img src="https://img.shields.io/badge/Docker-Ready-2496ed?style=flat-square&logo=docker" alt="Docker"/>
</p>
# 项目介绍
## LiteOps CICD 平台概述
LiteOps是一个实用型的CI/CD平台。它并非追求大而全的DevOps解决方案而是聚焦于团队日常工作中真正需要的自动化构建、和部署功能帮助开发团队提高效率减少重复性工作。
## 项目特点
LiteOps的核心特点是"实用、贴合需求、易于使用"
- **实用为先**:基于公司现有流程开发,解决实际问题,没有多余花里胡哨功能
- **贴合需求**:针对团队缺少的功能进行定制开发,填补工作流程中的空白
- **易于使用**简洁直观的界面设计降低使用门槛减少学习成本倾向于Jenkins 自由风格Job
## 项目背景
在日常开发工作中我发现现有的工作流程存在一些功能缺失。市面上的CI/CD工具虽然功能丰富但往往存在以下问题
1. 与公司现有流程不匹配需要大量定制。比如我们不允许自动化构建webhook调用只允许测试手动构建便于知道发布之后修改了什么功能/bug。
2. 功能过于复杂,团队实际只需要其中一小部分
3. 学习和维护成本高Jenkins Pipeline
4. 难以满足团队特定的自动化需求
LiteOps正是基于这些实际问题开发的它不追求"高大上"的全面解决方案而是专注于解决团队日常工作中的实际痛点提供刚好满足需求的功能。更多的是发布记录功能。如测试去构建的时候需要去填写构建需求、可观测发布分支最后提交人以及提交commit记录。
## 技术架构
LiteOps采用前后端分离的架构设计
### 前端技术栈
- **Vue 3**渐进式JavaScript框架
- **Ant Design Vue 4.x**基于Vue的UI组件库
- **Axios**基于Promise的HTTP客户端
- **Vue Router**Vue官方路由管理器
- **AntV G2**:数据可视化图表库
### 后端技术栈
- **Django 4.2**Python Web框架
- **Django Channels**WebSocket支持
- **MySQL 8**:关系型数据库
- **GitPython**Git操作库
- **Python-GitLab**GitLab API客户端
- **JWT认证**:用户身份验证
### 部署方案
- **Docker**:容器化部署
## 项目目标
LiteOps的目标是解决团队在开发流程中的实际问题具体包括
1. 自动化团队中重复性高的构建和部署工作,节省人力成本
2. 标准化项目的构建流程,减少人为错误
3. 提供清晰的构建状态和日志,方便问题排查
4. 支持团队特有的部署需求,适应现有的服务器环境
5. 简化权限管理
## 适用场景
LiteOps主要适用于以下场景
- 需要解决特定CI/CD痛点的开发团队
- 现有流程中缺少自动化构建和部署环节的项目
- 希望减少手动操作、提高效率的开发环境
- 对现有工具不满意,需要更贴合实际工作流程的解决方案
## 项目当前状态与未来规划
LiteOps目前处于未完善状态虽然核心功能已经初步实现但仍有许多需求和功能有待完善如实现部署k8s项目。我希望通过开放的方式收集更多的需求和建议使这个项目能够更好地服务于实际开发场景。
### 需求征集
我诚挚邀请你在查看[功能介绍文档](https://liteops.ext4.cn)和了解LiteOps后提供宝贵的意见和建议
功能介绍文档https://liteops.ext4.cn
- **功能需求**:你希望看到哪些新功能或改进?
- **用户体验**:界面和操作流程是否符合你的使用习惯?
- **实际场景**在你的工作环境中有哪些CI/CD痛点尚未解决
### 开源计划
在收集并实现足够的功能需求后能够简单的支持一些团队正常使用我计划将LiteOps完全开源让更多的团队能够受益于这个项目。你的每一条建议都将帮助我打造一个更实用、更贴合实际需求的CI/CD工具。
## 📞 联系我
如果您对LiteOps有任何建议、问题或需求欢迎通过以下方式联系我们
- **邮箱**hukdoesn@163.com
- **GitHub Issues**[提交问题或建议](https://github.com/hukdoesn/liteops/issues)
---

0
backend/apps/__init__.py Normal file
View File

3
backend/apps/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
backend/apps/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AppsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps'

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

410
backend/apps/models.py Normal file
View File

@@ -0,0 +1,410 @@
from django.db import models
class User(models.Model):
"""
用户表
"""
id = models.AutoField(primary_key=True)
user_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='用户ID')
username = models.CharField(max_length=50, unique=True, null=True, verbose_name='用户名')
name = models.CharField(max_length=50, null=True, verbose_name='姓名')
password = models.CharField(max_length=128, null=True, verbose_name='密码')
email = models.EmailField(max_length=100, unique=True, null=True, verbose_name='邮箱')
status = models.SmallIntegerField(null=True, verbose_name='状态')
login_time = models.DateTimeField(null=True, verbose_name='最后登录时间')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'user'
verbose_name = '用户'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.username
class Role(models.Model):
"""
角色表
"""
id = models.AutoField(primary_key=True)
role_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='角色ID')
name = models.CharField(max_length=50, unique=True, null=True, verbose_name='角色名称')
description = models.TextField(null=True, blank=True, verbose_name='角色描述')
permissions = models.JSONField(default=dict, null=True, verbose_name='权限配置')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'role'
verbose_name = '角色'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class UserRole(models.Model):
"""
用户角色关联表
"""
id = models.AutoField(primary_key=True)
user = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='用户')
role = models.ForeignKey('Role', on_delete=models.CASCADE, to_field='role_id', null=True, verbose_name='角色')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'user_role'
verbose_name = '用户角色关联'
verbose_name_plural = verbose_name
ordering = ['-create_time']
unique_together = ['user', 'role'] # 确保用户和角色的组合唯一
def __str__(self):
return f"{self.user.username} - {self.role.name}"
class UserToken(models.Model):
"""
用户Token表
"""
id = models.AutoField(primary_key=True)
token_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='TokenID')
user = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='用户')
token = models.CharField(max_length=256, null=True, verbose_name='Token信息')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'user_token'
verbose_name = '用户Token'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return f"{self.user.username}'s token"
class Project(models.Model):
"""
项目表 - 包含项目基本信息和服务信息
"""
id = models.AutoField(primary_key=True)
project_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='项目ID')
name = models.CharField(max_length=50, null=True, verbose_name='项目名称')
description = models.TextField(null=True, blank=True, verbose_name='项目描述')
category = models.CharField(max_length=20, null=True, verbose_name='服务类别') # frontend, backend, mobile
repository = models.CharField(max_length=255, null=True, verbose_name='GitLab仓库地址')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'project'
verbose_name = '项目'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class GitlabTokenCredential(models.Model):
"""
GitLab Token凭证表
"""
id = models.AutoField(primary_key=True)
credential_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='凭证ID')
name = models.CharField(max_length=50, null=True, verbose_name='凭证名称')
description = models.TextField(null=True, blank=True, verbose_name='凭证描述')
token = models.CharField(max_length=255, null=True, verbose_name='GitLab Token')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'gitlab_token_credential'
verbose_name = 'GitLab Token凭证'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class SSHKeyCredential(models.Model):
"""
SSH密钥凭证表
"""
id = models.AutoField(primary_key=True)
credential_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='凭证ID')
name = models.CharField(max_length=50, null=True, verbose_name='凭证名称')
description = models.TextField(null=True, blank=True, verbose_name='凭证描述')
private_key = models.TextField(null=True, verbose_name='SSH私钥内容')
passphrase = models.CharField(max_length=255, null=True, blank=True, verbose_name='私钥密码 (可选)')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'ssh_key_credential'
verbose_name = 'SSH密钥凭证'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class KubeconfigCredential(models.Model):
"""
Kubeconfig访问凭证表
"""
id = models.AutoField(primary_key=True)
credential_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='凭证ID')
name = models.CharField(max_length=50, null=True, verbose_name='凭证名称')
description = models.TextField(null=True, blank=True, verbose_name='凭证描述')
kubeconfig_content = models.TextField(null=True, verbose_name='Kubeconfig文件内容')
cluster_name = models.CharField(max_length=100, null=True, verbose_name='集群名称') # 用于显示和区分
context_name = models.CharField(max_length=100, null=True, verbose_name='上下文名称') # kubectl使用的context名称
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'kubeconfig_credential'
verbose_name = 'Kubeconfig访问凭证'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class Environment(models.Model):
"""
环境配置表 - 用于管理不同的部署环境(如开发、测试、预发布、生产等)
"""
id = models.AutoField(primary_key=True)
environment_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='环境ID')
name = models.CharField(max_length=50, null=True, verbose_name='环境名称')
type = models.CharField(max_length=20, null=True, verbose_name='环境类型') # development, testing, staging, production
description = models.TextField(null=True, blank=True, verbose_name='环境描述')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'environment'
verbose_name = '环境配置'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return self.name
class BuildTask(models.Model):
"""构建任务表"""
id = models.AutoField(primary_key=True)
task_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='任务ID')
name = models.CharField(max_length=100, null=True, verbose_name='任务名称')
project = models.ForeignKey('Project', on_delete=models.CASCADE, to_field='project_id', null=True, verbose_name='所属项目')
environment = models.ForeignKey('Environment', on_delete=models.CASCADE, to_field='environment_id', null=True, verbose_name='构建环境')
description = models.TextField(null=True, blank=True, verbose_name='任务描述')
requirement = models.TextField(null=True, blank=True, verbose_name='构建需求描述')
branch = models.CharField(max_length=100, default='main', null=True, verbose_name='默认分支')
git_token = models.ForeignKey('GitlabTokenCredential', on_delete=models.SET_NULL, to_field='credential_id', null=True, verbose_name='Git Token')
version = models.CharField(max_length=50, null=True, blank=True, verbose_name='构建版本号')
# 构建阶段
stages = models.JSONField(default=list, verbose_name='构建阶段')
# 外部脚本库配置
use_external_script = models.BooleanField(default=False, verbose_name='使用外部脚本库')
external_script_config = models.JSONField(default=dict, verbose_name='外部脚本库配置', help_text='''
{
"repo_url": "https://github.com/example/scripts.git", # Git仓库地址
"directory": "/data/scripts", # 存放目录
"branch": "main", # 分支名称(可选)
"token_id": "credential_id" # Git Token凭证ID私有仓库
}
''')
# 构建时间信息使用JSON存储
build_time = models.JSONField(default=dict, verbose_name='构建时间信息', help_text='''
{
"total_duration": "300", # 总耗时(秒)
"start_time": "2024-03-06 12:00:00", # 开始时间
"end_time": "2024-03-06 12:05:00", # 结束时间
"stages_time": [ # 各阶段时间信息
{
"name": "代码拉取",
"start_time": "2024-03-06 12:00:00",
"duration": "60" # 耗时(秒)
}
]
}
''')
# 构建后操作
notification_channels = models.JSONField(default=list, verbose_name='通知方式')
# 状态和统计
status = models.CharField(max_length=20, default='created', null=True, verbose_name='任务状态') # created, disabled
building_status = models.CharField(max_length=20, default='idle', null=True, verbose_name='构建状态') # idle, building
last_build_number = models.IntegerField(default=0, verbose_name='最后构建号')
total_builds = models.IntegerField(default=0, verbose_name='总构建次数')
success_builds = models.IntegerField(default=0, verbose_name='成功构建次数')
failure_builds = models.IntegerField(default=0, verbose_name='失败构建次数')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'build_task'
verbose_name = '构建任务'
verbose_name_plural = verbose_name
ordering = ['-create_time']
class BuildHistory(models.Model):
"""构建历史表"""
id = models.AutoField(primary_key=True)
history_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='历史ID')
task = models.ForeignKey('BuildTask', on_delete=models.CASCADE, to_field='task_id', null=True, verbose_name='构建任务')
build_number = models.IntegerField(verbose_name='构建序号')
branch = models.CharField(max_length=100, null=True, verbose_name='构建分支')
commit_id = models.CharField(max_length=40, null=True, verbose_name='Git Commit ID')
version = models.CharField(max_length=50, null=True, verbose_name='构建版本')
status = models.CharField(max_length=20, default='pending', verbose_name='构建状态') # pending, running, success, failed, terminated
requirement = models.TextField(null=True, blank=True, verbose_name='构建需求描述')
build_log = models.TextField(null=True, blank=True, verbose_name='构建日志')
stages = models.JSONField(default=list, verbose_name='构建阶段')
build_time = models.JSONField(default=dict, verbose_name='构建时间信息', help_text='''
{
"total_duration": "300", # 总耗时(秒)
"start_time": "2024-03-06 12:00:00", # 开始时间
"end_time": "2024-03-06 12:05:00", # 结束时间
"stages_time": [ # 各阶段时间信息
{
"name": "代码拉取",
"start_time": "2024-03-06 12:00:00",
"duration": "60" # 耗时(秒)
}
]
}
''')
operator = models.ForeignKey('User', on_delete=models.SET_NULL, to_field='user_id', null=True, verbose_name='构建人')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'build_history'
verbose_name = '构建历史'
verbose_name_plural = verbose_name
ordering = ['-create_time']
unique_together = ['task', 'build_number'] # 确保任务和构建号的组合唯一
def __str__(self):
return f"{self.task.name} #{self.build_number}"
class NotificationRobot(models.Model):
"""通知机器人表"""
id = models.AutoField(primary_key=True)
robot_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='机器人ID')
type = models.CharField(max_length=20, null=True, verbose_name='机器人类型') # dingtalk, wecom, feishu
name = models.CharField(max_length=50, null=True, verbose_name='机器人名称')
webhook = models.CharField(max_length=255, null=True, verbose_name='Webhook地址')
security_type = models.CharField(max_length=20, null=True, verbose_name='安全设置类型') # none, secret, keyword, ip
secret = models.CharField(max_length=255, null=True, blank=True, verbose_name='加签密钥')
keywords = models.JSONField(default=list, null=True, blank=True, verbose_name='自定义关键词')
ip_list = models.JSONField(default=list, null=True, blank=True, verbose_name='IP白名单')
remark = models.TextField(null=True, blank=True, verbose_name='备注')
creator = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='创建者')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'notification_robot'
verbose_name = '通知机器人'
verbose_name_plural = verbose_name
ordering = ['-create_time']
def __str__(self):
return f"{self.name} ({self.type})"
class LoginLog(models.Model):
"""
登录日志表
"""
id = models.AutoField(primary_key=True)
log_id = models.CharField(max_length=32, unique=True, null=True, verbose_name='日志ID')
user = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='用户')
ip_address = models.CharField(max_length=50, null=True, verbose_name='IP地址')
user_agent = models.TextField(null=True, blank=True, verbose_name='用户代理')
status = models.CharField(max_length=20, null=True, verbose_name='登录状态') # success, failed
fail_reason = models.CharField(max_length=100, null=True, blank=True, verbose_name='失败原因')
login_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='登录时间')
class Meta:
db_table = 'login_log'
verbose_name = '登录日志'
verbose_name_plural = verbose_name
ordering = ['-login_time']
def __str__(self):
return f"{self.user.username} - {self.login_time}"
class SecurityConfig(models.Model):
"""
安全配置表 - 存储系统安全策略配置
"""
id = models.AutoField(primary_key=True)
min_password_length = models.IntegerField(default=8, verbose_name='密码最小长度')
password_complexity = models.JSONField(default=list, verbose_name='密码复杂度要求', help_text='包含: uppercase, lowercase, number, special')
session_timeout = models.IntegerField(default=120, verbose_name='会话超时时间(分钟)')
max_login_attempts = models.IntegerField(default=5, verbose_name='最大登录尝试次数')
lockout_duration = models.IntegerField(default=30, verbose_name='账户锁定时间(分钟)')
enable_2fa = models.BooleanField(default=False, verbose_name='启用双因子认证')
update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
class Meta:
db_table = 'security_config'
verbose_name = '安全配置'
verbose_name_plural = verbose_name
def __str__(self):
return "系统安全配置"
class LoginAttempt(models.Model):
"""
登录尝试记录表 - 用于跟踪用户登录失败次数和账户锁定状态
"""
id = models.AutoField(primary_key=True)
user = models.ForeignKey('User', on_delete=models.CASCADE, to_field='user_id', null=True, verbose_name='用户')
ip_address = models.CharField(max_length=50, null=True, verbose_name='IP地址')
failed_attempts = models.IntegerField(default=0, verbose_name='失败尝试次数')
locked_until = models.DateTimeField(null=True, blank=True, verbose_name='锁定到期时间')
last_attempt_time = models.DateTimeField(auto_now=True, verbose_name='最后尝试时间')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
class Meta:
db_table = 'login_attempt'
verbose_name = '登录尝试记录'
verbose_name_plural = verbose_name
unique_together = ['user', 'ip_address'] # 确保用户和IP组合唯一
def __str__(self):
return f"{self.user.username if self.user else 'Unknown'} - {self.ip_address}"

3
backend/apps/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,56 @@
import jwt
import logging
from functools import wraps
from django.http import JsonResponse
from django.conf import settings
from ..models import UserToken
logger = logging.getLogger('apps')
def jwt_auth_required(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
token = request.headers.get('Authorization')
if not token:
logger.info('认证失败: 未提供Token')
return JsonResponse({
'code': 401,
'message': '未提供Token'
}, status=401)
try:
# 验证token是否存在于数据库
user_token = UserToken.objects.filter(token=token).first()
if not user_token:
logger.info('认证失败: Token无效')
return JsonResponse({
'code': 401,
'message': 'Token无效'
}, status=401)
# 解析token
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
request.user_id = payload.get('user_id')
return view_func(request, *args, **kwargs)
except jwt.ExpiredSignatureError:
logger.info('认证失败: Token已过期')
return JsonResponse({
'code': 401,
'message': 'Token已过期'
}, status=401)
except jwt.InvalidTokenError:
logger.info('认证失败: Token格式无效')
return JsonResponse({
'code': 401,
'message': '无效的Token'
}, status=401)
except Exception as e:
logger.error('认证过程发生错误', exc_info=True)
return JsonResponse({
'code': 500,
'message': '服务器错误'
}, status=500)
return wrapper

View File

@@ -0,0 +1,341 @@
import os
import subprocess
import logging
import time
import tempfile
from typing import List, Dict, Any, Callable
logger = logging.getLogger('apps')
class BuildStageExecutor:
"""构建阶段执行器"""
def __init__(self, build_path: str, send_log: Callable, record_time: Callable):
"""
初始化构建阶段执行器
Args:
build_path: 构建目录路径
send_log: 发送日志的回调函数
record_time: 记录时间的回调函数
"""
self.build_path = build_path
self.send_log = send_log
self.record_time = record_time
self.env = {} # 初始化为空字典,将由 Builder 设置
# 用于存储临时变量文件的路径
self.vars_file = os.path.join(self.build_path, '.build_vars')
self._init_vars_file()
def _init_vars_file(self):
"""初始化变量文件"""
try:
with open(self.vars_file, 'w') as f:
f.write('#!/bin/bash\n# 构建变量\n')
# 设置执行权限
os.chmod(self.vars_file, 0o755)
except Exception as e:
logger.error(f"初始化变量文件失败: {str(e)}", exc_info=True)
def _save_variables_to_file(self, variables):
"""
将变量保存到文件
Args:
variables: 变量字典
"""
try:
if not variables:
return
# 追加变量到变量文件
with open(self.vars_file, 'a') as f:
for name, value in variables.items():
if name.startswith(('_', 'BASH_', 'SHELL', 'HOME', 'PATH', 'PWD', 'OLDPWD')):
continue
safe_value = self._escape_shell_value(str(value))
f.write(f'export {name}={safe_value}\n')
except Exception as e:
logger.error(f"保存变量到文件失败: {str(e)}", exc_info=True)
def _escape_shell_value(self, value):
"""
转义shell变量值
Args:
value: 要转义的值
Returns:
str: 转义后的值
"""
try:
# 如果值为空,返回空字符串
if not value:
return '""'
# 如果值只包含安全字符(字母、数字、下划线、点、斜杠、冒号),不需要引号
import re
if re.match(r'^[a-zA-Z0-9_./:-]+$', value):
return value
# 用单引号包围,并转义其中的单引号
escaped_value = value.replace("'", "'\"'\"'")
return f"'{escaped_value}'"
except Exception as e:
logger.error(f"转义shell值失败: {str(e)}", exc_info=True)
return '""'
def _is_variable_assignment(self, line):
"""
检查是否是变量赋值语句
Args:
line: 要检查的行
Returns:
bool: 是否是变量赋值语句
"""
try:
import re
# 去除前导空格
line = line.strip()
# 检查是否是 export VAR=value 格式
if line.startswith('export '):
line = line[7:].strip() # 移除 'export ' 前缀
# 检查是否符合变量赋值格式VAR=value
# 变量名必须以字母或下划线开头,后面可以跟字母、数字、下划线
pattern = r'^[a-zA-Z_][a-zA-Z0-9_]*='
return bool(re.match(pattern, line))
except Exception as e:
logger.error(f"检查变量赋值语句失败: {str(e)}", exc_info=True)
return False
def _create_temp_script_file(self, script_content: str, stage_name: str) -> str:
"""
创建临时脚本文件支持Jenkins风格的命令显示
Args:
script_content: 内联脚本内容
stage_name: 阶段名称
Returns:
str: 临时脚本文件路径
"""
try:
# 创建临时脚本文件
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.sh',
prefix=f'build_stage_{stage_name}_',
dir=self.build_path,
delete=False
) as temp_file:
# 写入脚本头部
temp_file.write('#!/bin/bash\n')
temp_file.write('set -e # 遇到错误立即退出\n')
temp_file.write('set -o pipefail # 管道命令中任何一个失败都视为失败\n')
temp_file.write('\n')
# 加载变量文件
temp_file.write(f'# 加载构建变量\n')
temp_file.write(f'source "{self.vars_file}" 2>/dev/null || true\n')
temp_file.write('\n')
# 添加命令显示函数
temp_file.write('''# 命令显示函数
execute_with_display() {
echo "+ $*"
"$@"
}
''')
temp_file.write('# 用户脚本开始\n')
# 逐行处理脚本内容
for line in script_content.splitlines():
line = line.strip()
if line and not line.startswith('#'):
# 检查是否是变量赋值语句VAR=value 或 export VAR=value
if self._is_variable_assignment(line):
# 变量赋值:直接显示并执行
temp_file.write(f'echo "+ {line}"\n')
temp_file.write(f'{line}\n')
elif any(op in line for op in ['|', '>', '<', '&&', '||', ';']):
# 复杂命令先显示再在子shell中执行
temp_file.write(f'echo "+ {line}"\n')
temp_file.write(f'bash -c {repr(line)}\n')
else:
# 简单命令:使用函数显示并执行
temp_file.write(f'execute_with_display {line}\n')
elif line.startswith('#'):
# 保留注释
temp_file.write(f'{line}\n')
temp_file.write('\n')
script_path = temp_file.name
# 设置执行权限
os.chmod(script_path, 0o755)
return script_path
except Exception as e:
self.send_log(f"创建临时脚本文件失败: {str(e)}", stage_name)
return None
def _execute_script_unified(self, script_path: str, stage_name: str, check_termination: Callable = None) -> bool:
try:
# 检查是否应该终止
if check_termination and check_termination():
self.send_log("构建已被终止,跳过脚本执行", stage_name)
return False
# 执行脚本合并stdout和stderr到同一个流保持输出顺序
process = subprocess.Popen(
['/bin/bash', script_path],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # 将stderr重定向到stdout保持输出顺序
cwd=self.build_path,
env=self.env,
universal_newlines=True,
bufsize=1 # 行缓冲,确保输出能够实时获取
)
# 实时读取并发送输出
import fcntl
import os
# 设置非阻塞模式
fd = process.stdout.fileno()
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
# 持续读取直到进程结束
while process.poll() is None:
# 检查是否终止
if check_termination and check_termination():
process.terminate()
self.send_log("构建已被终止,停止当前脚本", stage_name)
return False
try:
line = process.stdout.readline()
if line:
line = line.rstrip()
self.send_log(line, stage_name, raw_output=True)
except BlockingIOError:
time.sleep(0.01)
continue
remaining_output = process.stdout.read()
if remaining_output:
for line in remaining_output.splitlines():
if line.strip():
self.send_log(line.rstrip(), stage_name, raw_output=True)
# 检查执行结果
success = process.returncode == 0
if not success:
self.send_log(f"脚本执行失败,返回码: {process.returncode}", stage_name)
return success
except Exception as e:
self.send_log(f"执行脚本时发生错误: {str(e)}", stage_name)
return False
finally:
if script_path.startswith(tempfile.gettempdir()) or '/build_stage_' in script_path:
try:
os.unlink(script_path)
except Exception as e:
logger.debug(f"清理临时脚本文件失败: {str(e)}")
def execute_stage(self, stage: Dict[str, Any], check_termination: Callable = None) -> bool:
"""
执行单个构建阶段
Args:
stage: 阶段配置信息
check_termination: 检查是否终止的回调函数
Returns:
bool: 执行是否成功
"""
try:
stage_name = stage.get('name', '未命名阶段')
if check_termination and check_termination():
self.send_log("构建已被终止,跳过此阶段", stage_name)
return False
# 记录阶段开始时间
stage_start_time = time.time()
# 执行脚本
success = self._execute_inline_script(stage, check_termination)
# 记录阶段耗时
stage_duration = time.time() - stage_start_time
self.record_time(stage_name, stage_start_time, stage_duration)
return success
except Exception as e:
self.send_log(f"执行阶段时发生错误: {str(e)}", stage_name)
return False
def _execute_inline_script(self, stage: Dict[str, Any], check_termination: Callable = None) -> bool:
"""
执行内联脚本
Args:
stage: 阶段配置信息
check_termination: 检查是否终止的回调函数
Returns:
bool: 执行是否成功
"""
stage_name = stage.get('name', '未命名阶段')
try:
script_content = stage.get('script', '').strip()
if not script_content:
self.send_log("脚本内容为空", stage_name)
return False
# 创建临时脚本文件
script_path = self._create_temp_script_file(script_content, stage_name)
if not script_path:
return False
# 脚本执行方法
success = self._execute_script_unified(script_path, stage_name, check_termination)
return success
except Exception as e:
self.send_log(f"执行内联脚本时发生错误: {str(e)}", stage_name)
return False
def execute_stages(self, stages: List[Dict[str, Any]], check_termination: Callable = None) -> bool:
"""
执行所有构建阶段
Args:
stages: 阶段配置列表
check_termination: 检查是否终止的回调函数
Returns:
bool: 所有阶段是否都执行成功
"""
if not stages:
self.send_log("没有配置构建阶段")
return False
for stage in stages:
if check_termination and check_termination():
self.send_log("构建已被终止,跳过后续阶段", "Build Stages")
return False
stage_name = stage.get('name', '未命名阶段')
self.send_log(f"开始执行阶段: {stage_name}", "Build Stages")
# 执行当前阶段
if not self.execute_stage(stage, check_termination):
self.send_log(f"阶段 {stage_name} 执行失败", "Build Stages")
return False
self.send_log(f"阶段 {stage_name} 执行完成", "Build Stages")
self.send_log("所有阶段执行完成", "Build Stages")
return True

View File

@@ -0,0 +1,578 @@
import os
import logging
import time
import subprocess
import tempfile
import re
import shutil
from datetime import datetime
from pathlib import Path
from django.conf import settings
from git import Repo
from git.exc import GitCommandError
from .build_stages import BuildStageExecutor
from .notifier import BuildNotifier
from .log_stream import log_stream_manager
from django.db.models import F
from ..models import BuildTask, BuildHistory
# from ..utils.builder import Builder
# from ..utils.crypto import decrypt_sensitive_data
logger = logging.getLogger('apps')
class Builder:
def __init__(self, task, build_number, commit_id, history):
self.task = task
self.build_number = build_number
self.commit_id = commit_id
self.history = history # 构建历史记录
self.log_buffer = [] # 缓存日志
# 检查是否已有指定的版本号
if self.history.version:
self.version = self.history.version
self.send_log(f"使用指定版本: {self.version}", "Version")
else:
# 为开发和测试环境生成新的版本号
self.version = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{commit_id[:8]}"
# 初始化构建时间信息
self.build_time = {
'start_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'stages_time': []
}
# 更新构建历史记录的状态和版本
self.history.status = 'running'
if not self.history.version: # 只有当没有版本号时才更新
self.history.version = self.version
self.history.build_time = self.build_time
self.history.save(update_fields=['status', 'version', 'build_time'])
# 设置构建目录
self.build_path = Path(settings.BUILD_ROOT) / task.name / self.version / task.project.name
# 创建实时日志流
log_stream_manager.create_build_stream(self.task.task_id, self.build_number)
def check_if_terminated(self):
"""检查构建是否已被终止"""
# 从数据库重新加载构建历史记录,以获取最新状态
try:
history_record = BuildHistory.objects.get(history_id=self.history.history_id)
if history_record.status == 'terminated':
# 如果状态为terminated构建已被手动终止
self.send_log("检测到构建已被手动终止,停止后续步骤", "System")
return True
return False
except Exception as e:
logger.error(f"检查构建状态时出错: {str(e)}", exc_info=True)
return False
def _filter_maven_progress(self, message):
# 只过滤Maven下载进度信息中的Progress()部分
if 'Progress (' in message and ('KB' in message or 'MB' in message or 'B/s' in message):
return None
# 过滤Maven下载进度条
if re.match(r'^Progress \(\d+\): .+', message.strip()):
return None
# 过滤空的进度行
if re.match(r'^\s*Progress\s*$', message.strip()):
return None
# 过滤下载进度百分比
if re.match(r'^\s*\d+%\s*$', message.strip()):
return None
return message
def send_log(self, message, stage=None, console_only=False, raw_output=False):
"""发送日志到缓存、实时流和控制台
Args:
message: 日志消息
stage: 阶段名称
console_only: 是否只输出到控制台(保留参数兼容性)
raw_output: 是否为原始输出(不添加阶段标记)
"""
# 过滤Maven Progress信息
filtered_message = self._filter_maven_progress(message)
if filtered_message is None:
return # 跳过被过滤的消息
# 格式化消息
if raw_output:
formatted_message = filtered_message
else:
formatted_message = filtered_message
# 如果有阶段名称,添加阶段标记
if stage:
formatted_message = f"[{stage}] {filtered_message}"
# 缓存日志
self.log_buffer.append(formatted_message)
# 推送到实时日志流
try:
log_stream_manager.push_log(
task_id=self.task.task_id,
build_number=self.build_number,
message=formatted_message + '\n',
stage=stage
)
except Exception as e:
# 降低日志级别,避免在清理阶段产生过多错误日志
if "日志队列不存在" in str(e):
logger.debug(f"日志队列已清理,跳过推送: {str(e)}")
else:
logger.error(f"推送实时日志失败: {str(e)}", exc_info=True)
# 批量更新数据库中的构建日志
try:
should_update_db = (
len(self.log_buffer) % 10 == 0 or # 每10条日志更新一次
not hasattr(self, '_last_db_update') or
time.time() - getattr(self, '_last_db_update', 0) >= 5 # 每5秒更新一次
)
if should_update_db:
current_log = '\n'.join(self.log_buffer)
self.history.build_log = current_log
self.history.save(update_fields=['build_log'])
self._last_db_update = time.time()
except Exception as e:
logger.error(f"批量更新构建日志失败: {str(e)}", exc_info=True)
# 输出到控制台 - 确保构建日志在控制台显示
logger.info(formatted_message, extra={
'from_builder': True, # 添加标记以区分构建日志
'task_id': self.task.task_id,
'build_number': self.build_number
})
def _save_build_log(self):
"""保存构建日志到历史记录"""
try:
self.history.build_log = '\n'.join(self.log_buffer)
self.history.save(update_fields=['build_log'])
except Exception as e:
logger.error(f"保存构建日志失败: {str(e)}", exc_info=True)
def clone_repository(self):
"""克隆Git仓库"""
try:
# 检查构建是否已被终止
if self.check_if_terminated():
return False
self.send_log("开始克隆代码...", "Git Clone")
self.send_log(f"构建目录: {self.build_path}", "Git Clone")
# 确保目录存在
self.build_path.parent.mkdir(parents=True, exist_ok=True)
# 获取Git凭证
repository = self.task.project.repository
self.send_log(f"仓库地址: {repository}", "Git Clone")
git_token = self.task.git_token.token if self.task.git_token else None
# 处理带有token的仓库URL
if git_token and repository.startswith('http'):
if '@' in repository:
repository = repository.split('@')[1]
repository = f'https://oauth2:{git_token}@{repository}'
else:
repository = repository.replace('://', f'://oauth2:{git_token}@')
# 使用构建历史记录中的分支
branch = self.history.branch
self.send_log(f"克隆分支: {branch}", "Git Clone")
self.send_log("正在克隆代码,请稍候...", "Git Clone")
# 克隆指定分支的代码
Repo.clone_from(
repository,
str(self.build_path),
branch=branch,
progress=self.git_progress
)
# 检查构建是否已被终止
if self.check_if_terminated():
return False
# 验证克隆是否成功
if not os.path.exists(self.build_path) or not os.listdir(self.build_path):
self.send_log("代码克隆失败:目录为空", "Git Clone")
return False
self.send_log("代码克隆完成", "Git Clone")
self.send_log(f"克隆目录验证成功: {self.build_path}", "Git Clone")
return True
except GitCommandError as e:
self.send_log(f"克隆代码失败: {str(e)}", "Git Clone")
return False
except Exception as e:
self.send_log(f"发生错误: {str(e)}", "Git Clone")
return False
def git_progress(self, op_code, cur_count, max_count=None, message=''):
"""Git进度回调"""
# 每秒检查一次构建是否已被终止
if int(time.time()) % 5 == 0: # 每5秒检查一次
if self.check_if_terminated():
# 如果构建已被终止尝试引发异常停止Git克隆
raise Exception("Build terminated")
pass
def clone_external_scripts(self):
"""克隆外部脚本库"""
try:
if not self.task.use_external_script:
return True
# 检查外部脚本库配置
config = self.task.external_script_config
if not config or not config.get('repo_url') or not config.get('directory') or not config.get('branch'):
self.send_log("外部脚本库配置不完整,跳过克隆", "External Scripts")
return True
# 检查构建是否已被终止
if self.check_if_terminated():
return False
repo_url = config.get('repo_url')
base_directory = config.get('directory')
branch = config.get('branch') # 分支为必填项
token_id = config.get('token_id')
# 从仓库URL中提取项目名称
import re
repo_name_match = re.search(r'/([^/]+?)(?:\.git)?/?$', repo_url)
if repo_name_match:
repo_name = repo_name_match.group(1)
if repo_name.endswith('.git'):
repo_name = repo_name[:-4]
else:
repo_name = 'external-scripts'
# 完整的克隆目录路径
directory = os.path.join(base_directory, repo_name)
self.send_log("开始克隆外部脚本库...", "External Scripts")
self.send_log(f"仓库地址: {repo_url}", "External Scripts")
self.send_log(f"基础目录: {base_directory}", "External Scripts")
self.send_log(f"项目名称: {repo_name}", "External Scripts")
self.send_log(f"完整目录: {directory}", "External Scripts")
self.send_log(f"分支: {branch}", "External Scripts")
# 获取Git Token如果配置了
git_token = None
if token_id:
try:
from ..models import GitlabTokenCredential
credential = GitlabTokenCredential.objects.get(credential_id=token_id)
git_token = credential.token
except:
self.send_log("获取Git Token失败尝试使用公开仓库方式克隆", "External Scripts")
# 处理带有token的仓库URL
if git_token and repo_url.startswith('http'):
if '@' in repo_url:
repo_url = repo_url.split('@')[1]
repo_url = f'https://oauth2:{git_token}@{repo_url}'
else:
repo_url = repo_url.replace('://', f'://oauth2:{git_token}@')
# 确保基础目录存在
os.makedirs(base_directory, exist_ok=True)
# 如果目标目录已存在且不为空,先清空
if os.path.exists(directory) and os.listdir(directory):
self.send_log(f"清空现有目录: {directory}", "External Scripts")
shutil.rmtree(directory)
# 克隆外部脚本库
self.send_log("正在克隆外部脚本库,请稍候...", "External Scripts")
from git import Repo
# 使用指定分支克隆
Repo.clone_from(
repo_url,
directory,
branch=branch
)
# 再次检查构建是否已被终止
if self.check_if_terminated():
return False
# 验证克隆是否成功
if not os.path.exists(directory) or not os.listdir(directory):
self.send_log("外部脚本库克隆失败:目录为空", "External Scripts")
return False
self.send_log("外部脚本库克隆完成", "External Scripts")
self.send_log(f"克隆目录验证成功: {directory}", "External Scripts")
return True
except Exception as e:
self.send_log(f"克隆外部脚本库失败: {str(e)}", "External Scripts")
# 如果用户配置了外部脚本库,克隆失败应该终止构建
self.send_log("外部脚本库克隆失败,终止构建", "External Scripts")
return False
def execute_stages(self, stage_executor):
"""执行构建阶段"""
try:
if not self.task.stages:
self.send_log("没有配置构建阶段", "Build Stages")
return False
# 检查构建是否已被终止
if self.check_if_terminated():
return False
# 执行所有阶段
success = stage_executor.execute_stages(self.task.stages, check_termination=self.check_if_terminated)
return success
except Exception as e:
self.send_log(f"执行构建阶段时发生错误: {str(e)}", "Build Stages")
return False
def execute(self):
"""执行构建"""
build_start_time = time.time()
success = False # 初始化成功状态
try:
# 在开始构建前检查构建是否已被终止
if self.check_if_terminated():
self._update_build_stats(False)
self._save_build_log()
return False
# 获取环境类型
environment_type = self.task.environment.type if self.task.environment else None
# 根据环境类型决定是否需要克隆代码
if environment_type in ['development', 'testing']:
# 只在开发和测试环境克隆代码
clone_start_time = time.time()
if not self.clone_repository():
self._update_build_stats(False) # 更新失败统计
self._update_build_time(build_start_time, False)
# 发送构建失败通知
notifier = BuildNotifier(self.history)
notifier.send_notifications()
return False
# 记录代码克隆阶段的时间
self.build_time['stages_time'].append({
'name': 'Git Clone',
'start_time': datetime.fromtimestamp(clone_start_time).strftime('%Y-%m-%d %H:%M:%S'),
'duration': str(int(time.time() - clone_start_time))
})
else:
self.send_log(f"预发布/生产环境构建,跳过代码克隆,直接使用版本: {self.version}", "Environment")
# 创建构建目录
os.makedirs(self.build_path, exist_ok=True)
# 再次检查构建是否已被终止
if self.check_if_terminated():
self._update_build_stats(False)
self._update_build_time(build_start_time, False)
return False
# 克隆外部脚本库(如果配置了)
external_script_start_time = time.time()
if not self.clone_external_scripts():
self._update_build_stats(False)
self._update_build_time(build_start_time, False)
# 发送构建失败通知
notifier = BuildNotifier(self.history)
notifier.send_notifications()
return False
# 记录外部脚本库克隆阶段的时间(如果启用了外部脚本库)
if self.task.use_external_script:
self.build_time['stages_time'].append({
'name': 'External Scripts Clone',
'start_time': datetime.fromtimestamp(external_script_start_time).strftime('%Y-%m-%d %H:%M:%S'),
'duration': str(int(time.time() - external_script_start_time))
})
# 检查构建是否已被终止
if self.check_if_terminated():
self._update_build_stats(False)
self._update_build_time(build_start_time, False)
return False
# 创建阶段执行器传递send_log方法和构建时间记录回调
stage_executor = BuildStageExecutor(
str(self.build_path),
lambda msg, stage=None, raw_output=False: self.send_log(msg, stage, raw_output=raw_output),
self._record_stage_time
)
# 设置系统内置环境变量
system_variables = {
# 编号相关变量
'BUILD_NUMBER': str(self.build_number),
'VERSION': self.version,
# Git相关变量
'COMMIT_ID': self.commit_id,
'BRANCH': self.history.branch,
# 项目相关变量
'PROJECT_NAME': self.task.project.name,
'PROJECT_ID': self.task.project.project_id,
'PROJECT_REPO': self.task.project.repository,
# 任务相关变量
'TASK_NAME': self.task.name,
'TASK_ID': self.task.task_id,
# 环境相关变量
'ENVIRONMENT': self.task.environment.name,
'ENVIRONMENT_TYPE': self.task.environment.type,
'ENVIRONMENT_ID': self.task.environment.environment_id,
# 别名(便于使用)
'service_name': self.task.name,
'build_env': self.task.environment.name,
'branch': self.history.branch,
'version': self.version,
# 构建路径
'BUILD_PATH': str(self.build_path),
'BUILD_WORKSPACE': str(self.build_path),
# Docker配置
'DOCKER_BUILDKIT': '0',
'BUILDKIT_PROGRESS': 'plain',
# Locale配置 - 使用稳定的POSIX locale避免SSH连接时的警告
'LC_ALL': 'POSIX',
'LANG': 'POSIX',
}
combined_env = {**os.environ, **system_variables}
stage_executor.env = combined_env
# 保存系统变量到文件
stage_executor._save_variables_to_file(system_variables)
# 执行构建阶段
success = self.execute_stages(stage_executor)
return success
except Exception as e:
self.send_log(f"构建过程中发生未捕获的异常: {str(e)}", "Error")
success = False
return False
finally:
# 更新构建统计和时间信息
self._update_build_stats(success)
self._update_build_time(build_start_time, success)
# 确保最终日志保存到数据库
self._save_build_log()
# 输出构建完成状态日志
self.history.refresh_from_db()
final_status = self.history.status
self.send_log(f"构建完成,状态: {final_status}", "Build")
# 确保构建完成状态日志也保存到数据库
self._save_build_log()
# 通知日志流管理器构建完成
try:
log_stream_manager.complete_build(
task_id=self.task.task_id,
build_number=self.build_number,
status=final_status
)
except Exception as e:
logger.error(f"通知日志流管理器构建完成失败: {str(e)}", exc_info=True)
# 发送构建通知
notifier = BuildNotifier(self.history)
notifier.send_notifications()
def _record_stage_time(self, stage_name: str, start_time: float, duration: float):
"""记录阶段执行时间
Args:
stage_name: 阶段名称
start_time: 开始时间戳
duration: 耗时(秒)
"""
stage_time = {
'name': stage_name,
'start_time': datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S'),
'duration': str(int(duration))
}
self.build_time['stages_time'].append(stage_time)
# 更新构建历史记录的阶段信息
self.history.stages = self.task.stages
self.history.save(update_fields=['stages'])
def _update_build_time(self, build_start_time: float, success: bool):
"""更新构建时间信息
Args:
build_start_time: 构建开始时间戳
success: 构建是否成功
"""
try:
# 计算总耗时
total_duration = int(time.time() - build_start_time)
# 更新构建时间信息
self.build_time['total_duration'] = str(total_duration)
self.build_time['end_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 检查当前构建状态如果已经是terminated则不覆盖状态
self.history.refresh_from_db()
if self.history.status != 'terminated':
# 只有在状态不是terminated时才更新状态
self.history.status = 'success' if success else 'failed'
self.history.build_time = self.build_time
self.history.save(update_fields=['status', 'build_time'])
except Exception as e:
logger.error(f"更新构建时间信息失败: {str(e)}", exc_info=True)
def _update_build_stats(self, success: bool):
"""更新构建统计信息
Args:
success: 构建是否成功
"""
try:
# 检查当前构建状态如果是terminated则不更新统计
self.history.refresh_from_db()
if self.history.status == 'terminated':
return
# 更新任务的构建统计信息
if success:
BuildTask.objects.filter(task_id=self.task.task_id).update(
success_builds=F('success_builds') + 1
)
# 只有成功的构建才更新版本号
BuildTask.objects.filter(task_id=self.task.task_id).update(
version=self.version
)
else:
BuildTask.objects.filter(task_id=self.task.task_id).update(
failure_builds=F('failure_builds') + 1
)
except Exception as e:
logger.error(f"更新构建统计信息失败: {str(e)}", exc_info=True)

View File

@@ -0,0 +1,194 @@
import threading
import queue
import time
import logging
from typing import Dict, Set, Optional
from dataclasses import dataclass
logger = logging.getLogger('apps')
@dataclass
class LogMessage:
"""日志消息数据类"""
task_id: str
build_number: int
message: str
stage: Optional[str] = None
timestamp: float = None
def __post_init__(self):
if self.timestamp is None:
self.timestamp = time.time()
class LogStreamManager:
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if hasattr(self, '_initialized'):
return
self._initialized = True
# 存储每个构建的日志队列 {(task_id, build_number): queue.Queue}
self._log_queues: Dict[tuple, queue.Queue] = {}
# 存储每个构建的SSE客户端集合 {(task_id, build_number): set}
self._sse_clients: Dict[tuple, Set] = {}
# 线程锁
self._queues_lock = threading.Lock()
self._clients_lock = threading.Lock()
logger.info("LogStreamManager initialized")
def get_build_key(self, task_id: str, build_number: int) -> tuple:
"""获取构建的唯一键"""
return (task_id, build_number)
def create_build_stream(self, task_id: str, build_number: int):
"""为新构建创建日志流"""
build_key = self.get_build_key(task_id, build_number)
with self._queues_lock:
if build_key not in self._log_queues:
self._log_queues[build_key] = queue.Queue(maxsize=20000) # 最大缓存20000条日志
logger.info(f"Created log stream for build {task_id}#{build_number}")
with self._clients_lock:
if build_key not in self._sse_clients:
self._sse_clients[build_key] = set()
def add_sse_client(self, task_id: str, build_number: int, client_id: str):
"""添加SSE客户端"""
build_key = self.get_build_key(task_id, build_number)
with self._clients_lock:
if build_key not in self._sse_clients:
self._sse_clients[build_key] = set()
self._sse_clients[build_key].add(client_id)
logger.info(f"Added SSE client {client_id} for build {task_id}#{build_number}")
def remove_sse_client(self, task_id: str, build_number: int, client_id: str):
"""移除SSE客户端"""
build_key = self.get_build_key(task_id, build_number)
with self._clients_lock:
if build_key in self._sse_clients:
self._sse_clients[build_key].discard(client_id)
logger.info(f"Removed SSE client {client_id} for build {task_id}#{build_number}")
if not self._sse_clients[build_key]:
del self._sse_clients[build_key]
self._cleanup_build_stream(build_key)
def _cleanup_build_stream(self, build_key: tuple):
"""清理构建流资源"""
with self._queues_lock:
if build_key in self._log_queues:
del self._log_queues[build_key]
logger.info(f"Cleaned up log stream for build {build_key[0]}#{build_key[1]}")
def push_log(self, task_id: str, build_number: int, message: str, stage: Optional[str] = None):
"""推送日志消息到流"""
build_key = self.get_build_key(task_id, build_number)
# 创建日志消息
log_msg = LogMessage(
task_id=task_id,
build_number=build_number,
message=message,
stage=stage
)
# 推送到队列
with self._queues_lock:
if build_key not in self._log_queues:
# 如果队列不存在,先创建
self._log_queues[build_key] = queue.Queue(maxsize=20000)
logger.debug(f"Created log queue for build {task_id}#{build_number} during push")
try:
# 非阻塞推送,如果队列满了就丢弃最老的消息
if self._log_queues[build_key].full():
try:
self._log_queues[build_key].get_nowait() # 移除最老的消息
except queue.Empty:
pass
self._log_queues[build_key].put_nowait(log_msg)
except queue.Full:
logger.warning(f"Log queue full for build {task_id}#{build_number}, dropping message")
def get_log_stream(self, task_id: str, build_number: int, client_id: str):
"""获取日志流生成器"""
build_key = self.get_build_key(task_id, build_number)
# 确保流存在
self.create_build_stream(task_id, build_number)
self.add_sse_client(task_id, build_number, client_id)
try:
while True:
try:
# 检查客户端是否还在连接
with self._clients_lock:
if build_key not in self._sse_clients or client_id not in self._sse_clients[build_key]:
logger.info(f"Client {client_id} disconnected from build {task_id}#{build_number}")
break
# 获取日志队列
with self._queues_lock:
log_queue = self._log_queues.get(build_key)
if log_queue is None:
break
try:
# 等待新日志消息超时时间为1秒
log_msg = log_queue.get(timeout=1.0)
yield log_msg
except queue.Empty:
# 超时,发送心跳
yield None # None表示心跳
except Exception as e:
logger.error(f"Error in log stream for {task_id}#{build_number}: {str(e)}")
break
finally:
# 清理客户端
self.remove_sse_client(task_id, build_number, client_id)
def complete_build(self, task_id: str, build_number: int, status: str):
"""标记构建完成"""
build_key = self.get_build_key(task_id, build_number)
# 推送完成消息
completion_msg = LogMessage(
task_id=task_id,
build_number=build_number,
message=f"BUILD_COMPLETE:{status}",
stage="SYSTEM"
)
with self._queues_lock:
if build_key in self._log_queues:
try:
self._log_queues[build_key].put_nowait(completion_msg)
except queue.Full:
pass
def has_active_clients(self, task_id: str, build_number: int) -> bool:
"""检查是否有活跃的SSE客户端"""
build_key = self.get_build_key(task_id, build_number)
with self._clients_lock:
return build_key in self._sse_clients and len(self._sse_clients[build_key]) > 0
# 全局单例实例
log_stream_manager = LogStreamManager()

View File

@@ -0,0 +1,252 @@
import json
import time
import hmac
import base64
import hashlib
import logging
import requests
from urllib.parse import quote_plus
from django.conf import settings
from ..models import NotificationRobot, BuildHistory
logger = logging.getLogger('apps')
class BuildNotifier:
"""构建通知工具类"""
def __init__(self, history: BuildHistory):
self.history = history
self.task = history.task
self.project = history.task.project
self.environment = history.task.environment
def _sign_dingtalk(self, secret: str, timestamp: str) -> str:
"""钉钉机器人签名"""
string_to_sign = f'{timestamp}\n{secret}'
hmac_code = hmac.new(
secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
return base64.b64encode(hmac_code).decode('utf-8')
def _sign_feishu(self, secret: str, timestamp: str) -> str:
"""飞书机器人签名"""
string_to_sign = f'{timestamp}\n{secret}'
hmac_code = hmac.new(
secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
return base64.b64encode(hmac_code).decode('utf-8')
def _get_build_status_emoji(self) -> str:
"""获取构建状态对应的emoji"""
status_emoji = {
'success': '',
'failed': '',
'running': '🔄',
'pending': '',
'terminated': '🛑'
}
return status_emoji.get(self.history.status, '')
def _get_duration_text(self) -> str:
"""获取构建耗时文本"""
if not self.history.build_time or 'total_duration' not in self.history.build_time:
return '未完成'
duration = int(self.history.build_time['total_duration'])
if duration < 60:
return f'{duration}'
minutes = duration // 60
seconds = duration % 60
return f'{minutes}{seconds}'
def _get_status_text(self) -> str:
"""获取状态文本"""
status_texts = {
'success': '构建成功',
'failed': '构建失败',
'running': '构建中',
'pending': '等待中',
'terminated': '构建已终止'
}
return status_texts.get(self.history.status, self.history.status)
def _get_build_url(self) -> str:
"""获取构建历史页面URL"""
base_url = getattr(settings, 'WEB_URL', 'http://localhost:5173') # 如果未配置,使用默认值
return f"{base_url}/build/history?history_id={self.history.history_id}"
def _format_dingtalk_message(self) -> dict:
"""格式化钉钉通知消息"""
status_text = self._get_status_text()
build_url = self._get_build_url()
content = [
f"## 🔔 构建通知:{status_text}",
"---",
"**构建详情:**",
f"- **任务名称**{self.task.name}",
f"- **构建编号**#{self.history.build_number}",
f"- **构建版本**{self.history.version}",
f"- **构建分支**{self.history.branch}",
f"- **提交ID**{self.history.commit_id[:8] if self.history.commit_id else ''}",
f"- **构建环境**{self.environment.name}",
f"- **构建人员**{self.history.operator.name if self.history.operator else '系统'}",
f"- **构建耗时**{self._get_duration_text()}",
"",
"**构建需求:**",
f"> {self.history.requirement or ''}",
"",
f"**查看详情:**[点击查看构建日志]({build_url})",
"",
"---",
"**注意事项:**",
"1. 此为自动通知,请勿回复",
"2. 如遇构建失败,请先查看构建日志进行排查",
"3. 如需帮助,请联系运维同学"
]
return {
"msgtype": "markdown",
"markdown": {
"title": f"{status_text}: {self.task.name} #{self.history.build_number}",
"text": "\n".join(content)
},
"at": {
"isAtAll": True
}
}
def _format_wecom_message(self) -> dict:
"""格式化企业微信通知消息"""
status_text = self._get_status_text()
build_url = self._get_build_url()
content = [
f"## 🔔 构建通知:{status_text}",
"---",
"@all", # 企业微信使用 @all 来@所有人
"",
"**构建详情:**",
f"- **任务名称**{self.task.name}",
f"- **构建编号**#{self.history.build_number}",
f"- **构建版本**{self.history.version}",
f"- **构建分支**{self.history.branch}",
f"- **提交ID**{self.history.commit_id[:8] if self.history.commit_id else ''}",
f"- **构建环境**{self.environment.name}",
f"- **构建人员**{self.history.operator.name if self.history.operator else '系统'}",
f"- **构建耗时**{self._get_duration_text()}",
"",
"**构建需求:**",
f"> {self.history.requirement or ''}",
"",
f"**查看详情:**[点击查看构建日志]({build_url})",
"",
"---",
"**注意事项:**",
"1. 此为自动通知,请勿回复",
"2. 如遇构建失败,请先查看构建日志进行排查",
"3. 如需帮助,请联系运维同学"
]
return {
"msgtype": "markdown",
"markdown": {
"content": "\n".join(content)
}
}
def _format_feishu_message(self) -> dict:
"""格式化飞书通知消息"""
status_text = self._get_status_text()
build_url = self._get_build_url()
content = [
f"🔔 构建通知:{status_text}",
"---",
"<at user_id=\"all\">所有人</at>", # 飞书使用这种格式@所有人
"",
"**构建详情:**",
f"- **任务名称**{self.task.name}",
f"- **构建编号**#{self.history.build_number}",
f"- **构建版本**{self.history.version}",
f"- **构建分支**{self.history.branch}",
f"- **提交ID**{self.history.commit_id[:8] if self.history.commit_id else ''}",
f"- **构建环境**{self.environment.name}",
f"- **构建人员**{self.history.operator.name if self.history.operator else '系统'}",
f"- **构建耗时**{self._get_duration_text()}",
"",
"**构建需求:**",
f"> {self.history.requirement or ''}",
"",
f"**查看详情:**[点击查看构建日志]({build_url})",
"",
"---",
"**注意事项:**",
"1. 此为自动通知,请勿回复",
"2. 如遇构建失败,请先查看构建日志进行排查",
"3. 如需帮助,请联系运维同学"
]
return {
"msg_type": "text",
"content": {
"text": "\n".join(content)
}
}
def send_notifications(self):
"""发送构建通知"""
if not self.task.notification_channels:
logger.info(f"任务 {self.task.name} 未配置通知方式")
return
# 获取需要通知的机器人
robots = NotificationRobot.objects.filter(robot_id__in=self.task.notification_channels)
for robot in robots:
try:
webhook = robot.webhook
timestamp = str(int(time.time() * 1000))
headers = {}
# 根据机器人类型处理安全设置
if robot.security_type == 'secret' and robot.secret:
if robot.type == 'dingtalk':
sign = self._sign_dingtalk(robot.secret, timestamp)
webhook = f"{webhook}&timestamp={timestamp}&sign={quote_plus(sign)}"
elif robot.type == 'feishu':
sign = self._sign_feishu(robot.secret, timestamp)
headers.update({
"X-Timestamp": timestamp,
"X-Sign": sign
})
# 根据机器人类型获取消息内容
if robot.type == 'dingtalk':
message = self._format_dingtalk_message()
elif robot.type == 'wecom':
message = self._format_wecom_message()
elif robot.type == 'feishu':
message = self._format_feishu_message()
else:
logger.error(f"不支持的机器人类型: {robot.type}")
continue
# 发送通知
response = requests.post(webhook, json=message, headers=headers)
if response.status_code == 200:
resp_json = response.json()
if resp_json.get('errcode') == 0 or resp_json.get('StatusCode') == 0 or resp_json.get('code') == 0:
logger.info(f"发送 {robot.type} 通知成功: {robot.name}")
else:
logger.error(f"发送 {robot.type} 通知失败: {response.text}")
else:
logger.error(f"发送 {robot.type} 通知失败: {response.text}")
except Exception as e:
logger.error(f"发送 {robot.type} 通知出错: {str(e)}", exc_info=True)

View File

@@ -0,0 +1,128 @@
import json
import logging
from ..models import User, UserRole
logger = logging.getLogger('apps')
def get_user_permissions(user_id):
"""
获取用户的权限信息
Args:
user_id: 用户ID
Returns:
dict: 用户权限信息,包含菜单权限、功能权限和数据权限
"""
try:
# 获取用户信息
try:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist:
logger.error(f'获取用户权限时用户不存在: {user_id}')
return {
'menu': [],
'function': {},
'data': {
'project_scope': 'all',
'project_ids': [],
'environment_scope': 'all',
'environment_types': []
}
}
# 获取用户的所有角色
user_roles = UserRole.objects.filter(user=user).select_related('role')
# 合并所有角色的权限
menu_permissions = set()
function_permissions = {}
data_permissions = {
'project_scope': 'all',
'project_ids': [],
'environment_scope': 'all',
'environment_types': []
}
has_custom_project_scope = False
has_custom_environment_scope = False
for user_role in user_roles:
role = user_role.role
permissions = role.permissions
# 如果permissions是字符串解析为JSON
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except:
permissions = {}
# 合并菜单权限
if permissions.get('menu'):
menu_permissions.update(permissions['menu'])
# 合并功能权限
if permissions.get('function'):
for module, actions in permissions['function'].items():
if module not in function_permissions:
function_permissions[module] = []
function_permissions[module].extend(actions)
# 确保不重复
function_permissions[module] = list(set(function_permissions[module]))
# 合并数据权限
if permissions.get('data'):
data_perms = permissions['data']
# 项目权限
if data_perms.get('project_scope') == 'custom':
has_custom_project_scope = True
if data_permissions['project_scope'] == 'all':
data_permissions['project_scope'] = 'custom'
data_permissions['project_ids'] = data_perms.get('project_ids', [])
else:
# 合并项目ID列表
data_permissions['project_ids'].extend(data_perms.get('project_ids', []))
# 确保不重复
data_permissions['project_ids'] = list(set(data_permissions['project_ids']))
# 环境权限
if data_perms.get('environment_scope') == 'custom':
has_custom_environment_scope = True
if data_permissions['environment_scope'] == 'all':
data_permissions['environment_scope'] = 'custom'
data_permissions['environment_types'] = data_perms.get('environment_types', [])
else:
# 合并环境类型列表
data_permissions['environment_types'].extend(data_perms.get('environment_types', []))
# 确保不重复
data_permissions['environment_types'] = list(set(data_permissions['environment_types']))
# 如果没有任何角色有自定义项目/环境范围,保持为'all'
if not has_custom_project_scope:
data_permissions['project_scope'] = 'all'
data_permissions['project_ids'] = []
if not has_custom_environment_scope:
data_permissions['environment_scope'] = 'all'
data_permissions['environment_types'] = []
return {
'menu': list(menu_permissions),
'function': function_permissions,
'data': data_permissions
}
except Exception as e:
logger.error(f'获取用户权限失败: {str(e)}', exc_info=True)
# 返回默认的空权限
return {
'menu': [],
'function': {},
'data': {
'project_scope': 'all',
'project_ids': [],
'environment_scope': 'all',
'environment_types': []
}
}

View File

@@ -0,0 +1,214 @@
import re
import hashlib
import logging
from datetime import datetime, timedelta
from django.utils import timezone
from ..models import SecurityConfig, LoginAttempt, User
logger = logging.getLogger('apps')
class SecurityValidator:
"""安全验证工具类"""
@staticmethod
def get_security_config():
"""获取安全配置"""
try:
config, created = SecurityConfig.objects.get_or_create(
id=1,
defaults={
'min_password_length': 8,
'password_complexity': ['lowercase', 'number'],
'session_timeout': 120,
'max_login_attempts': 5,
'lockout_duration': 30,
'enable_2fa': False
}
)
return config
except Exception as e:
logger.error(f'获取安全配置失败: {str(e)}')
# 返回默认配置
return type('SecurityConfig', (), {
'min_password_length': 8,
'password_complexity': ['lowercase', 'number'],
'session_timeout': 120,
'max_login_attempts': 5,
'lockout_duration': 30,
'enable_2fa': False
})()
@staticmethod
def validate_password(password):
"""验证密码是否符合安全策略"""
config = SecurityValidator.get_security_config()
# 检查密码长度
if len(password) < config.min_password_length:
return False, f'密码长度不能少于{config.min_password_length}'
# 检查密码复杂度
complexity_checks = {
'uppercase': (r'[A-Z]', '大写字母'),
'lowercase': (r'[a-z]', '小写字母'),
'number': (r'[0-9]', '数字'),
'special': (r'[!@#$%^&*(),.?":{}|<>]', '特殊字符')
}
missing_requirements = []
for requirement in config.password_complexity:
if requirement in complexity_checks:
pattern, description = complexity_checks[requirement]
if not re.search(pattern, password):
missing_requirements.append(description)
if missing_requirements:
return False, f'密码必须包含: {", ".join(missing_requirements)}'
return True, '密码验证通过'
@staticmethod
def check_account_lockout(user, ip_address):
"""检查账户是否被锁定"""
try:
# 首先检查用户表中的status字段
if user.status == 0:
return False, '账户已被锁定,请联系管理员解锁'
config = SecurityValidator.get_security_config()
# 获取登录尝试记录
try:
attempt = LoginAttempt.objects.get(user=user, ip_address=ip_address)
except LoginAttempt.DoesNotExist:
return True, '账户未被锁定'
# 如果账户被锁定,检查是否已过期
if attempt.locked_until and attempt.locked_until > timezone.now():
remaining_time = attempt.locked_until - timezone.now()
minutes = int(remaining_time.total_seconds() / 60)
return False, f'账户因登录失败次数过多被临时锁定,请在{minutes}分钟后重试'
# 如果临时锁定已过期,重置失败次数并解锁
if attempt.locked_until and attempt.locked_until <= timezone.now():
attempt.failed_attempts = 0
attempt.locked_until = None
attempt.save()
# 如果用户被系统锁定status=0检查是否需要自动解锁
if user.status == 0:
# 暂时保持锁定状态,需要管理员手动解锁
return False, '账户已被锁定,请联系管理员解锁'
return True, '账户未被锁定'
except Exception as e:
logger.error(f'检查账户锁定状态失败: {str(e)}')
return True, '锁定检查跳过'
@staticmethod
def record_failed_login(user, ip_address):
"""记录登录失败"""
try:
config = SecurityValidator.get_security_config()
# 获取或创建登录尝试记录
attempt, created = LoginAttempt.objects.get_or_create(
user=user,
ip_address=ip_address,
defaults={'failed_attempts': 0}
)
# 增加失败次数
attempt.failed_attempts += 1
attempt.last_attempt_time = timezone.now()
# 如果达到最大失败次数,锁定账户
if attempt.failed_attempts >= config.max_login_attempts:
attempt.locked_until = timezone.now() + timedelta(minutes=config.lockout_duration)
# 同时将用户状态设置为锁定
user.status = 0
user.save()
logger.warning(f'用户[{user.username}]账户因多次登录失败被锁定IP: {ip_address}')
attempt.save()
return attempt.failed_attempts, config.max_login_attempts
except Exception as e:
logger.error(f'记录登录失败失败: {str(e)}')
return 0, 5
@staticmethod
def record_successful_login(user, ip_address):
"""记录登录成功,重置失败次数"""
try:
# 清除登录失败记录
LoginAttempt.objects.filter(
user=user,
ip_address=ip_address
).delete()
except Exception as e:
logger.error(f'重置登录失败记录失败: {str(e)}')
@staticmethod
def unlock_user_account(user):
"""解锁用户账户(管理员操作)"""
try:
# 清除所有登录尝试记录
LoginAttempt.objects.filter(user=user).delete()
# 解锁用户状态
if user.status == 0:
user.status = 1
user.save()
logger.info(f'管理员解锁了用户[{user.username}]的账户')
return True, '账户解锁成功'
return True, '账户状态正常'
except Exception as e:
logger.error(f'解锁用户账户失败: {str(e)}')
return False, '解锁失败'
@staticmethod
def lock_user_account(user):
"""锁定用户账户(管理员操作)"""
try:
# 锁定用户状态
if user.status == 1:
user.status = 0
user.save()
logger.info(f'管理员锁定了用户[{user.username}]的账户')
return True, '账户锁定成功'
return True, '账户已处于锁定状态'
except Exception as e:
logger.error(f'锁定用户账户失败: {str(e)}')
return False, '锁定失败'
@staticmethod
def is_session_expired(login_time):
"""检查会话是否过期"""
try:
config = SecurityValidator.get_security_config()
if not login_time:
return True
# 计算会话过期时间
session_expire_time = login_time + timedelta(minutes=config.session_timeout)
return timezone.now() > session_expire_time
except Exception as e:
logger.error(f'检查会话过期失败: {str(e)}')
return False
def validate_password_strength(password):
"""独立的密码强度验证函数,供其他模块使用"""
return SecurityValidator.validate_password(password)

924
backend/apps/views/build.py Normal file
View File

@@ -0,0 +1,924 @@
import json
import uuid
import hashlib
import logging
import threading
import time
from datetime import datetime
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from django.db.models import Q, F
from ..models import BuildTask, BuildHistory, Project, Environment, GitlabTokenCredential, User, NotificationRobot
from ..utils.auth import jwt_auth_required
from ..utils.builder import Builder
from ..utils.permissions import get_user_permissions
logger = logging.getLogger('apps')
def generate_id():
"""生成唯一ID"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
def execute_build(task, build_number, commit_id, history):
"""执行构建任务"""
try:
builder = Builder(task, build_number, commit_id, history)
builder.execute()
finally:
# 无论构建成功、失败或异常,都将构建状态重置为空闲
from django.db import transaction
with transaction.atomic():
# 重新获取任务对象,确保获取最新状态
from ..models import BuildTask
BuildTask.objects.filter(task_id=task.task_id).update(building_status='idle')
logger.info(f"任务 [{task.task_id}] 构建状态已重置为空闲")
@method_decorator(csrf_exempt, name='dispatch')
class BuildTaskView(View):
@method_decorator(jwt_auth_required)
def get(self, request, task_id=None):
"""获取构建任务列表或单个任务详情"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有构建任务查看权限
function_permissions = user_permissions.get('function', {})
build_permissions = function_permissions.get('build_task', [])
if 'view' not in build_permissions:
logger.warning(f'用户[{request.user_id}]没有构建任务查看权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看构建任务'
}, status=403)
# 如果请求参数中包含get_robots=true则返回通知机器人列表
if request.GET.get('get_robots') == 'true':
robots = NotificationRobot.objects.all()
robot_list = []
for robot in robots:
robot_list.append({
'robot_id': robot.robot_id,
'type': robot.type,
'name': robot.name,
})
return JsonResponse({
'code': 200,
'message': '获取通知机器人列表成功',
'data': robot_list
})
# 如果提供了task_id则返回单个任务详情
if task_id:
try:
task = BuildTask.objects.select_related(
'project',
'environment',
'git_token',
'creator'
).get(task_id=task_id)
# 检查是否有权限查看该任务(项目权限和环境权限)
# 项目权限检查
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if task.project and task.project.project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的项目[{task.project.project_id}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限查看该项目的构建任务'
}, status=403)
# 环境权限检查
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if task.environment and task.environment.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境类型[{task.environment.type}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限查看该环境的构建任务'
}, status=403)
# 获取最新的构建历史
latest_build = BuildHistory.objects.filter(task=task).order_by('-build_number').first()
# 获取通知机器人详情
notification_robots = []
if task.notification_channels:
robots = NotificationRobot.objects.filter(robot_id__in=task.notification_channels)
for robot in robots:
notification_robots.append({
'robot_id': robot.robot_id,
'type': robot.type,
'name': robot.name
})
return JsonResponse({
'code': 200,
'message': '获取任务详情成功',
'data': {
'task_id': task.task_id,
'name': task.name,
'project': {
'project_id': task.project.project_id,
'name': task.project.name,
'repository': task.project.repository
} if task.project else None,
'environment': {
'environment_id': task.environment.environment_id,
'name': task.environment.name,
'type': task.environment.type
} if task.environment else None,
'description': task.description,
'requirement': task.requirement,
'branch': task.branch,
'git_token': {
'credential_id': task.git_token.credential_id,
'name': task.git_token.name
} if task.git_token else None,
'stages': task.stages,
'notification_channels': task.notification_channels,
'notification_robots': notification_robots,
# 外部脚本库配置
'use_external_script': task.use_external_script,
'external_script_repo_url': task.external_script_config.get('repo_url', '') if task.external_script_config else '',
'external_script_directory': task.external_script_config.get('directory', '') if task.external_script_config else '',
'external_script_branch': task.external_script_config.get('branch', '') if task.external_script_config else '',
'external_script_token_id': task.external_script_config.get('token_id') if task.external_script_config else None,
'status': task.status,
'building_status': task.building_status, # 添加构建状态字段
'version': task.version,
'last_build_number': task.last_build_number,
'total_builds': task.total_builds,
'success_builds': task.success_builds,
'failure_builds': task.failure_builds,
'last_build': {
'id': latest_build.history_id,
'number': latest_build.build_number,
'status': latest_build.status,
'time': latest_build.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'duration': '未完成' if latest_build.status in ['pending', 'running'] else str(latest_build.build_time.get('total_duration', 0)) + ''
} if latest_build else None,
'creator': {
'user_id': task.creator.user_id,
'name': task.creator.name
} if task.creator else None,
'create_time': task.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'update_time': task.update_time.strftime('%Y-%m-%d %H:%M:%S')
}
})
except BuildTask.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '任务不存在'
})
# 获取查询参数
project_id = request.GET.get('project_id')
environment_id = request.GET.get('environment_id')
name = request.GET.get('name')
# 构建查询条件
query = Q()
# 应用项目权限过滤
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if not permitted_project_ids:
# 如果设置了自定义项目权限但列表为空,意味着没有权限查看任何项目
logger.info(f'用户[{request.user_id}]没有权限查看任何项目的构建任务')
return JsonResponse({
'code': 200,
'message': '获取任务列表成功',
'data': []
})
# 用户只能查看有权限的项目
if project_id and project_id != 'all':
# 如果指定了项目,检查是否有该项目的权限
if project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的项目[{project_id}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限查看该项目的构建任务'
}, status=403)
query &= Q(project__project_id=project_id)
else:
# 如果没有指定项目或选择了全部,则限制为有权限的项目
query &= Q(project__project_id__in=permitted_project_ids)
else:
# 如果有全部项目权限并且指定了项目ID
if project_id and project_id != 'all':
query &= Q(project__project_id=project_id)
# 应用环境权限过滤
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if not permitted_environment_types:
# 如果设置了自定义环境权限但列表为空,意味着没有权限查看任何环境
logger.info(f'用户[{request.user_id}]没有权限查看任何环境的构建任务')
return JsonResponse({
'code': 200,
'message': '获取任务列表成功',
'data': []
})
if environment_id and environment_id != 'all':
# 如果指定了环境,需要检查是否在有权限的环境类型中
try:
env = Environment.objects.get(environment_id=environment_id)
if env.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境[{environment_id}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限查看该环境的构建任务'
}, status=403)
query &= Q(environment__environment_id=environment_id)
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
}, status=404)
else:
# 如果没有指定环境或选择了全部,则限制为有权限的环境类型
query &= Q(environment__type__in=permitted_environment_types)
else:
# 如果有全部环境权限并且指定了环境ID
if environment_id and environment_id != 'all':
query &= Q(environment__environment_id=environment_id)
# 添加其他查询条件
if name:
query &= Q(name__icontains=name)
# 查询任务列表
tasks = BuildTask.objects.select_related(
'project',
'environment',
'creator'
).filter(query)
task_list = []
for task in tasks:
# 获取最新的构建历史
latest_build = BuildHistory.objects.filter(task=task).order_by('-build_number').first()
task_list.append({
'task_id': task.task_id,
'name': task.name,
'project': {
'project_id': task.project.project_id,
'name': task.project.name
} if task.project else None,
'environment': {
'environment_id': task.environment.environment_id,
'name': task.environment.name,
'type': task.environment.type
} if task.environment else None,
'description': task.description,
'branch': task.branch,
'status': task.status,
'building_status': task.building_status, # 添加构建状态字段
'version': task.version,
'last_build_number': task.last_build_number,
'total_builds': task.total_builds,
'success_builds': task.success_builds,
'failure_builds': task.failure_builds,
'last_build': {
'id': latest_build.history_id,
'number': latest_build.build_number,
'status': latest_build.status,
'time': latest_build.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'duration': '未完成' if latest_build.status in ['pending', 'running'] else str(latest_build.build_time.get('total_duration', 0)) + ''
} if latest_build else None,
'creator': {
'user_id': task.creator.user_id,
'name': task.creator.name
} if task.creator else None,
'create_time': task.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'update_time': task.update_time.strftime('%Y-%m-%d %H:%M:%S')
})
return JsonResponse({
'code': 200,
'message': '获取任务列表成功',
'data': task_list
})
except Exception as e:
logger.error(f'获取构建任务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建构建任务"""
try:
with transaction.atomic():
data = json.loads(request.body)
name = data.get('name')
project_id = data.get('project_id')
environment_id = data.get('environment_id')
description = data.get('description')
branch = data.get('branch', 'main')
git_token_id = data.get('git_token_id')
stages = data.get('stages', [])
notification_channels = data.get('notification_channels', [])
# 外部脚本库配置
use_external_script = data.get('use_external_script')
external_script_config = None
if 'use_external_script' in data:
if use_external_script:
repo_url = data.get('external_script_repo_url', '').strip()
directory = data.get('external_script_directory', '').strip()
external_script_branch = data.get('external_script_branch', '').strip()
token_id = data.get('external_script_token_id')
# 验证外部脚本库必填字段
if not repo_url:
return JsonResponse({
'code': 400,
'message': '外部脚本库仓库地址不能为空'
})
if not directory:
return JsonResponse({
'code': 400,
'message': '外部脚本库存放目录不能为空'
})
if not external_script_branch:
return JsonResponse({
'code': 400,
'message': '外部脚本库分支名称不能为空'
})
external_script_config = {
'repo_url': repo_url,
'directory': directory,
'branch': external_script_branch,
'token_id': token_id
}
else:
external_script_config = {}
# 验证必要字段
if not all([name, project_id, environment_id]):
return JsonResponse({
'code': 400,
'message': '任务名称、项目和环境不能为空'
})
# 验证通知机器人是否存在
if notification_channels:
existing_robots = set(NotificationRobot.objects.filter(
robot_id__in=notification_channels
).values_list('robot_id', flat=True))
invalid_robots = set(notification_channels) - existing_robots
if invalid_robots:
return JsonResponse({
'code': 400,
'message': f'以下机器人不存在: {", ".join(invalid_robots)}'
})
# 检查项目是否存在
try:
project = Project.objects.get(project_id=project_id)
except Project.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '项目不存在'
})
# 检查环境是否存在
try:
environment = Environment.objects.get(environment_id=environment_id)
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
})
# 检查GitLab Token凭证是否存在
git_token = None
if git_token_id:
try:
git_token = GitlabTokenCredential.objects.get(credential_id=git_token_id)
except GitlabTokenCredential.DoesNotExist:
return JsonResponse({
'code': 404,
'message': 'GitLab Token凭证不存在'
})
# 创建构建任务
creator = User.objects.get(user_id=request.user_id)
task = BuildTask.objects.create(
task_id=generate_id(),
name=name,
project=project,
environment=environment,
description=description,
branch=branch,
git_token=git_token,
stages=stages,
notification_channels=notification_channels,
use_external_script=use_external_script,
external_script_config=external_script_config,
creator=creator
)
return JsonResponse({
'code': 200,
'message': '创建构建任务成功',
'data': {
'task_id': task.task_id,
'name': task.name
}
})
except Exception as e:
logger.error(f'创建构建任务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""更新构建任务"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
function_permissions = user_permissions.get('function', {})
build_permissions = function_permissions.get('build_task', [])
with transaction.atomic():
data = json.loads(request.body)
task_id = data.get('task_id')
name = data.get('name')
project_id = data.get('project_id')
environment_id = data.get('environment_id')
description = data.get('description')
branch = data.get('branch')
git_token_id = data.get('git_token_id')
stages = data.get('stages')
notification_channels = data.get('notification_channels')
status = data.get('status')
# 外部脚本库配置
use_external_script = data.get('use_external_script')
external_script_config = None
if 'use_external_script' in data:
if use_external_script:
repo_url = data.get('external_script_repo_url', '').strip()
directory = data.get('external_script_directory', '').strip()
external_script_branch = data.get('external_script_branch', '').strip()
token_id = data.get('external_script_token_id')
# 验证外部脚本库必填字段
if not repo_url:
return JsonResponse({
'code': 400,
'message': '外部脚本库仓库地址不能为空'
})
if not directory:
return JsonResponse({
'code': 400,
'message': '外部脚本库存放目录不能为空'
})
if not external_script_branch:
return JsonResponse({
'code': 400,
'message': '外部脚本库分支名称不能为空'
})
external_script_config = {
'repo_url': repo_url,
'directory': directory,
'branch': external_script_branch,
'token_id': token_id
}
else:
external_script_config = {}
if not task_id:
return JsonResponse({
'code': 400,
'message': '任务ID不能为空'
})
try:
task = BuildTask.objects.get(task_id=task_id)
except BuildTask.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '任务不存在'
})
# 如果只修改状态,需要检查是否有禁用权限
if status and len(data) == 2 and 'task_id' in data and 'status' in data:
if 'disable' not in build_permissions:
logger.warning(f'用户[{request.user_id}]没有禁用/启用任务权限')
return JsonResponse({
'code': 403,
'message': '没有权限禁用/启用任务'
}, status=403)
else:
# 否则检查是否有编辑权限
if 'edit' not in build_permissions:
logger.warning(f'用户[{request.user_id}]没有编辑任务权限')
return JsonResponse({
'code': 403,
'message': '没有权限编辑任务'
}, status=403)
# 项目权限检查
project_scope = data_permissions.get('project_scope', 'all')
if project_id and project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试编辑无权限的项目[{project_id}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限编辑该项目的构建任务'
}, status=403)
# 环境权限检查
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_id and environment_scope == 'custom':
try:
env = Environment.objects.get(environment_id=environment_id)
permitted_environment_types = data_permissions.get('environment_types', [])
if env.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试编辑无权限的环境类型[{env.type}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限编辑该环境的构建任务'
}, status=403)
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
})
# 更新项目关联
if project_id:
try:
project = Project.objects.get(project_id=project_id)
task.project = project
except Project.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '项目不存在'
})
# 更新环境关联
if environment_id:
try:
environment = Environment.objects.get(environment_id=environment_id)
task.environment = environment
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
})
# 更新GitLab Token凭证关联
if 'git_token_id' in data:
if git_token_id:
try:
git_token = GitlabTokenCredential.objects.get(credential_id=git_token_id)
task.git_token = git_token
except GitlabTokenCredential.DoesNotExist:
return JsonResponse({
'code': 404,
'message': 'GitLab Token凭证不存在'
})
else:
task.git_token = None
# 更新其他字段
if 'name' in data:
task.name = name
if 'description' in data:
task.description = description
if 'branch' in data:
task.branch = branch
if 'stages' in data:
task.stages = stages
if 'notification_channels' in data:
# 验证通知机器人是否存在
existing_robots = set(NotificationRobot.objects.filter(
robot_id__in=notification_channels
).values_list('robot_id', flat=True))
invalid_robots = set(notification_channels) - existing_robots
if invalid_robots:
return JsonResponse({
'code': 400,
'message': f'以下机器人不存在: {", ".join(invalid_robots)}'
})
task.notification_channels = notification_channels
if 'status' in data:
task.status = status
# 更新外部脚本库配置
if 'use_external_script' in data:
task.use_external_script = use_external_script
task.external_script_config = external_script_config
task.save()
return JsonResponse({
'code': 200,
'message': '更新构建任务成功'
})
except Exception as e:
logger.error(f'更新构建任务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除构建任务"""
try:
with transaction.atomic():
data = json.loads(request.body)
task_id = data.get('task_id')
if not task_id:
return JsonResponse({
'code': 400,
'message': '任务ID不能为空'
})
try:
task = BuildTask.objects.get(task_id=task_id)
task.delete()
return JsonResponse({
'code': 200,
'message': '删除构建任务成功'
})
except BuildTask.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '任务不存在'
})
except Exception as e:
logger.error(f'删除构建任务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class BuildExecuteView(View):
@method_decorator(jwt_auth_required)
def post(self, request):
"""执行构建"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有执行构建权限
function_permissions = user_permissions.get('function', {})
build_permissions = function_permissions.get('build_task', [])
if 'execute' not in build_permissions:
logger.warning(f'用户[{request.user_id}]没有执行构建权限')
return JsonResponse({
'code': 403,
'message': '没有权限执行构建任务'
}, status=403)
data = json.loads(request.body)
task_id = data.get('task_id')
branch = data.get('branch') # 获取用户选择的分支
commit_id = data.get('commit_id')
version = data.get('version')
requirement = data.get('requirement')
if not task_id:
return JsonResponse({
'code': 400,
'message': '任务ID不能为空'
})
try:
task = BuildTask.objects.select_related('project', 'environment').get(task_id=task_id)
except BuildTask.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '任务不存在'
})
# 检查是否有权限执行该任务
# 项目权限检查
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if task.project and task.project.project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试执行无权限的项目[{task.project.project_id}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限执行该项目的构建任务'
}, status=403)
# 环境权限检查
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if task.environment and task.environment.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试执行无权限的环境类型[{task.environment.type}]的构建任务')
return JsonResponse({
'code': 403,
'message': '没有权限执行该环境的构建任务'
}, status=403)
# 根据环境类型检查必要参数
env_type = task.environment.type if task.environment else None
if env_type in ['development', 'testing']:
# 开发环境和测试环境需要分支和commit_id
if not branch:
return JsonResponse({
'code': 400,
'message': '分支不能为空'
})
if not commit_id:
return JsonResponse({
'code': 400,
'message': 'Commit ID不能为空'
})
elif env_type in ['staging', 'production']:
if not version:
return JsonResponse({
'code': 400,
'message': '版本号不能为空'
})
parts = version.split('_')
if len(parts) == 2 and len(parts[1]) >= 8:
commit_id = parts[1]
else:
return JsonResponse({
'code': 400,
'message': '版本号格式不正确应为YYYYMMDDHHmmSS_commitId'
})
if not requirement:
return JsonResponse({
'code': 400,
'message': '构建需求描述不能为空'
})
# 检查任务的构建状态
if task.building_status == 'building':
return JsonResponse({
'code': 400,
'message': '当前任务正在构建中,请等待构建完成后再试'
})
running_build = BuildHistory.objects.filter(
task_id=task_id,
status__in=['pending', 'running']
).exists()
if running_build:
# 如果有正在进行的构建但building_status不是building则修正状态
BuildTask.objects.filter(task_id=task_id).update(building_status='building')
return JsonResponse({
'code': 400,
'message': '当前任务有正在进行的构建,请等待构建完成后再试'
})
if task.status == 'disabled':
return JsonResponse({
'code': 400,
'message': '任务已禁用'
})
# 生成构建号
build_number = task.last_build_number + 1
# 创建构建历史记录
history = BuildHistory.objects.create(
history_id=generate_id(),
task=task,
build_number=build_number,
branch=branch if branch else '', # 对于预发布和生产环境,分支可能为空
commit_id=commit_id,
version=version if version else None, # 对于预发布和生产环境,使用传入的版本号
status='pending', # 初始状态为等待中
requirement=requirement,
operator=User.objects.get(user_id=request.user_id) # 记录构建人
)
# 更新任务状态、构建号和构建状态
BuildTask.objects.filter(task_id=task_id).update(
last_build_number=build_number,
total_builds=F('total_builds') + 1,
building_status='building' # 设置为构建中状态
)
# 在新线程中执行构建
build_thread = threading.Thread(
target=execute_build,
args=(task, build_number, commit_id, history)
)
build_thread.start()
return JsonResponse({
'code': 200,
'message': '开始构建',
'data': {
'build_number': build_number,
'history_id': history.history_id
}
})
except Exception as e:
logger.error(f'执行构建失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""停止构建"""
try:
data = json.loads(request.body)
history_id = data.get('history_id')
if not history_id:
return JsonResponse({
'code': 400,
'message': '历史ID不能为空'
})
try:
history = BuildHistory.objects.get(history_id=history_id)
except BuildHistory.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '构建历史不存在'
})
# 只有进行中的构建可以停止
if history.status not in ['pending', 'running']:
return JsonResponse({
'code': 400,
'message': '只能停止进行中的构建'
})
# 更新构建状态为terminated
history.status = 'terminated'
# 如果构建日志存在,追加终止消息
if history.build_log:
history.build_log += "\n[系统] 构建被手动终止\n"
else:
history.build_log = "[系统] 构建被手动终止\n"
# 更新构建时间
if not history.build_time:
history.build_time = {}
if 'start_time' in history.build_time and 'total_duration' not in history.build_time:
# 计算从开始到现在的持续时间
start_time = datetime.strptime(history.build_time['start_time'], '%Y-%m-%d %H:%M:%S')
duration = int((datetime.now() - start_time).total_seconds())
history.build_time['total_duration'] = str(duration)
history.build_time['end_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
history.save()
# 更新任务统计信息和构建状态
BuildTask.objects.filter(task_id=history.task.task_id).update(
building_status='idle' # 重置构建状态为空闲
)
return JsonResponse({
'code': 200,
'message': '构建已终止'
})
except Exception as e:
logger.error(f'停止构建失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

@@ -0,0 +1,549 @@
import json
import logging
from datetime import datetime, timedelta
from django.http import JsonResponse, HttpResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db.models import Q
from ..models import BuildHistory, BuildTask, Project, Environment
from ..utils.auth import jwt_auth_required
from ..utils.permissions import get_user_permissions
logger = logging.getLogger('apps')
@method_decorator(csrf_exempt, name='dispatch')
class BuildHistoryView(View):
def _get_stage_status_from_log(self, log: str, stage_name: str, overall_status: str = None) -> str:
"""从日志中获取指定阶段的状态"""
if not log:
return 'failed'
# 处理特殊阶段Git Clone
if stage_name == 'Git Clone':
# Git Clone 阶段使用 [Git Clone] 格式
if '[Git Clone]' not in log:
return 'failed'
# 检查是否有完成标记
if '[Git Clone] 代码克隆完成' in log:
return 'success'
elif overall_status == 'terminated':
return 'terminated'
else:
return 'failed'
# 处理普通构建阶段
stage_start_pattern = f'[Build Stages] 开始执行阶段: {stage_name}'
stage_complete_pattern = f'[Build Stages] 阶段 {stage_name} 执行完成'
# 如果整体构建状态是terminated检查阶段是否有开始执行
if overall_status == 'terminated':
if stage_start_pattern in log:
# 检查阶段是否完成
if stage_complete_pattern in log:
return 'success'
else:
return 'terminated'
else:
# 没有该阶段的日志,说明还没开始执行就被终止了
return 'terminated'
if stage_start_pattern not in log:
return 'failed' # 阶段未开始执行
# 检查阶段是否完成
if stage_complete_pattern in log:
return 'success' # 阶段成功完成
else:
return 'failed' # 阶段开始了但没有完成
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取构建历史列表"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有构建历史查看权限
function_permissions = user_permissions.get('function', {})
build_history_permissions = function_permissions.get('build_history', [])
if 'view' not in build_history_permissions:
logger.warning(f'用户[{request.user_id}]没有构建历史查看权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看构建历史'
}, status=403)
# 获取查询参数
project_id = request.GET.get('project_id')
environment_id = request.GET.get('environment_id')
task_id = request.GET.get('task_id') # 添加task_id参数
task_name = request.GET.get('task_name')
page = int(request.GET.get('page', 1))
page_size = int(request.GET.get('page_size', 10))
# 构建查询条件
query = Q()
# 应用项目权限过滤
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if not permitted_project_ids:
logger.info(f'用户[{request.user_id}]没有权限查看任何项目的构建历史')
return JsonResponse({
'code': 200,
'message': '获取构建历史列表成功',
'data': [],
'total': 0,
'page': page,
'page_size': page_size
})
# 用户只能查看有权限的项目
if project_id and project_id != 'all':
# 如果指定了项目,检查是否有该项目的权限
if project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的项目[{project_id}]的构建历史')
return JsonResponse({
'code': 403,
'message': '没有权限查看该项目的构建历史'
}, status=403)
query &= Q(task__project__project_id=project_id)
else:
query &= Q(task__project__project_id__in=permitted_project_ids)
else:
# 如果有全部项目权限并且指定了项目ID
if project_id and project_id != 'all':
query &= Q(task__project__project_id=project_id)
# 应用环境权限过滤
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if not permitted_environment_types:
# 如果设置了自定义环境权限但列表为空,意味着没有权限查看任何环境
logger.info(f'用户[{request.user_id}]没有权限查看任何环境的构建历史')
return JsonResponse({
'code': 200,
'message': '获取构建历史列表成功',
'data': [],
'total': 0,
'page': page,
'page_size': page_size
})
if environment_id and environment_id != 'all':
# 如果指定了环境,需要检查是否在有权限的环境类型中
try:
env = Environment.objects.get(environment_id=environment_id)
if env.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境[{environment_id}]的构建历史')
return JsonResponse({
'code': 403,
'message': '没有权限查看该环境的构建历史'
}, status=403)
query &= Q(task__environment__environment_id=environment_id)
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
}, status=404)
else:
# 如果没有指定环境或选择了全部,则限制为有权限的环境类型
query &= Q(task__environment__type__in=permitted_environment_types)
else:
# 如果有全部环境权限并且指定了环境ID
if environment_id and environment_id != 'all':
query &= Q(task__environment__environment_id=environment_id)
# 添加其他查询条件
if task_id:
query &= Q(task__task_id=task_id)
if task_name:
query &= Q(task__name__icontains=task_name)
# 查询构建历史
histories = BuildHistory.objects.select_related(
'task',
'task__project',
'task__environment',
'operator'
).filter(query).order_by('-create_time')
# 计算总数
total = histories.count()
# 分页
start = (page - 1) * page_size
end = start + page_size
histories = histories[start:end]
# 构建返回数据
history_list = []
for history in histories:
# 计算构建耗时
duration = '未完成'
if history.build_time and 'total_duration' in history.build_time:
duration_seconds = int(history.build_time['total_duration'])
if duration_seconds < 60:
duration = f"{duration_seconds}"
else:
minutes = duration_seconds // 60
seconds = duration_seconds % 60
duration = f"{minutes}{seconds}"
# 处理构建阶段信息
stages = []
# 添加 Git Clone 阶段
git_clone_stage = next(
(t for t in history.build_time.get('stages_time', []) if t['name'] == 'Git Clone'),
None
) if history.build_time else None
if git_clone_stage:
git_clone_status = self._get_stage_status_from_log(history.build_log, 'Git Clone', history.status)
stages.append({
'name': 'Git Clone',
'status': git_clone_status,
'startTime': git_clone_stage['start_time'],
'duration': git_clone_stage['duration'] + ''
})
# 添加其他阶段
for stage in history.stages:
stage_time = next(
(t for t in history.build_time.get('stages_time', []) if t['name'] == stage['name']),
None
) if history.build_time else None
stage_status = self._get_stage_status_from_log(history.build_log, stage['name'], history.status)
stages.append({
'name': stage['name'],
'status': stage_status,
'startTime': stage_time['start_time'] if stage_time else None,
'duration': stage_time['duration'] + '' if stage_time else '未知'
})
# 检查是否有回滚权限
can_rollback = history.status == 'success'
history_list.append({
'id': history.history_id,
'build_number': history.build_number,
'status': history.status,
'branch': history.branch,
'commit': history.commit_id[:8] if history.commit_id else None,
'version': history.version,
'environment': history.task.environment.name if history.task.environment else None,
'startTime': history.build_time.get('start_time') if history.build_time else history.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'operator': history.operator.name if history.operator else None,
'requirement': history.requirement,
'stages': stages,
'canRollback': can_rollback,
'task': {
'id': history.task.task_id,
'name': history.task.name,
'description': history.task.description
}
})
return JsonResponse({
'code': 200,
'message': '获取构建历史列表成功',
'data': history_list,
'total': total,
'page': page,
'page_size': page_size
})
except Exception as e:
logger.error(f'获取构建历史列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""回滚到指定版本"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
function_permissions = user_permissions.get('function', {})
build_history_permissions = function_permissions.get('build_history', [])
# 检查是否有回滚权限
if 'rollback' not in build_history_permissions:
logger.warning(f'用户[{request.user_id}]没有构建历史回滚权限')
return JsonResponse({
'code': 403,
'message': '没有权限执行回滚操作'
}, status=403)
data = json.loads(request.body)
history_id = data.get('history_id')
if not history_id:
return JsonResponse({
'code': 400,
'message': '历史ID不能为空'
})
try:
history = BuildHistory.objects.select_related('task', 'task__project', 'task__environment').get(history_id=history_id)
except BuildHistory.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '构建历史不存在'
})
# 检查项目和环境权限
data_permissions = user_permissions.get('data', {})
# 项目权限检查
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if history.task.project.project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试回滚无权限的项目[{history.task.project.project_id}]的构建')
return JsonResponse({
'code': 403,
'message': '没有权限回滚该项目的构建'
}, status=403)
# 环境权限检查
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if history.task.environment.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试回滚无权限的环境类型[{history.task.environment.type}]的构建')
return JsonResponse({
'code': 403,
'message': '没有权限回滚该环境的构建'
}, status=403)
if history.status != 'success':
return JsonResponse({
'code': 400,
'message': '只能回滚到构建成功的版本'
})
# TODO: 实现回滚逻辑
return JsonResponse({
'code': 200,
'message': '开始回滚',
'data': {
'version': history.version
}
})
except Exception as e:
logger.error(f'回滚失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class BuildLogView(View):
@method_decorator(jwt_auth_required)
def get(self, request, history_id):
"""获取构建日志"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有构建历史查看日志权限
function_permissions = user_permissions.get('function', {})
build_task_permissions = function_permissions.get('build_task', [])
build_history_permissions = function_permissions.get('build_history', [])
has_log_permission = 'view_log' in build_task_permissions or 'view_log' in build_history_permissions
if not has_log_permission:
logger.warning(f'用户[{request.user_id}]没有构建历史查看日志权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看构建日志'
}, status=403)
try:
history = BuildHistory.objects.select_related('task', 'task__project', 'task__environment').get(history_id=history_id)
except BuildHistory.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '构建历史不存在'
})
# 项目权限检查
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if history.task.project and history.task.project.project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的项目[{history.task.project.project_id}]的构建日志')
return JsonResponse({
'code': 403,
'message': '没有权限查看该项目的构建日志'
}, status=403)
# 环境权限检查
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if history.task.environment and history.task.environment.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境类型[{history.task.environment.type}]的构建日志')
return JsonResponse({
'code': 403,
'message': '没有权限查看该环境的构建日志'
}, status=403)
# 检查是否为下载请求
is_download = request.GET.get('download') == 'true'
if is_download:
# 生成日志文件名
filename = f"build_log_{history.task.name}_{history.build_number}.txt"
# 准备日志内容
log_content = history.build_log or '暂无日志'
# 创建响应对象
response = HttpResponse(log_content, content_type='text/plain')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
return JsonResponse({
'code': 200,
'message': '获取构建日志成功',
'data': {
'log': history.build_log or '暂无日志'
}
})
except Exception as e:
logger.error(f'获取构建日志失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class BuildStageLogView(View):
@method_decorator(jwt_auth_required)
def get(self, request, history_id, stage_name):
"""获取构建阶段日志"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有构建历史查看日志权限
function_permissions = user_permissions.get('function', {})
build_task_permissions = function_permissions.get('build_task', [])
build_history_permissions = function_permissions.get('build_history', [])
# 只要有任何一方的view_log权限即可
has_log_permission = 'view_log' in build_task_permissions or 'view_log' in build_history_permissions
if not has_log_permission:
logger.warning(f'用户[{request.user_id}]没有构建历史查看日志权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看构建日志'
}, status=403)
try:
history = BuildHistory.objects.select_related('task', 'task__project', 'task__environment').get(history_id=history_id)
except BuildHistory.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '构建历史不存在'
})
# 项目权限检查
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if history.task.project and history.task.project.project_id not in permitted_project_ids:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的项目[{history.task.project.project_id}]的构建阶段日志')
return JsonResponse({
'code': 403,
'message': '没有权限查看该项目的构建日志'
}, status=403)
# 环境权限检查
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if history.task.environment and history.task.environment.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境类型[{history.task.environment.type}]的构建阶段日志')
return JsonResponse({
'code': 403,
'message': '没有权限查看该环境的构建日志'
}, status=403)
# 在完整日志中查找指定阶段的日志
if not history.build_log:
return JsonResponse({
'code': 200,
'message': '获取阶段日志成功',
'data': {
'log': '暂无日志'
}
})
# 适配Jenkins风格日志格式的阶段日志解析
stage_logs = []
lines = history.build_log.split('\n')
in_stage = False
# 处理特殊阶段Git Clone
if stage_name == 'Git Clone':
# Git Clone 阶段使用 [Git Clone] 格式
for line in lines:
if '[Git Clone]' in line:
stage_logs.append(line)
else:
# 普通构建阶段使用 [Build Stages] 格式
stage_start_pattern = f'[Build Stages] 开始执行阶段: {stage_name}'
stage_complete_pattern = f'[Build Stages] 阶段 {stage_name} 执行完成'
for line in lines:
if stage_start_pattern in line:
in_stage = True
stage_logs.append(line)
elif in_stage and stage_complete_pattern in line:
stage_logs.append(line)
break # 阶段结束
elif in_stage:
# 在阶段执行期间的所有日志都属于该阶段
# 排除其他阶段的开始标记
if '[Build Stages] 开始执行阶段:' not in line:
stage_logs.append(line)
else:
# 遇到其他阶段开始,当前阶段结束
break
return JsonResponse({
'code': 200,
'message': '获取阶段日志成功',
'data': {
'log': '\n'.join(stage_logs) if stage_logs else '暂无该阶段日志'
}
})
except Exception as e:
logger.error(f'获取阶段日志失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

@@ -0,0 +1,273 @@
import json
import time
import logging
import uuid
import threading
import queue
import asyncio
from django.http import StreamingHttpResponse, JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from asgiref.sync import sync_to_async
from ..utils.auth import jwt_auth_required
from ..utils.log_stream import log_stream_manager
from ..models import BuildHistory, UserToken
logger = logging.getLogger('apps')
@method_decorator(csrf_exempt, name='dispatch')
class BuildLogSSEView(View):
"""构建日志SSE流视图 - 异步实现以支持ASGI环境"""
def options(self, request, task_id, build_number):
"""处理CORS预检请求"""
response = JsonResponse({})
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Cache-Control, Authorization'
response['Access-Control-Max-Age'] = '86400' # 24小时
return response
def get(self, request, task_id, build_number):
"""获取构建日志SSE流"""
try:
# 从URL参数获取token进行认证
token = request.GET.get('token')
if not token:
return StreamingHttpResponse(
self._create_error_stream("缺少认证token"),
content_type='text/event-stream'
)
# 验证token
try:
user_info = self._verify_jwt_token(token)
if not user_info:
return StreamingHttpResponse(
self._create_error_stream("无效的认证token"),
content_type='text/event-stream'
)
except Exception as e:
logger.error(f"Token验证失败: {str(e)}", exc_info=True)
return StreamingHttpResponse(
self._create_error_stream("认证失败"),
content_type='text/event-stream'
)
# 验证构建历史记录是否存在
try:
history = BuildHistory.objects.get(
task__task_id=task_id,
build_number=int(build_number)
)
except BuildHistory.DoesNotExist:
return StreamingHttpResponse(
self._create_error_stream("构建记录不存在"),
content_type='text/event-stream'
)
except ValueError:
return StreamingHttpResponse(
self._create_error_stream("无效的构建号"),
content_type='text/event-stream'
)
# 创建异步SSE流
response = StreamingHttpResponse(
self._build_log_stream_async(task_id, int(build_number), history),
content_type='text/event-stream'
)
# 设置SSE相关的HTTP头
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Headers'] = 'Cache-Control'
response['Access-Control-Allow-Methods'] = 'GET'
response['X-Accel-Buffering'] = 'no' # 禁用nginx缓冲
return response
except Exception as e:
logger.error(f"创建SSE流失败: {str(e)}", exc_info=True)
return StreamingHttpResponse(
self._create_error_stream(f"服务器错误: {str(e)}"),
content_type='text/event-stream'
)
def _verify_jwt_token(self, token):
"""验证JWT token"""
try:
import jwt
from django.conf import settings
# 查询用户token
user_token = UserToken.objects.filter(token=token).first()
if not user_token:
logger.warning("Token不存在于数据库中")
return None
# 验证JWT token
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
return {
'user_id': payload.get('user_id'),
'username': payload.get('username')
}
except jwt.ExpiredSignatureError:
logger.warning("Token已过期")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"Token无效: {str(e)}")
return None
except Exception as e:
logger.error(f"Token验证过程发生错误: {str(e)}", exc_info=True)
return None
async def _build_log_stream_async(self, task_id, build_number, history):
"""异步生成构建日志流"""
# 生成唯一的客户端ID
client_id = str(uuid.uuid4())
try:
# 发送连接建立消息
yield self._format_sse_message({
'type': 'connection_established',
'message': '连接成功,开始接收构建日志...'
})
# 如果构建已完成,发送完成消息并结束
if history.status in ['success', 'failed', 'terminated']:
yield self._format_sse_message({
'type': 'build_complete',
'status': history.status,
'message': f'构建已完成,状态: {history.status}。请使用历史日志API获取完整日志。'
})
return
# 对于正在进行的构建,使用实时日志流
heartbeat_counter = 0
# 获取异步日志流
async for log_msg in self._async_log_stream(task_id, build_number, client_id):
if log_msg is None:
# 心跳包
heartbeat_counter += 1
if heartbeat_counter >= 30: # 每30秒发送一次心跳
yield self._format_sse_message({
'type': 'heartbeat',
'timestamp': int(time.time())
}, event_type='heartbeat')
heartbeat_counter = 0
continue
# 重置心跳计数器
heartbeat_counter = 0
# 检查是否是构建完成消息
if log_msg.message.startswith('BUILD_COMPLETE:'):
status = log_msg.message.split(':', 1)[1]
yield self._format_sse_message({
'type': 'build_complete',
'status': status,
'message': f'构建已完成,状态: {status}'
})
break
else:
# 普通日志消息
yield self._format_sse_message({
'type': 'build_log',
'message': log_msg.message
})
except Exception as e:
logger.error(f"生成构建日志流时发生错误: {str(e)}", exc_info=True)
yield self._format_sse_message({
'type': 'error',
'message': f'日志流发生错误: {str(e)}'
})
async def _async_log_stream(self, task_id, build_number, client_id):
"""异步日志流生成器 - 改进版本"""
try:
async_queue = asyncio.Queue(maxsize=1000) # 限制队列大小
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
def sync_log_reader():
"""在单独线程中读取同步日志流"""
try:
for log_msg in log_stream_manager.get_log_stream(task_id, build_number, client_id):
if stop_event.is_set():
break
# 使用线程安全的方式添加到异步队列
try:
asyncio.run_coroutine_threadsafe(
async_queue.put(log_msg), loop
).result(timeout=0.1)
except Exception as queue_error:
logger.debug(f"队列添加失败,可能客户端已断开: {queue_error}")
break
# 发送结束信号
try:
asyncio.run_coroutine_threadsafe(
async_queue.put(StopAsyncIteration), loop
).result(timeout=0.1)
except Exception:
pass # 忽略结束信号发送失败
except Exception as e:
logger.error(f"同步日志读取器出错: {str(e)}", exc_info=True)
try:
asyncio.run_coroutine_threadsafe(
async_queue.put(StopAsyncIteration), loop
).result(timeout=0.1)
except Exception:
pass
# 在线程池中启动同步日志读取器
thread = threading.Thread(target=sync_log_reader, daemon=True)
thread.start()
try:
while True:
try:
# 异步等待日志消息,使用较短的超时以提供更好的响应性
log_msg = await asyncio.wait_for(async_queue.get(), timeout=1.0)
if log_msg is StopAsyncIteration:
break
yield log_msg
except asyncio.TimeoutError:
# 超时,发送心跳
yield None
finally:
# 设置停止事件,清理资源
stop_event.set()
if thread.is_alive():
thread.join(timeout=2.0) # 增加join超时时间
except Exception as e:
logger.error(f"异步日志流错误: {str(e)}", exc_info=True)
yield None # 确保生成器正常结束
def _create_error_stream(self, error_message):
"""创建错误流"""
yield self._format_sse_message({
'type': 'error',
'message': error_message
})
def _format_sse_message(self, data, event_type='message'):
"""格式化SSE消息"""
message = f"event: {event_type}\n"
message += f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
return message

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
import logging
from datetime import datetime, timedelta
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db.models import Count
from ..models import Project, BuildTask, BuildHistory, User, Environment
logger = logging.getLogger('apps')
@method_decorator(csrf_exempt, name='dispatch')
class DashboardStatsView(View):
"""首页统计数据接口"""
def get(self, request):
"""获取首页统计数据"""
try:
# 获取项目总数
project_count = Project.objects.count()
# 获取构建任务总数
task_count = BuildTask.objects.count()
# 获取用户总数
user_count = User.objects.count()
# 获取环境总数
env_count = Environment.objects.count()
# 获取总构建数量
total_builds_count = BuildHistory.objects.count()
# 获取最近7天的构建成功率
seven_days_ago = datetime.now() - timedelta(days=7)
recent_builds = BuildHistory.objects.filter(create_time__gte=seven_days_ago)
total_recent_builds = recent_builds.count()
success_recent_builds = recent_builds.filter(status='success').count()
success_rate = 0
if total_recent_builds > 0:
success_rate = round((success_recent_builds / total_recent_builds) * 100, 2)
return JsonResponse({
'code': 200,
'message': '获取首页统计数据成功',
'data': {
'project_count': project_count,
'task_count': task_count,
'user_count': user_count,
'env_count': env_count,
'total_builds_count': total_builds_count,
'success_rate': success_rate,
'total_recent_builds': total_recent_builds
}
})
except Exception as e:
logger.error(f'获取首页统计数据失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class BuildTrendView(View):
"""构建任务趋势接口"""
def get(self, request):
"""获取构建任务趋势数据"""
try:
# 获取时间范围参数默认为最近7天
days = int(request.GET.get('days', 7))
# 计算日期范围:包含今天在内的最近 days 天
today = datetime.now().date() # 获取今天的日期部分
start_date = today - timedelta(days=days - 1) # 开始日期是今天往前 days-1 天
# 准备日期列表和结果数据
date_list = []
success_data = []
failed_data = []
# 生成从 start_date 到 today 的日期列表
current_date = start_date
while current_date <= today:
date_str = current_date.strftime('%Y-%m-%d')
date_list.append(date_str)
# 查询当天的构建数据
day_start = datetime.combine(current_date, datetime.min.time())
day_end = datetime.combine(current_date, datetime.max.time())
# 成功构建数
success_count = BuildHistory.objects.filter(
create_time__gte=day_start,
create_time__lte=day_end,
status='success'
).count()
# 失败构建数
failed_count = BuildHistory.objects.filter(
create_time__gte=day_start,
create_time__lte=day_end,
status='failed'
).count()
success_data.append(success_count)
failed_data.append(failed_count)
current_date += timedelta(days=1)
return JsonResponse({
'code': 200,
'message': '获取构建任务趋势数据成功',
'data': {
'dates': date_list,
'success': success_data,
'failed': failed_data
}
})
except Exception as e:
logger.error(f'获取构建任务趋势数据失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class BuildDetailView(View):
"""构建详细数据接口"""
def get(self, request):
"""获取指定日期的构建详细数据"""
try:
# 获取日期参数
date_str = request.GET.get('date')
if not date_str:
return JsonResponse({
'code': 400,
'message': '日期参数不能为空'
})
# 解析日期
try:
date = datetime.strptime(date_str, '%Y-%m-%d')
day_start = datetime(date.year, date.month, date.day, 0, 0, 0)
day_end = datetime(date.year, date.month, date.day, 23, 59, 59)
except ValueError:
return JsonResponse({
'code': 400,
'message': '日期格式不正确应为YYYY-MM-DD'
})
# 查询当天的构建历史
builds = BuildHistory.objects.filter(
create_time__gte=day_start,
create_time__lte=day_end
).select_related('task', 'operator').order_by('-create_time')
build_list = []
for build in builds:
# 计算构建耗时
duration = '未完成'
if build.build_time and 'total_duration' in build.build_time:
total_seconds = int(build.build_time.get('total_duration', 0))
minutes = total_seconds // 60
seconds = total_seconds % 60
duration = f"{minutes}{seconds}"
build_list.append({
'id': build.history_id,
'build_number': build.build_number,
'task_name': build.task.name,
'status': build.status,
'branch': build.branch,
'version': build.version,
'start_time': build.build_time.get('start_time') if build.build_time else build.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'operator': build.operator.name if build.operator else '系统'
})
return JsonResponse({
'code': 200,
'message': '获取构建详细数据成功',
'data': build_list
})
except Exception as e:
logger.error(f'获取构建详细数据失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class RecentBuildsView(View):
"""最近构建任务接口"""
def get(self, request):
"""获取最近构建任务数据"""
try:
# 获取数量参数默认为10条
limit = int(request.GET.get('limit', 5))
# 查询最近的构建历史
recent_builds = BuildHistory.objects.select_related(
'task', 'task__environment', 'operator' # 关联环境信息
).order_by('-create_time')[:limit]
build_list = []
for build in recent_builds:
# 计算构建耗时
duration = '未完成'
if build.build_time and 'total_duration' in build.build_time:
total_seconds = int(build.build_time.get('total_duration', 0))
minutes = total_seconds // 60
seconds = total_seconds % 60
duration = f"{minutes}{seconds}"
build_list.append({
'id': build.history_id,
'build_number': build.build_number,
'task_name': build.task.name,
'status': build.status,
'branch': build.branch,
'version': build.version,
'environment': build.task.environment.name if build.task.environment else None, # 添加环境名称
'requirement': build.requirement,
'start_time': build.build_time.get('start_time') if build.build_time else build.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration,
'operator': build.operator.name if build.operator else '系统'
})
return JsonResponse({
'code': 200,
'message': '获取最近构建任务数据成功',
'data': build_list
})
except Exception as e:
logger.error(f'获取最近构建任务数据失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class ProjectDistributionView(View):
"""项目分布接口"""
def get(self, request):
"""获取项目分布数据"""
try:
# 按项目类别统计
category_stats = Project.objects.values('category').annotate(count=Count('id'))
# 格式化数据
category_data = []
for stat in category_stats:
category = stat['category'] or '未分类'
category_data.append({
'type': self._get_category_name(category),
'value': stat['count']
})
return JsonResponse({
'code': 200,
'message': '获取项目分布数据成功',
'data': category_data
})
except Exception as e:
logger.error(f'获取项目分布数据失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
def _get_category_name(self, category):
"""获取项目类别名称"""
category_map = {
'frontend': '前端项目',
'backend': '后端项目',
'mobile': '移动端项目',
'other': '其他项目'
}
return category_map.get(category, '未分类')

View File

@@ -0,0 +1,337 @@
import json
import uuid
import hashlib
import logging
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from django.db.models import Q
from ..models import Environment, User
from ..utils.auth import jwt_auth_required
from ..utils.permissions import get_user_permissions
logger = logging.getLogger('apps')
def generate_id():
"""生成唯一ID"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
@method_decorator(csrf_exempt, name='dispatch')
class EnvironmentTypeView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取环境类型列表"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有环境查看权限
function_permissions = user_permissions.get('function', {})
environment_permissions = function_permissions.get('environment', [])
if 'view' not in environment_permissions:
logger.warning(f'用户[{request.user_id}]没有环境查看权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看环境'
}, status=403)
# 获取所有环境
query = Q()
# 应用环境权限过滤
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if not permitted_environment_types:
# 如果设置了自定义环境权限但列表为空,意味着没有权限查看任何环境
logger.info(f'用户[{request.user_id}]没有权限查看任何环境')
return JsonResponse({
'code': 200,
'message': '获取环境列表成功',
'data': []
})
# 限制只能查看有权限的环境类型
query &= Q(type__in=permitted_environment_types)
environments = Environment.objects.filter(query).order_by('name')
# 格式化结果
env_list = []
for env in environments:
if env.type:
env_list.append({
'environment_id': env.environment_id,
'type': env.type,
'name': env.name
})
return JsonResponse({
'code': 200,
'message': '获取环境列表成功',
'data': env_list
})
except Exception as e:
logger.error(f'获取环境列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class EnvironmentView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取环境列表"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有环境查看权限
function_permissions = user_permissions.get('function', {})
environment_permissions = function_permissions.get('environment', [])
if 'view' not in environment_permissions:
logger.warning(f'用户[{request.user_id}]没有环境查看权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看环境'
}, status=403)
environment_id = request.GET.get('environment_id')
name = request.GET.get('name')
type = request.GET.get('type')
page = int(request.GET.get('page', 1))
page_size = int(request.GET.get('page_size', 10))
# 构建查询条件
query = Q()
if environment_id:
query &= Q(environment_id=environment_id)
if name:
query &= Q(name__icontains=name)
if type:
query &= Q(type=type)
# 应用环境权限过滤
environment_scope = data_permissions.get('environment_scope', 'all')
if environment_scope == 'custom':
permitted_environment_types = data_permissions.get('environment_types', [])
if not permitted_environment_types:
logger.info(f'用户[{request.user_id}]没有权限查看任何环境')
return JsonResponse({
'code': 200,
'message': '获取环境列表成功',
'data': [],
'total': 0,
'page': page,
'page_size': page_size
})
# 如果指定了环境类型,检查是否有权限
if type and type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境类型[{type}]')
return JsonResponse({
'code': 403,
'message': '没有权限查看该类型的环境'
}, status=403)
if environment_id:
try:
env = Environment.objects.get(environment_id=environment_id)
if env.type not in permitted_environment_types:
logger.warning(f'用户[{request.user_id}]尝试查看无权限的环境[{environment_id}]')
return JsonResponse({
'code': 403,
'message': '没有权限查看该环境'
}, status=403)
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
}, status=404)
# 限制只能查看有权限的环境类型
query &= Q(type__in=permitted_environment_types)
# 获取环境列表
environments = Environment.objects.filter(query).select_related('creator')
# 计算总数
total = environments.count()
# 分页
start = (page - 1) * page_size
end = start + page_size
environments = environments[start:end]
environment_list = []
for env in environments:
environment_list.append({
'environment_id': env.environment_id,
'name': env.name,
'type': env.type,
'description': env.description,
'creator': {
'user_id': env.creator.user_id,
'username': env.creator.username,
'name': env.creator.name
} if env.creator else None,
'create_time': env.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'update_time': env.update_time.strftime('%Y-%m-%d %H:%M:%S')
})
return JsonResponse({
'code': 200,
'message': '获取环境列表成功',
'data': environment_list,
'total': total,
'page': page,
'page_size': page_size
})
except Exception as e:
logger.error(f'获取环境列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建环境"""
try:
with transaction.atomic():
data = json.loads(request.body)
name = data.get('name')
type = data.get('type')
description = data.get('description')
if not all([name, type]):
return JsonResponse({
'code': 400,
'message': '环境名称和类型不能为空'
})
# 检查环境名称是否已存在
if Environment.objects.filter(name=name).exists():
return JsonResponse({
'code': 400,
'message': '环境名称已存在'
})
# 创建环境
creator = User.objects.get(user_id=request.user_id)
environment = Environment.objects.create(
environment_id=generate_id(),
name=name,
type=type,
description=description,
creator=creator
)
return JsonResponse({
'code': 200,
'message': '创建环境成功',
'data': {
'environment_id': environment.environment_id,
'name': environment.name
}
})
except Exception as e:
logger.error(f'创建环境失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""编辑环境"""
try:
with transaction.atomic():
data = json.loads(request.body)
environment_id = data.get('environment_id')
name = data.get('name')
type = data.get('type')
description = data.get('description')
if not environment_id:
return JsonResponse({
'code': 400,
'message': '环境ID不能为空'
})
try:
environment = Environment.objects.get(environment_id=environment_id)
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
})
# 检查名称是否已存在(排除当前环境)
if name and name != environment.name:
if Environment.objects.filter(name=name).exists():
return JsonResponse({
'code': 400,
'message': '环境名称已存在'
})
environment.name = name
if type:
environment.type = type
if description is not None:
environment.description = description
environment.save()
return JsonResponse({
'code': 200,
'message': '更新环境成功'
})
except Exception as e:
logger.error(f'更新环境失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除环境"""
try:
with transaction.atomic():
data = json.loads(request.body)
environment_id = data.get('environment_id')
if not environment_id:
return JsonResponse({
'code': 400,
'message': '环境ID不能为空'
})
try:
environment = Environment.objects.get(environment_id=environment_id)
environment.delete()
return JsonResponse({
'code': 200,
'message': '删除环境成功'
})
except Environment.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '环境不存在'
})
except Exception as e:
logger.error(f'删除环境失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

@@ -0,0 +1,183 @@
import json
import logging
import gitlab
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from ..models import Project, BuildTask, GitlabTokenCredential
from ..utils.auth import jwt_auth_required
logger = logging.getLogger('apps')
def get_gitlab_client(repository, git_token=None):
"""获取GitLab客户端"""
try:
if not git_token:
# 获取第一个可用的GitLab Token凭证
credential = GitlabTokenCredential.objects.first()
if not credential:
raise ValueError('未找到GitLab Token凭证')
git_token = credential.token
# 从仓库地址中提取GitLab实例URL
repository_parts = repository.split('/')
gitlab_url = '/'.join(repository_parts[:3]) # 获取到域名部分
if not gitlab_url.startswith('http'):
gitlab_url = f'http://{gitlab_url}'
# 创建GitLab客户端
gl = gitlab.Gitlab(
url=gitlab_url,
private_token=git_token
)
gl.auth()
return gl
except Exception as e:
logger.error(f'获取GitLab客户端失败: {str(e)}', exc_info=True)
raise
def get_gitlab_project(repository, git_token=None):
"""获取GitLab项目"""
try:
gl = get_gitlab_client(repository, git_token)
repository_parts = repository.split('/')
project_path = '/'.join(repository_parts[3:]) # 获取group/project部分
project_path = project_path.replace('.git', '')
return gl.projects.get(project_path)
except Exception as e:
logger.error(f'获取GitLab项目失败: {str(e)}', exc_info=True)
raise
@method_decorator(csrf_exempt, name='dispatch')
class GitlabBranchView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取Git分支列表"""
try:
task_id = request.GET.get('task_id')
if not task_id:
return JsonResponse({
'code': 400,
'message': '缺少任务ID'
})
# 获取任务信息
try:
task = BuildTask.objects.select_related('project', 'git_token').get(task_id=task_id)
except BuildTask.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '任务不存在'
})
if not task.project or not task.project.repository:
return JsonResponse({
'code': 400,
'message': '任务未配置Git仓库'
})
# 获取GitLab项目
gitlab_project = get_gitlab_project(
task.project.repository,
task.git_token.token if task.git_token else None
)
# 获取分支列表
branches = gitlab_project.branches.list(all=True)
branch_list = []
for branch in branches:
branch_list.append({
'name': branch.name,
'protected': branch.protected,
'merged': branch.merged,
'default': branch.default,
'commit': {
'id': branch.commit['id'],
'title': branch.commit['title'],
'author_name': branch.commit['author_name'],
'authored_date': branch.commit['authored_date'],
}
})
return JsonResponse({
'code': 200,
'message': '获取分支列表成功',
'data': branch_list
})
except Exception as e:
logger.error(f'获取分支列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class GitlabCommitView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取Git提交记录"""
try:
task_id = request.GET.get('task_id')
branch = request.GET.get('branch')
if not all([task_id, branch]):
return JsonResponse({
'code': 400,
'message': '缺少必要参数'
})
# 获取任务信息
try:
task = BuildTask.objects.select_related('project', 'git_token').get(task_id=task_id)
except BuildTask.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '任务不存在'
})
if not task.project or not task.project.repository:
return JsonResponse({
'code': 400,
'message': '任务未配置Git仓库'
})
# 获取GitLab项目
gitlab_project = get_gitlab_project(
task.project.repository,
task.git_token.token if task.git_token else None
)
# 获取最近的提交记录
commits = gitlab_project.commits.list(
ref_name=branch,
all=False,
per_page=20, # 增加返回数量
order_by='created_at'
)
commit_list = []
for commit in commits:
commit_list.append({
'id': commit.id,
'short_id': commit.short_id,
'title': commit.title,
'message': commit.message,
'author_name': commit.author_name,
'author_email': commit.author_email,
'authored_date': commit.authored_date,
'created_at': commit.created_at,
'web_url': commit.web_url
})
return JsonResponse({
'code': 200,
'message': '获取提交记录成功',
'data': commit_list
})
except Exception as e:
logger.error(f'获取提交记录失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

221
backend/apps/views/login.py Normal file
View File

@@ -0,0 +1,221 @@
import json
import hashlib
import jwt
import uuid
from datetime import datetime, timedelta
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.conf import settings
from ..models import User, UserToken, LoginLog
from ..utils.security import SecurityValidator
def generate_token_id():
"""生成token_id"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
def generate_log_id():
"""生成log_id"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
@csrf_exempt
@require_http_methods(["POST"])
def login(request):
try:
data = json.loads(request.body)
username = data.get('username')
password = data.get('password')
# 获取客户端IP和用户代理
ip_address = request.META.get('HTTP_X_FORWARDED_FOR', request.META.get('REMOTE_ADDR', ''))
user_agent = request.META.get('HTTP_USER_AGENT', '')
if not username or not password:
return JsonResponse({
'code': 400,
'message': '用户名和密码不能为空'
})
# 密码加密
password_hash = hashlib.sha256(password.encode()).hexdigest()
try:
user = User.objects.get(username=username)
# 记录登录日志
log_id = generate_log_id()
# 检查账户是否被锁定
is_not_locked, lockout_message = SecurityValidator.check_account_lockout(user, ip_address)
if not is_not_locked:
# 账户被锁定,记录失败登录
LoginLog.objects.create(
log_id=log_id,
user=user,
ip_address=ip_address,
user_agent=user_agent,
status='failed',
fail_reason=lockout_message
)
return JsonResponse({
'code': 423, # 423 Locked
'message': lockout_message
})
if user.password != password_hash:
# 密码错误,记录失败尝试
failed_attempts, max_attempts = SecurityValidator.record_failed_login(user, ip_address)
# 记录失败登录日志
LoginLog.objects.create(
log_id=log_id,
user=user,
ip_address=ip_address,
user_agent=user_agent,
status='failed',
fail_reason='密码错误'
)
# 提供剩余尝试次数信息
remaining_attempts = max_attempts - failed_attempts
if remaining_attempts > 0:
message = f'密码错误,还可尝试{remaining_attempts}'
else:
config = SecurityValidator.get_security_config()
message = f'密码错误次数过多,账户已被锁定{config.lockout_duration}分钟'
return JsonResponse({
'code': 401,
'message': message
})
if user.status == 0:
# 账号锁定,记录失败登录
LoginLog.objects.create(
log_id=log_id,
user=user,
ip_address=ip_address,
user_agent=user_agent,
status='failed',
fail_reason='账号已被锁定'
)
return JsonResponse({
'code': 423, # 423 Locked
'message': '账号已被锁定,请联系管理员解锁'
})
# 登录成功,重置失败尝试次数
SecurityValidator.record_successful_login(user, ip_address)
# 检查会话超时设置
config = SecurityValidator.get_security_config()
# 生成token_id和JWT token
token_id = generate_token_id()
token_payload = {
'user_id': user.user_id,
'username': user.username,
'exp': datetime.utcnow() + timedelta(minutes=config.session_timeout) # 使用配置的会话超时时间
}
token = jwt.encode(token_payload, settings.SECRET_KEY, algorithm='HS256')
# 更新或创建token记录
UserToken.objects.update_or_create(
user=user,
defaults={
'token_id': token_id,
'token': token
}
)
# 更新登录时间
user.login_time = datetime.now()
user.save()
# 记录成功登录日志
LoginLog.objects.create(
log_id=log_id,
user=user,
ip_address=ip_address,
user_agent=user_agent,
status='success'
)
return JsonResponse({
'code': 200,
'message': '登录成功',
'data': {
'token_id': token_id,
'token': token,
'user': {
'user_id': user.user_id,
'username': user.username,
'name': user.name,
'email': user.email
}
}
})
except User.DoesNotExist:
# 用户不存在,记录失败登录
log_id = generate_log_id()
LoginLog.objects.create(
log_id=log_id,
ip_address=ip_address,
user_agent=user_agent,
status='failed',
fail_reason='用户不存在'
)
return JsonResponse({
'code': 404,
'message': '用户不存在'
})
except Exception as e:
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@csrf_exempt
@require_http_methods(["POST"])
def logout(request):
try:
token = request.headers.get('Authorization')
if not token:
return JsonResponse({
'code': 400,
'message': '未提供Token'
})
try:
# 解析token获取user_id
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
user_id = payload.get('user_id')
# 删除该用户的token记录
UserToken.objects.filter(user_id=user_id).delete()
return JsonResponse({
'code': 200,
'message': '退出成功'
})
except jwt.ExpiredSignatureError:
return JsonResponse({
'code': 401,
'message': 'Token已过期'
})
except jwt.InvalidTokenError:
return JsonResponse({
'code': 401,
'message': '无效的Token'
})
except Exception as e:
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

121
backend/apps/views/logs.py Normal file
View File

@@ -0,0 +1,121 @@
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.db.models import Q
from django.core.paginator import Paginator
from ..models import LoginLog, User
from ..utils.auth import jwt_auth_required
@csrf_exempt
@jwt_auth_required
@require_http_methods(["GET"])
def login_logs_list(request):
"""
获取登录日志列表
"""
try:
# 获取查询参数
page = int(request.GET.get('page', 1))
page_size = int(request.GET.get('page_size', 10))
username = request.GET.get('username', '')
status = request.GET.get('status', '')
ip_address = request.GET.get('ip_address', '')
start_time = request.GET.get('start_time', '')
end_time = request.GET.get('end_time', '')
# 构建查询条件
query = Q()
if username:
users = User.objects.filter(username__icontains=username)
query &= Q(user__in=users)
if status:
query &= Q(status=status)
if ip_address:
query &= Q(ip_address__icontains=ip_address)
if start_time:
query &= Q(login_time__gte=start_time)
if end_time:
query &= Q(login_time__lte=end_time)
# 获取登录日志
logs = LoginLog.objects.filter(query).select_related('user').order_by('-login_time')
# 分页
paginator = Paginator(logs, page_size)
current_page = paginator.page(page)
# 格式化返回数据
log_list = []
for log in current_page.object_list:
log_data = {
'log_id': log.log_id,
'username': log.user.username if log.user else None,
'user_id': log.user.user_id if log.user else None,
'ip_address': log.ip_address,
'user_agent': log.user_agent,
'status': log.status,
'fail_reason': log.fail_reason,
'login_time': log.login_time.strftime('%Y-%m-%d %H:%M:%S') if log.login_time else None
}
log_list.append(log_data)
return JsonResponse({
'code': 200,
'message': '获取登录日志成功',
'data': {
'total': paginator.count,
'page': page,
'page_size': page_size,
'logs': log_list
}
})
except Exception as e:
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@csrf_exempt
@jwt_auth_required
@require_http_methods(["GET"])
def login_log_detail(request, log_id):
"""
获取登录日志详情
"""
try:
try:
log = LoginLog.objects.select_related('user').get(log_id=log_id)
except LoginLog.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '登录日志不存在'
})
log_data = {
'log_id': log.log_id,
'username': log.user.username if log.user else None,
'user_id': log.user.user_id if log.user else None,
'user_name': log.user.name if log.user else None,
'ip_address': log.ip_address,
'user_agent': log.user_agent,
'status': log.status,
'fail_reason': log.fail_reason,
'login_time': log.login_time.strftime('%Y-%m-%d %H:%M:%S') if log.login_time else None
}
return JsonResponse({
'code': 200,
'message': '获取登录日志详情成功',
'data': log_data
})
except Exception as e:
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

@@ -0,0 +1,401 @@
import json
import uuid
import hashlib
import hmac
import base64
import time
import logging
import requests
from urllib.parse import quote_plus
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from ..models import NotificationRobot, User
from ..utils.auth import jwt_auth_required
logger = logging.getLogger('apps')
def generate_id():
"""生成唯一ID"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
@method_decorator(csrf_exempt, name='dispatch')
class NotificationRobotView(View):
@method_decorator(jwt_auth_required)
def get(self, request, robot_id=None):
"""获取机器人列表或单个机器人详情"""
try:
if robot_id:
try:
robot = NotificationRobot.objects.select_related('creator').get(robot_id=robot_id)
return JsonResponse({
'code': 200,
'message': '获取机器人详情成功',
'data': {
'robot_id': robot.robot_id,
'type': robot.type,
'name': robot.name,
'webhook': robot.webhook,
'security_type': robot.security_type,
'secret': robot.secret,
'keywords': robot.keywords,
'ip_list': robot.ip_list,
'remark': robot.remark,
'creator': {
'user_id': robot.creator.user_id,
'name': robot.creator.name
} if robot.creator else None,
'create_time': robot.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'update_time': robot.update_time.strftime('%Y-%m-%d %H:%M:%S')
}
})
except NotificationRobot.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '机器人不存在'
})
# 获取所有机器人列表
robots = NotificationRobot.objects.select_related('creator').all()
robot_list = []
for robot in robots:
robot_list.append({
'robot_id': robot.robot_id,
'type': robot.type,
'name': robot.name,
'webhook': robot.webhook,
'security_type': robot.security_type,
'secret': robot.secret,
'keywords': robot.keywords,
'ip_list': robot.ip_list,
'remark': robot.remark,
'creator': {
'user_id': robot.creator.user_id,
'name': robot.creator.name
} if robot.creator else None,
'create_time': robot.create_time.strftime('%Y-%m-%d %H:%M:%S')
})
return JsonResponse({
'code': 200,
'message': '获取机器人列表成功',
'data': robot_list
})
except Exception as e:
logger.error(f'获取机器人失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建机器人"""
try:
data = json.loads(request.body)
robot_type = data.get('type')
name = data.get('name')
webhook = data.get('webhook')
security_type = data.get('security_type', 'none')
secret = data.get('secret')
keywords = data.get('keywords', [])
ip_list = data.get('ip_list', [])
remark = data.get('remark')
# 验证必要字段
if not all([robot_type, name, webhook]):
return JsonResponse({
'code': 400,
'message': '机器人类型、名称和Webhook地址不能为空'
})
# 验证机器人类型
if robot_type not in ['dingtalk', 'wecom', 'feishu']:
return JsonResponse({
'code': 400,
'message': '不支持的机器人类型'
})
# 验证安全设置
if security_type == 'secret' and not secret:
return JsonResponse({
'code': 400,
'message': '使用加签密钥时,密钥不能为空'
})
elif security_type == 'keyword' and not keywords:
return JsonResponse({
'code': 400,
'message': '使用自定义关键词时,关键词不能为空'
})
elif security_type == 'ip' and not ip_list:
return JsonResponse({
'code': 400,
'message': '使用IP白名单时IP列表不能为空'
})
# 创建机器人
creator = User.objects.get(user_id=request.user_id)
robot = NotificationRobot.objects.create(
robot_id=generate_id(),
type=robot_type,
name=name,
webhook=webhook,
security_type=security_type,
secret=secret,
keywords=keywords,
ip_list=ip_list,
remark=remark,
creator=creator
)
return JsonResponse({
'code': 200,
'message': '创建机器人成功',
'data': {
'robot_id': robot.robot_id
}
})
except Exception as e:
logger.error(f'创建机器人失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""更新机器人"""
try:
data = json.loads(request.body)
robot_id = data.get('robot_id')
name = data.get('name')
webhook = data.get('webhook')
security_type = data.get('security_type')
secret = data.get('secret')
keywords = data.get('keywords')
ip_list = data.get('ip_list')
remark = data.get('remark')
if not robot_id:
return JsonResponse({
'code': 400,
'message': '机器人ID不能为空'
})
try:
robot = NotificationRobot.objects.get(robot_id=robot_id)
except NotificationRobot.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '机器人不存在'
})
# 验证必要字段
if name:
robot.name = name
if webhook:
robot.webhook = webhook
if security_type:
robot.security_type = security_type
if secret is not None:
robot.secret = secret
if keywords is not None:
robot.keywords = keywords
if ip_list is not None:
robot.ip_list = ip_list
if remark is not None:
robot.remark = remark
# 验证安全设置
if robot.security_type == 'secret' and not robot.secret:
return JsonResponse({
'code': 400,
'message': '使用加签密钥时,密钥不能为空'
})
elif robot.security_type == 'keyword' and not robot.keywords:
return JsonResponse({
'code': 400,
'message': '使用自定义关键词时,关键词不能为空'
})
elif robot.security_type == 'ip' and not robot.ip_list:
return JsonResponse({
'code': 400,
'message': '使用IP白名单时IP列表不能为空'
})
robot.save()
return JsonResponse({
'code': 200,
'message': '更新机器人成功'
})
except Exception as e:
logger.error(f'更新机器人失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除机器人"""
try:
data = json.loads(request.body)
robot_id = data.get('robot_id')
if not robot_id:
return JsonResponse({
'code': 400,
'message': '机器人ID不能为空'
})
try:
robot = NotificationRobot.objects.get(robot_id=robot_id)
robot.delete()
return JsonResponse({
'code': 200,
'message': '删除机器人成功'
})
except NotificationRobot.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '机器人不存在'
})
except Exception as e:
logger.error(f'删除机器人失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class NotificationTestView(View):
def _sign_dingtalk(self, secret, timestamp):
"""钉钉机器人签名"""
string_to_sign = f'{timestamp}\n{secret}'
hmac_code = hmac.new(
secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
return base64.b64encode(hmac_code).decode('utf-8')
def _sign_feishu(self, secret, timestamp):
"""飞书机器人签名"""
string_to_sign = f'{timestamp}\n{secret}'
hmac_code = hmac.new(
secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
return base64.b64encode(hmac_code).decode('utf-8')
@method_decorator(jwt_auth_required)
def post(self, request, *args, **kwargs):
"""测试机器人"""
try:
data = json.loads(request.body)
robot_id = data.get('robot_id')
if not robot_id:
return JsonResponse({
'code': 400,
'message': '机器人ID不能为空'
})
try:
robot = NotificationRobot.objects.get(robot_id=robot_id)
except NotificationRobot.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '机器人不存在'
})
# 准备测试消息
timestamp = str(int(time.time() * 1000))
test_message = "这是一条测试消息,如果您收到了这条消息,说明机器人配置正确。"
# 根据不同类型的机器人发送测试消息
try:
if robot.type == 'dingtalk':
# 钉钉机器人
webhook = robot.webhook
# 如果使用加签方式
if robot.security_type == 'secret' and robot.secret:
sign = self._sign_dingtalk(robot.secret, timestamp)
webhook = f"{webhook}&timestamp={timestamp}&sign={quote_plus(sign)}"
# 构建消息内容
message_data = {
"msgtype": "text",
"text": {
"content": test_message
}
}
response = requests.post(webhook, json=message_data)
elif robot.type == 'wecom':
# 企业微信机器人
response = requests.post(robot.webhook, json={
"msgtype": "text",
"text": {
"content": test_message
}
})
elif robot.type == 'feishu':
# 飞书机器人
headers = {}
if robot.security_type == 'secret' and robot.secret:
sign = self._sign_feishu(robot.secret, timestamp)
headers.update({
"X-Timestamp": timestamp,
"X-Sign": sign
})
response = requests.post(robot.webhook, json={
"msg_type": "text",
"content": {
"text": test_message
}
}, headers=headers)
if response.status_code == 200:
resp_json = response.json()
if resp_json.get('errcode') == 0 or resp_json.get('StatusCode') == 0 or resp_json.get('code') == 0:
return JsonResponse({
'code': 200,
'message': '测试消息发送成功'
})
else:
return JsonResponse({
'code': 400,
'message': f'测试消息发送失败: {response.text}'
})
else:
return JsonResponse({
'code': 400,
'message': f'测试消息发送失败: {response.text}'
})
except Exception as e:
logger.error(f'发送测试消息失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'发送测试消息失败: {str(e)}'
})
except Exception as e:
logger.error(f'测试机器人失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

@@ -0,0 +1,392 @@
import json
import uuid
import hashlib
import logging
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from django.db.models import Q
from ..models import Project, User
from ..utils.auth import jwt_auth_required
from ..utils.permissions import get_user_permissions
logger = logging.getLogger('apps')
def generate_id():
"""生成唯一ID"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
@method_decorator(csrf_exempt, name='dispatch')
class ProjectView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取项目列表"""
try:
# 获取当前用户的权限信息
user_permissions = get_user_permissions(request.user_id)
data_permissions = user_permissions.get('data', {})
# 检查用户是否有项目查看权限
function_permissions = user_permissions.get('function', {})
project_permissions = function_permissions.get('project', [])
if 'view' not in project_permissions:
logger.warning(f'用户[{request.user_id}]没有项目查看权限')
return JsonResponse({
'code': 403,
'message': '没有权限查看项目'
}, status=403)
project_id = request.GET.get('project_id')
name = request.GET.get('name')
category = request.GET.get('category')
# 构建查询条件
query = Q()
if project_id:
query &= Q(project_id=project_id)
if name:
query &= Q(name__icontains=name) # 使用 icontains 进行不区分大小写的模糊查询
if category:
query &= Q(category=category)
# 应用项目权限过滤
project_scope = data_permissions.get('project_scope', 'all')
if project_scope == 'custom':
permitted_project_ids = data_permissions.get('project_ids', [])
if not permitted_project_ids:
# 如果设置了自定义项目权限但列表为空,意味着没有权限查看任何项目
logger.info(f'用户[{request.user_id}]没有权限查看任何项目')
return JsonResponse({
'code': 200,
'message': '获取项目列表成功',
'data': []
})
# 限制只能查看有权限的项目
query &= Q(project_id__in=permitted_project_ids)
# 使用查询条件过滤项目
projects = Project.objects.select_related('creator').filter(query)
project_list = []
for project in projects:
project_list.append({
'project_id': project.project_id,
'name': project.name,
'description': project.description,
'category': project.category,
'repository': project.repository,
'creator': {
'user_id': project.creator.user_id,
'username': project.creator.username,
'name': project.creator.name
},
'create_time': project.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'update_time': project.update_time.strftime('%Y-%m-%d %H:%M:%S')
})
return JsonResponse({
'code': 200,
'message': '获取项目列表成功',
'data': project_list
})
except Exception as e:
logger.error(f'获取项目列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建项目"""
try:
with transaction.atomic():
data = json.loads(request.body)
name = data.get('name')
description = data.get('description')
category = data.get('category')
repository = data.get('repository')
if not name:
return JsonResponse({
'code': 400,
'message': '项目名称不能为空'
})
# 检查项目名称是否已存在
if Project.objects.filter(name=name).exists():
return JsonResponse({
'code': 400,
'message': '项目名称已存在'
})
# 创建项目
creator = User.objects.get(user_id=request.user_id)
project = Project.objects.create(
project_id=generate_id(),
name=name,
description=description,
category=category,
repository=repository,
creator=creator
)
return JsonResponse({
'code': 200,
'message': '创建项目成功',
'data': {
'project_id': project.project_id,
'name': project.name
}
})
except Exception as e:
logger.error(f'创建项目失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""编辑项目"""
try:
with transaction.atomic():
data = json.loads(request.body)
project_id = data.get('project_id')
name = data.get('name')
description = data.get('description')
category = data.get('category')
repository = data.get('repository')
if not project_id:
return JsonResponse({
'code': 400,
'message': '项目ID不能为空'
})
try:
project = Project.objects.get(project_id=project_id)
except Project.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '项目不存在'
})
# 检查项目名称是否已存在
if name and name != project.name:
if Project.objects.filter(name=name).exists():
return JsonResponse({
'code': 400,
'message': '项目名称已存在'
})
project.name = name
if description is not None:
project.description = description
if category:
project.category = category
if repository:
project.repository = repository
project.save()
return JsonResponse({
'code': 200,
'message': '更新项目成功'
})
except Exception as e:
logger.error(f'更新项目失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除项目"""
try:
with transaction.atomic():
data = json.loads(request.body)
project_id = data.get('project_id')
if not project_id:
return JsonResponse({
'code': 400,
'message': '项目ID不能为空'
})
try:
project = Project.objects.get(project_id=project_id)
project.delete()
return JsonResponse({
'code': 200,
'message': '删除项目成功'
})
except Project.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '项目不存在'
})
except Exception as e:
logger.error(f'删除项目失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class ProjectServiceView(View):
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建项目服务"""
try:
with transaction.atomic():
data = json.loads(request.body)
project_id = data.get('project_id')
name = data.get('name')
description = data.get('description')
category = data.get('category')
repository = data.get('repository')
if not all([project_id, name, category, repository]):
return JsonResponse({
'code': 400,
'message': '项目ID、服务名称、类别和仓库地址不能为空'
})
try:
project = Project.objects.get(project_id=project_id)
except Project.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '项目不存在'
})
# 检查服务名称在项目下是否已存在
if ProjectService.objects.filter(project=project, name=name).exists():
return JsonResponse({
'code': 400,
'message': '该项目下已存在同名服务'
})
creator = User.objects.get(user_id=request.user_id)
service = ProjectService.objects.create(
service_id=generate_id(),
project=project,
name=name,
description=description,
category=category,
repository=repository,
creator=creator
)
return JsonResponse({
'code': 200,
'message': '创建服务成功',
'data': {
'service_id': service.service_id,
'name': service.name
}
})
except Exception as e:
logger.error(f'创建服务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""更新项目服务"""
try:
with transaction.atomic():
data = json.loads(request.body)
service_id = data.get('service_id')
name = data.get('name')
description = data.get('description')
category = data.get('category')
repository = data.get('repository')
if not service_id:
return JsonResponse({
'code': 400,
'message': '服务ID不能为空'
})
try:
service = ProjectService.objects.get(service_id=service_id)
except ProjectService.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '服务不存在'
})
# 检查服务名称是否已存在
if name and name != service.name:
if ProjectService.objects.filter(project=service.project, name=name).exists():
return JsonResponse({
'code': 400,
'message': '该项目下已存在同名服务'
})
service.name = name
if description is not None:
service.description = description
if category:
service.category = category
if repository:
service.repository = repository
service.save()
return JsonResponse({
'code': 200,
'message': '更新服务成功'
})
except Exception as e:
logger.error(f'更新服务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除项目服务"""
try:
with transaction.atomic():
data = json.loads(request.body)
service_id = data.get('service_id')
if not service_id:
return JsonResponse({
'code': 400,
'message': '服务ID不能为空'
})
try:
service = ProjectService.objects.get(service_id=service_id)
service.delete()
return JsonResponse({
'code': 200,
'message': '删除服务成功'
})
except ProjectService.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '服务不存在'
})
except Exception as e:
logger.error(f'删除服务失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

346
backend/apps/views/role.py Normal file
View File

@@ -0,0 +1,346 @@
import json
import uuid
import hashlib
import logging
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from django.db.models import Q
from ..models import User, Role, UserRole
from ..utils.auth import jwt_auth_required
logger = logging.getLogger('apps')
def generate_id():
"""生成唯一ID"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
@method_decorator(csrf_exempt, name='dispatch')
class RoleView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取角色列表"""
try:
role_id = request.GET.get('role_id')
name = request.GET.get('name')
# 构建查询条件
query = {}
if role_id:
query['role_id'] = role_id
if name:
query['name__icontains'] = name
# 使用查询条件过滤角色
roles = Role.objects.select_related('creator').filter(**query)
role_list = []
for role in roles:
# 处理permissions字段确保是对象形式
permissions = role.permissions
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except json.JSONDecodeError:
logger.error(f'角色[{role.name}]的权限数据格式错误')
permissions = {}
role_data = {
'role_id': role.role_id,
'name': role.name,
'description': role.description,
'permissions': permissions, # 返回处理后的权限配置
'creator': {
'user_id': role.creator.user_id,
'username': role.creator.username,
'name': role.creator.name
} if role.creator else None,
'create_time': role.create_time.strftime('%Y-%m-%d %H:%M:%S'),
'update_time': role.update_time.strftime('%Y-%m-%d %H:%M:%S'),
}
role_list.append(role_data)
return JsonResponse({
'code': 200,
'message': '获取角色列表成功',
'data': role_list
})
except Exception as e:
logger.error(f'获取角色列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建角色"""
try:
with transaction.atomic():
data = json.loads(request.body)
name = data.get('name')
description = data.get('description')
permissions = data.get('permissions', {})
if not name:
return JsonResponse({
'code': 400,
'message': '角色名称不能为空'
})
# 检查角色名称是否已存在
if Role.objects.filter(name=name).exists():
return JsonResponse({
'code': 400,
'message': '角色名称已存在'
})
# 创建角色
creator = User.objects.get(user_id=request.user_id)
role = Role.objects.create(
role_id=generate_id(),
name=name,
description=description,
permissions=permissions,
creator=creator
)
return JsonResponse({
'code': 200,
'message': '创建角色成功',
'data': {
'role_id': role.role_id,
'name': role.name
}
})
except Exception as e:
logger.error(f'创建角色失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""编辑角色"""
try:
with transaction.atomic():
data = json.loads(request.body)
role_id = data.get('role_id')
name = data.get('name')
description = data.get('description')
permissions = data.get('permissions')
if not role_id:
return JsonResponse({
'code': 400,
'message': '角色ID不能为空'
})
try:
role = Role.objects.get(role_id=role_id)
except Role.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '角色不存在'
})
# 检查角色名称是否已存在
if name and name != role.name:
if Role.objects.filter(name=name).exists():
return JsonResponse({
'code': 400,
'message': '角色名称已存在'
})
role.name = name
if description is not None:
role.description = description
# 处理permissions字段
if permissions is not None:
# 确保permissions是对象而不是字符串
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except json.JSONDecodeError:
return JsonResponse({
'code': 400,
'message': '权限数据格式错误'
})
role.permissions = permissions
role.save()
return JsonResponse({
'code': 200,
'message': '更新角色成功'
})
except Exception as e:
logger.error(f'更新角色失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除角色"""
try:
with transaction.atomic():
data = json.loads(request.body)
role_id = data.get('role_id')
if not role_id:
return JsonResponse({
'code': 400,
'message': '角色ID不能为空'
})
try:
role = Role.objects.get(role_id=role_id)
# 检查是否有用户使用该角色
if UserRole.objects.filter(role=role).exists():
return JsonResponse({
'code': 400,
'message': '该角色已分配给用户,无法删除'
})
role.delete()
return JsonResponse({
'code': 200,
'message': '删除角色成功'
})
except Role.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '角色不存在'
})
except Exception as e:
logger.error(f'删除角色失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class UserPermissionView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取当前用户的权限"""
try:
user_id = request.user_id
try:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '用户不存在'
})
# 记录操作日志
logger.info(f'用户[{user.username}]获取权限信息')
# 获取用户的所有角色
user_roles = UserRole.objects.filter(user=user).select_related('role')
# 合并所有角色的权限
menu_permissions = set()
function_permissions = {}
data_permissions = {
'project_scope': 'all',
'project_ids': [],
'environment_scope': 'all',
'environment_types': []
}
has_custom_project_scope = False
has_custom_environment_scope = False
for user_role in user_roles:
role = user_role.role
permissions = role.permissions
# 如果permissions是字符串解析为JSON
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except json.JSONDecodeError:
logger.error(f'解析角色[{role.name}]的权限数据失败')
permissions = {}
# 合并菜单权限
if permissions.get('menu') and isinstance(permissions['menu'], list):
menu_permissions.update(permissions['menu'])
# 合并功能权限
if permissions.get('function') and isinstance(permissions['function'], dict):
for module, actions in permissions['function'].items():
if not isinstance(actions, list):
continue
if module not in function_permissions:
function_permissions[module] = []
function_permissions[module].extend(actions)
# 确保不重复
function_permissions[module] = list(set(function_permissions[module]))
# 合并数据权限
if permissions.get('data') and isinstance(permissions['data'], dict):
data_perms = permissions['data']
# 项目权限
if data_perms.get('project_scope') == 'custom':
has_custom_project_scope = True
if data_permissions['project_scope'] == 'all':
data_permissions['project_scope'] = 'custom'
data_permissions['project_ids'] = data_perms.get('project_ids', [])
else:
# 合并项目ID列表
data_permissions['project_ids'].extend(data_perms.get('project_ids', []))
# 确保不重复
data_permissions['project_ids'] = list(set(data_permissions['project_ids']))
# 环境权限
if data_perms.get('environment_scope') == 'custom':
has_custom_environment_scope = True
if data_permissions['environment_scope'] == 'all':
data_permissions['environment_scope'] = 'custom'
data_permissions['environment_types'] = data_perms.get('environment_types', [])
else:
# 合并环境类型列表
data_permissions['environment_types'].extend(data_perms.get('environment_types', []))
# 确保不重复
data_permissions['environment_types'] = list(set(data_permissions['environment_types']))
# 如果没有任何角色有自定义项目/环境范围,保持为'all'
if not has_custom_project_scope:
data_permissions['project_scope'] = 'all'
data_permissions['project_ids'] = []
if not has_custom_environment_scope:
data_permissions['environment_scope'] = 'all'
data_permissions['environment_types'] = []
permissions_result = {
'menu': list(menu_permissions),
'function': function_permissions,
'data': data_permissions
}
return JsonResponse({
'code': 200,
'message': '获取用户权限成功',
'data': permissions_result
})
except Exception as e:
logger.error(f'获取用户权限失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

@@ -0,0 +1,143 @@
import json
import logging
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from ..models import SecurityConfig, User
from ..utils.auth import jwt_auth_required
logger = logging.getLogger('apps')
@method_decorator(csrf_exempt, name='dispatch')
class SecurityConfigView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取安全配置"""
try:
# 获取或创建安全配置
security_config, created = SecurityConfig.objects.get_or_create(
id=1,
defaults={
'min_password_length': 8,
'password_complexity': ['lowercase', 'number'],
'session_timeout': 120,
'max_login_attempts': 5,
'lockout_duration': 30,
'enable_2fa': False
}
)
return JsonResponse({
'code': 200,
'message': '获取安全配置成功',
'data': {
'min_password_length': security_config.min_password_length,
'password_complexity': security_config.password_complexity,
'session_timeout': security_config.session_timeout,
'max_login_attempts': security_config.max_login_attempts,
'lockout_duration': security_config.lockout_duration,
'enable_2fa': security_config.enable_2fa,
'update_time': security_config.update_time.strftime('%Y-%m-%d %H:%M:%S') if security_config.update_time else None
}
})
except Exception as e:
logger.error(f'获取安全配置失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""更新安全配置"""
try:
with transaction.atomic():
data = json.loads(request.body)
min_password_length = data.get('min_password_length')
password_complexity = data.get('password_complexity')
session_timeout = data.get('session_timeout')
max_login_attempts = data.get('max_login_attempts')
lockout_duration = data.get('lockout_duration')
enable_2fa = data.get('enable_2fa')
# 验证输入数据
if min_password_length is not None:
if not isinstance(min_password_length, int) or min_password_length < 6 or min_password_length > 20:
return JsonResponse({
'code': 400,
'message': '密码最小长度必须在6-20之间'
})
if password_complexity is not None:
if not isinstance(password_complexity, list):
return JsonResponse({
'code': 400,
'message': '密码复杂度要求格式错误'
})
valid_complexity = ['uppercase', 'lowercase', 'number', 'special']
for item in password_complexity:
if item not in valid_complexity:
return JsonResponse({
'code': 400,
'message': f'无效的密码复杂度要求: {item}'
})
if session_timeout is not None:
if not isinstance(session_timeout, int) or session_timeout < 10 or session_timeout > 1440:
return JsonResponse({
'code': 400,
'message': '会话超时时间必须在10-1440分钟之间'
})
if max_login_attempts is not None:
if not isinstance(max_login_attempts, int) or max_login_attempts < 3 or max_login_attempts > 10:
return JsonResponse({
'code': 400,
'message': '最大登录尝试次数必须在3-10次之间'
})
if lockout_duration is not None:
if not isinstance(lockout_duration, int) or lockout_duration < 5 or lockout_duration > 60:
return JsonResponse({
'code': 400,
'message': '账户锁定时间必须在5-60分钟之间'
})
# 获取或创建安全配置
security_config, created = SecurityConfig.objects.get_or_create(id=1)
# 更新配置
if min_password_length is not None:
security_config.min_password_length = min_password_length
if password_complexity is not None:
security_config.password_complexity = password_complexity
if session_timeout is not None:
security_config.session_timeout = session_timeout
if max_login_attempts is not None:
security_config.max_login_attempts = max_login_attempts
if lockout_duration is not None:
security_config.lockout_duration = lockout_duration
if enable_2fa is not None:
security_config.enable_2fa = enable_2fa
security_config.save()
# 记录操作日志
user = User.objects.get(user_id=request.user_id)
logger.info(f'用户[{user.username}]更新了安全配置')
return JsonResponse({
'code': 200,
'message': '安全配置更新成功'
})
except Exception as e:
logger.error(f'更新安全配置失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

334
backend/apps/views/user.py Normal file
View File

@@ -0,0 +1,334 @@
import json
import uuid
import hashlib
import logging
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.db import transaction
from django.db.models import Q
from ..models import User, Role, UserRole
from ..utils.auth import jwt_auth_required
from ..utils.security import SecurityValidator
logger = logging.getLogger('apps')
def generate_id():
"""生成唯一ID"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
@method_decorator(csrf_exempt, name='dispatch')
class UserView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取用户列表"""
try:
user_id = request.GET.get('user_id')
username = request.GET.get('username')
email = request.GET.get('email')
status = request.GET.get('status')
# 构建查询条件
query = {}
if user_id:
query['user_id'] = user_id
if username:
query['username__icontains'] = username # 使用 icontains 进行不区分大小写的模糊查询
if email:
query['email__icontains'] = email
if status:
query['status'] = status
# 使用查询条件过滤用户
users = User.objects.filter(**query)
user_list = []
for user in users:
# 获取用户角色
user_roles = UserRole.objects.filter(user=user).select_related('role')
roles = [{"role_id": ur.role.role_id, "name": ur.role.name} for ur in user_roles]
user_list.append({
'user_id': user.user_id,
'username': user.username,
'name': user.name,
'email': user.email,
'status': user.status,
'roles': roles,
'login_time': user.login_time.strftime('%Y-%m-%d %H:%M:%S') if user.login_time else None,
'create_time': user.create_time.strftime('%Y-%m-%d %H:%M:%S'),
})
return JsonResponse({
'code': 200,
'message': '获取用户列表成功',
'data': user_list
})
except Exception as e:
logger.error(f'获取用户列表失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def post(self, request):
"""创建用户"""
try:
with transaction.atomic():
data = json.loads(request.body)
username = data.get('username')
name = data.get('name')
password = data.get('password')
email = data.get('email')
role_ids = data.get('role_ids', [])
if not all([username, name, password, email]):
return JsonResponse({
'code': 400,
'message': '用户名、姓名、密码和邮箱不能为空'
})
# 检查用户名是否已存在
if User.objects.filter(username=username).exists():
return JsonResponse({
'code': 400,
'message': '用户名已存在'
})
# 检查邮箱是否已存在
if User.objects.filter(email=email).exists():
return JsonResponse({
'code': 400,
'message': '邮箱已存在'
})
# 验证密码强度
is_valid, message = SecurityValidator.validate_password(password)
if not is_valid:
return JsonResponse({
'code': 400,
'message': message
})
# 密码加密
password_hash = hashlib.sha256(password.encode()).hexdigest()
# 创建用户
user = User.objects.create(
user_id=generate_id(),
username=username,
name=name,
password=password_hash,
email=email,
status=1 # 默认启用
)
# 分配角色
for role_id in role_ids:
try:
role = Role.objects.get(role_id=role_id)
UserRole.objects.create(user=user, role=role)
except Role.DoesNotExist:
logger.warning(f'角色不存在: {role_id}')
return JsonResponse({
'code': 200,
'message': '创建用户成功',
'data': {
'user_id': user.user_id,
'username': user.username
}
})
except Exception as e:
logger.error(f'创建用户失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def put(self, request):
"""编辑用户"""
try:
with transaction.atomic():
data = json.loads(request.body)
user_id = data.get('user_id')
name = data.get('name')
email = data.get('email')
status = data.get('status')
password = data.get('password')
role_ids = data.get('role_ids')
if not user_id:
return JsonResponse({
'code': 400,
'message': '用户ID不能为空'
})
try:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '用户不存在'
})
# 更新用户信息
if name:
user.name = name
if email and email != user.email:
# 检查邮箱是否已存在
if User.objects.filter(email=email).exclude(user_id=user_id).exists():
return JsonResponse({
'code': 400,
'message': '邮箱已存在'
})
user.email = email
if status is not None:
# 如果是解锁操作从0改为1使用安全验证器的解锁方法
if user.status == 0 and status == 1:
success, message = SecurityValidator.unlock_user_account(user)
if not success:
return JsonResponse({
'code': 400,
'message': message
})
# 如果是锁定操作从1改为0使用安全验证器的锁定方法
elif user.status == 1 and status == 0:
success, message = SecurityValidator.lock_user_account(user)
if not success:
return JsonResponse({
'code': 400,
'message': message
})
else:
user.status = status
if password:
# 验证密码强度
is_valid, message = SecurityValidator.validate_password(password)
if not is_valid:
return JsonResponse({
'code': 400,
'message': message
})
# 密码加密
password_hash = hashlib.sha256(password.encode()).hexdigest()
user.password = password_hash
user.save()
# 更新角色
if role_ids is not None:
# 删除旧角色关联
UserRole.objects.filter(user=user).delete()
# 添加新角色关联
for role_id in role_ids:
try:
role = Role.objects.get(role_id=role_id)
UserRole.objects.create(user=user, role=role)
except Role.DoesNotExist:
logger.warning(f'角色不存在: {role_id}')
return JsonResponse({
'code': 200,
'message': '更新用户成功'
})
except Exception as e:
logger.error(f'更新用户失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(jwt_auth_required)
def delete(self, request):
"""删除用户"""
try:
with transaction.atomic():
data = json.loads(request.body)
user_id = data.get('user_id')
if not user_id:
return JsonResponse({
'code': 400,
'message': '用户ID不能为空'
})
try:
user = User.objects.get(user_id=user_id)
# 删除关联的角色
UserRole.objects.filter(user=user).delete()
# 删除用户
user.delete()
return JsonResponse({
'code': 200,
'message': '删除用户成功'
})
except User.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '用户不存在'
})
except Exception as e:
logger.error(f'删除用户失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})
@method_decorator(csrf_exempt, name='dispatch')
class UserProfileView(View):
@method_decorator(jwt_auth_required)
def get(self, request):
"""获取当前登录用户的个人信息"""
try:
user_id = request.user_id
try:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist:
return JsonResponse({
'code': 404,
'message': '用户不存在'
})
# 获取用户角色
user_roles = UserRole.objects.filter(user=user).select_related('role')
roles = [{
"role_id": ur.role.role_id,
"name": ur.role.name,
"description": ur.role.description
} for ur in user_roles]
# 构建用户信息
user_info = {
'user_id': user.user_id,
'username': user.username,
'name': user.name,
'email': user.email,
'status': user.status,
'roles': roles,
'login_time': user.login_time.strftime('%Y-%m-%d %H:%M:%S') if user.login_time else None,
'create_time': user.create_time.strftime('%Y-%m-%d %H:%M:%S') if user.create_time else None,
'update_time': user.update_time.strftime('%Y-%m-%d %H:%M:%S') if user.update_time else None,
}
return JsonResponse({
'code': 200,
'message': '获取用户信息成功',
'data': user_info
})
except Exception as e:
logger.error(f'获取用户信息失败: {str(e)}', exc_info=True)
return JsonResponse({
'code': 500,
'message': f'服务器错误: {str(e)}'
})

View File

14
backend/backend/asgi.py Normal file
View File

@@ -0,0 +1,14 @@
"""
ASGI config for backend project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
application = get_asgi_application()

199
backend/backend/settings.py Normal file
View File

@@ -0,0 +1,199 @@
from pathlib import Path
import pymysql
import logging
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'django-insecure-5z@^6hpk^zxffj_7)&l3pvww8@ky3qsai4)m!vcog!8#=@a3&%'
DEBUG = True
ALLOWED_HOSTS = ['*'] # 允许所有主机访问
# CORS设置
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True # 允许所有来源
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders', # 添加 cors-headers
'apps',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'backend.wsgi.application'
pymysql.install_as_MySQLdb()
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'OPTIONS': {
'read_default_file': str(BASE_DIR / 'conf' / 'config.txt'),
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# 基础设置,包括语言、时区等
LANGUAGE_CODE = 'en-us' # 修改为中文
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
USE_TZ = False # 修改为 True使用时区功能
# 日志配置
LOG_DIR = BASE_DIR / 'logs'
LOG_DIR.mkdir(exist_ok=True) # 确保日志目录存在
class BuildLogFilter(logging.Filter):
def filter(self, record):
# 如果是构建日志,使用简单格式
if getattr(record, 'from_builder', False):
return True
# 其他日志使用默认格式
return True
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
'build_log_filter': {
'()': 'backend.settings.BuildLogFilter',
},
'non_build_log_filter': {
'()': lambda: type('Filter', (logging.Filter,), {
'filter': lambda self, record: not getattr(record, 'from_builder', False)
})(),
},
},
'formatters': {
'verbose': {
'format': '[{asctime}] {levelname} [{name}:{lineno}] {message}',
'style': '{',
'datefmt': '%Y-%m-%d %H:%M:%S'
},
'simple': {
'format': '{message}',
'style': '{'
},
'console_with_time': {
'format': '[{asctime}] {levelname} {message}',
'style': '{',
'datefmt': '%Y-%m-%d %H:%M:%S'
},
},
'handlers': {
'console_build': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'simple',
'filters': ['build_log_filter'],
},
'console_normal': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'console_with_time',
'filters': ['non_build_log_filter'],
},
'file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': str(LOG_DIR / 'django.log'),
'maxBytes': 1024 * 1024 * 5, # 5MB
'backupCount': 5,
'formatter': 'verbose',
},
'error_file': {
'level': 'ERROR',
'class': 'logging.handlers.RotatingFileHandler',
'filename': str(LOG_DIR / 'error.log'),
'maxBytes': 1024 * 1024 * 5, # 5MB
'backupCount': 5,
'formatter': 'verbose',
}
},
'loggers': {
'django': {
'handlers': ['console_normal', 'file'],
'level': 'INFO',
'propagate': True,
},
'django.request': {
'handlers': ['error_file'],
'level': 'ERROR',
'propagate': False,
},
'django.server': {
'handlers': ['console_normal', 'file'],
'level': 'INFO',
'propagate': False,
},
'apps': {
'handlers': ['console_build', 'console_normal', 'file', 'error_file'],
'level': 'DEBUG',
'propagate': False, # 设置为False以避免重复记录
},
},
'root': {
'handlers': ['console_normal', 'file', 'error_file'],
'level': 'INFO',
},
}
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# 构建相关配置
#BUILD_ROOT = Path('/data/liteops/build') # 修改为指定目录
BUILD_ROOT = Path('/Users/huk/Downloads/data')
BUILD_ROOT.mkdir(exist_ok=True, parents=True) # 确保目录存在,包括父目录

67
backend/backend/urls.py Normal file
View File

@@ -0,0 +1,67 @@
from django.contrib import admin
from django.urls import path
from apps.views.login import login, logout
from apps.views.project import ProjectView, ProjectServiceView
from apps.views.environment import EnvironmentView, EnvironmentTypeView
from apps.views.credentials import CredentialView
from apps.views.gitlab import GitlabBranchView, GitlabCommitView
from apps.views.build import BuildTaskView, BuildExecuteView
from apps.views.build_history import BuildHistoryView, BuildLogView, BuildStageLogView
from apps.views.build_sse import BuildLogSSEView
from apps.views.notification import NotificationRobotView, NotificationTestView
from apps.views.user import UserView, UserProfileView
from apps.views.role import RoleView, UserPermissionView
from apps.views.logs import login_logs_list, login_log_detail
from apps.views.dashboard import DashboardStatsView, BuildTrendView, BuildDetailView, RecentBuildsView, ProjectDistributionView
from apps.views.security import SecurityConfigView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/login/', login, name='login'),
path('api/logout/', logout, name='logout'),
path('api/projects/', ProjectView.as_view(), name='projects'),
path('api/project-services/', ProjectServiceView.as_view(), name='project-services'),
path('api/environments/', EnvironmentView.as_view(), name='environments'),
path('api/environments/types/', EnvironmentTypeView.as_view(), name='environment-types'),
path('api/credentials/', CredentialView.as_view(), name='credentials'),
path('api/gitlab/branches/', GitlabBranchView.as_view(), name='gitlab-branches'),
path('api/gitlab/commits/', GitlabCommitView.as_view(), name='gitlab-commits'),
path('api/build/tasks/', BuildTaskView.as_view(), name='build-tasks'),
path('api/build/tasks/<str:task_id>/', BuildTaskView.as_view(), name='build-task-detail'),
path('api/build/tasks/build', BuildExecuteView.as_view(), name='build-execute'),
# 构建历史相关路由
path('api/build/history/', BuildHistoryView.as_view(), name='build-history'),
path('api/build/history/log/<str:history_id>/', BuildLogView.as_view(), name='build-log'),
path('api/build/history/log/<str:history_id>/download/', BuildLogView.as_view(), name='build-log-download'),
path('api/build/history/stage-log/<str:history_id>/<str:stage_name>/', BuildStageLogView.as_view(), name='build-stage-log'),
# SSE构建日志流
path('api/build/logs/stream/<str:task_id>/<str:build_number>/', BuildLogSSEView.as_view(), name='build-log-sse'),
# 通知机器人相关路由
path('api/notification/robots/', NotificationRobotView.as_view(), name='notification-robots'),
path('api/notification/robots/test/', NotificationTestView.as_view(), name='notification-robot-test'),
path('api/notification/robots/<str:robot_id>/', NotificationRobotView.as_view(), name='notification-robot-detail'),
# 用户管理相关路由
path('api/users/', UserView.as_view(), name='users'),
path('api/roles/', RoleView.as_view(), name='roles'),
path('api/user/permissions/', UserPermissionView.as_view(), name='user-permissions'),
path('api/user/profile/', UserProfileView.as_view(), name='user-profile'),
# 登录日志相关路由
path('api/logs/login/', login_logs_list, name='login-logs'),
path('api/logs/login/<str:log_id>/', login_log_detail, name='login-log-detail'),
# 首页仪表盘相关路由
path('api/dashboard/stats/', DashboardStatsView.as_view(), name='dashboard-stats'),
path('api/dashboard/build-trend/', BuildTrendView.as_view(), name='build-trend'),
path('api/dashboard/build-detail/', BuildDetailView.as_view(), name='build-detail'),
path('api/dashboard/recent-builds/', RecentBuildsView.as_view(), name='recent-builds'),
path('api/dashboard/project-distribution/', ProjectDistributionView.as_view(), name='project-distribution'),
# 安全配置相关路由
path('api/system/security/', SecurityConfigView.as_view(), name='security-config'),
]

7
backend/backend/wsgi.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
application = get_wsgi_application()

8
backend/conf/config.txt Normal file
View File

@@ -0,0 +1,8 @@
[client]
#host = 127.0.0.1
host = mysql
port = 3306
database = liteops
user = root
password = 1234567xx
default-character-set = utf8mb4

25
backend/manage.py Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
from django.core.management.commands.runserver import Command as Runserver
Runserver.default_addr = '0.0.0.0' # 修改默认地址
Runserver.default_port = '8900' # 修改默认端口
main()

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
asgiref==3.6.0
# Django==4.2.13
GitPython==3.1.44
python-gitlab==5.6.0
PyJWT==2.10.1
PyMySQL==1.1.1
Requests==2.32.3
uvicorn==0.34.0
decorator==5.1.1
django-cors-headers==4.2.0
cryptography==42.0.5
PyYAML==6.0.1

139
ci-entrypoint-dind.sh Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Docker in Docker (DinD) 启动脚本 - 轻量级CI/CD版本
# =============================================================================
echo "🐳 启动 Docker in Docker 环境..."
# 检查是否在特权模式下运行
if [ ! -w /sys/fs/cgroup ]; then
echo "❌ 错误: 容器必须在特权模式下运行才能使用 Docker in Docker"
echo "请使用 --privileged 参数启动容器"
exit 1
fi
# 确保必要的内核模块和设备
modprobe overlay 2>/dev/null || true
modprobe br_netfilter 2>/dev/null || true
# 创建必要的设备节点
if [ ! -e /dev/fuse ]; then
mknod /dev/fuse c 10 229 2>/dev/null || true
fi
# 创建必要的目录
mkdir -p /var/lib/docker
mkdir -p /var/run/docker
mkdir -p /etc/docker
# 配置轻量级Docker daemon - 使用vfs存储驱动确保兼容性
cat > /etc/docker/daemon.json << 'EOF'
{
"storage-driver": "vfs",
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "2"
},
"registry-mirrors": [
"https://mirrors.aliyun.com/docker-hub",
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com"
],
"insecure-registries": [],
"exec-opt": ["native.cgroupdriver=cgroupfs"],
"max-concurrent-downloads": 3,
"max-concurrent-uploads": 3
}
EOF
# 启动轻量级Docker daemon
echo "🚀 启动 Docker daemon (轻量级CI/CD模式)..."
# 清理可能存在的旧进程
pkill dockerd 2>/dev/null || true
rm -f /var/run/docker.sock /var/run/docker.pid 2>/dev/null || true
# 启动dockerd
dockerd \
--host=unix:///var/run/docker.sock \
--userland-proxy=false \
--experimental=false \
--live-restore=false \
--iptables=false \
--ip-forward=false \
--pidfile=/var/run/docker.pid \
--tls=false \
--log-level=warn &
# 记录dockerd进程ID
DOCKERD_PID=$!
# 等待Docker daemon启动
echo "⏳ 等待 Docker daemon 启动..."
timeout=60
while [ $timeout -gt 0 ]; do
# 检查socket文件是否存在
if [ -S /var/run/docker.sock ]; then
# 尝试连接Docker daemon
if docker version >/dev/null 2>&1; then
echo "✅ Docker daemon 启动成功"
break
fi
fi
# 检查dockerd进程是否还在运行
if ! kill -0 $DOCKERD_PID 2>/dev/null; then
echo "❌ Docker daemon 进程意外退出"
echo "检查最近的错误日志:"
dmesg | tail -5 2>/dev/null || echo "无法获取系统日志"
exit 1
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "❌ Docker daemon 启动超时"
echo "检查dockerd进程状态:"
ps aux | grep dockerd || true
echo "检查socket文件:"
ls -la /var/run/docker.sock 2>/dev/null || echo "socket文件不存在"
exit 1
fi
# 简单验证Docker功能
echo "🔍 验证 Docker 功能..."
DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if [ $? -eq 0 ]; then
echo "✅ Docker daemon 版本: $DOCKER_VERSION"
echo "✅ 存储驱动: $(docker info --format '{{.Driver}}' 2>/dev/null || echo 'unknown')"
else
echo "❌ Docker daemon 验证失败"
exit 1
fi
# 设置环境变量
export DOCKER_HOST=unix:///var/run/docker.sock
export DOCKER_BUILDKIT=1
echo "🎉 Docker in Docker 环境启动完成 (轻量级CI/CD模式)"
# 设置清理函数
cleanup() {
echo "🧹 清理 Docker daemon..."
if [ -n "$DOCKERD_PID" ] && kill -0 $DOCKERD_PID 2>/dev/null; then
kill $DOCKERD_PID
wait $DOCKERD_PID 2>/dev/null || true
fi
exit 0
}
# 注册信号处理
trap cleanup SIGTERM SIGINT
# 执行传入的命令
exec "$@"

10
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
echo "Starting nginx..."
service nginx start
echo "Starting backend service..."
python -m uvicorn backend.asgi:application \
--host 0.0.0.0 \
--port 8900 \
--workers 1

BIN
liteops-sidebar.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

106
start-containers.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/bin/bash
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # 无颜色
# 设置变量
CONTAINER_IMAGE="liteops:v1"
CONTAINER_NAME="liteops"
MYSQL_CONTAINER="liteops-mysql"
MYSQL_VERSION="8"
MYSQL_PASSWORD="1234567xx"
MYSQL_PORT="3306"
NETWORK_NAME="liteops-network"
# 打印带颜色的信息
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_step() {
echo -e "\n${PURPLE}=== $1 ===${NC}"
}
# 创建Docker网络如果不存在
print_step "创建Docker网络"
if ! docker network inspect $NETWORK_NAME >/dev/null 2>&1; then
print_info "创建Docker网络: $NETWORK_NAME"
docker network create $NETWORK_NAME
print_success "网络创建成功"
else
print_info "网络 $NETWORK_NAME 已存在,跳过创建"
fi
# 停止并删除已存在的容器
print_step "清理已存在的容器"
print_info "停止并删除已存在的容器..."
docker stop $CONTAINER_NAME $MYSQL_CONTAINER 2>/dev/null || true
docker rm $CONTAINER_NAME $MYSQL_CONTAINER 2>/dev/null || true
print_success "容器清理完成"
# 构建镜像
print_step "构建应用镜像"
print_info "构建LiteOps镜像..."
# 确保前端已经构建
if [ ! -d "web/dist" ]; then
print_error "前端dist目录不存在请先运行 npm run build"
exit 1
fi
docker build --platform linux/amd64 -t $CONTAINER_IMAGE .
print_success "镜像构建成功: $CONTAINER_IMAGE"
# 启动MySQL容器
print_step "启动MySQL容器"
print_info "启动MySQL $MYSQL_VERSION 容器..."
docker run -d \
--name $MYSQL_CONTAINER \
--network $NETWORK_NAME \
-p $MYSQL_PORT:3306 \
-e MYSQL_ROOT_PASSWORD=$MYSQL_PASSWORD \
-e MYSQL_DATABASE=liteops \
mysql:$MYSQL_VERSION
# 等待MySQL启动
print_info "等待MySQL启动..."
sleep 10
# 初始化数据库
print_step "初始化数据库"
print_info "导入初始数据..."
docker exec -i $MYSQL_CONTAINER mysql -uroot -p$MYSQL_PASSWORD liteops < liteops_init.sql
print_success "数据库初始化完成"
# 启动应用容器
print_step "启动应用容器"
print_info "启动LiteOps容器Docker in Docker模式..."
docker run -d \
--name $CONTAINER_NAME \
--network $NETWORK_NAME \
--privileged \
-p 80:80 \
-p 8900:8900 \
$CONTAINER_IMAGE
print_step "部署完成"
print_success "LiteOps已成功部署"
print_info "前端访问地址: ${CYAN}http://localhost${NC}"
print_info "后端API地址: ${CYAN}http://localhost:8900/api/${NC}"
print_info "MySQL端口映射: ${CYAN}$MYSQL_PORT${NC}"