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:
2026-04-03 16:10:12 +08:00
parent 284b5a5260
commit 6b3fcc1791
25 changed files with 1078 additions and 972 deletions

View File

@@ -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

View 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

View File

@@ -1,3 +1,4 @@
# 含数据库密码、SMTP 等,勿提交
.env.production
.env.local
# 含数据库密码、SMTP、JWT、Redis 等,勿提交
.env
.env.*
!.env.example

View File

@@ -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"]
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
)

View File

@@ -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=

View 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未启用或未命中返回 falseerr 仅表示 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()
}

View File

@@ -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})
}

View File

@@ -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) != "",
},
})
}

View File

@@ -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")
{

View 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 查看。")
}

View File

@@ -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 ·可选Redisgo-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/ # 可选:本地查看各逻辑库 keyspacego 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**`