package handler import ( "encoding/json" "fmt" "net/http" "regexp" "strings" "git.zkcoi.com/zkcoi/meshray/internal/model" "git.zkcoi.com/zkcoi/meshray/pkg/shortid" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // DDNSUsageHandler DDNS Usage 管理 Handler type DDNSUsageHandler struct { db *gorm.DB logger *zap.Logger } // NewDDNSUsageHandler 创建 DDNS Usage Handler func NewDDNSUsageHandler(db *gorm.DB, logger *zap.Logger) *DDNSUsageHandler { return &DDNSUsageHandler{ db: db, logger: logger, } } // CreateUsageRequest 创建 Usage 请求 type CreateUsageRequest struct { ServiceID string `json:"service_id"` // DDNS 服务 ID PrefixMode string `json:"prefix_mode"` // "auto" | "custom" CustomPrefix string `json:"custom_prefix"` // 自定义前缀(仅 custom 模式需要) NetworkID uint64 `json:"network_id"` // 绑定的网络 ID NetworkName string `json:"network_name"` // 网络名称(用于显示) } // CreateUsage 创建 DDNS Usage // POST /api/v1/ddns/usages func (h *DDNSUsageHandler) CreateUsage(c *gin.Context) { var req CreateUsageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) return } // 验证必填字段 if req.ServiceID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 DDNS 服务"}) return } if req.PrefixMode != "auto" && req.PrefixMode != "custom" { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的前缀模式"}) return } // 开启事务 tx := h.db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() // 1. 验证 DDNS 服务存在 var service model.ExternalService if err := tx.First(&service, req.ServiceID).Error; err != nil { tx.Rollback() c.JSON(http.StatusNotFound, gin.H{"error": "DDNS 服务不存在"}) return } // 2. 解析配置获取域名 var config map[string]interface{} if err := json.Unmarshal([]byte(service.Config), &config); err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "解析 DDNS 配置失败"}) return } domain, _ := config["domain"].(string) if domain == "" { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": "DDNS 配置缺少域名"}) return } // 3. 根据模式生成前缀 var recordPrefix string if req.PrefixMode == "auto" { // ✅ 算法生成:Base64(NetworkID) recordPrefix = shortid.EncodeID(req.NetworkID) h.logger.Debug("自动生成前缀", zap.Uint64("network_id", req.NetworkID), zap.String("prefix", recordPrefix)) } else if req.PrefixMode == "custom" { // ✅ 用户自定义:验证格式 + 检测占用 if err := validateCustomPrefix(req.CustomPrefix); err != nil { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 检测是否被占用 var count int64 tx.Model(&model.DDNSUsage{}). Where("service_id = ? AND record_prefix = ?", req.ServiceID, req.CustomPrefix). Count(&count) if count > 0 { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{"error": "该前缀已被其他组网占用"}) return } recordPrefix = req.CustomPrefix h.logger.Debug("使用自定义前缀", zap.String("prefix", req.CustomPrefix)) } // 4. 创建 Usage 记录 usage := &model.DDNSUsage{ ID: uuid.New().String(), ProviderID: req.ServiceID, // ✅ 使用 ProviderID 字段 UsageType: "meshseed_sync", // MeshSeed 同步用途 PrefixMode: req.PrefixMode, RecordPrefix: recordPrefix, RecordType: "TXT", // MeshSeed 同步固定使用 TXT 记录 Description: fmt.Sprintf("MeshSeed 同步 - %s", req.NetworkName), IsExclusive: true, // 独占性,一个前缀只能绑定一个网络 } if err := tx.Create(usage).Error; err != nil { tx.Rollback() h.logger.Error("创建 Usage 失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()}) return } // 5. 创建绑定关系 binding := &model.NetworkDDNSBinding{ ID: uuid.New().String(), NetworkID: req.NetworkID, UsageID: usage.ID, ProviderID: req.ServiceID, Status: "active", } if err := tx.Create(binding).Error; err != nil { tx.Rollback() h.logger.Error("创建绑定关系失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "创建绑定失败:" + err.Error()}) return } // 提交事务 if err := tx.Commit().Error; err != nil { h.logger.Error("事务提交失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "提交失败"}) return } // 构造完整域名 fullDomain := fmt.Sprintf("_meshray.%s.%s", recordPrefix, domain) h.logger.Info("DDNS Usage 创建成功", zap.String("usage_id", usage.ID), zap.Uint64("network_id", req.NetworkID), zap.String("full_domain", fullDomain)) c.JSON(http.StatusOK, gin.H{ "message": "创建成功", "data": gin.H{ "id": usage.ID, "provider_id": usage.ProviderID, "prefix_mode": usage.PrefixMode, "record_prefix": recordPrefix, "full_domain": fullDomain, "network_id": req.NetworkID, }, }) } // CheckPrefixOccupied 检查前缀是否被占用 // GET /api/v1/ddns/check-prefix?service_id=xxx&prefix=yyy func (h *DDNSUsageHandler) CheckPrefixOccupied(c *gin.Context) { serviceID := c.Query("service_id") prefix := c.Query("prefix") if serviceID == "" || prefix == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必要参数"}) return } // 查询占用情况 var count int64 h.db.Model(&model.DDNSUsage{}). Where("service_id = ? AND record_prefix = ?", serviceID, prefix). Count(&count) c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "occupied": count > 0, "count": count, }, }) } // GetAvailableUsages 获取可用的 DDNS Usage 列表 // GET /api/v1/ddns/usages/available func (h *DDNSUsageHandler) GetAvailableUsages(c *gin.Context) { serviceID := c.Query("service_id") // 查询所有 TXT 类型的 Usage query := h.db.Where("record_type = ?", "TXT"). Preload("Provider") if serviceID != "" { query = query.Where("service_id = ?", serviceID) } var usages []model.DDNSUsage if err := query.Find(&usages).Error; err != nil { h.logger.Error("查询 Usage 列表失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) return } // 构造返回数据 type UsageVO struct { ID string `json:"id"` ProviderID string `json:"provider_id"` PrefixMode string `json:"prefix_mode"` RecordPrefix string `json:"record_prefix"` RecordType string `json:"record_type"` Description string `json:"description"` IsOccupied bool `json:"is_occupied"` NetworkID *uint64 `json:"network_id,omitempty"` FullDomain string `json:"full_domain"` Provider interface{} `json:"provider,omitempty"` } result := make([]UsageVO, 0, len(usages)) for _, usage := range usages { // 获取完整域名 var provider model.ExternalService h.db.First(&provider, usage.ProviderID) var config map[string]interface{} json.Unmarshal([]byte(provider.Config), &config) domain, _ := config["domain"].(string) fullDomain := fmt.Sprintf("_meshray.%s.%s", usage.RecordPrefix, domain) // 从 NetworkDDNSBinding 表查询关联的 Network ID var networkID *uint64 var binding model.NetworkDDNSBinding if err := h.db.Where("ddns_usage_id = ?", usage.ID).First(&binding).Error; err == nil { networkID = &binding.NetworkID } vo := UsageVO{ ID: usage.ID, ProviderID: usage.ProviderID, PrefixMode: usage.PrefixMode, RecordPrefix: usage.RecordPrefix, RecordType: usage.RecordType, Description: usage.Description, IsOccupied: usage.IsExclusive, NetworkID: networkID, FullDomain: fullDomain, } if usage.Provider != nil { vo.Provider = gin.H{ "id": usage.Provider.ID, "name": usage.Provider.Name, } } result = append(result, vo) } c.JSON(http.StatusOK, gin.H{"data": result}) } // validateCustomPrefix 验证自定义前缀 func validateCustomPrefix(prefix string) error { if prefix == "" { return fmt.Errorf("前缀不能为空") } if len(prefix) > 63 { return fmt.Errorf("前缀不能超过 63 个字符") } // DNS 标签规范 matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, prefix) if !matched { return fmt.Errorf("前缀只能包含字母、数字、下划线和连字符") } // 系统保留字检查 if strings.HasPrefix(prefix, "_meshray") { return fmt.Errorf("_meshray.* 前缀为系统保留,请使用自动生成模式") } return nil }