Files
aiops/backend/internal/api/handlers/cloud_sync.go
T
Logan d0344c2f52 feat: Docker部署 + 云主机导入 + 终端优化
Docker 部署:
- 新增根目录 docker-compose.yml(MySQL+Redis+前后端四容器)
- 新增 frontend/Dockerfile(多阶段构建)和 nginx.conf(SPA+API反代)
- 修复 backend/Dockerfile:GOPROXY国内加速、编译整个cmd包

云主机导入功能:
- 新增 backend/internal/cloud 包:Provider接口 + 阿里云API客户端
- 新增 cloud_sync.go:区域同步/主机发现/导入 API
- 重写 CloudServerImport.tsx:4步流程(选云→选账号→选区域→发现导入)
- 修复导入外键约束失败(region_id/cloud_id 设为 NULL)

终端优化:
- 修复双X关闭按钮重影(showCloseButton=false)
- 修复最大化→最小化不恢复(ResizeObserver + 禁用CSS过渡)

其他:
- GORM AutoMigrate 逐表迁移,避免索引冲突阻塞启动
- Makefile 更新为 AIOps + 新增 docker 命令组
- .env.example 移至 backend 目录
2026-06-18 09:22:13 +08:00

278 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"net/http"
"strconv"
"time"
"ezops/internal/cloud"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// CloudSyncHandler 云平台同步处理器
type CloudSyncHandler struct {
db *gorm.DB
}
func NewCloudSyncHandler(db *gorm.DB) *CloudSyncHandler {
return &CloudSyncHandler{db: db}
}
// SyncRegions 同步云账号的区域列表到本地 regions 表
// POST /cloud-accounts/:id/sync-regions
func (h *CloudSyncHandler) SyncRegions(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
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 account.Status != "active" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "云账号未激活,无法同步"})
return
}
provider := cloud.NewProvider(account.Provider, account.AccessKey, account.SecretKey)
if provider == nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "暂不支持的云平台: " + account.Provider})
return
}
cloudRegions, err := provider.ListRegions()
if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "同步区域失败: " + err.Error()})
return
}
// Upsert: 不存在则创建,存在则更新
synced := 0
for _, cr := range cloudRegions {
var region models.Region
result := h.db.Where("code = ? AND provider = ?", cr.RegionID, account.Provider).First(&region)
if result.Error == gorm.ErrRecordNotFound {
// 新区域 → 创建
h.db.Create(&models.Region{
Name: cr.LocalName,
Code: cr.RegionID,
Provider: account.Provider,
Endpoint: cr.Endpoint,
Status: "active",
})
synced++
} else if result.Error == nil {
// 已存在 → 更新 endpoint 和名称
region.Name = cr.LocalName
region.Endpoint = cr.Endpoint
h.db.Save(&region)
}
}
// 更新账号的 region_count 和 last_sync_at
h.db.Model(&account).Updates(map[string]interface{}{
"region_count": len(cloudRegions),
"last_sync_at": time.Now(),
})
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "同步成功",
Data: gin.H{
"synced": synced,
"total": len(cloudRegions),
},
})
}
// GetAccountRegions 获取指定云账号的区域列表(按 provider 过滤)
// GET /cloud-accounts/:id/regions
func (h *CloudSyncHandler) GetAccountRegions(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var account models.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"})
return
}
var regions []models.Region
h.db.Where("provider = ?", account.Provider).Order("name ASC").Find(&regions)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: models.ListResponse{
List: regions,
Total: int64(len(regions)),
},
})
}
// discoverRequest 发现主机请求
type discoverRequest struct {
Regions []string `json:"regions"`
}
// DiscoverInstances 发现指定区域的云主机实例
// POST /cloud-accounts/:id/discover-instances
func (h *CloudSyncHandler) DiscoverInstances(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var account models.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"})
return
}
var req discoverRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"})
return
}
if len(req.Regions) == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请至少选择一个区域"})
return
}
provider := cloud.NewProvider(account.Provider, account.AccessKey, account.SecretKey)
if provider == nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "暂不支持的云平台"})
return
}
var allInstances []cloud.CloudInstance
var errors []string
for _, regionID := range req.Regions {
instances, err := provider.ListInstances(regionID)
if err != nil {
errors = append(errors, regionID+": "+err.Error())
continue
}
allInstances = append(allInstances, instances...)
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: gin.H{
"list": allInstances,
"total": len(allInstances),
"errors": errors,
},
})
}
// importRequest 导入主机请求
type importRequest struct {
Instances []cloud.CloudInstance `json:"instances"`
}
// ImportInstances 将发现的云主机实例导入为本地服务器资产
// POST /cloud-accounts/:id/import-instances
func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var account models.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"})
return
}
var req importRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"})
return
}
imported := 0
skipped := 0
for _, inst := range req.Instances {
// 以 IP 去重(没有 IP 则用 InstanceID)
ip := inst.PrivateIP
if ip == "" {
ip = inst.PublicIP
}
if ip == "" {
ip = inst.InstanceID
}
// 检查是否已存在(按 IP 或 hostname)
var count int64
h.db.Model(&models.Server{}).Where("ip = ? OR hostname = ?", ip, inst.Hostname).Count(&count)
if count > 0 {
skipped++
continue
}
// 查找 Region 关联
var region models.Region
var regionID *uint
if h.db.Where("code = ? AND provider = ?", inst.RegionID, account.Provider).First(&region).Error == nil {
regionID = &region.ID
}
osName := "linux"
if inst.OSType != "" {
osName = inst.OSType
}
status := "stopped"
if inst.Status == "Running" {
status = "running"
}
ipAddr := inst.PrivateIP
if ipAddr == "" {
ipAddr = inst.PublicIP
}
if ipAddr == "" {
ipAddr = inst.InstanceID
}
hostname := inst.Hostname
if hostname == "" {
hostname = inst.InstanceName
}
if hostname == "" {
hostname = inst.InstanceID
}
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
server := models.Server{
Hostname: hostname,
IP: ipAddr,
PublicIP: inst.PublicIP,
Port: 22,
Username: "root",
OS: osName,
CPU: inst.CPU,
Memory: int64(inst.Memory),
Status: status,
RegionID: regionID, // nil = NULL
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
AgentStatus: "offline",
}
if err := h.db.Create(&server).Error; err == nil {
imported++
} else {
skipped++
}
}
// 更新账号的 server_count
h.db.Model(&account).UpdateColumn("server_count", imported)
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "导入完成",
Data: gin.H{
"imported": imported,
"skipped": skipped,
},
})
}