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:
@@ -1,30 +0,0 @@
|
||||
# InfoGenie Go Backend - 开发环境配置
|
||||
APP_ENV=development
|
||||
APP_PORT=5002
|
||||
|
||||
# MySQL 测试数据库
|
||||
DB_HOST=10.1.1.100
|
||||
DB_PORT=3306
|
||||
DB_NAME=infogenie-test
|
||||
DB_USER=infogenie-test
|
||||
DB_PASSWORD=infogenie-test
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8
|
||||
JWT_EXPIRE_DAYS=7
|
||||
|
||||
# 邮件服务
|
||||
MAIL_HOST=smtp.qiye.aliyun.com
|
||||
MAIL_PORT=465
|
||||
MAIL_USERNAME=notice@smyhub.com
|
||||
MAIL_PASSWORD=tyh@19900420
|
||||
|
||||
# AI 配置文件路径
|
||||
AI_CONFIG_PATH=ai_config.json
|
||||
|
||||
# 萌芽账户认证中心
|
||||
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
|
||||
AUTH_CENTER_ADMIN_TOKEN=
|
||||
|
||||
# 站点前台配置(与前端管理员口令一致,用于保存 60s 功能展示开关)
|
||||
INFOGENIE_SITE_ADMIN_TOKEN=shumengya520
|
||||
22
infogenie-backend-go/.env.example
Normal file
22
infogenie-backend-go/.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
APP_ENV=development
|
||||
APP_PORT=5002
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=infogenie-test
|
||||
DB_USER=infogenie-test
|
||||
DB_PASSWORD=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRE_DAYS=7
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=465
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
|
||||
AUTH_CENTER_ADMIN_TOKEN=
|
||||
INFOGENIE_SITE_ADMIN_TOKEN=
|
||||
# REDIS_ENABLED=false
|
||||
# REDIS_ADDR=127.0.0.1:6379
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=10
|
||||
# REDIS_KEY_PREFIX=infogenie:go:v1:
|
||||
# REDIS_SITE_TTL=60
|
||||
7
infogenie-backend-go/.gitignore
vendored
7
infogenie-backend-go/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
# 含数据库密码、SMTP 等,勿提交
|
||||
.env.production
|
||||
.env.local
|
||||
# 含数据库密码、SMTP、JWT、Redis 等,勿提交
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"deepseek": {
|
||||
"api_key": "sk-832f8e5250464de08a31523c7fd712",
|
||||
"api_base": "https://api.deepseek.com",
|
||||
"model": ["deepseek-chat","deepseek-reasoner"]
|
||||
},
|
||||
|
||||
"kimi": {
|
||||
"api_key": "sk-zdg9NBpTlhOcDDpoWfaBKu0KNDdGv18SipORnL2utawja",
|
||||
"api_base": "https://api.moonshot.cn",
|
||||
"model": ["kimi-k2-0905-preview","kimi-k2-0711-preview"]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"infogenie-backend/config"
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/router"
|
||||
)
|
||||
@@ -23,6 +24,13 @@ func main() {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
if err := cache.Init(cfg.Redis); err != nil {
|
||||
log.Fatalf("Redis 初始化失败: %v", err)
|
||||
}
|
||||
if cfg.Redis.Enabled {
|
||||
log.Printf("Redis 已启用: %s DB=%d prefix=%s TTL=%s", cfg.Redis.Addr, cfg.Redis.DB, cfg.Redis.KeyPrefix, cfg.Redis.SiteTTL)
|
||||
}
|
||||
|
||||
if err := database.AutoMigrate(); err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -17,10 +18,21 @@ type AppConfig struct {
|
||||
Mail MailConfig
|
||||
AI AIConfig
|
||||
AuthCenter AuthCenterConfig
|
||||
Redis RedisConfig
|
||||
// SiteAdminToken 与前端管理员口令一致,用于更新站点展示配置(如 60s 功能开关);为空则禁止写入
|
||||
SiteAdminToken string
|
||||
}
|
||||
|
||||
// RedisConfig 可选缓存;Enabled 为 false 时不连接 Redis,行为与未接入缓存时一致
|
||||
type RedisConfig struct {
|
||||
Enabled bool
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
KeyPrefix string
|
||||
SiteTTL time.Duration
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
@@ -67,6 +79,10 @@ const (
|
||||
defaultDevDBName = "infogenie-test"
|
||||
defaultDevDBUser = "infogenie-test"
|
||||
defaultDevDBPassword = "infogenie-test"
|
||||
|
||||
defaultDevRedisAddr = "10.1.1.100:6379"
|
||||
defaultRedisDB = 10
|
||||
defaultRedisKeyPrefix = "infogenie:go:v1:"
|
||||
)
|
||||
|
||||
func Load() (*AppConfig, error) {
|
||||
@@ -129,6 +145,12 @@ func Load() (*AppConfig, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redisCfg, err := loadRedisConfig(env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Redis = redisCfg
|
||||
|
||||
// AI配置现在完全从数据库读取,不再加载ai_config.json文件
|
||||
cfg.AI = AIConfig{Providers: make(map[string]AIProviderConfig)}
|
||||
|
||||
@@ -136,6 +158,53 @@ func Load() (*AppConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadRedisConfig(env string) (RedisConfig, error) {
|
||||
if !parseBoolEnv(os.Getenv("REDIS_ENABLED")) {
|
||||
return RedisConfig{}, nil
|
||||
}
|
||||
var addr string
|
||||
var err error
|
||||
if env == envProduction {
|
||||
addr, err = getEnvByEnvironment(env, "REDIS_ADDR", "")
|
||||
if err != nil {
|
||||
return RedisConfig{}, err
|
||||
}
|
||||
} else {
|
||||
addr = getEnv("REDIS_ADDR", defaultDevRedisAddr)
|
||||
}
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return RedisConfig{}, fmt.Errorf("已启用 REDIS_ENABLED 但 REDIS_ADDR 为空")
|
||||
}
|
||||
dbIdx, err := strconv.Atoi(strings.TrimSpace(getEnv("REDIS_DB", strconv.Itoa(defaultRedisDB))))
|
||||
if err != nil || dbIdx < 0 {
|
||||
return RedisConfig{}, fmt.Errorf("无效的 REDIS_DB: %s", getEnv("REDIS_DB", ""))
|
||||
}
|
||||
ttlSec, err := strconv.Atoi(strings.TrimSpace(getEnv("REDIS_SITE_TTL", "60")))
|
||||
if err != nil || ttlSec < 1 {
|
||||
return RedisConfig{}, fmt.Errorf("无效的 REDIS_SITE_TTL: %s", getEnv("REDIS_SITE_TTL", ""))
|
||||
}
|
||||
prefix := strings.TrimSpace(getEnv("REDIS_KEY_PREFIX", defaultRedisKeyPrefix))
|
||||
if prefix == "" {
|
||||
prefix = defaultRedisKeyPrefix
|
||||
}
|
||||
if !strings.HasSuffix(prefix, ":") {
|
||||
prefix += ":"
|
||||
}
|
||||
return RedisConfig{
|
||||
Enabled: true,
|
||||
Addr: addr,
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: dbIdx,
|
||||
KeyPrefix: prefix,
|
||||
SiteTTL: time.Duration(ttlSec) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseBoolEnv(raw string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
}
|
||||
|
||||
func loadEnvFile(env string) error {
|
||||
envFile := fmt.Sprintf(".env.%s", env)
|
||||
if _, err := os.Stat(envFile); err == nil {
|
||||
|
||||
@@ -2,26 +2,33 @@ module infogenie-backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/cors v1.7.6 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -34,12 +41,11 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
@@ -18,6 +27,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
@@ -30,8 +41,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
@@ -54,11 +65,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -68,12 +82,20 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
@@ -89,6 +111,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
|
||||
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")
|
||||
{
|
||||
|
||||
84
infogenie-backend-go/scripts/redis_keyspace/main.go
Normal file
84
infogenie-backend-go/scripts/redis_keyspace/main.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 用法: go run . <host:port> [password]
|
||||
// 例: go run . 10.1.1.100:6379
|
||||
func main() {
|
||||
addr := "10.1.1.100:6379"
|
||||
pass := os.Getenv("REDIS_PASSWORD")
|
||||
if len(os.Args) >= 2 {
|
||||
addr = os.Args[1]
|
||||
}
|
||||
if len(os.Args) >= 3 {
|
||||
pass = os.Args[2]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r := redis.NewClient(&redis.Options{Addr: addr, Password: pass})
|
||||
defer r.Close()
|
||||
if err := r.Ping(ctx).Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "连接失败 %s: %v\n", addr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s, err := r.Info(ctx, "keyspace").Result()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "INFO keyspace: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
used := make(map[int]int64)
|
||||
for _, line := range strings.Split(s, "\r\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "db") {
|
||||
continue
|
||||
}
|
||||
colon := strings.IndexByte(line, ':')
|
||||
if colon <= 2 {
|
||||
continue
|
||||
}
|
||||
dbStr := line[2:colon]
|
||||
dbNum, err := strconv.Atoi(dbStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var keys int64
|
||||
for _, part := range strings.Split(line[colon+1:], ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "keys=") {
|
||||
keys, _ = strconv.ParseInt(strings.TrimPrefix(part, "keys="), 10, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
used[dbNum] = keys
|
||||
}
|
||||
|
||||
fmt.Printf("Redis %s — 逻辑库 key 统计(INFO keyspace)\n\n", addr)
|
||||
for i := 0; i < 16; i++ {
|
||||
k, ok := used[i]
|
||||
if !ok {
|
||||
fmt.Printf(" db%-2d : 无记录(通常为 0 个 key / 未写入过)\n", i)
|
||||
} else if k == 0 {
|
||||
fmt.Printf(" db%-2d : 0 keys\n", i)
|
||||
} else {
|
||||
fmt.Printf(" db%-2d : %d keys (已占用)\n", i, k)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("说明: Redis 默认 16 个逻辑库(0-15);「无记录」一般可当空闲选用。")
|
||||
fmt.Println("若 redis.conf 里 databases=N,实际库数量可能更大,可用 redis-cli CONFIG GET databases 查看。")
|
||||
}
|
||||
@@ -1,117 +1,173 @@
|
||||
# 万象口袋 — Go 后端文档
|
||||
|
||||
**技术栈**:Go 1.25+ · Gin · GORM · MySQL
|
||||
|
||||
**模块路径**:`infogenie-backend`(见 `go.mod`)
|
||||
|
||||
**入口**:`cmd/server/main.go` — 加载配置、连接数据库、`AutoMigrate`、启动 HTTP 服务。
|
||||
|
||||
---
|
||||
|
||||
## 运行与配置
|
||||
|
||||
- 环境由 **`APP_ENV`** 决定:`development` 或 `production`(见 `config.Load()`)。
|
||||
- 若存在 **`.env.development`** / **`.env.production`**,会通过 `godotenv` 加载对应文件。
|
||||
- **`APP_PORT`** 默认 **5002**(与前端 `REACT_APP_API_URL` 开发默认一致)。
|
||||
- 数据库、邮件、认证中心、`INFOGENIE_SITE_ADMIN_TOKEN` 等从环境变量读取,详见 `config/config.go`。
|
||||
|
||||
**健康检查**:`GET /api/health` — 返回服务状态与数据库 `Ping` 结果。
|
||||
|
||||
**根路径**:`GET /` — 返回服务说明与主要 endpoint 分组(`version` 当前为 **3.3.0-go**)。
|
||||
|
||||
---
|
||||
|
||||
## 数据库(GORM AutoMigrate)
|
||||
|
||||
启动时会迁移以下模型(见 `internal/database/mysql.go`):
|
||||
|
||||
| 模型 | 用途 |
|
||||
|------|------|
|
||||
| `AIConfig` | 多厂商 AI Key / Base / 模型列表(如 deepseek、kimi) |
|
||||
| `Site60sDisabled` | 60s 功能在前端隐藏的 `feature_id` |
|
||||
| `SiteAIRuntime` | DeepSeek 兼容网关(Base + Key + 默认模型),优先级高于部分 AIConfig |
|
||||
| `Site60sUpstream` | 60s 上游节点(单例 id=1) |
|
||||
| `SiteAIModelDisabled` | AI 应用在前端隐藏的 `app_id` |
|
||||
| `SiteFeatureCardClick` | 四大板块功能卡片点击统计(`section` + `item_id` 联合主键) |
|
||||
|
||||
---
|
||||
|
||||
## 路由概览(`internal/router/router.go`)
|
||||
|
||||
### CORS
|
||||
|
||||
全局 `middleware.CORS()`,放行常用 Method/Header(含 `Authorization`、`X-Site-Admin-Token`)。
|
||||
|
||||
### 认证与用户
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/auth/check` | 可选 JWT:校验登录态 |
|
||||
| GET | `/api/user/profile` | **需 JWT**:用户资料 |
|
||||
|
||||
实际登录、发 token 由 **萌芽账户认证中心** 完成;后端校验 JWT。
|
||||
|
||||
### 站点公开配置(无需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/site/60s-disabled` | 被隐藏的 60s `feature_id` 列表 |
|
||||
| GET | `/api/site/60s-source` | 60s 上游 `source_id` / `base_url` |
|
||||
| GET | `/api/site/ai-model-disabled` | 被隐藏的 AI 应用 id 列表 |
|
||||
| GET | `/api/site/feature-card-clicks?section=` | 功能卡片点击次数(见下) |
|
||||
| POST | `/api/site/feature-card-clicks/increment` | 上报一次点击,返回最新 count |
|
||||
|
||||
**`section` 合法值**:`60sapi` · `smallgame` · `toolbox` · `aimodel`
|
||||
|
||||
**increment 请求体**:`{ "section": "...", "item_id": "..." }`
|
||||
|
||||
### 站点管理(需 `X-Site-Admin-Token`,与环境变量 `INFOGENIE_SITE_ADMIN_TOKEN` 一致)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| PUT | `/api/admin/site/60s-disabled` | 更新 60s 隐藏列表 |
|
||||
| PUT | `/api/admin/site/60s-source` | 切换 60s 上游 |
|
||||
| PUT | `/api/admin/site/ai-model-disabled` | 更新 AI 应用隐藏列表 |
|
||||
| GET/PUT | `/api/admin/site/ai-runtime` | 读取/更新 DeepSeek 兼容运行时配置 |
|
||||
|
||||
### AI 应用(`/api/aimodelapp`,默认 **需 JWT**)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/chat` | 非流式对话,JSON 返回全文 |
|
||||
| POST | `/chat/stream` | **SSE 流式**:透传上游 OpenAI 兼容流(`text/event-stream`) |
|
||||
| POST | `/name-analysis` 等 | 各垂直能力(姓名、变量命名、写诗、翻译等) |
|
||||
| GET | `/models` | 模型列表 |
|
||||
|
||||
**流式说明**(`internal/handler/aimodel.go` + `internal/service/ai.go`):
|
||||
|
||||
- 上游请求带 `stream: true`,成功后将上游 body **分块写入并 Flush** 到客户端。
|
||||
- 支持 **deepseek**(运行时或 `AIConfig`)与 **kimi**(`AIConfig`)。
|
||||
- 与 `/chat` 共用同一套 `bindAIModelChat` 校验(消息条数、长度、模型白名单等)。
|
||||
|
||||
**模型白名单**:见 `internal/handler/aimodel.go` 中 `allowedModels`(如 deepseek-chat、deepseek-reasoner、部分 kimi 模型)。
|
||||
|
||||
---
|
||||
|
||||
## 核心源码目录
|
||||
|
||||
```
|
||||
cmd/server/ # main
|
||||
config/ # 配置加载
|
||||
internal/
|
||||
database/ # MySQL 初始化、AutoMigrate
|
||||
handler/ # HTTP 处理器(auth、user、aimodel、siteconfig、ai_runtime、feature_card_clicks)
|
||||
middleware/ # CORS、JWT
|
||||
model/ # GORM 模型
|
||||
router/ # 路由注册
|
||||
service/ # AI 调用(含 OpenAI 兼容非流式与流式)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与其他工程的关系
|
||||
|
||||
- **前端 SPA** 通过 `REACT_APP_API_URL` 指向本服务(开发默认 `http://127.0.0.1:5002`)。
|
||||
- **`public/aimodelapp/*/shared/ai-chat.js`** 优先调用 `/api/aimodelapp/chat/stream`,失败时回退 `/chat`。
|
||||
|
||||
更完整的前端集成说明见 **`infogenie-frontend/前端文档.md`**。
|
||||
# 万象口袋 — Go 后端文档
|
||||
|
||||
**技术栈**:Go 1.25+ · Gin · GORM · MySQL ·(可选)Redis(go-redis v9)
|
||||
|
||||
**模块路径**:`infogenie-backend`(见 `go.mod`)
|
||||
|
||||
**入口**:`cmd/server/main.go` — 加载配置、连接 MySQL、`cache.Init`(若启用 Redis)、`AutoMigrate`、启动 HTTP 服务。
|
||||
|
||||
---
|
||||
|
||||
## 运行与配置
|
||||
|
||||
- 环境由 `**APP_ENV`** 决定:`development` 或 `production`(见 `config.Load()`)。
|
||||
- 若存在 `**.env.development**` / `**.env.production**`,会通过 `godotenv` 加载对应文件。
|
||||
- `**APP_PORT**` 默认 **5002**(与前端 `VITE_API_URL` 开发默认一致)。
|
||||
- 数据库、邮件、认证中心、`INFOGENIE_SITE_ADMIN_TOKEN`、可选 Redis 等从环境变量读取,详见 `config/config.go`。
|
||||
|
||||
### Redis(可选)
|
||||
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ------------------ | --------------------------------------------------------- |
|
||||
| `REDIS_ENABLED` | `true` / `1` / `yes` / `on` 时启用;未启用则不连 Redis,站点接口直连 MySQL |
|
||||
| `REDIS_ADDR` | `host:port`;生产环境必填;开发未设时默认 `10.1.1.100:6379` |
|
||||
| `REDIS_PASSWORD` | 可选 |
|
||||
| `REDIS_DB` | 逻辑库编号,默认 **10**(与 `db0` 等业务隔离) |
|
||||
| `REDIS_KEY_PREFIX` | Key 前缀,默认 `infogenie:go:v1:`(可自动补 `:`) |
|
||||
| `REDIS_SITE_TTL` | 站点类 JSON 缓存 TTL(秒),默认 **60** |
|
||||
|
||||
|
||||
启用时启动阶段会 **Ping**;失败则进程退出。站点只读接口对 `60s-disabled`、`60s-source`、`ai-model-disabled`、`feature-card-clicks` 等做 cache-aside,管理端写入成功后删对应 Key(见 `internal/cache/redis.go` 与 `internal/handler/siteconfig.go`、`feature_card_clicks.go`)。
|
||||
|
||||
---
|
||||
|
||||
## 健康与诊断
|
||||
|
||||
### `GET /api/health`(公开)
|
||||
|
||||
- `**status`**:`running` 或 `degraded`(MySQL 未连通 **或** 60s 上游探测失败时为 degraded;**不**因 Redis 失败而 degraded)。
|
||||
- `**mysql`**:`ok`、`status`(`connected` / `disconnected` / `not_initialized`)。
|
||||
- `**sixty_api**`:当前生效的上游 `source_id`、`base_url`、`label`,以及对 `…/v2/ip` 的探测结果(`probe_url`、`http_status`、`latency_ms`、`error`)。
|
||||
- `**redis**`:未启用时 `{ "enabled": false }`;启用时 `{ "enabled": true, "ok": bool, "error"?: string }`。
|
||||
|
||||
### `GET /api/admin/site/diagnostics`(需管理员)
|
||||
|
||||
请求头 `**X-Site-Admin-Token**` 须与 `**INFOGENIE_SITE_ADMIN_TOKEN**` 一致。
|
||||
|
||||
返回**不含密码明文**,仅连接与进程摘要,供运维/后台展示:
|
||||
|
||||
|
||||
| 字段 | 内容 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `app` | `env`、`listen_addr`(`0.0.0.0:端口`)、`listen_port` |
|
||||
| `mysql` | `host`、`port`、`database`、`user`、`password_configured` |
|
||||
| `redis` | `enabled`;若启用则含 `addr`、`logical_db`、`key_prefix`、`site_cache_ttl_sec`、`password_configured` |
|
||||
| `sixty_upstream` | 库内当前 60s 节点 `source_id` / `base_url` / `label` |
|
||||
| `auth_center` | `api_url` |
|
||||
| `mail` | SMTP `host`、`port`、`username`、`password_configured` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
**根路径**:`GET /` — 返回服务说明与主要 endpoint 分组(`version` 当前为 **3.3.0-go**)。
|
||||
|
||||
---
|
||||
|
||||
## 数据库(GORM AutoMigrate)
|
||||
|
||||
启动时会迁移以下模型(见 `internal/database/mysql.go`):
|
||||
|
||||
|
||||
| 模型 | 用途 |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| `AIConfig` | 多厂商 AI Key / Base / 模型列表(如 deepseek、kimi) |
|
||||
| `Site60sDisabled` | 60s 功能在前端隐藏的 `feature_id` |
|
||||
| `SiteAIRuntime` | DeepSeek 兼容网关(Base + Key + 默认模型),优先级高于部分 AIConfig |
|
||||
| `Site60sUpstream` | 60s 上游节点(单例 id=1) |
|
||||
| `SiteAIModelDisabled` | AI 应用在前端隐藏的 `app_id` |
|
||||
| `SiteFeatureCardClick` | 四大板块功能卡片点击统计(`section` + `item_id` 联合主键) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 路由概览(`internal/router/router.go`)
|
||||
|
||||
### CORS
|
||||
|
||||
全局 `middleware.CORS()`,放行常用 Method/Header(含 `Authorization`、`X-Site-Admin-Token`)。
|
||||
|
||||
### 认证与用户
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | ------------------- | -------------- |
|
||||
| GET | `/api/auth/check` | 可选 JWT:校验登录态 |
|
||||
| GET | `/api/user/profile` | **需 JWT**:用户资料 |
|
||||
|
||||
|
||||
实际登录、发 token 由 **萌芽账户认证中心** 完成;后端校验 JWT。
|
||||
|
||||
### 站点公开配置(无需登录)
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ----------------------------------------- | ------------------------------- |
|
||||
| GET | `/api/site/60s-disabled` | 被隐藏的 60s `feature_id` 列表 |
|
||||
| GET | `/api/site/60s-source` | 60s 上游 `source_id` / `base_url` |
|
||||
| GET | `/api/site/ai-model-disabled` | 被隐藏的 AI 应用 id 列表 |
|
||||
| GET | `/api/site/feature-card-clicks?section=` | 功能卡片点击次数(见下) |
|
||||
| POST | `/api/site/feature-card-clicks/increment` | 上报一次点击,返回最新 count |
|
||||
|
||||
|
||||
`**section` 合法值**:`60sapi` · `smallgame` · `toolbox` · `aimodel`
|
||||
|
||||
**increment 请求体**:`{ "section": "...", "item_id": "..." }`
|
||||
|
||||
### 站点管理(需 `X-Site-Admin-Token`,与环境变量 `INFOGENIE_SITE_ADMIN_TOKEN` 一致)
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------- | ----------------------------------- | ---------------------- |
|
||||
| PUT | `/api/admin/site/60s-disabled` | 更新 60s 隐藏列表 |
|
||||
| PUT | `/api/admin/site/60s-source` | 切换 60s 上游 |
|
||||
| PUT | `/api/admin/site/ai-model-disabled` | 更新 AI 应用隐藏列表 |
|
||||
| GET/PUT | `/api/admin/site/ai-runtime` | 读取/更新 DeepSeek 兼容运行时配置 |
|
||||
| GET | `/api/admin/site/diagnostics` | 连接与进程配置快照(不含密钥明文) |
|
||||
|
||||
|
||||
### AI 应用(`/api/aimodelapp`,默认 **需 JWT**)
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ------------------ | ----------------------------------------------- |
|
||||
| POST | `/chat` | 非流式对话,JSON 返回全文 |
|
||||
| POST | `/chat/stream` | **SSE 流式**:透传上游 OpenAI 兼容流(`text/event-stream`) |
|
||||
| POST | `/name-analysis` 等 | 各垂直能力(姓名、变量命名、写诗、翻译等) |
|
||||
| GET | `/models` | 模型列表 |
|
||||
|
||||
|
||||
**流式说明**(`internal/handler/aimodel.go` + `internal/service/ai.go`):
|
||||
|
||||
- 上游请求带 `stream: true`,成功后将上游 body **分块写入并 Flush** 到客户端。
|
||||
- 支持 **deepseek**(运行时或 `AIConfig`)与 **kimi**(`AIConfig`)。
|
||||
- 与 `/chat` 共用同一套 `bindAIModelChat` 校验(消息条数、长度、模型白名单等)。
|
||||
|
||||
**模型白名单**:见 `internal/handler/aimodel.go` 中 `allowedModels`(如 deepseek-chat、deepseek-reasoner、部分 kimi 模型)。
|
||||
|
||||
---
|
||||
|
||||
## 核心源码目录
|
||||
|
||||
```
|
||||
cmd/server/ # main
|
||||
config/ # 配置加载(含 Redis)
|
||||
internal/
|
||||
cache/ # Redis 可选封装(Get/Set JSON、Delete、Ping)
|
||||
database/ # MySQL 初始化、AutoMigrate
|
||||
handler/ # HTTP 处理器(auth、user、aimodel、siteconfig、ai_runtime、feature_card_clicks)
|
||||
middleware/ # CORS、JWT
|
||||
model/ # GORM 模型
|
||||
router/ # 路由注册(含 /api/health、diagnostics)
|
||||
service/ # AI 调用(含 OpenAI 兼容非流式与流式)
|
||||
scripts/redis_keyspace/ # 可选:本地查看各逻辑库 keyspace(go run,需 REDIS_PASSWORD)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与其他工程的关系
|
||||
|
||||
- **前端 SPA** 通过 `VITE_API_URL` 指向本服务(开发默认 `http://127.0.0.1:5002`)。
|
||||
- `**public/aimodelapp/*/shared/ai-chat.js`** 优先调用 `/api/aimodelapp/chat/stream`,失败时回退 `/chat`。
|
||||
|
||||
更完整的前端集成说明见 `**infogenie-frontend/前端文档.md**`。
|
||||
Reference in New Issue
Block a user