Files
Logan.Li 9b895efee3 fix: 完善多云同步验证与实例统计
- 修复华为云签名、账单字段与区域映射缓存
- 按真实云实例更新账号服务器数并补充并发测试
2026-07-29 06:35:54 +08:00

361 lines
10 KiB
Go
Raw Permalink 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 controller
import (
"net/http"
"strconv"
"time"
"ezops/cloud"
"ezops/model"
"ezops/pkg/crypto"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// CloudSyncHandler 云平台同步处理器
type CloudSyncHandler struct {
db *gorm.DB
crypt *crypto.SecretBox
}
func NewCloudSyncHandler(db *gorm.DB, masterKey string) *CloudSyncHandler {
return &CloudSyncHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
}
// SyncRegions 同步云账号的区域列表到本地 regions 表
// POST /cloud-accounts/:id/sync-regions
func (h *CloudSyncHandler) SyncRegions(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var account model.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "云账号不存在"})
return
}
if account.Status != "active" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "云账号未激活,无法同步"})
return
}
provider := cloud.NewProvider(account.Provider, account.AccessKey, account.SecretKey)
if provider == nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "暂不支持的云平台: " + account.Provider})
return
}
cloudRegions, err := provider.ListRegions()
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "同步区域失败: " + err.Error()})
return
}
// Upsert: 不存在则创建,存在则更新
synced := 0
for _, cr := range cloudRegions {
var region model.Region
result := h.db.Where("code = ? AND provider = ?", cr.RegionID, account.Provider).First(&region)
if result.Error == gorm.ErrRecordNotFound {
// 新区域 → 创建
h.db.Create(&model.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, model.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 model.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "云账号不存在"})
return
}
var regions []model.Region
h.db.Where("provider = ?", account.Provider).Order("name ASC").Find(&regions)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: model.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 model.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "云账号不存在"})
return
}
var req discoverRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
return
}
if len(req.Regions) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请至少选择一个区域"})
return
}
provider := cloud.NewProvider(account.Provider, account.AccessKey, account.SecretKey)
if provider == nil {
c.JSON(http.StatusBadRequest, model.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, model.Response{
Code: 200,
Message: "success",
Data: gin.H{
"list": allInstances,
"total": len(allInstances),
"errors": errors,
},
})
}
// importRequest 导入主机请求
type importInstancesRequest struct {
Instances []cloud.CloudInstance `json:"instances"`
DefaultPort int `json:"default_port"`
CredentialID *uint `json:"credential_id"` // 兼容旧字段:默认 SSH 凭据 // Logan.Li
SSHCredentialID *uint `json:"ssh_credential_id"` // Linux 默认 SSH 凭据 // Logan.Li
RDPCredentialID *uint `json:"rdp_credential_id"` // Windows 默认 RDP 凭据 // Logan.Li
}
// ImportInstances 将发现的云主机实例导入为本地服务器资产
// POST /cloud-accounts/:id/import-instances
func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var account model.CloudAccount
if err := h.db.First(&account, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "云账号不存在"})
return
}
var req importInstancesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
return
}
defaultPort := req.DefaultPort
if defaultPort <= 0 {
defaultPort = 22
}
sshCredentialID := req.SSHCredentialID
if sshCredentialID == nil {
sshCredentialID = req.CredentialID
}
sshCred, sshPlain, hasSSHCred, err := h.loadImportCredential(sshCredentialID, "ssh")
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
rdpCred, rdpPlain, hasRDPCred, err := h.loadImportCredential(req.RDPCredentialID, "rdp")
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
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
}
hostname := cloudImportHostname(inst)
alias := cloudImportAlias(inst)
// 检查是否已存在(优先实例 ID,其次 IP/hostname),重复导入只补空别名,避免覆盖人工命名。 // Logan.Li
var existing model.Server
existingQuery := h.db.Where("ip = ? OR hostname = ?", ip, hostname)
if inst.InstanceID != "" {
existingQuery = h.db.Where("cloud_instance_id = ? OR ip = ? OR hostname = ?", inst.InstanceID, ip, hostname)
}
if err := existingQuery.Take(&existing).Error; err == nil {
updates := map[string]interface{}{
"cloud_instance_name": inst.InstanceName,
"cloud_instance_id": inst.InstanceID,
"description": "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
}
if existing.Alias == "" && alias != "" {
updates["alias"] = alias
}
h.db.Model(&existing).Updates(updates)
skipped++
continue
} else if err != gorm.ErrRecordNotFound {
skipped++
continue
}
// 查找 Region 关联
var region model.Region
var regionID *uint
if h.db.Where("code = ? AND provider = ?", inst.RegionID, account.Provider).First(&region).Error == nil {
regionID = &region.ID
}
osName := inst.OSType
osType := normalizeImportOSType(inst.OSType, inst.InstanceName)
protocol := "ssh"
username := "root"
if osType == "windows" {
protocol = "rdp"
username = "Administrator"
}
if osName == "" {
osName = osType
}
status := "stopped"
if inst.Status == "Running" {
status = "running"
}
ipAddr := inst.PrivateIP
if ipAddr == "" {
ipAddr = inst.PublicIP
}
if ipAddr == "" {
ipAddr = inst.InstanceID
}
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
server := model.Server{
Hostname: hostname,
Alias: alias,
CloudInstanceName: inst.InstanceName,
CloudInstanceID: inst.InstanceID,
IP: ipAddr,
PublicIP: inst.PublicIP,
Port: defaultPort,
Username: username,
AuthType: "password",
OSType: osType,
Protocol: protocol,
RDPPort: 3389,
OS: osName,
CPU: inst.CPU,
Memory: int64(inst.Memory),
Status: status,
RegionID: regionID, // nil = NULL
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
AgentStatus: "offline",
}
if protocol == "rdp" && hasRDPCred {
applyCredential(rdpCred, rdpPlain, &server)
} else if protocol != "rdp" && hasSSHCred {
applyCredential(sshCred, sshPlain, &server)
}
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, model.Response{
Code: 200,
Message: "导入完成",
Data: gin.H{
"imported": imported,
"skipped": skipped,
},
})
}
func cloudImportHostname(inst cloud.CloudInstance) string {
// hostname 保留技术主机名;展示友好名称写入 alias/cloud_instance_name。 // Logan.Li
if inst.Hostname != "" && inst.Hostname != inst.InstanceID {
return inst.Hostname
}
if inst.InstanceName != "" && inst.InstanceName != inst.InstanceID {
return inst.InstanceName
}
if inst.PrivateIP != "" {
return inst.PrivateIP
}
if inst.PublicIP != "" {
return inst.PublicIP
}
if inst.InstanceName != "" {
return inst.InstanceName
}
return inst.InstanceID
}
func cloudImportAlias(inst cloud.CloudInstance) string {
// 云导入初始别名优先使用控制台实例名称,后续人工别名不被同步覆盖。 // Logan.Li
if inst.InstanceName != "" && inst.InstanceName != inst.InstanceID {
return inst.InstanceName
}
return ""
}