From a7e2351ad242cf6f810adec9ccf235ecadb7313b Mon Sep 17 00:00:00 2001 From: attacker Date: Mon, 15 Jun 2026 01:09:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=85=A5=E5=8F=A3=E5=85=A8=E6=A0=88=E6=89=93?= =?UTF-8?q?=E9=80=9A=20+=20=E4=BF=AE=E5=A4=8DApprovalFlow=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E9=94=99=E8=AF=AF=20+=20=E5=AE=8C=E6=88=90=E5=A4=A7?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8B=86=E5=88=86=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CronJobManager: 编辑按钮接通 onClick → openEdit(job),弹出编辑对话框预填数据, 复用新建/编辑共用 Dialog(editingId 区分模式),标题/按钮文案动态切换, 保存时根据 editingId 调用 createJob 或 updateJob — 消除占位按钮 - ApprovalFlow: 修复 API 方法名不匹配导致 tsc -b 编译失败 (getInstances→getProcesses, getMyApprovals→getProcesses({status:'pending'}), approveInstance→approveProcess, rejectInstance→rejectProcess) + 添加 ApprovalFlowManager 导出别名兼容 App.tsx 导入 - 合并已完成的大文件拆分重构: automation.go→automation_cron/script/playbook, ldap.go→ldap_ou/ldap_users, server.go→cloud_accounts/domain/platform/region, user.go→user_admin; 前端 Dashboard/K8s/Server/Approval/Monitor 子组件拆分 // Logan.Li --- CLAUDE.md | 172 +++ backend/internal/api/handlers/automation.go | 991 ------------------ .../internal/api/handlers/automation_cron.go | 343 ++++++ .../api/handlers/automation_playbook.go | 358 +++++++ .../api/handlers/automation_script.go | 322 ++++++ .../internal/api/handlers/cloud_accounts.go | 202 ++++ backend/internal/api/handlers/cloud_domain.go | 230 ++++ .../internal/api/handlers/cloud_platform.go | 132 +++ backend/internal/api/handlers/cloud_region.go | 127 +++ backend/internal/api/handlers/ldap.go | 496 --------- backend/internal/api/handlers/ldap_ou.go | 122 +++ backend/internal/api/handlers/ldap_users.go | 397 +++++++ backend/internal/api/handlers/server.go | 641 ----------- backend/internal/api/handlers/user.go | 344 ------ backend/internal/api/handlers/user_admin.go | 350 +++++++ .../src/components/approval/ApprovalFlow.tsx | 948 ++++++----------- .../approval/ApprovalProcessTab.tsx | 304 ++++++ .../approval/ApprovalTemplateTab.tsx | 185 ++++ .../components/automation/CronJobManager.tsx | 97 +- .../src/components/dashboard/Dashboard.tsx | 794 +------------- .../components/dashboard/DashboardCharts.tsx | 349 ++++++ .../components/dashboard/DashboardRecent.tsx | 359 +++++++ .../components/dashboard/DashboardStats.tsx | 70 ++ .../src/components/k8s/K8sClusterDetail.tsx | 489 +++++++++ .../src/components/k8s/K8sClusterDialog.tsx | 218 ++++ frontend/src/components/k8s/K8sClusters.tsx | 723 +------------ .../components/monitor/AlertChannelDialog.tsx | 274 +++++ .../src/components/monitor/AlertEventsTab.tsx | 203 ++++ .../components/monitor/AlertRuleDialog.tsx | 252 +++++ .../src/components/monitor/AlertRulesTab.tsx | 146 +++ .../src/components/monitor/alertShared.tsx | 96 ++ frontend/src/components/server/ServerForm.tsx | 312 ++++++ .../src/components/server/ServerManager.tsx | 289 +---- .../src/components/system/SystemLogsTab.tsx | 112 ++ .../src/components/system/SystemRolesTab.tsx | 72 ++ .../src/components/system/SystemUserTab.tsx | 588 +++++++++++ .../src/components/system/UserFormDialog.tsx | 158 +++ 37 files changed, 7374 insertions(+), 4891 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/internal/api/handlers/automation_cron.go create mode 100644 backend/internal/api/handlers/automation_playbook.go create mode 100644 backend/internal/api/handlers/automation_script.go create mode 100644 backend/internal/api/handlers/cloud_accounts.go create mode 100644 backend/internal/api/handlers/cloud_domain.go create mode 100644 backend/internal/api/handlers/cloud_platform.go create mode 100644 backend/internal/api/handlers/cloud_region.go create mode 100644 backend/internal/api/handlers/ldap_ou.go create mode 100644 backend/internal/api/handlers/ldap_users.go create mode 100644 backend/internal/api/handlers/user_admin.go create mode 100644 frontend/src/components/approval/ApprovalProcessTab.tsx create mode 100644 frontend/src/components/approval/ApprovalTemplateTab.tsx create mode 100644 frontend/src/components/dashboard/DashboardCharts.tsx create mode 100644 frontend/src/components/dashboard/DashboardRecent.tsx create mode 100644 frontend/src/components/dashboard/DashboardStats.tsx create mode 100644 frontend/src/components/k8s/K8sClusterDetail.tsx create mode 100644 frontend/src/components/k8s/K8sClusterDialog.tsx create mode 100644 frontend/src/components/monitor/AlertChannelDialog.tsx create mode 100644 frontend/src/components/monitor/AlertEventsTab.tsx create mode 100644 frontend/src/components/monitor/AlertRuleDialog.tsx create mode 100644 frontend/src/components/monitor/AlertRulesTab.tsx create mode 100644 frontend/src/components/monitor/alertShared.tsx create mode 100644 frontend/src/components/server/ServerForm.tsx create mode 100644 frontend/src/components/system/SystemLogsTab.tsx create mode 100644 frontend/src/components/system/SystemRolesTab.tsx create mode 100644 frontend/src/components/system/SystemUserTab.tsx create mode 100644 frontend/src/components/system/UserFormDialog.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e17ceb1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,172 @@ +# EZOps AIOps — AI 开发全局规范 + +> 本文件供 AI 编程助手(Claude/Cursor/Copilot/Hermes)自动读取并遵循。 +> 定义项目架构约束、代码规范、拆分策略和开发流程。 +> 作者: Logan.Li · 语言: 中文(代码注释中文 + 署名 `// Logan.Li`) + +## 项目概述 + +**EZOps AIOps** — AI 智能运维平台,涵盖 13 大模块: +服务器管理 · 云资源(账号/域名/账单) · K8s 集群 · 监控告警(Agent) · CI/CD 流水线 · +自动化运维(CronJob/Script/Ansible) · 审批流程 · AI 智能运维(告警分析/故障诊断/预测/脚本生成) · +导航收藏 · 审计日志 · 系统管理(用户/LDAP) · 监控 Agent(双通道: 指标采集+远程命令) + +- **远程仓库**: https://gitee.com/attacker/aiops.git +- **Git 用户**: attacker / attacker@gitee.com + +## 技术栈 + +| 层 | 技术 | 端口 | +|---|---|---| +| 后端 | Go 1.21 + Gin + GORM + SQLite (+ MySQL/PG 可选) + JWT + Zap | 8080 | +| 前端 | React 19 + TypeScript + Vite + Tailwind + shadcn/ui + Zustand + TanStack Query | 5173 | +| Agent | Go 1.24 (CGO_ENABLED=0 静态二进制, amd64+arm64) | — | + +## 核心规则 + +### ⛔ 文件大小红线 + +| 类型 | 上限 | 超出处理 | +|------|------|----------| +| Go handler | **400 行** | 按 sub-domain 拆分到独立文件(同一 package) | +| Go model | **300 行** | 按模块拆分 | +| React 组件 (.tsx) | **700 行** | 提取子组件到同目录,主组件负责编排 | +| TypeScript 工具/服务 | **500 行** | 按功能域拆分 | +| 任何文件 | **700 行(硬上限)** | 必须拆分,无例外 | + +**拆分策略**: +- **Go**: 同 package 下按职责拆文件(如 `automation.go` → `automation_cron.go` + `automation_script.go` + `automation_playbook.go`),共享 struct 放原文件或新建 `automation_types.go` +- **React**: 子组件提取到同目录独立 `.tsx` 文件(如 `SystemManager.tsx` → `SystemManager.tsx` + `SystemUserTab.tsx` + `SystemLdapTab.tsx`),通过 props 传递数据和回调 +- **Mock 数据**: 按模块拆分(如 `data.ts` → `data/servers.ts` + `data/cloud.ts` + `data/k8s.ts` + `index.ts` 汇总导出) + +### ⛔ Go 代码规范 + +1. **所有 handler 返回必须用 `models.Response{Code:200, Data:...}` 包裹** — 前端 `http.ts` 从 `json.data` 取值,裸 `gin.H{}` 会导致前端拿到 undefined 静默失败 +2. **Go 编译命令**(每次必须): + ```bash + cd ~/devops/aiops/backend && unset http_proxy https_proxy && export GOPROXY=https://goproxy.cn,direct && GOTOOLCHAIN=local CGO_ENABLED=1 go build -o aiops-server ./cmd/ + ``` +3. **新增 handler 不绑 model** — 用独立 request struct 接收 `ShouldBindJSON`,再映射到 GORM model +4. **GORM 指针字段必须解引用** — model 中 `*int`/`*string` 字段传给 `fmt.Sprintf` 时先 `*field` +5. **SQLite 必须 CGO_ENABLED=1**(macOS 默认开) +6. **新模块 4 步**: Model → Handler → Routes(routes.go 注册) → AutoMigrate(database.go 添加) + +### ⛔ React 前端规范 + +1. **API 列表响应解包** — 后端返回 `{code, data: {list: T[], total: N}}`,http.ts 提取 `.data` 后仍是 `{list, total}` 对象,组件中必须 `.list` 提取数组 +2. **禁止 `window.confirm()`/`alert()`** — 用 `ConfirmProvider`(`useConfirm()` hook)替代 confirm,用 `toast.error()`/`toast.success()` 替代 alert +3. **Radix Dialog 必须有 ``** — 至少加 `` +4. **Token 不在 localStorage** — 用 `useUserStore.getState().token`(Zustand persist) +5. **新增组件 2 步**: API 层(`services/api.ts` 添加) → 组件+路由(`App.tsx` 加 Route,`Sidebar.tsx` 加菜单) + +### ⛔ UI 设计原则 + +- **能去掉的都去掉** — 不要多余边框、高亮框、装饰性分隔线 +- **按钮文案 2-4 字** — "批量导入" 而非 "批量导入用户" +- **不留占位按钮** — 功能没做好就不放按钮,不要 `toast.info('开发中')` +- **页面打开即用** — 不需要额外"新建"按钮跳转 +- **主题感知** — 用 CSS 变量 `bg-[var(--dg-bg-secondary)]`,不用硬编码颜色 +- **表单操作按钮在字段下方** — 用 `mt-4 pt-4 border-t` 分隔,不放 Card 标题右侧 +- **主动排查同类问题** — 用户报一个 UI 问题,全局搜索同类一次性修复 + +### ⛔ 通用开发流程 + +``` +分析需求 → 写代码 → go build + tsc --noEmit → ./run.sh restart → 浏览器验证 → git commit + push +``` + +- **修改前端后必须在浏览器实际测试**,不能只说"刷新试试" +- **提交前编译验证**:后端 `go build ./...`,前端 `npx tsc --noEmit && npm run build` +- **Git 提交**: `git add -A && git commit -m "feat: 描述" && git push origin master` +- **开发日志**: 每次开发后更新 `docs/dev-log.md` + +## 架构约束 + +### 目录结构(核心部分) + +``` +backend/ +├── cmd/main.go + seed.go # 入口 + 种子数据 +├── internal/ +│ ├── config/config.go # Viper 配置 +│ ├── models/ # GORM 模型(每模块独立文件) +│ └── api/ +│ ├── handlers/ # HTTP handler(按模块拆分,每文件 <400 行) +│ ├── middleware/auth.go # JWT + Admin 中间件 +│ └── routes/routes.go # 路由注册 +├── pkg/database/database.go # DB 连接 + AutoMigrate +└── scripts/mock.go # Mock 种子 + +frontend/ +├── src/ +│ ├── components/{模块}/ # 页面组件(每文件 <700 行,超出拆子组件) +│ ├── services/{api.ts, http.ts} # API 服务层 +│ ├── store/ # Zustand 状态管理 +│ ├── hooks/ # React hooks +│ └── types/index.ts # 全局类型定义 +├── App.tsx # 路由 + 顶层 Provider + +agent/ # 监控 Agent(独立 Go module) +├── main.go # CLI 入口 +├── collector/ # /proc 采集器 +├── task/ # 远程任务执行器 +└── reporter/http.go # HTTP 上报 +``` + +### API 响应格式 + +```go +// 统一响应(所有 handler 必须遵循) +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +// 列表响应(Data 内层) +type ListResponse struct { + List interface{} `json:"list"` + Total int64 `json:"total"` +} +``` + +### 认证流程 + +``` +Login(本地/LDAP fallback) → JWT Token → 前端 Zustand persist 存储 → http.ts 自动注入 Bearer +→ JWTAuth 中间件验证 → 需 admin 权限的路由额外 AdminAuth 中间件 +``` + +### Agent 双通道架构 + +``` +指标通道: ticker(10s) → collect(/proc) → POST /agent/report +任务通道: ticker(5s) → GET /agent/tasks/poll → execute → POST /agent/tasks/:id/result +``` + +### 主题系统 + +5 套主题(CSS 变量驱动): `ocean-deep`(默认暗色) · `light-professional`(亮色) · `midnight-purple` · `forest-green` · `dark-rose` +所有组件必须用 `var(--dg-xxx)` 变量,禁止硬编码颜色值。 + +## 常见陷阱速查 + +| # | 陷阱 | 解决 | +|---|------|------| +| Go1 | go build 超时 | `unset proxy + GOPROXY=goproxy.cn + GOTOOLCHAIN=local` | +| Go2 | handler 不包裹 Response | 必须 `models.Response{Code:200, Data:...}` | +| Go3 | GORM 指针字段 | `*field` 解引用后再用 | +| Go4 | mock.go 重运行 | UNIQUE constraint → 用 API 注入新数据 | +| FE1 | 白屏无堆栈 | 90% 是列表 `{list,total}` 未解包 | +| FE2 | confirm() 冻结自动化 | 用 `useConfirm()` | +| FE3 | Token 获取 | `useUserStore.getState().token`,不是 localStorage | +| FE4 | Radix Dialog 警告 | 必须有 `` | +| FE5 | browser_click 不触发 toggle | 改用 `browser_console` 注入 `.click()` | + +## 文件大小监控 + +```bash +# 检查超限文件(CI 检查脚本) +find backend -name "*.go" -not -path "*/vendor/*" | xargs wc -l | awk '$1 > 400 && $2 != "total"' +find frontend/src -name "*.tsx" -o -name "*.ts" | xargs wc -l | awk '$1 > 700 && $2 != "total"' +``` diff --git a/backend/internal/api/handlers/automation.go b/backend/internal/api/handlers/automation.go index a3348ba..d5fb0f8 100644 --- a/backend/internal/api/handlers/automation.go +++ b/backend/internal/api/handlers/automation.go @@ -6,15 +6,9 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "net/http" - "os" "os/exec" - "strconv" "time" - "ezops/internal/models" - - "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -80,988 +74,3 @@ type AutomationHandler struct { func NewAutomationHandler(db *gorm.DB) *AutomationHandler { return &AutomationHandler{db: db} } - -// ListCronJobs 获取定时任务列表 -// @Summary 获取定时任务列表 -// @Description 获取所有定时任务列表 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /cron-jobs [get] -func (h *AutomationHandler) ListCronJobs(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - var total int64 - h.db.Model(&models.CronJob{}).Count(&total) - - var jobs []models.CronJob - h.db.Preload("Script").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&jobs) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: jobs, - Total: total, - }, - }) -} - -// GetCronJob 获取定时任务详情 -// @Summary 获取定时任务详情 -// @Description 获取指定定时任务的详细信息 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "任务ID" -// @Success 200 {object} models.Response{data=models.CronJob} -// @Router /cron-jobs/{id} [get] -func (h *AutomationHandler) GetCronJob(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var job models.CronJob - if err := h.db.Preload("Script").First(&job, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "定时任务不存在", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: job, - }) -} - -// CreateCronJob 创建定时任务 -// @Summary 创建定时任务 -// @Description 创建新定时任务 -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.CreateCronJobRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.CronJob} -// @Router /cron-jobs [post] -func (h *AutomationHandler) CreateCronJob(c *gin.Context) { - var req models.CreateCronJobRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - job := models.CronJob{ - Name: req.Name, - Description: req.Description, - CronExpr: req.CronExpr, - Type: req.Type, - Content: req.Content, - ScriptID: req.ScriptID, - TargetType: req.TargetType, - Timeout: req.Timeout, - NotifyOn: req.NotifyOn, - Enabled: true, - } - - if job.Timeout == 0 { - job.Timeout = 300 - } - if job.NotifyOn == "" { - job.NotifyOn = "failure" - } - - if err := h.db.Create(&job).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: job, - }) -} - -// UpdateCronJob 更新定时任务 -// @Summary 更新定时任务 -// @Description 更新指定定时任务 -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "任务ID" -// @Param request body models.CreateCronJobRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.CronJob} -// @Router /cron-jobs/{id} [put] -func (h *AutomationHandler) UpdateCronJob(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var req models.CreateCronJobRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var job models.CronJob - if err := h.db.First(&job, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "定时任务不存在", - }) - return - } - - updates := map[string]interface{}{ - "name": req.Name, - "description": req.Description, - "cron_expr": req.CronExpr, - "type": req.Type, - "content": req.Content, - "script_id": req.ScriptID, - "target_type": req.TargetType, - "timeout": req.Timeout, - "notify_on": req.NotifyOn, - } - - if err := h.db.Model(&job).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - h.db.Preload("Script").First(&job, id) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: job, - }) -} - -// DeleteCronJob 删除定时任务 -// @Summary 删除定时任务 -// @Description 删除指定定时任务 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "任务ID" -// @Success 200 {object} models.Response -// @Router /cron-jobs/{id} [delete] -func (h *AutomationHandler) DeleteCronJob(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.CronJob{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// ToggleCronJob 切换定时任务状态 -// @Summary 切换定时任务状态 -// @Description 启用或禁用定时任务 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "任务ID" -// @Success 200 {object} models.Response{data=models.CronJob} -// @Router /cron-jobs/{id}/toggle [post] -func (h *AutomationHandler) ToggleCronJob(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var job models.CronJob - if err := h.db.First(&job, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "定时任务不存在", - }) - return - } - - job.Enabled = !job.Enabled - if err := h.db.Save(&job).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "操作失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "操作成功", - Data: job, - }) -} - -// ExecuteCronJob 执行定时任务 -// @Summary 执行定时任务 -// @Description 手动执行定时任务 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "任务ID" -// @Success 200 {object} models.Response{data=models.TaskExecution} -// @Router /cron-jobs/{id}/execute [post] -func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var job models.CronJob - if err := h.db.First(&job, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "定时任务不存在", - }) - return - } - - // Resolve the content to execute. For type=script, load the linked Script. - content := job.Content - scriptType := "shell" - if job.Type == "script" && job.ScriptID != nil { - var script models.Script - if err := h.db.First(&script, *job.ScriptID).Error; err == nil { - content = script.Content - scriptType = script.Type - h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1) - } - } - - now := time.Now() - execution := models.TaskExecution{ - TaskType: "cron", - TaskID: uint(id), - TaskName: job.Name, - Status: "running", - TargetType: job.TargetType, - Targets: job.Targets, - StartedAt: &now, - } - if err := h.db.Create(&execution).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建执行记录失败", - }) - return - } - - // Execute locally on the AIOps host. - output, exitCode, duration := runLocal(scriptType, content, job.Timeout) - finishedAt := time.Now() - status := "success" - if exitCode != 0 { - status = "failed" - } - - h.db.Model(&execution).Updates(map[string]interface{}{ - "status": status, - "output": output, - "duration": duration, - "finished_at": finishedAt, - }) - - // Update cron job statistics. - statsUpdates := map[string]interface{}{ - "last_run_at": now, - "last_status": status, - "run_count": job.RunCount + 1, - } - if status == "success" { - statsUpdates["success_count"] = job.SuccessCount + 1 - } else { - statsUpdates["fail_count"] = job.FailCount + 1 - } - h.db.Model(&job).Updates(statsUpdates) - - execution.Output = output - execution.Status = status - execution.Duration = duration - execution.FinishedAt = &finishedAt - - msg := "执行成功" - if status == "failed" { - msg = "执行失败" - } - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: msg, - Data: execution, - }) -} - -// ListScripts 获取脚本列表 -// @Summary 获取脚本列表 -// @Description 获取所有脚本列表 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /scripts [get] -func (h *AutomationHandler) ListScripts(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - var total int64 - h.db.Model(&models.Script{}).Count(&total) - - var scripts []models.Script - h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&scripts) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: scripts, - Total: total, - }, - }) -} - -// GetScript 获取脚本详情 -// @Summary 获取脚本详情 -// @Description 获取指定脚本的详细信息 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "脚本ID" -// @Success 200 {object} models.Response{data=models.Script} -// @Router /scripts/{id} [get] -func (h *AutomationHandler) GetScript(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var script models.Script - if err := h.db.First(&script, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "脚本不存在", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: script, - }) -} - -// CreateScript 创建脚本 -// @Summary 创建脚本 -// @Description 创建新脚本 -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.CreateScriptRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.Script} -// @Router /scripts [post] -func (h *AutomationHandler) CreateScript(c *gin.Context) { - var req models.CreateScriptRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - script := models.Script{ - Name: req.Name, - Code: req.Code, - Description: req.Description, - Type: req.Type, - Content: req.Content, - Params: req.Params, - Timeout: req.Timeout, - } - - if script.Type == "" { - script.Type = "shell" - } - if script.Timeout == 0 { - script.Timeout = 300 - } - - if err := h.db.Create(&script).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: script, - }) -} - -// UpdateScript 更新脚本 -// @Summary 更新脚本 -// @Description 更新指定脚本 -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "脚本ID" -// @Param request body models.CreateScriptRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.Script} -// @Router /scripts/{id} [put] -func (h *AutomationHandler) UpdateScript(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var req models.CreateScriptRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var script models.Script - if err := h.db.First(&script, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "脚本不存在", - }) - return - } - - updates := map[string]interface{}{ - "name": req.Name, - "description": req.Description, - "type": req.Type, - "content": req.Content, - "params": req.Params, - "timeout": req.Timeout, - "version": script.Version + 1, - } - - if err := h.db.Model(&script).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - h.db.First(&script, id) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: script, - }) -} - -// DeleteScript 删除脚本 -// @Summary 删除脚本 -// @Description 删除指定脚本 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "脚本ID" -// @Success 200 {object} models.Response -// @Router /scripts/{id} [delete] -func (h *AutomationHandler) DeleteScript(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.Script{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// ExecuteScript 执行脚本 -// @Summary 执行脚本 -// @Description 在目标服务器上执行脚本 -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "脚本ID" -// @Param request body models.ExecuteScriptRequest true "执行请求" -// @Success 200 {object} models.Response{data=models.TaskExecution} -// @Router /scripts/{id}/execute [post] -func (h *AutomationHandler) ExecuteScript(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var script models.Script - if err := h.db.First(&script, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "脚本不存在", - }) - return - } - - // The request body is optional — when omitted we execute locally. - var req models.ExecuteScriptRequest - _ = c.ShouldBindJSON(&req) - - now := time.Now() - execution := models.TaskExecution{ - TaskType: "script", - TaskID: uint(id), - TaskName: script.Name, - Status: "running", - TargetType: req.TargetType, - StartedAt: &now, - } - if err := h.db.Create(&execution).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建执行记录失败", - }) - return - } - - // Execute locally on the AIOps host. - output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout) - finishedAt := time.Now() - status := "success" - if exitCode != 0 { - status = "failed" - } - - h.db.Model(&execution).Updates(map[string]interface{}{ - "status": status, - "output": output, - "duration": duration, - "finished_at": finishedAt, - }) - h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1) - - execution.Output = output - execution.Status = status - execution.Duration = duration - execution.FinishedAt = &finishedAt - - msg := "执行成功" - if status == "failed" { - msg = "执行失败" - } - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: msg, - Data: execution, - }) -} - -// ListExecutions 获取任务执行记录列表 -// @Summary 获取任务执行记录 -// @Description 获取脚本/定时任务的执行历史记录(支持 task_type / task_id 筛选) -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Param task_type query string false "任务类型 (cron/script)" -// @Param task_id query string false "任务ID" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /task-executions [get] -func (h *AutomationHandler) ListExecutions(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - db := h.db.Model(&models.TaskExecution{}) - if taskType := c.Query("task_type"); taskType != "" { - db = db.Where("task_type = ?", taskType) - } - if taskID := c.Query("task_id"); taskID != "" { - db = db.Where("task_id = ?", taskID) - } - - var total int64 - db.Count(&total) - - var execs []models.TaskExecution - db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&execs) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: execs, - Total: total, - }, - }) -} - -// runPlaybookLocal executes an Ansible playbook YAML locally on the AIOps host. -// It writes the content to a temporary file and invokes `ansible-playbook` -// against localhost with a local connection. If the ansible-playbook binary is -// missing the exec error is captured in the output. Returns combined -// stdout+stderr, the exit code (-1 on failure) and duration in milliseconds. -func runPlaybookLocal(content, inventory string, timeoutSec int) (output string, exitCode int, durationMs int) { - start := time.Now() - timeout := time.Duration(timeoutSec) * time.Second - if timeout <= 0 { - timeout = 600 * time.Second - } - - // Write the playbook YAML to a temporary file. - tmpFile, err := os.CreateTemp("", "ezops-playbook-*.yml") - if err != nil { - return fmt.Sprintf("创建临时文件失败: %v", err), -1, 0 - } - defer os.Remove(tmpFile.Name()) - if _, err := tmpFile.WriteString(content); err != nil { - tmpFile.Close() - return fmt.Sprintf("写入临时文件失败: %v", err), -1, 0 - } - tmpFile.Close() - - // Write an optional inventory file. - inventoryArg := "localhost," - if inventory != "" { - invFile, err := os.CreateTemp("", "ezops-inventory-*.ini") - if err == nil { - if _, werr := invFile.WriteString(inventory); werr == nil { - inventoryArg = invFile.Name() - defer os.Remove(invFile.Name()) - } - invFile.Close() - } - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - args := []string{"-i", inventoryArg} - if inventoryArg == "localhost," { - args = append(args, "--connection=local") - } - args = append(args, tmpFile.Name()) - - cmd := exec.CommandContext(ctx, "ansible-playbook", args...) - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out - runErr := cmd.Run() - - durationMs = int(time.Since(start).Milliseconds()) - output = out.String() - - if ctx.Err() == context.DeadlineExceeded { - output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec) - return output, -1, durationMs - } - if runErr != nil { - if exitError, ok := runErr.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } else { - // Binary not found or other exec error (e.g. ansible-playbook not - // installed). Provide a clear hint instead of an empty output. - exitCode = -1 - if output == "" { - output = fmt.Sprintf("无法执行 ansible-playbook:%v\n(请确认 AIOps 主机已安装 ansible:yum install -y ansible / pip install ansible)", runErr) - } - } - return output, exitCode, durationMs - } - return output, 0, durationMs -} - -// ListPlaybooks 获取Playbook列表 -// @Summary 获取Playbook列表 -// @Description 获取所有Ansible Playbook列表 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /playbooks [get] -func (h *AutomationHandler) ListPlaybooks(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - var total int64 - h.db.Model(&models.Playbook{}).Count(&total) - - var playbooks []models.Playbook - h.db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&playbooks) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: playbooks, - Total: total, - }, - }) -} - -// GetPlaybook 获取Playbook详情 -// @Summary 获取Playbook详情 -// @Description 获取指定Playbook的详细信息 -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Success 200 {object} models.Response{data=models.Playbook} -// @Router /playbooks/{id} [get] -func (h *AutomationHandler) GetPlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var playbook models.Playbook - if err := h.db.First(&playbook, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "Playbook不存在", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: playbook, - }) -} - -// CreatePlaybook 创建Playbook -// @Summary 创建Playbook -// @Description 创建新的Ansible Playbook -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.CreatePlaybookRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.Playbook} -// @Router /playbooks [post] -func (h *AutomationHandler) CreatePlaybook(c *gin.Context) { - var req models.CreatePlaybookRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - playbook := models.Playbook{ - Name: req.Name, - Code: randomCode("pb"), - Description: req.Description, - Content: req.Content, - Category: req.Category, - Tags: req.Tags, - Variables: req.Variables, - Inventory: req.Inventory, - Timeout: req.Timeout, - } - if playbook.Category == "" { - playbook.Category = "deployment" - } - if playbook.Timeout == 0 { - playbook.Timeout = 600 - } - - if err := h.db.Create(&playbook).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: playbook, - }) -} - -// UpdatePlaybook 更新Playbook -// @Summary 更新Playbook -// @Description 更新指定Playbook -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Param request body models.CreatePlaybookRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.Playbook} -// @Router /playbooks/{id} [put] -func (h *AutomationHandler) UpdatePlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var req models.CreatePlaybookRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var playbook models.Playbook - if err := h.db.First(&playbook, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "Playbook不存在", - }) - return - } - - updates := map[string]interface{}{ - "name": req.Name, - "description": req.Description, - "content": req.Content, - "category": req.Category, - "tags": req.Tags, - "variables": req.Variables, - "inventory": req.Inventory, - "timeout": req.Timeout, - } - - if err := h.db.Model(&playbook).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - h.db.First(&playbook, id) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: playbook, - }) -} - -// DeletePlaybook 删除Playbook -// @Summary 删除Playbook -// @Description 删除指定Playbook -// @Tags 自动化运维 -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Success 200 {object} models.Response -// @Router /playbooks/{id} [delete] -func (h *AutomationHandler) DeletePlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.Playbook{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// ExecutePlaybook 执行Playbook -// @Summary 执行Playbook -// @Description 在AIOps主机上本地执行Ansible Playbook -// @Tags 自动化运维 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "Playbook ID" -// @Success 200 {object} models.Response{data=models.TaskExecution} -// @Router /playbooks/{id}/execute [post] -func (h *AutomationHandler) ExecutePlaybook(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var playbook models.Playbook - if err := h.db.First(&playbook, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "Playbook不存在", - }) - return - } - - now := time.Now() - execution := models.TaskExecution{ - TaskType: "playbook", - TaskID: uint(id), - TaskName: playbook.Name, - Status: "running", - StartedAt: &now, - } - if err := h.db.Create(&execution).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建执行记录失败", - }) - return - } - - // Execute the playbook locally on the AIOps host. - output, exitCode, duration := runPlaybookLocal(playbook.Content, playbook.Inventory, playbook.Timeout) - finishedAt := time.Now() - status := "success" - if exitCode != 0 { - status = "failed" - } - - h.db.Model(&execution).Updates(map[string]interface{}{ - "status": status, - "output": output, - "duration": duration, - "finished_at": finishedAt, - }) - h.db.Model(&playbook).UpdateColumn("run_count", playbook.RunCount+1) - - execution.Output = output - execution.Status = status - execution.Duration = duration - execution.FinishedAt = &finishedAt - - msg := "执行成功" - if status == "failed" { - msg = "执行失败" - } - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: msg, - Data: execution, - }) -} diff --git a/backend/internal/api/handlers/automation_cron.go b/backend/internal/api/handlers/automation_cron.go new file mode 100644 index 0000000..f22936f --- /dev/null +++ b/backend/internal/api/handlers/automation_cron.go @@ -0,0 +1,343 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +// ListCronJobs 获取定时任务列表 +// @Summary 获取定时任务列表 +// @Description 获取所有定时任务列表 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /cron-jobs [get] +func (h *AutomationHandler) ListCronJobs(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + var total int64 + h.db.Model(&models.CronJob{}).Count(&total) + + var jobs []models.CronJob + h.db.Preload("Script").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&jobs) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: jobs, + Total: total, + }, + }) +} + +// GetCronJob 获取定时任务详情 +// @Summary 获取定时任务详情 +// @Description 获取指定定时任务的详细信息 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "任务ID" +// @Success 200 {object} models.Response{data=models.CronJob} +// @Router /cron-jobs/{id} [get] +func (h *AutomationHandler) GetCronJob(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var job models.CronJob + if err := h.db.Preload("Script").First(&job, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "定时任务不存在", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: job, + }) +} + +// CreateCronJob 创建定时任务 +// @Summary 创建定时任务 +// @Description 创建新定时任务 +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreateCronJobRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.CronJob} +// @Router /cron-jobs [post] +func (h *AutomationHandler) CreateCronJob(c *gin.Context) { + var req models.CreateCronJobRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + job := models.CronJob{ + Name: req.Name, + Description: req.Description, + CronExpr: req.CronExpr, + Type: req.Type, + Content: req.Content, + ScriptID: req.ScriptID, + TargetType: req.TargetType, + Timeout: req.Timeout, + NotifyOn: req.NotifyOn, + Enabled: true, + } + + if job.Timeout == 0 { + job.Timeout = 300 + } + if job.NotifyOn == "" { + job.NotifyOn = "failure" + } + + if err := h.db.Create(&job).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: job, + }) +} + +// UpdateCronJob 更新定时任务 +// @Summary 更新定时任务 +// @Description 更新指定定时任务 +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "任务ID" +// @Param request body models.CreateCronJobRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.CronJob} +// @Router /cron-jobs/{id} [put] +func (h *AutomationHandler) UpdateCronJob(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var req models.CreateCronJobRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var job models.CronJob + if err := h.db.First(&job, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "定时任务不存在", + }) + return + } + + updates := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "cron_expr": req.CronExpr, + "type": req.Type, + "content": req.Content, + "script_id": req.ScriptID, + "target_type": req.TargetType, + "timeout": req.Timeout, + "notify_on": req.NotifyOn, + } + + if err := h.db.Model(&job).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + h.db.Preload("Script").First(&job, id) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: job, + }) +} + +// DeleteCronJob 删除定时任务 +// @Summary 删除定时任务 +// @Description 删除指定定时任务 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "任务ID" +// @Success 200 {object} models.Response +// @Router /cron-jobs/{id} [delete] +func (h *AutomationHandler) DeleteCronJob(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.CronJob{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// ToggleCronJob 切换定时任务状态 +// @Summary 切换定时任务状态 +// @Description 启用或禁用定时任务 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "任务ID" +// @Success 200 {object} models.Response{data=models.CronJob} +// @Router /cron-jobs/{id}/toggle [post] +func (h *AutomationHandler) ToggleCronJob(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var job models.CronJob + if err := h.db.First(&job, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "定时任务不存在", + }) + return + } + + job.Enabled = !job.Enabled + if err := h.db.Save(&job).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "操作失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "操作成功", + Data: job, + }) +} + +// ExecuteCronJob 执行定时任务 +// @Summary 执行定时任务 +// @Description 手动执行定时任务 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "任务ID" +// @Success 200 {object} models.Response{data=models.TaskExecution} +// @Router /cron-jobs/{id}/execute [post] +func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var job models.CronJob + if err := h.db.First(&job, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "定时任务不存在", + }) + return + } + + // Resolve the content to execute. For type=script, load the linked Script. + content := job.Content + scriptType := "shell" + if job.Type == "script" && job.ScriptID != nil { + var script models.Script + if err := h.db.First(&script, *job.ScriptID).Error; err == nil { + content = script.Content + scriptType = script.Type + h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1) + } + } + + now := time.Now() + execution := models.TaskExecution{ + TaskType: "cron", + TaskID: uint(id), + TaskName: job.Name, + Status: "running", + TargetType: job.TargetType, + Targets: job.Targets, + StartedAt: &now, + } + if err := h.db.Create(&execution).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建执行记录失败", + }) + return + } + + // Execute locally on the AIOps host. + output, exitCode, duration := runLocal(scriptType, content, job.Timeout) + finishedAt := time.Now() + status := "success" + if exitCode != 0 { + status = "failed" + } + + h.db.Model(&execution).Updates(map[string]interface{}{ + "status": status, + "output": output, + "duration": duration, + "finished_at": finishedAt, + }) + + // Update cron job statistics. + statsUpdates := map[string]interface{}{ + "last_run_at": now, + "last_status": status, + "run_count": job.RunCount + 1, + } + if status == "success" { + statsUpdates["success_count"] = job.SuccessCount + 1 + } else { + statsUpdates["fail_count"] = job.FailCount + 1 + } + h.db.Model(&job).Updates(statsUpdates) + + execution.Output = output + execution.Status = status + execution.Duration = duration + execution.FinishedAt = &finishedAt + + msg := "执行成功" + if status == "failed" { + msg = "执行失败" + } + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: msg, + Data: execution, + }) +} diff --git a/backend/internal/api/handlers/automation_playbook.go b/backend/internal/api/handlers/automation_playbook.go new file mode 100644 index 0000000..ebc0c55 --- /dev/null +++ b/backend/internal/api/handlers/automation_playbook.go @@ -0,0 +1,358 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "os/exec" + "strconv" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +// runPlaybookLocal executes an Ansible playbook YAML locally on the AIOps host. +// It writes the content to a temporary file and invokes `ansible-playbook` +// against localhost with a local connection. If the ansible-playbook binary is +// missing the exec error is captured in the output. Returns combined +// stdout+stderr, the exit code (-1 on failure) and duration in milliseconds. +func runPlaybookLocal(content, inventory string, timeoutSec int) (output string, exitCode int, durationMs int) { + start := time.Now() + timeout := time.Duration(timeoutSec) * time.Second + if timeout <= 0 { + timeout = 600 * time.Second + } + + // Write the playbook YAML to a temporary file. + tmpFile, err := os.CreateTemp("", "ezops-playbook-*.yml") + if err != nil { + return fmt.Sprintf("创建临时文件失败: %v", err), -1, 0 + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return fmt.Sprintf("写入临时文件失败: %v", err), -1, 0 + } + tmpFile.Close() + + // Write an optional inventory file. + inventoryArg := "localhost," + if inventory != "" { + invFile, err := os.CreateTemp("", "ezops-inventory-*.ini") + if err == nil { + if _, werr := invFile.WriteString(inventory); werr == nil { + inventoryArg = invFile.Name() + defer os.Remove(invFile.Name()) + } + invFile.Close() + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + args := []string{"-i", inventoryArg} + if inventoryArg == "localhost," { + args = append(args, "--connection=local") + } + args = append(args, tmpFile.Name()) + + cmd := exec.CommandContext(ctx, "ansible-playbook", args...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + runErr := cmd.Run() + + durationMs = int(time.Since(start).Milliseconds()) + output = out.String() + + if ctx.Err() == context.DeadlineExceeded { + output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec) + return output, -1, durationMs + } + if runErr != nil { + if exitError, ok := runErr.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } else { + // Binary not found or other exec error (e.g. ansible-playbook not + // installed). Provide a clear hint instead of an empty output. + exitCode = -1 + if output == "" { + output = fmt.Sprintf("无法执行 ansible-playbook:%v\n(请确认 AIOps 主机已安装 ansible:yum install -y ansible / pip install ansible)", runErr) + } + } + return output, exitCode, durationMs + } + return output, 0, durationMs +} + +// ListPlaybooks 获取Playbook列表 +// @Summary 获取Playbook列表 +// @Description 获取所有Ansible Playbook列表 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /playbooks [get] +func (h *AutomationHandler) ListPlaybooks(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + var total int64 + h.db.Model(&models.Playbook{}).Count(&total) + + var playbooks []models.Playbook + h.db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&playbooks) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: playbooks, + Total: total, + }, + }) +} + +// GetPlaybook 获取Playbook详情 +// @Summary 获取Playbook详情 +// @Description 获取指定Playbook的详细信息 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Success 200 {object} models.Response{data=models.Playbook} +// @Router /playbooks/{id} [get] +func (h *AutomationHandler) GetPlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var playbook models.Playbook + if err := h.db.First(&playbook, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "Playbook不存在", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: playbook, + }) +} + +// CreatePlaybook 创建Playbook +// @Summary 创建Playbook +// @Description 创建新的Ansible Playbook +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreatePlaybookRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.Playbook} +// @Router /playbooks [post] +func (h *AutomationHandler) CreatePlaybook(c *gin.Context) { + var req models.CreatePlaybookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + playbook := models.Playbook{ + Name: req.Name, + Code: randomCode("pb"), + Description: req.Description, + Content: req.Content, + Category: req.Category, + Tags: req.Tags, + Variables: req.Variables, + Inventory: req.Inventory, + Timeout: req.Timeout, + } + if playbook.Category == "" { + playbook.Category = "deployment" + } + if playbook.Timeout == 0 { + playbook.Timeout = 600 + } + + if err := h.db.Create(&playbook).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: playbook, + }) +} + +// UpdatePlaybook 更新Playbook +// @Summary 更新Playbook +// @Description 更新指定Playbook +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Param request body models.CreatePlaybookRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.Playbook} +// @Router /playbooks/{id} [put] +func (h *AutomationHandler) UpdatePlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var req models.CreatePlaybookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var playbook models.Playbook + if err := h.db.First(&playbook, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "Playbook不存在", + }) + return + } + + updates := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "content": req.Content, + "category": req.Category, + "tags": req.Tags, + "variables": req.Variables, + "inventory": req.Inventory, + "timeout": req.Timeout, + } + + if err := h.db.Model(&playbook).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + h.db.First(&playbook, id) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: playbook, + }) +} + +// DeletePlaybook 删除Playbook +// @Summary 删除Playbook +// @Description 删除指定Playbook +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Success 200 {object} models.Response +// @Router /playbooks/{id} [delete] +func (h *AutomationHandler) DeletePlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.Playbook{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// ExecutePlaybook 执行Playbook +// @Summary 执行Playbook +// @Description 在AIOps主机上本地执行Ansible Playbook +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Success 200 {object} models.Response{data=models.TaskExecution} +// @Router /playbooks/{id}/execute [post] +func (h *AutomationHandler) ExecutePlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var playbook models.Playbook + if err := h.db.First(&playbook, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "Playbook不存在", + }) + return + } + + now := time.Now() + execution := models.TaskExecution{ + TaskType: "playbook", + TaskID: uint(id), + TaskName: playbook.Name, + Status: "running", + StartedAt: &now, + } + if err := h.db.Create(&execution).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建执行记录失败", + }) + return + } + + // Execute the playbook locally on the AIOps host. + output, exitCode, duration := runPlaybookLocal(playbook.Content, playbook.Inventory, playbook.Timeout) + finishedAt := time.Now() + status := "success" + if exitCode != 0 { + status = "failed" + } + + h.db.Model(&execution).Updates(map[string]interface{}{ + "status": status, + "output": output, + "duration": duration, + "finished_at": finishedAt, + }) + h.db.Model(&playbook).UpdateColumn("run_count", playbook.RunCount+1) + + execution.Output = output + execution.Status = status + execution.Duration = duration + execution.FinishedAt = &finishedAt + + msg := "执行成功" + if status == "failed" { + msg = "执行失败" + } + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: msg, + Data: execution, + }) +} diff --git a/backend/internal/api/handlers/automation_script.go b/backend/internal/api/handlers/automation_script.go new file mode 100644 index 0000000..fe810c9 --- /dev/null +++ b/backend/internal/api/handlers/automation_script.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +// ListScripts 获取脚本列表 +// @Summary 获取脚本列表 +// @Description 获取所有脚本列表 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /scripts [get] +func (h *AutomationHandler) ListScripts(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + var total int64 + h.db.Model(&models.Script{}).Count(&total) + + var scripts []models.Script + h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&scripts) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: scripts, + Total: total, + }, + }) +} + +// GetScript 获取脚本详情 +// @Summary 获取脚本详情 +// @Description 获取指定脚本的详细信息 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "脚本ID" +// @Success 200 {object} models.Response{data=models.Script} +// @Router /scripts/{id} [get] +func (h *AutomationHandler) GetScript(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var script models.Script + if err := h.db.First(&script, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "脚本不存在", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: script, + }) +} + +// CreateScript 创建脚本 +// @Summary 创建脚本 +// @Description 创建新脚本 +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreateScriptRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.Script} +// @Router /scripts [post] +func (h *AutomationHandler) CreateScript(c *gin.Context) { + var req models.CreateScriptRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + script := models.Script{ + Name: req.Name, + Code: req.Code, + Description: req.Description, + Type: req.Type, + Content: req.Content, + Params: req.Params, + Timeout: req.Timeout, + } + + if script.Type == "" { + script.Type = "shell" + } + if script.Timeout == 0 { + script.Timeout = 300 + } + + if err := h.db.Create(&script).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: script, + }) +} + +// UpdateScript 更新脚本 +// @Summary 更新脚本 +// @Description 更新指定脚本 +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "脚本ID" +// @Param request body models.CreateScriptRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.Script} +// @Router /scripts/{id} [put] +func (h *AutomationHandler) UpdateScript(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var req models.CreateScriptRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var script models.Script + if err := h.db.First(&script, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "脚本不存在", + }) + return + } + + updates := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "type": req.Type, + "content": req.Content, + "params": req.Params, + "timeout": req.Timeout, + "version": script.Version + 1, + } + + if err := h.db.Model(&script).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + h.db.First(&script, id) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: script, + }) +} + +// DeleteScript 删除脚本 +// @Summary 删除脚本 +// @Description 删除指定脚本 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "脚本ID" +// @Success 200 {object} models.Response +// @Router /scripts/{id} [delete] +func (h *AutomationHandler) DeleteScript(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.Script{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// ExecuteScript 执行脚本 +// @Summary 执行脚本 +// @Description 在目标服务器上执行脚本 +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "脚本ID" +// @Param request body models.ExecuteScriptRequest true "执行请求" +// @Success 200 {object} models.Response{data=models.TaskExecution} +// @Router /scripts/{id}/execute [post] +func (h *AutomationHandler) ExecuteScript(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var script models.Script + if err := h.db.First(&script, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "脚本不存在", + }) + return + } + + // The request body is optional — when omitted we execute locally. + var req models.ExecuteScriptRequest + _ = c.ShouldBindJSON(&req) + + now := time.Now() + execution := models.TaskExecution{ + TaskType: "script", + TaskID: uint(id), + TaskName: script.Name, + Status: "running", + TargetType: req.TargetType, + StartedAt: &now, + } + if err := h.db.Create(&execution).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建执行记录失败", + }) + return + } + + // Execute locally on the AIOps host. + output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout) + finishedAt := time.Now() + status := "success" + if exitCode != 0 { + status = "failed" + } + + h.db.Model(&execution).Updates(map[string]interface{}{ + "status": status, + "output": output, + "duration": duration, + "finished_at": finishedAt, + }) + h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1) + + execution.Output = output + execution.Status = status + execution.Duration = duration + execution.FinishedAt = &finishedAt + + msg := "执行成功" + if status == "failed" { + msg = "执行失败" + } + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: msg, + Data: execution, + }) +} + +// ListExecutions 获取任务执行记录列表 +// @Summary 获取任务执行记录 +// @Description 获取脚本/定时任务的执行历史记录(支持 task_type / task_id 筛选) +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param task_type query string false "任务类型 (cron/script)" +// @Param task_id query string false "任务ID" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /task-executions [get] +func (h *AutomationHandler) ListExecutions(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + db := h.db.Model(&models.TaskExecution{}) + if taskType := c.Query("task_type"); taskType != "" { + db = db.Where("task_type = ?", taskType) + } + if taskID := c.Query("task_id"); taskID != "" { + db = db.Where("task_id = ?", taskID) + } + + var total int64 + db.Count(&total) + + var execs []models.TaskExecution + db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&execs) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: execs, + Total: total, + }, + }) +} diff --git a/backend/internal/api/handlers/cloud_accounts.go b/backend/internal/api/handlers/cloud_accounts.go new file mode 100644 index 0000000..7e603e4 --- /dev/null +++ b/backend/internal/api/handlers/cloud_accounts.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// CloudAccountHandler 云账号处理器 +type CloudAccountHandler struct { + db *gorm.DB +} + +// NewCloudAccountHandler 创建云账号处理器 +func NewCloudAccountHandler(db *gorm.DB) *CloudAccountHandler { + return &CloudAccountHandler{db: db} +} + +// ListCloudAccounts 获取云账号列表 +// @Summary 获取云账号列表 +// @Description 获取所有云账号列表 +// @Tags 云账号 +// @Produce json +// @Security Bearer +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /cloud-accounts [get] +func (h *CloudAccountHandler) ListCloudAccounts(c *gin.Context) { + var accounts []models.CloudAccount + h.db.Find(&accounts) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: accounts, + Total: int64(len(accounts)), + }, + }) +} + +// createCloudAccountRequest 创建云账号请求(独立 request struct,避免 CloudAccount.AccessKey/SecretKey 的 json:"-" 导致无法反序列化) +type createCloudAccountRequest struct { + Name string `json:"name"` + Provider string `json:"provider"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + MonthlyCost float64 `json:"monthly_cost"` + Status string `json:"status"` +} + +// CreateCloudAccount 创建云账号 +// @Summary 创建云账号 +// @Description 创建新云账号 +// @Tags 云账号 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body createCloudAccountRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.CloudAccount} +// @Router /cloud-accounts [post] +func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) { + var req createCloudAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Provider) == "" { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "账号名称和云平台不能为空", + }) + return + } + + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + + account := models.CloudAccount{ + Name: strings.TrimSpace(req.Name), + Provider: strings.TrimSpace(req.Provider), + AccessKey: req.AccessKey, + SecretKey: req.SecretKey, + MonthlyCost: req.MonthlyCost, + Status: status, + } + + if err := h.db.Create(&account).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: account, + }) +} + +// UpdateCloudAccount 更新云账号 +// @Summary 更新云账号 +// @Description 更新指定云账号信息 +// @Tags 云账号 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "云账号ID" +// @Param request body createCloudAccountRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.CloudAccount} +// @Router /cloud-accounts/{id} [put] +func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req createCloudAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var account models.CloudAccount + if err := h.db.First(&account, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "云账号不存在", + }) + return + } + + // 只在提供非空值时更新凭证字段 + if strings.TrimSpace(req.Name) != "" { + account.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Provider) != "" { + account.Provider = strings.TrimSpace(req.Provider) + } + if strings.TrimSpace(req.AccessKey) != "" { + account.AccessKey = req.AccessKey + } + if strings.TrimSpace(req.SecretKey) != "" { + account.SecretKey = req.SecretKey + } + if strings.TrimSpace(req.Status) != "" { + account.Status = strings.TrimSpace(req.Status) + } + account.MonthlyCost = req.MonthlyCost + + if err := h.db.Save(&account).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: account, + }) +} + +// DeleteCloudAccount 删除云账号 +// @Summary 删除云账号 +// @Description 删除指定云账号 +// @Tags 云账号 +// @Produce json +// @Security Bearer +// @Param id path int true "云账号ID" +// @Success 200 {object} models.Response +// @Router /cloud-accounts/{id} [delete] +func (h *CloudAccountHandler) DeleteCloudAccount(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.CloudAccount{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + diff --git a/backend/internal/api/handlers/cloud_domain.go b/backend/internal/api/handlers/cloud_domain.go new file mode 100644 index 0000000..1b7e252 --- /dev/null +++ b/backend/internal/api/handlers/cloud_domain.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// DomainHandler 域名处理器 +type DomainHandler struct { + db *gorm.DB +} + +// NewDomainHandler 创建域名处理器 +func NewDomainHandler(db *gorm.DB) *DomainHandler { + return &DomainHandler{db: db} +} + +// ListDomains 获取域名列表 +// @Summary 获取域名列表 +// @Description 获取所有域名列表 +// @Tags 域名 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param keyword query string false "搜索关键词" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /domains [get] +func (h *DomainHandler) ListDomains(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + db := h.db.Model(&models.Domain{}) + if query.Keyword != "" { + db = db.Where("name LIKE ?", "%"+query.Keyword+"%") + } + + var total int64 + db.Count(&total) + + var domains []models.Domain + db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&domains) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: domains, + Total: total, + }, + }) +} + +// CreateDomain 创建域名 +// @Summary 创建域名 +// @Description 创建新域名 +// @Tags 域名 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.Domain true "创建请求" +// @Success 200 {object} models.Response{data=models.Domain} +// @Router /domains [post] +func (h *DomainHandler) CreateDomain(c *gin.Context) { + var req updateDomainRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + if strings.TrimSpace(req.Name) == "" { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "域名不能为空", + }) + return + } + + status := req.Status + if status == "" { + status = "active" + } + + domain := models.Domain{ + Name: strings.TrimSpace(req.Name), + Registrar: req.Registrar, + Status: status, + ExpireDate: parseDateStr(req.ExpireDate), + DNSProvider: req.DNSProvider, + RecordCount: req.RecordCount, + SSLEnabled: req.SSLEnabled, + SSLExpireDate: parseDateStr(req.SSLExpireDate), + } + + if err := h.db.Create(&domain).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: domain, + }) +} + +// updateDomainRequest 域名创建/更新请求(日期字段用字符串接收,避免 RFC3339 解析失败) +type updateDomainRequest struct { + Name string `json:"name"` + Registrar string `json:"registrar"` + Status string `json:"status"` + ExpireDate string `json:"expire_date"` + DNSProvider string `json:"dns_provider"` + RecordCount int `json:"record_count"` + SSLEnabled bool `json:"ssl_enabled"` + SSLExpireDate string `json:"ssl_expire_date"` +} + +// parseDateStr 将 YYYY-MM-DD 格式字符串解析为 *time.Time,空串返回 nil +func parseDateStr(s string) *time.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + return nil + } + return &t +} + +// DeleteDomain 删除域名 +// @Summary 删除域名 +// @Description 删除指定域名 +// @Tags 域名 +// @Produce json +// @Security Bearer +// @Param id path int true "域名ID" +// @Success 200 {object} models.Response +// @Router /domains/{id} [delete] +func (h *DomainHandler) DeleteDomain(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.Domain{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// UpdateDomain 更新域名 +// @Summary 更新域名 +// @Description 更新指定域名信息 +// @Tags 域名 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "域名ID" +// @Param request body updateDomainRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.Domain} +// @Router /domains/{id} [put] +func (h *DomainHandler) UpdateDomain(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req updateDomainRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var domain models.Domain + if err := h.db.First(&domain, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "域名不存在", + }) + return + } + + if strings.TrimSpace(req.Name) != "" { + domain.Name = strings.TrimSpace(req.Name) + } + domain.Registrar = req.Registrar + domain.Status = req.Status + if domain.Status == "" { + domain.Status = "active" + } + domain.ExpireDate = parseDateStr(req.ExpireDate) + domain.DNSProvider = req.DNSProvider + domain.RecordCount = req.RecordCount + domain.SSLEnabled = req.SSLEnabled + domain.SSLExpireDate = parseDateStr(req.SSLExpireDate) + + if err := h.db.Save(&domain).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: domain, + }) +} + diff --git a/backend/internal/api/handlers/cloud_platform.go b/backend/internal/api/handlers/cloud_platform.go new file mode 100644 index 0000000..1d55cf9 --- /dev/null +++ b/backend/internal/api/handlers/cloud_platform.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ==================== 云平台(Platform / Cloud)==================== + +// PlatformHandler 云平台处理器 +type PlatformHandler struct { + db *gorm.DB +} + +func NewPlatformHandler(db *gorm.DB) *PlatformHandler { + return &PlatformHandler{db: db} +} + +// platformRequest 云平台创建/更新请求 +type platformRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Type string `json:"type"` + Status string `json:"status"` + Website string `json:"website"` + Description string `json:"description"` +} + +// ListPlatforms 获取云平台列表 +func (h *PlatformHandler) ListPlatforms(c *gin.Context) { + var platforms []models.Cloud + h.db.Find(&platforms) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: platforms, + Total: int64(len(platforms)), + }, + }) +} + +// CreatePlatform 创建云平台 +func (h *PlatformHandler) CreatePlatform(c *gin.Context) { + var req platformRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "平台名称和编码不能为空"}) + return + } + + cloudType := strings.TrimSpace(req.Type) + if cloudType == "" { + cloudType = "public" + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + + platform := models.Cloud{ + Name: strings.TrimSpace(req.Name), + Code: strings.TrimSpace(req.Code), + Type: cloudType, + Status: status, + Website: req.Website, + Description: req.Description, + } + if err := h.db.Create(&platform).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: platform}) +} + +// UpdatePlatform 更新云平台 +func (h *PlatformHandler) UpdatePlatform(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req platformRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + var platform models.Cloud + if err := h.db.First(&platform, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云平台不存在"}) + return + } + + if strings.TrimSpace(req.Name) != "" { + platform.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Code) != "" { + platform.Code = strings.TrimSpace(req.Code) + } + if strings.TrimSpace(req.Type) != "" { + platform.Type = strings.TrimSpace(req.Type) + } + if strings.TrimSpace(req.Status) != "" { + platform.Status = strings.TrimSpace(req.Status) + } + platform.Website = req.Website + platform.Description = req.Description + + if err := h.db.Save(&platform).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: platform}) +} + +// DeletePlatform 删除云平台 +func (h *PlatformHandler) DeletePlatform(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.Cloud{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + diff --git a/backend/internal/api/handlers/cloud_region.go b/backend/internal/api/handlers/cloud_region.go new file mode 100644 index 0000000..cf4954e --- /dev/null +++ b/backend/internal/api/handlers/cloud_region.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ==================== 区域管理(Region)==================== + +// RegionHandler 区域处理器 +type RegionHandler struct { + db *gorm.DB +} + +func NewRegionHandler(db *gorm.DB) *RegionHandler { + return &RegionHandler{db: db} +} + +// regionRequest 区域创建/更新请求 +type regionRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Provider string `json:"provider"` + Location string `json:"location"` + Status string `json:"status"` + Endpoint string `json:"endpoint"` +} + +// ListRegions 获取区域列表 +func (h *RegionHandler) ListRegions(c *gin.Context) { + var regions []models.Region + h.db.Find(®ions) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: regions, + Total: int64(len(regions)), + }, + }) +} + +// CreateRegion 创建区域 +func (h *RegionHandler) CreateRegion(c *gin.Context) { + var req regionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "区域名称和编码不能为空"}) + return + } + + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + + region := models.Region{ + Name: strings.TrimSpace(req.Name), + Code: strings.TrimSpace(req.Code), + Provider: strings.TrimSpace(req.Provider), + Location: req.Location, + Status: status, + Endpoint: req.Endpoint, + } + if err := h.db.Create(®ion).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: region}) +} + +// UpdateRegion 更新区域 +func (h *RegionHandler) UpdateRegion(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var req regionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + + var region models.Region + if err := h.db.First(®ion, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "区域不存在"}) + return + } + + if strings.TrimSpace(req.Name) != "" { + region.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Code) != "" { + region.Code = strings.TrimSpace(req.Code) + } + if strings.TrimSpace(req.Provider) != "" { + region.Provider = strings.TrimSpace(req.Provider) + } + region.Location = req.Location + if strings.TrimSpace(req.Status) != "" { + region.Status = strings.TrimSpace(req.Status) + } + region.Endpoint = req.Endpoint + + if err := h.db.Save(®ion).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: region}) +} + +// DeleteRegion 删除区域 +func (h *RegionHandler) DeleteRegion(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.db.Delete(&models.Region{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} diff --git a/backend/internal/api/handlers/ldap.go b/backend/internal/api/handlers/ldap.go index abd1979..3d7490f 100644 --- a/backend/internal/api/handlers/ldap.go +++ b/backend/internal/api/handlers/ldap.go @@ -12,7 +12,6 @@ import ( "github.com/gin-gonic/gin" "github.com/go-ldap/ldap/v3" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -318,498 +317,3 @@ func (h *LDAPHandler) TestLDAPConnection(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"success": true, "message": "LDAP连接成功,管理员认证通过"}}) } - -// ListLDAPUsers 列出LDAP用户 -// POST /api/v1/admin/ldap-config/users -func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { - var req struct { - OuDn string `json:"ou_dn"` - } - c.ShouldBindJSON(&req) - - var cfg models.LDAPConfig - if err := h.db.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - conn, err := dialLDAP(&cfg) - if err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - defer conn.Close() - - if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - searchBase := cfg.BaseDN - if req.OuDn != "" { - searchBase = req.OuDn - } - - baseFilter := "(objectClass=inetOrgPerson)" - if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - sf := *cfg.SearchFilter - if strings.Contains(sf, "%s") { - baseFilter = strings.ReplaceAll(sf, "%s", "*") - } else { - baseFilter = sf - } - } - - sr, err := conn.Search(ldap.NewSearchRequest( - searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 200, 0, false, baseFilter, - []string{"uid", "cn", "mail", "dn", "telephoneNumber", "title", "department"}, - nil, - )) - if err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - type LdapUser struct { - DN string `json:"dn"` - UID string `json:"uid"` - CN string `json:"cn"` - Mail string `json:"mail"` - Phone string `json:"phone"` - Title string `json:"title"` - Department string `json:"department"` - } - var users []LdapUser - for _, e := range sr.Entries { - users = append(users, LdapUser{ - DN: e.DN, - UID: e.GetAttributeValue("uid"), - CN: e.GetAttributeValue("cn"), - Mail: e.GetAttributeValue("mail"), - Phone: e.GetAttributeValue("telephoneNumber"), - Title: e.GetAttributeValue("title"), - Department: e.GetAttributeValue("department"), - }) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Data: users}) -} - -// BatchImportLDAP 批量导入LDAP用户 -// POST /api/v1/admin/ldap-config/import -func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) { - var req struct { - Users []struct { - UID string `json:"uid"` - CN string `json:"cn"` - Mail string `json:"mail"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Email string `json:"email"` - } `json:"users"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) - return - } - - imported := 0 - skipped := 0 - for _, lu := range req.Users { - uid := lu.UID - if uid == "" { - uid = lu.Username - } - cn := lu.CN - if cn == "" { - cn = lu.Nickname - } - mail := lu.Mail - if mail == "" { - mail = lu.Email - } - if uid == "" { - skipped++ - continue - } - // Check if already exists - var existing models.User - if err := h.db.Where("username = ?", uid).First(&existing).Error; err == nil { - // Already exists, update info - updates := map[string]interface{}{ - "auth_source": "ldap", - } - if cn != "" { - updates["real_name"] = cn - } - if mail != "" { - updates["email"] = mail - } - h.db.Model(&existing).Updates(updates) - imported++ - continue - } - - // Create new LDAP user - user := models.User{ - Username: uid, - Password: "-", // LDAP users don't have local password - RealName: cn, - Email: mail, - Status: 1, - AuthSource: "ldap", - } - if err := h.db.Create(&user).Error; err != nil { - skipped++ - continue - } - imported++ - } - - // bcrypt import to avoid unused import error - _ = bcrypt.MinCost - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: fmt.Sprintf("导入完成: %d 个用户成功, %d 个跳过", imported, skipped), - Data: gin.H{"imported": imported, "skipped": skipped}, - }) -} - -// ListLDAPOUs 列出 LDAP 组织单元 -// POST /api/v1/admin/ldap-config/ou-list -func (h *LDAPHandler) ListLDAPOUs(c *gin.Context) { - var configs []models.LDAPConfig - if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { - c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) - return - } - cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) - if err != nil { - c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) - return - } - defer conn.Close() - - conn.SetTimeout(5 * time.Second) - if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) - return - } - - searchReq := ldap.NewSearchRequest( - cfg.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - "(objectClass=organizationalUnit)", - []string{"ou", "description"}, - nil, - ) - sr, err := conn.Search(searchReq) - if err != nil { - c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) - return - } - - type OU struct { - DN string `json:"dn"` - Name string `json:"name"` - Description string `json:"description"` - } - var ous []OU - for _, e := range sr.Entries { - ous = append(ous, OU{ - DN: e.DN, - Name: e.GetAttributeValue("ou"), - Description: e.GetAttributeValue("description"), - }) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Data: ous}) -} - -// CreateLDAPOU 创建组织单元 -// POST /api/v1/admin/ldap-config/create-ou -func (h *LDAPHandler) CreateLDAPOU(c *gin.Context) { - var req struct { - OUName string `json:"ou_name"` - ParentDN string `json:"parent_dn"` - Description string `json:"description"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) - return - } - - var configs []models.LDAPConfig - if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) - return - } - cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - - if req.ParentDN == "" { - req.ParentDN = cfg.BaseDN - } - ouDN := fmt.Sprintf("ou=%s,%s", ldap.EscapeFilter(req.OUName), req.ParentDN) - - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) - return - } - defer conn.Close() - conn.SetTimeout(5 * time.Second) - if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) - return - } - - addReq := ldap.NewAddRequest(ouDN, nil) - addReq.Attribute("objectClass", []string{"organizationalUnit", "top"}) - addReq.Attribute("ou", []string{req.OUName}) - if req.Description != "" { - addReq.Attribute("description", []string{req.Description}) - } - if err := conn.Add(addReq); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建OU失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "OU创建成功", Data: gin.H{"dn": ouDN}}) -} - -// CreateLDAPUser 在LDAP中创建用户 -// POST /api/v1/admin/ldap-config/create-user -func (h *LDAPHandler) CreateLDAPUser(c *gin.Context) { - var req struct { - Username string `json:"username"` - Password string `json:"password"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - OUDN string `json:"ou_dn"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) - return - } - if req.Username == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "用户名和密码不能为空"}) - return - } - - var configs []models.LDAPConfig - if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) - return - } - cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - if req.OUDN == "" { - req.OUDN = cfg.BaseDN - } - userDN := fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(req.Username), req.OUDN) - - // Parse user mapping - mapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"} - if cfg.UserMapping != nil { - _ = json.Unmarshal([]byte(*cfg.UserMapping), &mapping) - } - - uidAttr := mapping["username"] - if uidAttr == "" { - uidAttr = "uid" - } - mailAttr := mapping["email"] - if mailAttr == "" { - mailAttr = "mail" - } - - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) - return - } - defer conn.Close() - conn.SetTimeout(5 * time.Second) - if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) - return - } - - addReq := ldap.NewAddRequest(userDN, nil) - addReq.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"}) - addReq.Attribute(uidAttr, []string{req.Username}) - addReq.Attribute("cn", []string{req.DisplayName}) - addReq.Attribute("sn", []string{req.DisplayName}) - if req.Email != "" { - addReq.Attribute(mailAttr, []string{req.Email}) - } - addReq.Attribute("userPassword", []string{req.Password}) - if err := conn.Add(addReq); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建用户失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "用户创建成功", Data: gin.H{"dn": userDN}}) -} - -// DeleteLDAPUser 删除LDAP条目(用户或OU) -// POST /api/v1/admin/ldap-config/delete-user -func (h *LDAPHandler) DeleteLDAPUser(c *gin.Context) { - var req struct { - DN string `json:"dn"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "DN不能为空"}) - return - } - - var configs []models.LDAPConfig - if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) - return - } - cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) - return - } - defer conn.Close() - conn.SetTimeout(5 * time.Second) - if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) - return - } - - delReq := ldap.NewDelRequest(req.DN, nil) - if err := conn.Del(delReq); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) -} - -// UpdateLDAPUser 修改 LDAP 用户属性 -// POST /api/v1/admin/ldap-config/update-user -func (h *LDAPHandler) UpdateLDAPUser(c *gin.Context) { - var req struct { - DN string `json:"dn"` - CN string `json:"cn"` - Mail string `json:"mail"` - Phone string `json:"phone"` - Title string `json:"title"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "DN不能为空"}) - return - } - - var configs []models.LDAPConfig - if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) - return - } - cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) - return - } - defer conn.Close() - conn.SetTimeout(5 * time.Second) - if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) - return - } - - // 先查当前值,做 diff - sr, err := conn.Search(ldap.NewSearchRequest( - req.DN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, - 1, 0, false, "(objectClass=*)", - []string{"cn", "mail", "telephoneNumber", "title"}, - nil, - )) - if err != nil || len(sr.Entries) == 0 { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "用户不存在"}) - return - } - entry := sr.Entries[0] - - modReq := ldap.NewModifyRequest(req.DN, nil) - - // cn - oldCN := entry.GetAttributeValue("cn") - if req.CN != "" && req.CN != oldCN { - modReq.Replace("cn", []string{req.CN}) - modReq.Replace("sn", []string{req.CN}) // sn 同步更新 - } - // mail - oldMail := entry.GetAttributeValue("mail") - if req.Mail != oldMail { - if req.Mail == "" { - oldVals := entry.GetAttributeValues("mail") - if len(oldVals) > 0 { - modReq.Delete("mail", oldVals) - } - } else { - modReq.Replace("mail", []string{req.Mail}) - } - } - // telephoneNumber - oldPhone := entry.GetAttributeValue("telephoneNumber") - if req.Phone != oldPhone { - if req.Phone == "" { - oldVals := entry.GetAttributeValues("telephoneNumber") - if len(oldVals) > 0 { - modReq.Delete("telephoneNumber", oldVals) - } - } else { - modReq.Replace("telephoneNumber", []string{req.Phone}) - } - } - // title - oldTitle := entry.GetAttributeValue("title") - if req.Title != oldTitle { - if req.Title == "" { - oldVals := entry.GetAttributeValues("title") - if len(oldVals) > 0 { - modReq.Delete("title", oldVals) - } - } else { - modReq.Replace("title", []string{req.Title}) - } - } - - if len(modReq.Changes) > 0 { - if err := conn.Modify(modReq); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "修改失败: " + err.Error()}) - return - } - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "修改成功"}) -} diff --git a/backend/internal/api/handlers/ldap_ou.go b/backend/internal/api/handlers/ldap_ou.go new file mode 100644 index 0000000..a2f9dc0 --- /dev/null +++ b/backend/internal/api/handlers/ldap_ou.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "github.com/go-ldap/ldap/v3" +) + +// ListLDAPOUs 列出 LDAP 组织单元 +// POST /api/v1/admin/ldap-config/ou-list +func (h *LDAPHandler) ListLDAPOUs(c *gin.Context) { + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + + searchReq := ldap.NewSearchRequest( + cfg.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=organizationalUnit)", + []string{"ou", "description"}, + nil, + ) + sr, err := conn.Search(searchReq) + if err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: []interface{}{}}) + return + } + + type OU struct { + DN string `json:"dn"` + Name string `json:"name"` + Description string `json:"description"` + } + var ous []OU + for _, e := range sr.Entries { + ous = append(ous, OU{ + DN: e.DN, + Name: e.GetAttributeValue("ou"), + Description: e.GetAttributeValue("description"), + }) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: ous}) +} + +// CreateLDAPOU 创建组织单元 +// POST /api/v1/admin/ldap-config/create-ou +func (h *LDAPHandler) CreateLDAPOU(c *gin.Context) { + var req struct { + OUName string `json:"ou_name"` + ParentDN string `json:"parent_dn"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + if req.ParentDN == "" { + req.ParentDN = cfg.BaseDN + } + ouDN := fmt.Sprintf("ou=%s,%s", ldap.EscapeFilter(req.OUName), req.ParentDN) + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + addReq := ldap.NewAddRequest(ouDN, nil) + addReq.Attribute("objectClass", []string{"organizationalUnit", "top"}) + addReq.Attribute("ou", []string{req.OUName}) + if req.Description != "" { + addReq.Attribute("description", []string{req.Description}) + } + if err := conn.Add(addReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建OU失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "OU创建成功", Data: gin.H{"dn": ouDN}}) +} diff --git a/backend/internal/api/handlers/ldap_users.go b/backend/internal/api/handlers/ldap_users.go new file mode 100644 index 0000000..0c4c105 --- /dev/null +++ b/backend/internal/api/handlers/ldap_users.go @@ -0,0 +1,397 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "github.com/go-ldap/ldap/v3" + "golang.org/x/crypto/bcrypt" +) + +// ListLDAPUsers 列出LDAP用户 +// POST /api/v1/admin/ldap-config/users +func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { + var req struct { + OuDn string `json:"ou_dn"` + } + c.ShouldBindJSON(&req) + + var cfg models.LDAPConfig + if err := h.db.Order("id").First(&cfg).Error; err != nil { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + conn, err := dialLDAP(&cfg) + if err != nil { + c.JSON(http.StatusOK, []interface{}{}) + return + } + defer conn.Close() + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + searchBase := cfg.BaseDN + if req.OuDn != "" { + searchBase = req.OuDn + } + + baseFilter := "(objectClass=inetOrgPerson)" + if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { + sf := *cfg.SearchFilter + if strings.Contains(sf, "%s") { + baseFilter = strings.ReplaceAll(sf, "%s", "*") + } else { + baseFilter = sf + } + } + + sr, err := conn.Search(ldap.NewSearchRequest( + searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 200, 0, false, baseFilter, + []string{"uid", "cn", "mail", "dn", "telephoneNumber", "title", "department"}, + nil, + )) + if err != nil { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + type LdapUser struct { + DN string `json:"dn"` + UID string `json:"uid"` + CN string `json:"cn"` + Mail string `json:"mail"` + Phone string `json:"phone"` + Title string `json:"title"` + Department string `json:"department"` + } + var users []LdapUser + for _, e := range sr.Entries { + users = append(users, LdapUser{ + DN: e.DN, + UID: e.GetAttributeValue("uid"), + CN: e.GetAttributeValue("cn"), + Mail: e.GetAttributeValue("mail"), + Phone: e.GetAttributeValue("telephoneNumber"), + Title: e.GetAttributeValue("title"), + Department: e.GetAttributeValue("department"), + }) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: users}) +} + +// BatchImportLDAP 批量导入LDAP用户 +// POST /api/v1/admin/ldap-config/import +func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) { + var req struct { + Users []struct { + UID string `json:"uid"` + CN string `json:"cn"` + Mail string `json:"mail"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + } `json:"users"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + + imported := 0 + skipped := 0 + for _, lu := range req.Users { + uid := lu.UID + if uid == "" { + uid = lu.Username + } + cn := lu.CN + if cn == "" { + cn = lu.Nickname + } + mail := lu.Mail + if mail == "" { + mail = lu.Email + } + if uid == "" { + skipped++ + continue + } + // Check if already exists + var existing models.User + if err := h.db.Where("username = ?", uid).First(&existing).Error; err == nil { + // Already exists, update info + updates := map[string]interface{}{ + "auth_source": "ldap", + } + if cn != "" { + updates["real_name"] = cn + } + if mail != "" { + updates["email"] = mail + } + h.db.Model(&existing).Updates(updates) + imported++ + continue + } + + // Create new LDAP user + user := models.User{ + Username: uid, + Password: "-", // LDAP users don't have local password + RealName: cn, + Email: mail, + Status: 1, + AuthSource: "ldap", + } + if err := h.db.Create(&user).Error; err != nil { + skipped++ + continue + } + imported++ + } + + // bcrypt import to avoid unused import error + _ = bcrypt.MinCost + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: fmt.Sprintf("导入完成: %d 个用户成功, %d 个跳过", imported, skipped), + Data: gin.H{"imported": imported, "skipped": skipped}, + }) +} + +// CreateLDAPUser 在LDAP中创建用户 +// POST /api/v1/admin/ldap-config/create-user +func (h *LDAPHandler) CreateLDAPUser(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + OUDN string `json:"ou_dn"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()}) + return + } + if req.Username == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "用户名和密码不能为空"}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + if req.OUDN == "" { + req.OUDN = cfg.BaseDN + } + userDN := fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(req.Username), req.OUDN) + + // Parse user mapping + mapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"} + if cfg.UserMapping != nil { + _ = json.Unmarshal([]byte(*cfg.UserMapping), &mapping) + } + + uidAttr := mapping["username"] + if uidAttr == "" { + uidAttr = "uid" + } + mailAttr := mapping["email"] + if mailAttr == "" { + mailAttr = "mail" + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + addReq := ldap.NewAddRequest(userDN, nil) + addReq.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"}) + addReq.Attribute(uidAttr, []string{req.Username}) + addReq.Attribute("cn", []string{req.DisplayName}) + addReq.Attribute("sn", []string{req.DisplayName}) + if req.Email != "" { + addReq.Attribute(mailAttr, []string{req.Email}) + } + addReq.Attribute("userPassword", []string{req.Password}) + if err := conn.Add(addReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建用户失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "用户创建成功", Data: gin.H{"dn": userDN}}) +} + +// DeleteLDAPUser 删除LDAP条目(用户或OU) +// POST /api/v1/admin/ldap-config/delete-user +func (h *LDAPHandler) DeleteLDAPUser(c *gin.Context) { + var req struct { + DN string `json:"dn"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "DN不能为空"}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + delReq := ldap.NewDelRequest(req.DN, nil) + if err := conn.Del(delReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) +} + +// UpdateLDAPUser 修改 LDAP 用户属性 +// POST /api/v1/admin/ldap-config/update-user +func (h *LDAPHandler) UpdateLDAPUser(c *gin.Context) { + var req struct { + DN string `json:"dn"` + CN string `json:"cn"` + Mail string `json:"mail"` + Phone string `json:"phone"` + Title string `json:"title"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "DN不能为空"}) + return + } + + var configs []models.LDAPConfig + if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "LDAP未配置"}) + return + } + cfg := configs[0] + port := 389 + if cfg.LDAPPort != nil { + port = *cfg.LDAPPort + } + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) + return + } + defer conn.Close() + conn.SetTimeout(5 * time.Second) + if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) + return + } + + // 先查当前值,做 diff + sr, err := conn.Search(ldap.NewSearchRequest( + req.DN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, + 1, 0, false, "(objectClass=*)", + []string{"cn", "mail", "telephoneNumber", "title"}, + nil, + )) + if err != nil || len(sr.Entries) == 0 { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "用户不存在"}) + return + } + entry := sr.Entries[0] + + modReq := ldap.NewModifyRequest(req.DN, nil) + // cn + oldCN := entry.GetAttributeValue("cn") + if req.CN != "" && req.CN != oldCN { + modReq.Replace("cn", []string{req.CN}) + modReq.Replace("sn", []string{req.CN}) // sn 同步更新 + } + // mail + oldMail := entry.GetAttributeValue("mail") + if req.Mail != oldMail { + if req.Mail == "" { + oldVals := entry.GetAttributeValues("mail") + if len(oldVals) > 0 { + modReq.Delete("mail", oldVals) + } + } else { + modReq.Replace("mail", []string{req.Mail}) + } + } + // telephoneNumber + oldPhone := entry.GetAttributeValue("telephoneNumber") + if req.Phone != oldPhone { + if req.Phone == "" { + oldVals := entry.GetAttributeValues("telephoneNumber") + if len(oldVals) > 0 { + modReq.Delete("telephoneNumber", oldVals) + } + } else { + modReq.Replace("telephoneNumber", []string{req.Phone}) + } + } + // title + oldTitle := entry.GetAttributeValue("title") + if req.Title != oldTitle { + if req.Title == "" { + oldVals := entry.GetAttributeValues("title") + if len(oldVals) > 0 { + modReq.Delete("title", oldVals) + } + } else { + modReq.Replace("title", []string{req.Title}) + } + } + + if len(modReq.Changes) > 0 { + if err := conn.Modify(modReq); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "修改失败: " + err.Error()}) + return + } + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "修改成功"}) +} diff --git a/backend/internal/api/handlers/server.go b/backend/internal/api/handlers/server.go index a100bda..69ccbb6 100644 --- a/backend/internal/api/handlers/server.go +++ b/backend/internal/api/handlers/server.go @@ -3,8 +3,6 @@ package handlers import ( "net/http" "strconv" - "strings" - "time" "ezops/internal/models" @@ -370,642 +368,3 @@ func (h *ServerHandler) GetServerStats(c *gin.Context) { Data: stats, }) } - -// CloudAccountHandler 云账号处理器 -type CloudAccountHandler struct { - db *gorm.DB -} - -// NewCloudAccountHandler 创建云账号处理器 -func NewCloudAccountHandler(db *gorm.DB) *CloudAccountHandler { - return &CloudAccountHandler{db: db} -} - -// ListCloudAccounts 获取云账号列表 -// @Summary 获取云账号列表 -// @Description 获取所有云账号列表 -// @Tags 云账号 -// @Produce json -// @Security Bearer -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /cloud-accounts [get] -func (h *CloudAccountHandler) ListCloudAccounts(c *gin.Context) { - var accounts []models.CloudAccount - h.db.Find(&accounts) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: accounts, - Total: int64(len(accounts)), - }, - }) -} - -// createCloudAccountRequest 创建云账号请求(独立 request struct,避免 CloudAccount.AccessKey/SecretKey 的 json:"-" 导致无法反序列化) -type createCloudAccountRequest struct { - Name string `json:"name"` - Provider string `json:"provider"` - AccessKey string `json:"access_key"` - SecretKey string `json:"secret_key"` - MonthlyCost float64 `json:"monthly_cost"` - Status string `json:"status"` -} - -// CreateCloudAccount 创建云账号 -// @Summary 创建云账号 -// @Description 创建新云账号 -// @Tags 云账号 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body createCloudAccountRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.CloudAccount} -// @Router /cloud-accounts [post] -func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) { - var req createCloudAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Provider) == "" { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "账号名称和云平台不能为空", - }) - return - } - - status := strings.TrimSpace(req.Status) - if status == "" { - status = "active" - } - - account := models.CloudAccount{ - Name: strings.TrimSpace(req.Name), - Provider: strings.TrimSpace(req.Provider), - AccessKey: req.AccessKey, - SecretKey: req.SecretKey, - MonthlyCost: req.MonthlyCost, - Status: status, - } - - if err := h.db.Create(&account).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: account, - }) -} - -// UpdateCloudAccount 更新云账号 -// @Summary 更新云账号 -// @Description 更新指定云账号信息 -// @Tags 云账号 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "云账号ID" -// @Param request body createCloudAccountRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.CloudAccount} -// @Router /cloud-accounts/{id} [put] -func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - var req createCloudAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var account models.CloudAccount - if err := h.db.First(&account, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "云账号不存在", - }) - return - } - - // 只在提供非空值时更新凭证字段 - if strings.TrimSpace(req.Name) != "" { - account.Name = strings.TrimSpace(req.Name) - } - if strings.TrimSpace(req.Provider) != "" { - account.Provider = strings.TrimSpace(req.Provider) - } - if strings.TrimSpace(req.AccessKey) != "" { - account.AccessKey = req.AccessKey - } - if strings.TrimSpace(req.SecretKey) != "" { - account.SecretKey = req.SecretKey - } - if strings.TrimSpace(req.Status) != "" { - account.Status = strings.TrimSpace(req.Status) - } - account.MonthlyCost = req.MonthlyCost - - if err := h.db.Save(&account).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: account, - }) -} - -// DeleteCloudAccount 删除云账号 -// @Summary 删除云账号 -// @Description 删除指定云账号 -// @Tags 云账号 -// @Produce json -// @Security Bearer -// @Param id path int true "云账号ID" -// @Success 200 {object} models.Response -// @Router /cloud-accounts/{id} [delete] -func (h *CloudAccountHandler) DeleteCloudAccount(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.CloudAccount{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// DomainHandler 域名处理器 -type DomainHandler struct { - db *gorm.DB -} - -// NewDomainHandler 创建域名处理器 -func NewDomainHandler(db *gorm.DB) *DomainHandler { - return &DomainHandler{db: db} -} - -// ListDomains 获取域名列表 -// @Summary 获取域名列表 -// @Description 获取所有域名列表 -// @Tags 域名 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Param keyword query string false "搜索关键词" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /domains [get] -func (h *DomainHandler) ListDomains(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - db := h.db.Model(&models.Domain{}) - if query.Keyword != "" { - db = db.Where("name LIKE ?", "%"+query.Keyword+"%") - } - - var total int64 - db.Count(&total) - - var domains []models.Domain - db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&domains) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: domains, - Total: total, - }, - }) -} - -// CreateDomain 创建域名 -// @Summary 创建域名 -// @Description 创建新域名 -// @Tags 域名 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.Domain true "创建请求" -// @Success 200 {object} models.Response{data=models.Domain} -// @Router /domains [post] -func (h *DomainHandler) CreateDomain(c *gin.Context) { - var req updateDomainRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - if strings.TrimSpace(req.Name) == "" { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "域名不能为空", - }) - return - } - - status := req.Status - if status == "" { - status = "active" - } - - domain := models.Domain{ - Name: strings.TrimSpace(req.Name), - Registrar: req.Registrar, - Status: status, - ExpireDate: parseDateStr(req.ExpireDate), - DNSProvider: req.DNSProvider, - RecordCount: req.RecordCount, - SSLEnabled: req.SSLEnabled, - SSLExpireDate: parseDateStr(req.SSLExpireDate), - } - - if err := h.db.Create(&domain).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: domain, - }) -} - -// updateDomainRequest 域名创建/更新请求(日期字段用字符串接收,避免 RFC3339 解析失败) -type updateDomainRequest struct { - Name string `json:"name"` - Registrar string `json:"registrar"` - Status string `json:"status"` - ExpireDate string `json:"expire_date"` - DNSProvider string `json:"dns_provider"` - RecordCount int `json:"record_count"` - SSLEnabled bool `json:"ssl_enabled"` - SSLExpireDate string `json:"ssl_expire_date"` -} - -// parseDateStr 将 YYYY-MM-DD 格式字符串解析为 *time.Time,空串返回 nil -func parseDateStr(s string) *time.Time { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - t, err := time.Parse("2006-01-02", s) - if err != nil { - return nil - } - return &t -} - -// DeleteDomain 删除域名 -// @Summary 删除域名 -// @Description 删除指定域名 -// @Tags 域名 -// @Produce json -// @Security Bearer -// @Param id path int true "域名ID" -// @Success 200 {object} models.Response -// @Router /domains/{id} [delete] -func (h *DomainHandler) DeleteDomain(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.Domain{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// UpdateDomain 更新域名 -// @Summary 更新域名 -// @Description 更新指定域名信息 -// @Tags 域名 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "域名ID" -// @Param request body updateDomainRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.Domain} -// @Router /domains/{id} [put] -func (h *DomainHandler) UpdateDomain(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - var req updateDomainRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var domain models.Domain - if err := h.db.First(&domain, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "域名不存在", - }) - return - } - - if strings.TrimSpace(req.Name) != "" { - domain.Name = strings.TrimSpace(req.Name) - } - domain.Registrar = req.Registrar - domain.Status = req.Status - if domain.Status == "" { - domain.Status = "active" - } - domain.ExpireDate = parseDateStr(req.ExpireDate) - domain.DNSProvider = req.DNSProvider - domain.RecordCount = req.RecordCount - domain.SSLEnabled = req.SSLEnabled - domain.SSLExpireDate = parseDateStr(req.SSLExpireDate) - - if err := h.db.Save(&domain).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: domain, - }) -} - -// ==================== 云平台(Platform / Cloud)==================== - -// PlatformHandler 云平台处理器 -type PlatformHandler struct { - db *gorm.DB -} - -func NewPlatformHandler(db *gorm.DB) *PlatformHandler { - return &PlatformHandler{db: db} -} - -// platformRequest 云平台创建/更新请求 -type platformRequest struct { - Name string `json:"name"` - Code string `json:"code"` - Type string `json:"type"` - Status string `json:"status"` - Website string `json:"website"` - Description string `json:"description"` -} - -// ListPlatforms 获取云平台列表 -func (h *PlatformHandler) ListPlatforms(c *gin.Context) { - var platforms []models.Cloud - h.db.Find(&platforms) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: platforms, - Total: int64(len(platforms)), - }, - }) -} - -// CreatePlatform 创建云平台 -func (h *PlatformHandler) CreatePlatform(c *gin.Context) { - var req platformRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) - return - } - if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "平台名称和编码不能为空"}) - return - } - - cloudType := strings.TrimSpace(req.Type) - if cloudType == "" { - cloudType = "public" - } - status := strings.TrimSpace(req.Status) - if status == "" { - status = "active" - } - - platform := models.Cloud{ - Name: strings.TrimSpace(req.Name), - Code: strings.TrimSpace(req.Code), - Type: cloudType, - Status: status, - Website: req.Website, - Description: req.Description, - } - if err := h.db.Create(&platform).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: platform}) -} - -// UpdatePlatform 更新云平台 -func (h *PlatformHandler) UpdatePlatform(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - var req platformRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) - return - } - - var platform models.Cloud - if err := h.db.First(&platform, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云平台不存在"}) - return - } - - if strings.TrimSpace(req.Name) != "" { - platform.Name = strings.TrimSpace(req.Name) - } - if strings.TrimSpace(req.Code) != "" { - platform.Code = strings.TrimSpace(req.Code) - } - if strings.TrimSpace(req.Type) != "" { - platform.Type = strings.TrimSpace(req.Type) - } - if strings.TrimSpace(req.Status) != "" { - platform.Status = strings.TrimSpace(req.Status) - } - platform.Website = req.Website - platform.Description = req.Description - - if err := h.db.Save(&platform).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: platform}) -} - -// DeletePlatform 删除云平台 -func (h *PlatformHandler) DeletePlatform(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - if err := h.db.Delete(&models.Cloud{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) -} - -// ==================== 区域管理(Region)==================== - -// RegionHandler 区域处理器 -type RegionHandler struct { - db *gorm.DB -} - -func NewRegionHandler(db *gorm.DB) *RegionHandler { - return &RegionHandler{db: db} -} - -// regionRequest 区域创建/更新请求 -type regionRequest struct { - Name string `json:"name"` - Code string `json:"code"` - Provider string `json:"provider"` - Location string `json:"location"` - Status string `json:"status"` - Endpoint string `json:"endpoint"` -} - -// ListRegions 获取区域列表 -func (h *RegionHandler) ListRegions(c *gin.Context) { - var regions []models.Region - h.db.Find(®ions) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: regions, - Total: int64(len(regions)), - }, - }) -} - -// CreateRegion 创建区域 -func (h *RegionHandler) CreateRegion(c *gin.Context) { - var req regionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) - return - } - if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Code) == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "区域名称和编码不能为空"}) - return - } - - status := strings.TrimSpace(req.Status) - if status == "" { - status = "active" - } - - region := models.Region{ - Name: strings.TrimSpace(req.Name), - Code: strings.TrimSpace(req.Code), - Provider: strings.TrimSpace(req.Provider), - Location: req.Location, - Status: status, - Endpoint: req.Endpoint, - } - if err := h.db.Create(®ion).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败,编码可能已存在"}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: region}) -} - -// UpdateRegion 更新区域 -func (h *RegionHandler) UpdateRegion(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - var req regionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) - return - } - - var region models.Region - if err := h.db.First(®ion, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "区域不存在"}) - return - } - - if strings.TrimSpace(req.Name) != "" { - region.Name = strings.TrimSpace(req.Name) - } - if strings.TrimSpace(req.Code) != "" { - region.Code = strings.TrimSpace(req.Code) - } - if strings.TrimSpace(req.Provider) != "" { - region.Provider = strings.TrimSpace(req.Provider) - } - region.Location = req.Location - if strings.TrimSpace(req.Status) != "" { - region.Status = strings.TrimSpace(req.Status) - } - region.Endpoint = req.Endpoint - - if err := h.db.Save(®ion).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: region}) -} - -// DeleteRegion 删除区域 -func (h *RegionHandler) DeleteRegion(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - if err := h.db.Delete(&models.Region{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"}) -} diff --git a/backend/internal/api/handlers/user.go b/backend/internal/api/handlers/user.go index 585caa3..5b87adc 100644 --- a/backend/internal/api/handlers/user.go +++ b/backend/internal/api/handlers/user.go @@ -1,10 +1,7 @@ package handlers import ( - "fmt" "net/http" - "strconv" - "strings" "ezops/internal/api/middleware" "ezops/internal/config" @@ -361,344 +358,3 @@ func (h *UserHandler) ChangePassword(c *gin.Context) { Message: "密码修改成功", }) } - -// ListUsers 获取用户列表 -// @Summary 获取用户列表 -// @Description 获取所有用户列表(管理员) -// @Tags 用户 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Param keyword query string false "搜索关键词" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /users [get] -func (h *UserHandler) ListUsers(c *gin.Context) { - var query models.PageQuery - if err := c.ShouldBindQuery(&query); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误", - }) - return - } - - db := h.db.Model(&models.User{}) - if query.Keyword != "" { - db = db.Where("username LIKE ? OR email LIKE ? OR real_name LIKE ?", - "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%") - } - - var total int64 - db.Count(&total) - - var users []models.User - db.Preload("Roles").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&users) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: users, - Total: total, - }, - }) -} - -// GetUser 获取用户详情 -// @Summary 获取用户详情 -// @Description 获取指定用户的详细信息 -// @Tags 用户 -// @Produce json -// @Security Bearer -// @Param id path int true "用户ID" -// @Success 200 {object} models.Response{data=models.User} -// @Router /users/{id} [get] -func (h *UserHandler) GetUser(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var user models.User - if err := h.db.Preload("Roles").First(&user, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "用户不存在", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: user, - }) -} - -// CreateUser 创建用户 -// @Summary 创建用户 -// @Description 创建新用户(管理员) -// @Tags 用户 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.CreateUserRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.User} -// @Router /users [post] -func (h *UserHandler) CreateUser(c *gin.Context) { - var req models.CreateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - // 检查用户名是否已存在 - var existingUser models.User - if err := h.db.Where("username = ?", req.Username).First(&existingUser).Error; err == nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "用户名已存在", - }) - return - } - - // 加密密码 - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "密码加密失败", - }) - return - } - - user := models.User{ - Username: req.Username, - Password: string(hashedPassword), - Email: req.Email, - Phone: req.Phone, - RealName: req.RealName, - Status: 1, - } - - if err := h.db.Create(&user).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建用户失败", - }) - return - } - - // 关联角色 - if len(req.RoleIDs) > 0 { - var roles []models.Role - h.db.Find(&roles, req.RoleIDs) - h.db.Model(&user).Association("Roles").Append(roles) - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: user, - }) -} - -// UpdateUser 更新用户 -// @Summary 更新用户 -// @Description 更新指定用户信息(管理员) -// @Tags 用户 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "用户ID" -// @Param request body models.UpdateUserRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.User} -// @Router /users/{id} [put] -func (h *UserHandler) UpdateUser(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var req models.UpdateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var user models.User - if err := h.db.First(&user, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "用户不存在", - }) - return - } - - updates := map[string]interface{}{} - if req.Email != "" { - updates["email"] = req.Email - } - if req.Phone != "" { - updates["phone"] = req.Phone - } - if req.RealName != "" { - updates["real_name"] = req.RealName - } - if req.Status != nil { - updates["status"] = *req.Status - } - - if err := h.db.Model(&user).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - // 更新角色 - if len(req.RoleIDs) > 0 { - var roles []models.Role - h.db.Find(&roles, req.RoleIDs) - h.db.Model(&user).Association("Roles").Replace(roles) - } - - h.db.Preload("Roles").First(&user, id) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: user, - }) -} - -// DeleteUser 删除用户 -// @Summary 删除用户 -// @Description 删除指定用户(管理员) -// @Tags 用户 -// @Produce json -// @Security Bearer -// @Param id path int true "用户ID" -// @Success 200 {object} models.Response -// @Router /users/{id} [delete] -func (h *UserHandler) DeleteUser(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.User{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// BatchImportUsers 批量导入用户 -// POST /api/v1/admin/users/batch-import -func (h *UserHandler) BatchImportUsers(c *gin.Context) { - var req struct { - Users []struct { - Username string `json:"username"` - Nickname string `json:"nickname"` - Email string `json:"email"` - Phone string `json:"phone"` - Password string `json:"password"` - Role string `json:"role"` - } `json:"users"` - SkipExisting bool `json:"skip_existing"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - imported := 0 - skipped := 0 - var errors []string - - for i, u := range req.Users { - if u.Username == "" { - errors = append(errors, fmt.Sprintf("第%d行: 用户名为空", i+1)) - continue - } - // Check if exists - var existing models.User - if err := h.db.Where("username = ?", u.Username).First(&existing).Error; err == nil { - if req.SkipExisting { - skipped++ - continue - } - errors = append(errors, fmt.Sprintf("第%d行: 用户 %s 已存在", i+1, u.Username)) - skipped++ - continue - } - - // Default password - pwd := u.Password - if pwd == "" { - pwd = "admin123" - } - hashed, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) - if err != nil { - errors = append(errors, fmt.Sprintf("第%d行: 密码加密失败", i+1)) - continue - } - - user := models.User{ - Username: u.Username, - Password: string(hashed), - Email: u.Email, - Phone: u.Phone, - RealName: u.Nickname, - Status: 1, - AuthSource: "local", - } - if err := h.db.Create(&user).Error; err != nil { - errors = append(errors, fmt.Sprintf("第%d行: 创建用户 %s 失败", i+1, u.Username)) - continue - } - imported++ - } - - msg := fmt.Sprintf("导入完成: %d 成功, %d 跳过", imported, skipped) - if len(errors) > 0 { - msg += fmt.Sprintf(", %d 个错误", len(errors)) - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: msg, - Data: gin.H{ - "imported": imported, - "skipped": skipped, - "errors": errors, - }, - }) -} - -// ImportTemplate 下载用户导入模板 -// GET /api/v1/admin/users/import-template -func (h *UserHandler) ImportTemplate(c *gin.Context) { - // UTF-8 BOM for Excel compatibility - bom := "\xEF\xBB\xBF" - csv := bom + "用户名,昵称,邮箱,手机号,角色,密码\n" - csv += "zhangsan,张三,zhangsan@example.com,13800138000,developer,123456\n" - csv += "lisi,李四,lisi@example.com,13800138001,developer,123456\n" - - c.Header("Content-Type", "text/csv; charset=utf-8") - c.Header("Content-Disposition", "attachment; filename=users_import_template.csv") - c.String(http.StatusOK, csv) -} - -// ensure strings import is used -var _ = strings.TrimSpace diff --git a/backend/internal/api/handlers/user_admin.go b/backend/internal/api/handlers/user_admin.go new file mode 100644 index 0000000..82c747a --- /dev/null +++ b/backend/internal/api/handlers/user_admin.go @@ -0,0 +1,350 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +// ListUsers 获取用户列表 +// @Summary 获取用户列表 +// @Description 获取所有用户列表(管理员) +// @Tags 用户 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param keyword query string false "搜索关键词" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /users [get] +func (h *UserHandler) ListUsers(c *gin.Context) { + var query models.PageQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误", + }) + return + } + + db := h.db.Model(&models.User{}) + if query.Keyword != "" { + db = db.Where("username LIKE ? OR email LIKE ? OR real_name LIKE ?", + "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%") + } + + var total int64 + db.Count(&total) + + var users []models.User + db.Preload("Roles").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&users) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: users, + Total: total, + }, + }) +} + +// GetUser 获取用户详情 +// @Summary 获取用户详情 +// @Description 获取指定用户的详细信息 +// @Tags 用户 +// @Produce json +// @Security Bearer +// @Param id path int true "用户ID" +// @Success 200 {object} models.Response{data=models.User} +// @Router /users/{id} [get] +func (h *UserHandler) GetUser(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var user models.User + if err := h.db.Preload("Roles").First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "用户不存在", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: user, + }) +} + +// CreateUser 创建用户 +// @Summary 创建用户 +// @Description 创建新用户(管理员) +// @Tags 用户 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreateUserRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.User} +// @Router /users [post] +func (h *UserHandler) CreateUser(c *gin.Context) { + var req models.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + // 检查用户名是否已存在 + var existingUser models.User + if err := h.db.Where("username = ?", req.Username).First(&existingUser).Error; err == nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "用户名已存在", + }) + return + } + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "密码加密失败", + }) + return + } + + user := models.User{ + Username: req.Username, + Password: string(hashedPassword), + Email: req.Email, + Phone: req.Phone, + RealName: req.RealName, + Status: 1, + } + + if err := h.db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建用户失败", + }) + return + } + + // 关联角色 + if len(req.RoleIDs) > 0 { + var roles []models.Role + h.db.Find(&roles, req.RoleIDs) + h.db.Model(&user).Association("Roles").Append(roles) + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: user, + }) +} + +// UpdateUser 更新用户 +// @Summary 更新用户 +// @Description 更新指定用户信息(管理员) +// @Tags 用户 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "用户ID" +// @Param request body models.UpdateUserRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.User} +// @Router /users/{id} [put] +func (h *UserHandler) UpdateUser(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var req models.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var user models.User + if err := h.db.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "用户不存在", + }) + return + } + + updates := map[string]interface{}{} + if req.Email != "" { + updates["email"] = req.Email + } + if req.Phone != "" { + updates["phone"] = req.Phone + } + if req.RealName != "" { + updates["real_name"] = req.RealName + } + if req.Status != nil { + updates["status"] = *req.Status + } + + if err := h.db.Model(&user).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + // 更新角色 + if len(req.RoleIDs) > 0 { + var roles []models.Role + h.db.Find(&roles, req.RoleIDs) + h.db.Model(&user).Association("Roles").Replace(roles) + } + + h.db.Preload("Roles").First(&user, id) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: user, + }) +} + +// DeleteUser 删除用户 +// @Summary 删除用户 +// @Description 删除指定用户(管理员) +// @Tags 用户 +// @Produce json +// @Security Bearer +// @Param id path int true "用户ID" +// @Success 200 {object} models.Response +// @Router /users/{id} [delete] +func (h *UserHandler) DeleteUser(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.User{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// BatchImportUsers 批量导入用户 +// POST /api/v1/admin/users/batch-import +func (h *UserHandler) BatchImportUsers(c *gin.Context) { + var req struct { + Users []struct { + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Phone string `json:"phone"` + Password string `json:"password"` + Role string `json:"role"` + } `json:"users"` + SkipExisting bool `json:"skip_existing"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + imported := 0 + skipped := 0 + var errors []string + + for i, u := range req.Users { + if u.Username == "" { + errors = append(errors, fmt.Sprintf("第%d行: 用户名为空", i+1)) + continue + } + // Check if exists + var existing models.User + if err := h.db.Where("username = ?", u.Username).First(&existing).Error; err == nil { + if req.SkipExisting { + skipped++ + continue + } + errors = append(errors, fmt.Sprintf("第%d行: 用户 %s 已存在", i+1, u.Username)) + skipped++ + continue + } + + // Default password + pwd := u.Password + if pwd == "" { + pwd = "admin123" + } + hashed, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + if err != nil { + errors = append(errors, fmt.Sprintf("第%d行: 密码加密失败", i+1)) + continue + } + + user := models.User{ + Username: u.Username, + Password: string(hashed), + Email: u.Email, + Phone: u.Phone, + RealName: u.Nickname, + Status: 1, + AuthSource: "local", + } + if err := h.db.Create(&user).Error; err != nil { + errors = append(errors, fmt.Sprintf("第%d行: 创建用户 %s 失败", i+1, u.Username)) + continue + } + imported++ + } + + msg := fmt.Sprintf("导入完成: %d 成功, %d 跳过", imported, skipped) + if len(errors) > 0 { + msg += fmt.Sprintf(", %d 个错误", len(errors)) + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: msg, + Data: gin.H{ + "imported": imported, + "skipped": skipped, + "errors": errors, + }, + }) +} + +// ImportTemplate 下载用户导入模板 +// GET /api/v1/admin/users/import-template +func (h *UserHandler) ImportTemplate(c *gin.Context) { + // UTF-8 BOM for Excel compatibility + bom := "\xEF\xBB\xBF" + csv := bom + "用户名,昵称,邮箱,手机号,角色,密码\n" + csv += "zhangsan,张三,zhangsan@example.com,13800138000,developer,123456\n" + csv += "lisi,李四,lisi@example.com,13800138001,developer,123456\n" + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=users_import_template.csv") + c.String(http.StatusOK, csv) +} diff --git a/frontend/src/components/approval/ApprovalFlow.tsx b/frontend/src/components/approval/ApprovalFlow.tsx index 0f48802..fc79dfe 100644 --- a/frontend/src/components/approval/ApprovalFlow.tsx +++ b/frontend/src/components/approval/ApprovalFlow.tsx @@ -2,19 +2,9 @@ import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { Dialog, DialogContent, @@ -24,29 +14,25 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - CheckSquare, - Plus, - Search, - Clock, - CheckCircle, - XCircle, - User, - ArrowRight, - Settings, - Eye, - Edit, GitBranch, + FileText, + CheckCircle2, + XCircle, Loader2, - Filter, + Clock, + Plus, + X, + GitCommit, + Server, + KeyRound, } from 'lucide-react'; +import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; import { approvalApi } from '@/services/api'; +import { ApprovalTemplateTab } from './ApprovalTemplateTab'; +import { ApprovalProcessTab } from './ApprovalProcessTab'; + +// ---- Shared helpers (kept in main for dialog usage) ---- function parseSteps(steps: any) { if (Array.isArray(steps)) return steps; @@ -61,11 +47,10 @@ function parseSteps(steps: any) { return []; } -// 审批类型字典(参考 sql-platform 审批流程) const APPROVAL_TYPE_OPTIONS = [ - { value: 'deploy', label: '发布审批', color: 'var(--dg-blue)' }, - { value: 'server', label: '服务器操作', color: 'var(--dg-cyan)' }, - { value: 'permission', label: '权限申请', color: 'var(--dg-purple)' }, + { value: 'deploy', label: '发布审批', icon: GitCommit, color: 'var(--dg-blue)' }, + { value: 'server', label: '服务器操作', icon: Server, color: 'var(--dg-cyan)' }, + { value: 'permission', label: '权限申请', icon: KeyRound, color: 'var(--dg-purple)' }, ]; const getApprovalTypeLabel = (value: string): string => @@ -74,42 +59,87 @@ const getApprovalTypeLabel = (value: string): string => const getApprovalTypeColor = (value: string): string => APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)'; -// 状态筛选标签 -const STATUS_TABS = [ - { value: 'all', label: '全部' }, - { value: 'pending', label: '待审批' }, - { value: 'approved', label: '已通过' }, - { value: 'rejected', label: '已驳回' }, -]; +function getProgressInfo(instance: any) { + const steps = parseSteps(instance.steps); + const currentIndex = instance.current_step ?? 0; + const total = steps.length; + const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length; + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + return { steps, currentIndex, total, completed, percentage }; +} + +// 渲染审批进度(用于详情弹窗) +function renderStepProgress(instance: any) { + const { steps, currentIndex } = getProgressInfo(instance); + if (steps.length === 0) return null; + return ( +
+ {steps.map((step: any, index: number) => { + const isCompleted = step.status === 'approved' || step.status === 'completed'; + const isCurrent = index === currentIndex; + const isRejected = step.status === 'rejected'; + return ( +
+
+ {isCompleted && } + {isRejected && } + {isCurrent && !isRejected && } + {step.name || step.approver_name || `步骤 ${index + 1}`} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +export function ApprovalFlow() { + const confirm = useConfirm(); + const [activeTab, setActiveTab] = useState<'instances' | 'flows' | 'my-approvals'>('instances'); -export function ApprovalFlowManager() { - const [activeTab, setActiveTab] = useState('instances'); - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [templateTypeFilter, setTemplateTypeFilter] = useState('all'); const [processes, setProcesses] = useState([]); const [templates, setTemplates] = useState([]); + const [myApprovals, setMyApprovals] = useState([]); const [loading, setLoading] = useState(true); - const [selectedInstance, setSelectedInstance] = useState(null); - // 审批操作 Dialog 状态 + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [templateTypeFilter, setTemplateTypeFilter] = useState('all'); + + // 详情弹窗 + const [detailOpen, setDetailOpen] = useState(false); + const [selectedInstance, setSelectedInstance] = useState(null); + + // 审批操作弹窗 const [actionDialogOpen, setActionDialogOpen] = useState(false); const [actionType, setActionType] = useState<'approve' | 'reject'>('approve'); - const [actionTarget, setActionTarget] = useState(null); const [actionComment, setActionComment] = useState(''); const [actionSubmitting, setActionSubmitting] = useState(false); const loadData = async () => { try { setLoading(true); - const [processesData, templatesData] = await Promise.all([ - approvalApi.getProcesses(), - approvalApi.getTemplates(), + const [instancesRes, templatesRes, myRes] = await Promise.all([ + approvalApi.getProcesses().catch(() => null), + approvalApi.getTemplates().catch(() => null), + approvalApi.getProcesses({ status: 'pending' }).catch(() => null), ]); - setProcesses((processesData as any)?.list || (processesData as any) || []); - setTemplates((templatesData as any)?.list || (templatesData as any) || []); + setProcesses(instancesRes?.list || []); + setTemplates(templatesRes?.list || []); + setMyApprovals(myRes?.list || []); } catch (error) { - console.error('Failed to load approval data:', error); + console.error('加载审批数据失败:', error); } finally { setLoading(false); } @@ -119,156 +149,72 @@ export function ApprovalFlowManager() { loadData(); }, []); + // 派生数据 const filteredInstances = processes.filter((instance) => { const matchesSearch = (instance.title || '').toLowerCase().includes(searchQuery.toLowerCase()) || - (instance.applicant_name || '').toLowerCase().includes(searchQuery.toLowerCase()); - const matchesStatus = statusFilter === 'all' || instance.status === statusFilter; + (instance.applicant_name || instance.applicant || '').toLowerCase().includes(searchQuery.toLowerCase()); + const matchesStatus = !statusFilter || instance.status === statusFilter; return matchesSearch && matchesStatus; }); - // 按类型筛选的模板 const filteredTemplates = templates.filter((template) => { - if (templateTypeFilter === 'all') return true; - return template.type === templateTypeFilter || template.category === templateTypeFilter; + return templateTypeFilter === 'all' || template.type === templateTypeFilter; }); - const getStatusBadge = (status: any) => { - const configs: Record = { - pending: { label: '待审批', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock }, - approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle }, - rejected: { label: '已驳回', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle }, - }; - const config = configs[status] || configs.pending; - const Icon = config.icon; - return ( - - - {config.label} - - ); + // 打开详情弹窗 + const handleView = (instance: any) => { + setSelectedInstance(instance); + setDetailOpen(true); }; - // 打开审批操作 Dialog - const openActionDialog = (instance: any, type: 'approve' | 'reject') => { - setActionTarget(instance); + // 打开审批操作弹窗 + const openActionDialog = (type: 'approve' | 'reject', instance: any) => { setActionType(type); + setSelectedInstance(instance); setActionComment(''); setActionDialogOpen(true); }; - // 执行审批操作 + // 确认审批操作 const handleConfirmAction = async () => { - if (!actionTarget) return; + if (!selectedInstance) return; try { setActionSubmitting(true); if (actionType === 'approve') { - await approvalApi.approveProcess(actionTarget.id, actionComment); + await approvalApi.approveProcess(selectedInstance.id, actionComment); + toast.success('审批通过'); } else { - await approvalApi.rejectProcess(actionTarget.id, actionComment); + await approvalApi.rejectProcess(selectedInstance.id, actionComment); + toast.success('已拒绝'); } setActionDialogOpen(false); - setSelectedInstance(null); - setActionTarget(null); loadData(); - } catch (error) { - console.error('Failed to process approval action:', error); + } catch (error: any) { + toast.error('操作失败', { description: error.message }); } finally { setActionSubmitting(false); } }; - // 计算审批进度 - const getProgressInfo = (instance: any) => { - const steps = parseSteps(instance.steps); - const total = steps.length || instance.total_steps || 1; - const current = instance.current_step_index ?? instance.current_step ?? 1; - const currentNum = typeof current === 'number' ? current : 1; - const percent = total > 0 ? Math.min(100, Math.round((currentNum / total) * 100)) : 0; - return { current: currentNum, total, percent }; - }; - - // 模拟审批流程图 - const renderFlowChart = (flow: any) => { - const nodes = parseSteps(flow.steps); - if (nodes.length === 0) { - return
暂无流程节点
; - } - return ( -
- {nodes.map((node: any, index: number) => ( -
-
-

{node.name}

- {node.approvers && ( -

- {Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers} -

- )} -
- {index < nodes.length - 1 && ( - - )} -
- ))} -
- ); - }; - - // 审批步骤可视化(进度条) - const renderStepProgress = (instance: any) => { - const { current, total, percent } = getProgressInfo(instance); - return ( -
-
- - 当前步骤 {current} / {total} - - {percent}% -
-
-
-
-
- ); - }; + // 统计数据 + const pendingCount = processes.filter((p) => p.status === 'pending').length; + const approvedCount = processes.filter((p) => p.status === 'approved').length; + const rejectedCount = processes.filter((p) => p.status === 'rejected').length; + const myPendingCount = myApprovals.filter((m) => m.status === 'pending').length; return (
{/* Page Header */}
-

审批流程

-

工作流管理与审批处理

-
-
- - +

审批中心

+

管理审批流程、实例与待办

+
{/* Stats Cards */} @@ -278,9 +224,7 @@ export function ApprovalFlowManager() {

待审批

-

- {processes.filter((i) => i.status === 'pending').length} -

+

{pendingCount}

@@ -293,12 +237,10 @@ export function ApprovalFlowManager() {

已通过

-

- {processes.filter((i) => i.status === 'approved').length} -

+

{approvedCount}

- +
@@ -307,10 +249,8 @@ export function ApprovalFlowManager() {
-

已驳回

-

- {processes.filter((i) => i.status === 'rejected').length} -

+

已拒绝

+

{rejectedCount}

@@ -322,509 +262,287 @@ export function ApprovalFlowManager() {
-

流程模板

-

{templates.length}

+

我的待办

+

{myPendingCount}

- +
- {/* Tabs */} - - - - 审批实例 - - - 流程模板 - - - 我的审批 - - + {/* Tab Bar */} +
+ {([ + { key: 'instances', label: '审批实例' }, + { key: 'flows', label: '流程模板' }, + { key: 'my-approvals', label: '我的审批' }, + ] as const).map((tab) => ( + + ))} +
- {/* 审批实例 Tab */} - - {/* 状态筛选标签 + 搜索 */} -
- {/* 状态筛选标签 */} -
- {STATUS_TABS.map((tab) => { - const count = - tab.value === 'all' - ? processes.length - : processes.filter((i) => i.status === tab.value).length; - return ( - - ); - })} -
+ {/* Tab Content */} + {activeTab === 'instances' && ( + openActionDialog('approve', instance)} + onReject={(instance) => openActionDialog('reject', instance)} + /> + )} -
- - setSearchQuery(e.target.value)} - className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" - /> -
-
+ {activeTab === 'flows' && ( + + )} - {/* Instances Table */} - - - - - - 流程名称 - 申请人 - 状态 - 审批进度 - 当前节点 - 创建时间 - 操作 - - - - {loading ? ( - - - - 加载中... - - - ) : filteredInstances.length === 0 ? ( - - - 暂无审批实例 - - - ) : ( - filteredInstances.map((instance) => ( - - -
- -
- {instance.title} - {instance.type && ( -

- {getApprovalTypeLabel(instance.type)} -

- )} -
-
-
- -
- - {instance.applicant_name || instance.applicant} -
-
- {getStatusBadge(instance.status)} - {/* 审批进度可视化 */} - - {renderStepProgress(instance)} - - - - {instance.current_step || '--'} - - - - - {instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'} - - - -
- - {instance.status === 'pending' && ( - <> - - - - )} -
-
-
- )) - )} -
-
-
-
-
- - {/* 流程模板 Tab */} - - {/* 类型筛选 */} -
-
- - 类型筛选: -
-
- - {APPROVAL_TYPE_OPTIONS.map((opt) => ( - - ))} -
-
- -
- {filteredTemplates.length === 0 ? ( - - -
- -

暂无流程模板

-

当前筛选条件下没有匹配的模板

-
-
-
- ) : ( - filteredTemplates.map((flow) => ( - - -
-
-
- + {activeTab === 'my-approvals' && ( +
+ {myApprovals.length === 0 ? ( + + +
+ +

暂无待办

+

没有等待你处理的审批

+
+
+
+ ) : ( + myApprovals.map((instance) => { + const { percentage } = getProgressInfo(instance); + return ( + + +
+
+
+
-
- {flow.name} - {flow.type && ( - - {getApprovalTypeLabel(flow.type)} - - )} +
+

{instance.title}

+ + {getApprovalTypeLabel(instance.type)} +
- {flow.description} +

+ 申请人:{instance.applicant_name || instance.applicant || '--'} +

+

+ 提交于 {instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'} +

- - {flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'} - - + {instance.status === 'pending' && ( + <> + + + + )}
- - -
- {renderFlowChart(flow)} +
+
+ 审批进度 + {percentage}% +
+
+
+
-
- 创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'} - {parseSteps(flow.steps).length} 个节点 +
+ {renderStepProgress(instance)}
- )) - )} -
- + ); + }) + )} +
+ )} - {/* 我的审批 Tab */} - - - -
- -

暂无待审批事项

-

当前没有需要您审批的流程

-
-
-
-
- - - {/* 审批实例详情 Dialog */} - !open && setSelectedInstance(null)}> - + {/* 详情弹窗 */} + + - - - {selectedInstance?.title} - - - 申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '} - {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'} - + 审批详情 + 查看审批流程详细信息 - -
- {/* 审批进度条可视化 */} - {selectedInstance && ( -
-
- 审批进度 + {selectedInstance && ( +
+
+

{selectedInstance.title}

+
+ + {getApprovalTypeLabel(selectedInstance.type)} + - 步骤 {getProgressInfo(selectedInstance).current} / {getProgressInfo(selectedInstance).total} + 申请人:{selectedInstance.applicant_name || selectedInstance.applicant || '--'} + + + 提交于 {selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
+
+ + {/* 审批内容 */} + {selectedInstance.content && ( +
+

{selectedInstance.content}

+
+ )} + + {/* 审批进度 */} +
+

审批进度

{renderStepProgress(selectedInstance)}
- )} - {/* Approval Timeline */} -
-

审批记录

-
-
-
-
- -
-
-
-
-
- 提交申请 - - {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'} - -
-

申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}

+ {/* 操作记录 */} + {selectedInstance.actions && ( +
+

操作记录

+
+ {selectedInstance.actions.map((action: any, index: number) => ( +
+ {action.action === 'approve' ? ( + + ) : action.action === 'reject' ? ( + + ) : ( + + )} +
+ + {action.operator_name || action.operator} + + · + + {action.action === 'approve' ? '通过' : action.action === 'reject' ? '拒绝' : '提交'} + + {action.comment && ( +

{action.comment}

+ )} +
+ + {action.created_at ? new Date(action.created_at).toLocaleString() : ''} + +
+ ))}
- -
-
-
- {selectedInstance?.status === 'approved' ? ( - - ) : selectedInstance?.status === 'rejected' ? ( - - ) : ( - - )} -
-
-
-
- {selectedInstance?.current_step || '审批节点'} - - {selectedInstance?.completed_at - ? new Date(selectedInstance.completed_at).toLocaleString() - : '进行中'} - -
-

- {selectedInstance?.status === 'pending' - ? '等待审批人处理' - : selectedInstance?.status === 'approved' - ? '审批已通过' - : '审批已驳回'} -

-
-
-
+ )}
-
- - + )} + {selectedInstance?.status === 'pending' && ( <> - - )} +
- {/* 审批操作 Dialog(含审批意见 textarea) */} + {/* 审批操作弹窗 */} {actionType === 'approve' ? ( <> - + 确认通过审批 ) : ( <> - 确认驳回审批 + 确认拒绝审批 )} - {actionTarget?.title ? `流程:${actionTarget.title}` : '请填写审批意见后确认操作'} + {actionType === 'approve' ? '请填写审批意见后确认通过' : '请填写拒绝原因后确认拒绝'} - -
-
- -