package handler import ( "crypto/subtle" "errors" "log" "net/http" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "infogenie-backend/config" "infogenie-backend/internal/cache" "infogenie-backend/internal/database" "infogenie-backend/internal/model" ) type SiteConfigHandler struct{} func NewSiteConfigHandler() *SiteConfigHandler { return &SiteConfigHandler{} } func siteAdminTokenOK(headerToken string) bool { if config.Cfg == nil { return false } expected := strings.TrimSpace(config.Cfg.SiteAdminToken) if expected == "" { return false } return subtle.ConstantTimeCompare([]byte(headerToken), []byte(expected)) == 1 } // Get60sDisabled 公开:返回当前隐藏的 60s 功能 id 列表(与前端 item.id 对应) func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) { ctx := c.Request.Context() var cached struct { Disabled []string `json:"disabled"` } if hit, err := cache.GetJSON(ctx, cache.KeySite60sDisabled, &cached); err != nil { log.Printf("redis GET %s: %v", cache.KeySite60sDisabled, err) } else if hit { c.JSON(http.StatusOK, gin.H{"disabled": cached.Disabled}) return } var rows []model.Site60sDisabled if err := database.DB.Order("feature_id").Find(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } ids := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.FeatureID) } payload := gin.H{"disabled": ids} if err := cache.SetJSON(ctx, cache.KeySite60sDisabled, payload, 0); err != nil { log.Printf("redis SET %s: %v", cache.KeySite60sDisabled, err) } c.JSON(http.StatusOK, payload) } type put60sDisabledBody struct { Disabled []string `json:"disabled"` } // Put60sDisabled 需请求头 X-Site-Admin-Token,与后端环境变量 INFOGENIE_SITE_ADMIN_TOKEN 一致(建议与前端管理员口令相同) func (h *SiteConfigHandler) Put60sDisabled(c *gin.Context) { if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "admin_not_configured", "message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN,禁止写入", }) return } if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) { c.JSON(http.StatusForbidden, gin.H{"error": "forbidden", "message": "站点管理员令牌无效"}) return } var body put60sDisabledBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"}) return } if len(body.Disabled) > 512 { c.JSON(http.StatusBadRequest, gin.H{"error": "too_many"}) return } seen := make(map[string]struct{}) clean := make([]string, 0, len(body.Disabled)) for _, raw := range body.Disabled { id := strings.TrimSpace(raw) if id == "" || len(id) > 96 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} clean = append(clean, id) } tx := database.DB.Begin() if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Site60sDisabled{}).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } for _, id := range clean { if err := tx.Create(&model.Site60sDisabled{FeatureID: id}).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } cache.Delete(c.Request.Context(), cache.KeySite60sDisabled) c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)}) } // —— 60s API 上游节点(仅管理员可切换)—— type sixtySrcInfo struct { Base string Label string } var sixtyUpstreamRegistry = map[string]sixtySrcInfo{ "self": {Base: "https://60s.api.shumengya.top", Label: "萌芽节点"}, "official": {Base: "https://60s.viki.moe", Label: "官方节点"}, } func resolve60sUpstream(sourceID string) (id string, info sixtySrcInfo) { id = strings.TrimSpace(sourceID) if id == "" { id = "self" } var ok bool info, ok = sixtyUpstreamRegistry[id] if !ok { id = "self" info = sixtyUpstreamRegistry["self"] } return id, info } // EffectiveSixtyUpstream 返回当前站点配置的 60s API 根地址(读库;无记录或库不可用则回落默认 self) func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label string) { sid := "" if db != nil { var row model.Site60sUpstream if err := db.First(&row, 1).Error; err == nil { sid = row.SourceID } } id, info := resolve60sUpstream(sid) return id, info.Base, info.Label } // Get60sSource 公开:当前站点使用的 60s 上游 base_url(供静态页 iframe 传参) func (h *SiteConfigHandler) Get60sSource(c *gin.Context) { ctx := c.Request.Context() var cached struct { SourceID string `json:"source_id"` BaseURL string `json:"base_url"` Label string `json:"label"` } if hit, err := cache.GetJSON(ctx, cache.KeySite60sSource, &cached); err != nil { log.Printf("redis GET %s: %v", cache.KeySite60sSource, err) } else if hit { c.JSON(http.StatusOK, gin.H{ "source_id": cached.SourceID, "base_url": cached.BaseURL, "label": cached.Label, }) return } var row model.Site60sUpstream _ = database.DB.First(&row, 1).Error sid, info := resolve60sUpstream(row.SourceID) payload := gin.H{ "source_id": sid, "base_url": info.Base, "label": info.Label, } if err := cache.SetJSON(ctx, cache.KeySite60sSource, payload, 0); err != nil { log.Printf("redis SET %s: %v", cache.KeySite60sSource, err) } c.JSON(http.StatusOK, payload) } type put60sSourceBody struct { SourceID string `json:"source_id"` } // Put60sSource 管理员切换节点:source_id 为 self | official func (h *SiteConfigHandler) Put60sSource(c *gin.Context) { if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin_not_configured"}) return } if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) { c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) return } var body put60sSourceBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"}) return } sid := strings.TrimSpace(body.SourceID) if _, ok := sixtyUpstreamRegistry[sid]; !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_source_id"}) return } var row model.Site60sUpstream err := database.DB.First(&row, 1).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { row = model.Site60sUpstream{ID: 1} } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } } row.SourceID = sid if err := database.DB.Save(&row).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } cache.Delete(c.Request.Context(), cache.KeySite60sSource) _, info := resolve60sUpstream(sid) c.JSON(http.StatusOK, gin.H{"ok": true, "source_id": sid, "base_url": info.Base, "label": info.Label}) } // —— AI 应用可见性控制(仅管理员可配置)—— // GetAIModelDisabled 公开:返回当前隐藏的 AI 应用 id 列表(与前端 StaticPageConfig 中 AI_MODEL_APPS 的索引对应) func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) { ctx := c.Request.Context() var cached struct { Disabled []string `json:"disabled"` } if hit, err := cache.GetJSON(ctx, cache.KeySiteAIModelDisabled, &cached); err != nil { log.Printf("redis GET %s: %v", cache.KeySiteAIModelDisabled, err) } else if hit { c.JSON(http.StatusOK, gin.H{"disabled": cached.Disabled}) return } var rows []model.SiteAIModelDisabled if err := database.DB.Order("app_id").Find(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } ids := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.AppID) } payload := gin.H{"disabled": ids} if err := cache.SetJSON(ctx, cache.KeySiteAIModelDisabled, payload, 0); err != nil { log.Printf("redis SET %s: %v", cache.KeySiteAIModelDisabled, err) } c.JSON(http.StatusOK, payload) } type putAIModelDisabledBody struct { Disabled []string `json:"disabled"` } // PutAIModelDisabled 需请求头 X-Site-Admin-Token,与后端环境变量 INFOGENIE_SITE_ADMIN_TOKEN 一致 func (h *SiteConfigHandler) PutAIModelDisabled(c *gin.Context) { if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "admin_not_configured", "message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN,禁止写入", }) return } if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) { c.JSON(http.StatusForbidden, gin.H{"error": "forbidden", "message": "站点管理员令牌无效"}) return } var body putAIModelDisabledBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"}) return } if len(body.Disabled) > 64 { c.JSON(http.StatusBadRequest, gin.H{"error": "too_many"}) return } seen := make(map[string]struct{}) clean := make([]string, 0, len(body.Disabled)) for _, raw := range body.Disabled { id := strings.TrimSpace(raw) if id == "" || len(id) > 96 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} clean = append(clean, id) } tx := database.DB.Begin() if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.SiteAIModelDisabled{}).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } for _, id := range clean { if err := tx.Create(&model.SiteAIModelDisabled{AppID: id}).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"}) return } cache.Delete(c.Request.Context(), cache.KeySiteAIModelDisabled) c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)}) } // GetDiagnostics 返回当前进程解析到的连接配置摘要(不含密码/密钥明文),需 X-Site-Admin-Token func (h *SiteConfigHandler) GetDiagnostics(c *gin.Context) { if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "admin_not_configured", "message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN", }) return } if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) { c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) return } cfg := config.Cfg sixtyID, sixtyBase, sixtyLabel := EffectiveSixtyUpstream(database.DB) redisOut := gin.H{"enabled": cfg.Redis.Enabled} if cfg.Redis.Enabled { redisOut["addr"] = cfg.Redis.Addr redisOut["logical_db"] = cfg.Redis.DB redisOut["key_prefix"] = cfg.Redis.KeyPrefix redisOut["site_cache_ttl_sec"] = int(cfg.Redis.SiteTTL.Seconds()) redisOut["password_configured"] = strings.TrimSpace(cfg.Redis.Password) != "" } c.JSON(http.StatusOK, gin.H{ "app": gin.H{ "env": cfg.Env, "listen_addr": "0.0.0.0:" + cfg.Port, "listen_port": cfg.Port, }, "mysql": gin.H{ "host": cfg.DB.Host, "port": cfg.DB.Port, "database": cfg.DB.Name, "user": cfg.DB.User, "password_configured": strings.TrimSpace(cfg.DB.Password) != "", }, "redis": redisOut, "sixty_upstream": gin.H{ "source_id": sixtyID, "base_url": sixtyBase, "label": sixtyLabel, }, "auth_center": gin.H{ "api_url": cfg.AuthCenter.APIURL, }, "mail": gin.H{ "host": cfg.Mail.Host, "port": cfg.Mail.Port, "username": cfg.Mail.Username, "password_configured": strings.TrimSpace(cfg.Mail.Password) != "", }, }) }