security: stop tracking env files; admin gate via VITE_SITE_ADMIN_GATE; Redis/cache/docs
Remove InfoGenie-frontend and Go .env from version control; add .env.example templates; ignore .claude local settings. Admin UI reads site gate from env only. Note: rotate secrets if repo history was ever public. Made-with: Cursor
This commit is contained in:
119
infogenie-backend-go/internal/cache/redis.go
vendored
Normal file
119
infogenie-backend-go/internal/cache/redis.go
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"infogenie-backend/config"
|
||||
)
|
||||
|
||||
// Key suffixes(完整 key = KeyPrefix + suffix)
|
||||
const (
|
||||
KeySite60sDisabled = "site:60s-disabled"
|
||||
KeySite60sSource = "site:60s-source"
|
||||
KeySiteAIModelDisabled = "site:ai-model-disabled"
|
||||
)
|
||||
|
||||
func KeySiteFeatureClicks(section string) string {
|
||||
return "site:feature-clicks:" + section
|
||||
}
|
||||
|
||||
var (
|
||||
rdb *redis.Client
|
||||
keyPrefix string
|
||||
siteCacheTTL time.Duration
|
||||
redisOn bool
|
||||
)
|
||||
|
||||
// Init 在 REDIS_ENABLED 时连接并 Ping;未启用时为空操作
|
||||
func Init(rc config.RedisConfig) error {
|
||||
if !rc.Enabled {
|
||||
redisOn = false
|
||||
rdb = nil
|
||||
return nil
|
||||
}
|
||||
redisOn = true
|
||||
keyPrefix = rc.KeyPrefix
|
||||
siteCacheTTL = rc.SiteTTL
|
||||
if siteCacheTTL <= 0 {
|
||||
siteCacheTTL = 60 * time.Second
|
||||
}
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: rc.Addr,
|
||||
Password: rc.Password,
|
||||
DB: rc.DB,
|
||||
})
|
||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||
return fmt.Errorf("redis: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled 表示已初始化且客户端可用
|
||||
func Enabled() bool {
|
||||
return redisOn && rdb != nil
|
||||
}
|
||||
|
||||
func fullKey(suffix string) string {
|
||||
return keyPrefix + suffix
|
||||
}
|
||||
|
||||
// GetJSON 命中返回 true;未启用或未命中返回 false(err 仅表示 Redis/JSON 异常)
|
||||
func GetJSON(ctx context.Context, suffix string, dest interface{}) (bool, error) {
|
||||
if !Enabled() {
|
||||
return false, nil
|
||||
}
|
||||
val, err := rdb.Get(ctx, fullKey(suffix)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(val, dest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetJSON ttl<=0 时使用配置的 SiteTTL
|
||||
func SetJSON(ctx context.Context, suffix string, v interface{}, ttl time.Duration) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = siteCacheTTL
|
||||
}
|
||||
return rdb.Set(ctx, fullKey(suffix), b, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete 删除若干后缀对应的 key;失败仅打日志
|
||||
func Delete(ctx context.Context, suffixes ...string) {
|
||||
if !Enabled() || len(suffixes) == 0 {
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(suffixes))
|
||||
for _, s := range suffixes {
|
||||
keys = append(keys, fullKey(s))
|
||||
}
|
||||
if err := rdb.Del(ctx, keys...).Err(); err != nil {
|
||||
log.Printf("redis DEL 失败: %v keys=%v", err, keys)
|
||||
}
|
||||
}
|
||||
|
||||
// Ping 未启用时返回 nil
|
||||
func Ping(ctx context.Context) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/model"
|
||||
)
|
||||
@@ -43,6 +45,19 @@ func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
key := cache.KeySiteFeatureClicks(section)
|
||||
var cached struct {
|
||||
Section string `json:"section"`
|
||||
Counts map[string]uint64 `json:"counts"`
|
||||
}
|
||||
if hit, err := cache.GetJSON(ctx, key, &cached); err != nil {
|
||||
log.Printf("redis GET %s: %v", key, err)
|
||||
} else if hit {
|
||||
c.JSON(http.StatusOK, gin.H{"section": cached.Section, "counts": cached.Counts})
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.SiteFeatureCardClick
|
||||
if err := database.DB.Where("section = ?", section).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
@@ -52,7 +67,11 @@ func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
counts[r.ItemID] = r.ClickCount
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"section": section, "counts": counts})
|
||||
payload := gin.H{"section": section, "counts": counts}
|
||||
if err := cache.SetJSON(ctx, key, payload, 0); err != nil {
|
||||
log.Printf("redis SET %s: %v", key, err)
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
type postFeatureCardClickBody struct {
|
||||
@@ -91,5 +110,6 @@ ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()`
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
return
|
||||
}
|
||||
cache.Delete(c.Request.Context(), cache.KeySiteFeatureClicks(section))
|
||||
c.JSON(http.StatusOK, gin.H{"section": section, "item_id": itemID, "count": row.ClickCount})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"infogenie-backend/config"
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/model"
|
||||
)
|
||||
@@ -31,6 +33,17 @@ func siteAdminTokenOK(headerToken string) bool {
|
||||
|
||||
// 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"})
|
||||
@@ -40,7 +53,11 @@ func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.FeatureID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"disabled": ids})
|
||||
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 {
|
||||
@@ -102,6 +119,7 @@ func (h *SiteConfigHandler) Put60sDisabled(c *gin.Context) {
|
||||
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)})
|
||||
}
|
||||
|
||||
@@ -146,14 +164,35 @@ func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label st
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
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 {
|
||||
@@ -195,6 +234,7 @@ func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
@@ -203,6 +243,17 @@ func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
|
||||
|
||||
// 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"})
|
||||
@@ -212,7 +263,11 @@ func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.AppID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"disabled": ids})
|
||||
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 {
|
||||
@@ -274,5 +329,63 @@ func (h *SiteConfigHandler) PutAIModelDisabled(c *gin.Context) {
|
||||
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) != "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"infogenie-backend/config"
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/handler"
|
||||
"infogenie-backend/internal/middleware"
|
||||
@@ -91,6 +93,15 @@ func Setup(r *gin.Engine) {
|
||||
overall = "degraded"
|
||||
}
|
||||
|
||||
redisHealth := gin.H{"enabled": false}
|
||||
if config.Cfg != nil && config.Cfg.Redis.Enabled {
|
||||
if err := cache.Ping(ctx); err != nil {
|
||||
redisHealth = gin.H{"enabled": true, "ok": false, "error": err.Error()}
|
||||
} else {
|
||||
redisHealth = gin.H{"enabled": true, "ok": true}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": overall,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
@@ -99,6 +110,7 @@ func Setup(r *gin.Engine) {
|
||||
"ok": mysqlOK,
|
||||
"status": dbStatus,
|
||||
},
|
||||
"redis": redisHealth,
|
||||
"backend_api": gin.H{
|
||||
"ok": true,
|
||||
},
|
||||
@@ -136,6 +148,7 @@ func Setup(r *gin.Engine) {
|
||||
r.PUT("/api/admin/site/ai-model-disabled", siteH.PutAIModelDisabled)
|
||||
r.GET("/api/admin/site/ai-runtime", aiRtH.GetAIRuntime)
|
||||
r.PUT("/api/admin/site/ai-runtime", aiRtH.PutAIRuntime)
|
||||
r.GET("/api/admin/site/diagnostics", siteH.GetDiagnostics)
|
||||
|
||||
ai := r.Group("/api/aimodelapp")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user