update: 2026-03-28 20:59

This commit is contained in:
2026-03-28 20:59:52 +08:00
parent e21d58e603
commit 1c81d4e6ea
611 changed files with 27847 additions and 65061 deletions

View File

@@ -0,0 +1,57 @@
package database
import (
"fmt"
"log"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"infogenie-backend/config"
"infogenie-backend/internal/model"
)
var DB *gorm.DB
func Init(cfg config.DBConfig) error {
logLevel := logger.Warn
if config.Cfg.Env == "development" {
logLevel = logger.Info
}
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
return fmt.Errorf("连接MySQL失败: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取底层DB连接失败: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
if err := sqlDB.Ping(); err != nil {
return fmt.Errorf("Ping数据库失败: %w", err)
}
DB = db
log.Printf("MySQL连接成功 [env=%s]: %s:%s/%s", config.Cfg.Env, cfg.Host, cfg.Port, cfg.Name)
return nil
}
func AutoMigrate() error {
return DB.AutoMigrate(
&model.AIConfig{},
&model.Site60sDisabled{},
&model.SiteAIRuntime{},
&model.Site60sUpstream{},
&model.SiteAIModelDisabled{},
)
}

View File

@@ -0,0 +1,109 @@
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"infogenie-backend/config"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
type AIRuntimeHandler struct{}
func NewAIRuntimeHandler() *AIRuntimeHandler { return &AIRuntimeHandler{} }
func maskAPIKey(k string) (set bool, hint string) {
k = strings.TrimSpace(k)
if k == "" {
return false, ""
}
if len(k) <= 4 {
return true, "****"
}
return true, "****" + k[len(k)-4:]
}
// GetAIRuntime 管理员读取当前 AI 上游配置(密钥脱敏)
func (h *AIRuntimeHandler) GetAIRuntime(c *gin.Context) {
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin_not_configured"})
return
}
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var row model.SiteAIRuntime
_ = database.DB.First(&row, 1).Error
keySet, keyHint := maskAPIKey(row.APIKey)
c.JSON(http.StatusOK, gin.H{
"api_base": strings.TrimSpace(row.APIBase),
"api_key_set": keySet,
"api_key_hint": keyHint,
"default_model": strings.TrimSpace(row.DefaultModel),
"default_provider": strings.TrimSpace(row.DefaultProv),
})
}
type putAIRuntimeBody struct {
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
DefaultModel string `json:"default_model"`
DefaultProvider string `json:"default_provider"`
}
// PutAIRuntime 管理员写入api_key 留空或不传则保留原密钥
func (h *AIRuntimeHandler) PutAIRuntime(c *gin.Context) {
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin_not_configured"})
return
}
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var body putAIRuntimeBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
return
}
var row model.SiteAIRuntime
err := database.DB.First(&row, 1).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.SiteAIRuntime{ID: 1}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
}
row.APIBase = strings.TrimSpace(body.APIBase)
if strings.TrimSpace(body.DefaultModel) != "" {
row.DefaultModel = strings.TrimSpace(body.DefaultModel)
}
if strings.TrimSpace(body.DefaultProvider) != "" {
row.DefaultProv = strings.TrimSpace(body.DefaultProvider)
} else if row.DefaultProv == "" {
row.DefaultProv = "deepseek"
}
newKey := strings.TrimSpace(body.APIKey)
if newKey != "" && !strings.Contains(newKey, "****") {
row.APIKey = newKey
}
if err := database.DB.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,625 @@
package handler
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"infogenie-backend/config"
"infogenie-backend/internal/service"
)
type AIModelHandler struct{}
func NewAIModelHandler() *AIModelHandler { return &AIModelHandler{} }
// 输入长度限制
const (
maxInputLen = 5000
maxChatMsgCount = 20
)
// 允许用户选择的模型白名单
var allowedModels = map[string]map[string]bool{
"deepseek": {
"deepseek-chat": true,
"deepseek-reasoner": true,
},
"kimi": {
"kimi-k2-0905-preview": true,
"kimi-k2-0711-preview": true,
},
}
func safeAIError(err error) string {
log.Printf("AI调用失败: %v", err)
return "AI 服务暂时不可用,请稍后重试"
}
func validateTextLen(text string, label string) (string, error) {
t := strings.TrimSpace(text)
if t == "" {
return "", fmt.Errorf("%s不能为空", label)
}
if len(t) > maxInputLen {
return "", fmt.Errorf("%s超出长度限制最大 %d 字符)", label, maxInputLen)
}
return t, nil
}
// POST /api/aimodelapp/chat
func (h *AIModelHandler) Chat(c *gin.Context) {
var req struct {
Messages []service.ChatMessage `json:"messages"`
Provider string `json:"provider"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求数据为空"})
return
}
if req.Provider == "" {
req.Provider = "deepseek"
}
if req.Model == "" {
req.Model = "deepseek-chat"
}
// 模型白名单校验
if models, ok := allowedModels[req.Provider]; !ok || !models[req.Model] {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的模型"})
return
}
if len(req.Messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息内容不能为空"})
return
}
if len(req.Messages) > maxChatMsgCount {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("消息数量不能超过 %d 条", maxChatMsgCount)})
return
}
// 校验每条消息的长度
for _, m := range req.Messages {
if len(m.Content) > maxInputLen {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("单条消息长度不能超过 %d 字符", maxInputLen)})
return
}
}
content, err := service.CallAI(req.Provider, req.Model, req.Messages)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"content": content,
"provider": req.Provider,
"model": req.Model,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/name-analysis
func (h *AIModelHandler) NameAnalysis(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "姓名不能为空"})
return
}
name, err := validateTextLen(req.Name, "姓名")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prompt := fmt.Sprintf(`你是一位专业的姓名学专家和语言学家,请对输入的姓名进行全面分析。请直接输出分析结果,不要包含任何思考过程或<think>标签。
姓名:%s
请按照以下格式严格输出分析结果:
【稀有度评分】
评分X%%
评价:[对稀有度的详细说明,包括姓氏和名字的常见程度分析]
【音韵评价】
评分X%%
评价:[对音韵美感的分析,包括声调搭配、读音流畅度、音律和谐度等]
【含义解读】
[详细分析姓名的寓意内涵,包括:
1. 姓氏的历史渊源和文化背景
2. 名字各字的含义和象征
3. 整体姓名的寓意组合
4. 可能体现的父母期望或文化内涵
5. 与传统文化、诗词典故的关联等]
要求:
1. 评分必须是1-100的整数百分比要有明显区分度避免雷同
2. 分析要专业、客观、有依据,评分要根据实际情况有所差异
3. 含义解读要详细深入至少150字
4. 严格按照上述格式输出,不要添加思考过程、<think>标签或其他内容
5. 如果是生僻字或罕见姓名,要特别说明
6. 直接输出最终结果,不要显示推理过程`, name)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"analysis": content,
"name": name,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/variable-naming
func (h *AIModelHandler) VariableNaming(c *gin.Context) {
var req struct {
Description string `json:"description"`
Language string `json:"language"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "变量描述不能为空"})
return
}
desc, err := validateTextLen(req.Description, "变量描述")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
lang := req.Language
if lang == "" {
lang = "javascript"
}
prompt := fmt.Sprintf(`你是一个专业的变量命名助手。请根据以下描述为变量生成合适的名称:
描述:%s
请为每种命名规范生成3个变量名建议
1. camelCase (驼峰命名法)
2. PascalCase (帕斯卡命名法)
3. snake_case (下划线命名法)
4. kebab-case (短横线命名法)
5. CONSTANT_CASE (常量命名法)
请按JSON格式返回
{"suggestions":{"camelCase":[{"name":"变量名","description":"说明"}],"PascalCase":[{"name":"变量名","description":"说明"}],"snake_case":[{"name":"变量名","description":"说明"}],"kebab-case":[{"name":"变量名","description":"说明"}],"CONSTANT_CASE":[{"name":"变量名","description":"说明"}]}}
只返回JSON格式的结果不要包含其他文字。`, desc)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"suggestions": extractOrRaw(content),
"description": desc,
"language": lang,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/poetry
func (h *AIModelHandler) Poetry(c *gin.Context) {
var req struct {
Theme string `json:"theme"`
Style string `json:"style"`
Mood string `json:"mood"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "诗歌主题不能为空"})
return
}
theme, err := validateTextLen(req.Theme, "诗歌主题")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
style := req.Style
if style == "" {
style = "现代诗"
}
mood := req.Mood
if mood == "" {
mood = "自由发挥"
}
prompt := fmt.Sprintf(`你是一位才华横溢的诗人,请根据以下要求创作一首诗歌。
主题:%s
风格:%s
情感基调:%s
创作要求:
1. 紧扣主题,情感真挚
2. 语言优美,意境深远
3. 符合指定的诗歌风格
4. 长度适中,朗朗上口
5. 如果是古体诗,注意平仄和韵律
请直接输出诗歌作品,不需要额外的解释或分析。`, theme, style, mood)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"poem": content,
"theme": theme,
"style": style,
"mood": mood,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/translation
func (h *AIModelHandler) Translation(c *gin.Context) {
var req struct {
SourceText string `json:"source_text"`
TargetLanguage string `json:"target_language"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "翻译内容不能为空"})
return
}
text, err := validateTextLen(req.SourceText, "翻译内容")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
targetLang := req.TargetLanguage
if targetLang == "" {
targetLang = "zh-CN"
}
langMap := map[string]string{
"zh-CN": "中文(简体)", "zh-TW": "中文(繁体)", "en": "英语",
"ja": "日语", "ko": "韩语", "fr": "法语", "de": "德语",
"es": "西班牙语", "it": "意大利语", "pt": "葡萄牙语",
"ru": "俄语", "ar": "阿拉伯语", "hi": "印地语",
"th": "泰语", "vi": "越南语",
}
langName := langMap[targetLang]
if langName == "" {
langName = targetLang
}
prompt := fmt.Sprintf(`你是一位专业的翻译专家,精通多种语言的翻译工作。请将以下文本翻译成%s。
原文:%s
翻译要求:
1. 【信】- 忠实原文,准确传达原意
2. 【达】- 译文通顺流畅,符合目标语言的表达习惯
3. 【雅】- 用词优美得体,风格与原文相符
请按以下JSON格式返回翻译结果
{"detected_language":"检测到的源语言","target_language":"%s","translation":"翻译结果","alternative_translations":["备选翻译1","备选翻译2"],"explanation":"翻译说明","pronunciation":"发音指导"}
只返回JSON格式的结果不要包含其他文字。`, langName, text, langName)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"translation_result": content,
"source_text": text,
"target_language": targetLang,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/classical_conversion
func (h *AIModelHandler) ClassicalConversion(c *gin.Context) {
var req struct {
ModernText string `json:"modern_text"`
Style string `json:"style"`
ArticleType string `json:"article_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "现代文内容不能为空"})
return
}
text, err := validateTextLen(req.ModernText, "现代文内容")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
style := req.Style
if style == "" {
style = "古雅"
}
artType := req.ArticleType
if artType == "" {
artType = "散文"
}
prompt := fmt.Sprintf(`你是一位精通古代文言文的文学大师,擅长将现代文转换为优美的文言文。请将以下现代文转换为文言文。
现代文:%s
风格:%s
文体:%s
请按以下JSON格式返回转换结果
{"classical_text":"转换后的文言文","translation_notes":"转换说明","style_analysis":"风格分析","difficulty_level":"难度等级","key_phrases":[{"modern":"现代词汇","classical":"文言文词汇","explanation":"转换说明"}],"cultural_elements":"文化内涵说明"}
只返回JSON格式的结果不要包含其他文字。`, text, style, artType)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"conversion_result": content,
"modern_text": text,
"style": style,
"article_type": artType,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/expression-maker
func (h *AIModelHandler) ExpressionMaker(c *gin.Context) {
var req struct {
Text string `json:"text"`
Style string `json:"style"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文字内容不能为空"})
return
}
text, err := validateTextLen(req.Text, "文字内容")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
style := req.Style
if style == "" {
style = "mixed"
}
styleMap := map[string]string{
"mixed": "混合使用Emoji表情和颜文字", "emoji": "仅使用Emoji表情符号",
"kaomoji": "仅使用颜文字", "cute": "使用可爱风格的表情符号",
"cool": "使用酷炫风格的表情符号",
}
styleDesc := styleMap[style]
if styleDesc == "" {
styleDesc = styleMap["mixed"]
}
prompt := fmt.Sprintf(`你是一个专业的表情符号专家。请根据以下文字内容生成相应的表情符号:
文字内容:%s
表情风格:%s
请按JSON格式返回
{"expressions":{"emoji":[{"symbol":"😊","description":"场景说明","intensity":"中等","usage":"使用建议"}],"kaomoji":[{"symbol":"(^_^)","description":"场景说明","intensity":"轻微","usage":"使用建议"}],"combination":[{"symbol":"🎉✨","description":"场景说明","intensity":"强烈","usage":"使用建议"}]},"summary":{"emotion_analysis":"情感分析","recommended_usage":"推荐使用场景","style_notes":"风格特点"}}
只返回JSON格式的结果不要包含其他文字。`, text, styleDesc)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"expressions": extractOrRaw(content),
"text": text,
"style": style,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/linux-command
func (h *AIModelHandler) LinuxCommand(c *gin.Context) {
var req struct {
TaskDescription string `json:"task_description"`
DifficultyLevel string `json:"difficulty_level"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "任务描述不能为空"})
return
}
desc, err := validateTextLen(req.TaskDescription, "任务描述")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
level := req.DifficultyLevel
if level == "" {
level = "beginner"
}
prompt := fmt.Sprintf(`你是一位Linux系统专家请根据用户的任务描述生成相应的Linux命令。
任务描述:%s
用户水平:%s
请按JSON格式返回
{"commands":[{"command":"具体命令","description":"说明","safety_level":"safe","explanation":"解释","example_output":"示例输出","alternatives":["替代命令"]}],"safety_warnings":["安全提示"],"prerequisites":["前置条件"],"related_concepts":["相关概念"]}
只返回JSON格式的结果不要包含其他文字。`, desc, level)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_result": content,
"task_description": desc,
"difficulty_level": level,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/markdown_formatting
func (h *AIModelHandler) MarkdownFormatting(c *gin.Context) {
var req struct {
ArticleText string `json:"article_text"`
EmojiStyle string `json:"emoji_style"`
MarkdownOption string `json:"markdown_option"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文章内容不能为空"})
return
}
text, err := validateTextLen(req.ArticleText, "文章内容")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
emojiStyle := req.EmojiStyle
if emojiStyle == "" {
emojiStyle = "balanced"
}
mdOption := req.MarkdownOption
if mdOption == "" {
mdOption = "standard"
}
prompt := fmt.Sprintf(`你是一位专业的文档排版助手。请将用户提供的全文按"标准Markdown格式"进行排版,并在不改变任何原文内容的前提下进行结构化呈现。严格遵守以下规则:
1) 保留所有原始内容,严禁改写、删减或添加新内容。
2) 使用合理的Markdown结构标题、分节、段落、列表、引用、表格如有必要、代码块仅当原文包含
3) 智能添加适量Emoji以增强可读性%s在标题、关键句、列表项等处点缀避免过度使用保持专业。
4) 保持语言与语气不变,只优化排版和表现形式。
5) 输出"纯Markdown文本"不要包含任何JSON、HTML、XML、解释文字、或代码块围栏标记。
如果原文本较长,可在开头自动生成简洁的"目录"以便阅读。
原文如下:
%s`, emojiStyle, text)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"formatted_markdown": content,
"source_text": text,
"emoji_style": emojiStyle,
"markdown_option": mdOption,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/kinship-calculator
func (h *AIModelHandler) KinshipCalculator(c *gin.Context) {
var req struct {
RelationChain string `json:"relation_chain"`
Dialects []string `json:"dialects"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "亲属关系链不能为空"})
return
}
chain, err := validateTextLen(req.RelationChain, "亲属关系链")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dialects := req.Dialects
if len(dialects) == 0 {
dialects = []string{"粤语", "闽南语", "上海话", "四川话", "东北话", "客家话"}
}
prompt := fmt.Sprintf(`你是一位中国亲属称呼专家。请解析下面的亲属关系链,给出最终的亲属称呼。
请遵循:
1) 以中国大陆通行的标准普通话称呼为准。
2) 同时给出若干方言的对应称呼:%s。
3) 如存在地区差异或性别歧义请在notes中说明。
4) 不要展示推理过程只输出JSON。
严格按以下JSON结构输出
{"mandarin_title":"标准普通话称呼","dialect_titles":{"粤语":{"title":"称呼","romanization":"粤拼","notes":"说明"}},"notes":"总体说明"}
关系链:%s`, strings.Join(dialects, "、"), chain)
messages := []service.ChatMessage{{Role: "user", Content: prompt}}
content, err := service.CallDeepSeek(messages, "", 3)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"kinship_result": content,
"relation_chain": chain,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// GET /api/aimodelapp/models — 仅返回允许的模型列表
func (h *AIModelHandler) GetModels(c *gin.Context) {
models := make(map[string][]string)
for provider, modelSet := range allowedModels {
for m := range modelSet {
models[provider] = append(models[provider], m)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"models": models,
"default_provider": "deepseek",
"default_model": "deepseek-chat",
})
}
func extractOrRaw(content string) interface{} {
return content
}
// Ping 用于健康检查(从 config 引用原始配置可选)
func Ping(c *gin.Context) {
_ = config.Cfg
c.JSON(http.StatusOK, gin.H{
"status": "running",
"timestamp": time.Now().Format(time.RFC3339),
})
}

View File

@@ -0,0 +1,30 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
type AuthHandler struct{}
func NewAuthHandler() *AuthHandler { return &AuthHandler{} }
func (h *AuthHandler) Check(c *gin.Context) {
account, exists := c.Get("account")
if !exists || account == "" {
c.JSON(http.StatusOK, gin.H{"success": true, "logged_in": false})
return
}
username, _ := c.Get("username")
email, _ := c.Get("email")
c.JSON(http.StatusOK, gin.H{
"success": true,
"logged_in": true,
"user": gin.H{
"account": account,
"username": username,
"email": email,
},
})
}

View File

@@ -0,0 +1,265 @@
package handler
import (
"crypto/subtle"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"infogenie-backend/config"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
type SiteConfigHandler struct{}
func NewSiteConfigHandler() *SiteConfigHandler { return &SiteConfigHandler{} }
func siteAdminTokenOK(headerToken string) bool {
if config.Cfg == nil {
return false
}
expected := strings.TrimSpace(config.Cfg.SiteAdminToken)
if expected == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(headerToken), []byte(expected)) == 1
}
// Get60sDisabled 公开:返回当前隐藏的 60s 功能 id 列表(与前端 item.id 对应)
func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) {
var rows []model.Site60sDisabled
if err := database.DB.Order("feature_id").Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.FeatureID)
}
c.JSON(http.StatusOK, gin.H{"disabled": ids})
}
type put60sDisabledBody struct {
Disabled []string `json:"disabled"`
}
// Put60sDisabled 需请求头 X-Site-Admin-Token与后端环境变量 INFOGENIE_SITE_ADMIN_TOKEN 一致(建议与前端管理员口令相同)
func (h *SiteConfigHandler) Put60sDisabled(c *gin.Context) {
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "admin_not_configured",
"message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN禁止写入",
})
return
}
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden", "message": "站点管理员令牌无效"})
return
}
var body put60sDisabledBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
return
}
if len(body.Disabled) > 512 {
c.JSON(http.StatusBadRequest, gin.H{"error": "too_many"})
return
}
seen := make(map[string]struct{})
clean := make([]string, 0, len(body.Disabled))
for _, raw := range body.Disabled {
id := strings.TrimSpace(raw)
if id == "" || len(id) > 96 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
clean = append(clean, id)
}
tx := database.DB.Begin()
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Site60sDisabled{}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
for _, id := range clean {
if err := tx.Create(&model.Site60sDisabled{FeatureID: id}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
}
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)})
}
// —— 60s API 上游节点(仅管理员可切换)——
type sixtySrcInfo struct {
Base string
Label string
}
var sixtyUpstreamRegistry = map[string]sixtySrcInfo{
"self": {Base: "https://60s.api.shumengya.top", Label: "萌芽节点"},
"official": {Base: "https://60s.viki.moe", Label: "官方节点"},
}
func resolve60sUpstream(sourceID string) (id string, info sixtySrcInfo) {
id = strings.TrimSpace(sourceID)
if id == "" {
id = "self"
}
var ok bool
info, ok = sixtyUpstreamRegistry[id]
if !ok {
id = "self"
info = sixtyUpstreamRegistry["self"]
}
return id, info
}
// Get60sSource 公开:当前站点使用的 60s 上游 base_url供静态页 iframe 传参)
func (h *SiteConfigHandler) Get60sSource(c *gin.Context) {
var row model.Site60sUpstream
_ = database.DB.First(&row, 1).Error
sid, info := resolve60sUpstream(row.SourceID)
c.JSON(http.StatusOK, gin.H{
"source_id": sid,
"base_url": info.Base,
"label": info.Label,
})
}
type put60sSourceBody struct {
SourceID string `json:"source_id"`
}
// Put60sSource 管理员切换节点source_id 为 self | official
func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin_not_configured"})
return
}
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var body put60sSourceBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
return
}
sid := strings.TrimSpace(body.SourceID)
if _, ok := sixtyUpstreamRegistry[sid]; !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_source_id"})
return
}
var row model.Site60sUpstream
err := database.DB.First(&row, 1).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.Site60sUpstream{ID: 1}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
}
row.SourceID = sid
if err := database.DB.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
_, info := resolve60sUpstream(sid)
c.JSON(http.StatusOK, gin.H{"ok": true, "source_id": sid, "base_url": info.Base, "label": info.Label})
}
// —— AI 应用可见性控制(仅管理员可配置)——
// GetAIModelDisabled 公开:返回当前隐藏的 AI 应用 id 列表(与前端 StaticPageConfig 中 AI_MODEL_APPS 的索引对应)
func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) {
var rows []model.SiteAIModelDisabled
if err := database.DB.Order("app_id").Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.AppID)
}
c.JSON(http.StatusOK, gin.H{"disabled": ids})
}
type putAIModelDisabledBody struct {
Disabled []string `json:"disabled"`
}
// PutAIModelDisabled 需请求头 X-Site-Admin-Token与后端环境变量 INFOGENIE_SITE_ADMIN_TOKEN 一致
func (h *SiteConfigHandler) PutAIModelDisabled(c *gin.Context) {
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "admin_not_configured",
"message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN禁止写入",
})
return
}
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden", "message": "站点管理员令牌无效"})
return
}
var body putAIModelDisabledBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
return
}
if len(body.Disabled) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "too_many"})
return
}
seen := make(map[string]struct{})
clean := make([]string, 0, len(body.Disabled))
for _, raw := range body.Disabled {
id := strings.TrimSpace(raw)
if id == "" || len(id) > 96 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
clean = append(clean, id)
}
tx := database.DB.Begin()
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.SiteAIModelDisabled{}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
for _, id := range clean {
if err := tx.Create(&model.SiteAIModelDisabled{AppID: id}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
}
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)})
}

View File

@@ -0,0 +1,36 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"infogenie-backend/internal/middleware"
)
type UserHandler struct{}
func NewUserHandler() *UserHandler { return &UserHandler{} }
func (h *UserHandler) GetProfile(c *gin.Context) {
authUser, exists := c.Get("auth_user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未认证"})
return
}
user := authUser.(*middleware.AuthCenterUser)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"account": user.Account,
"username": user.Username,
"email": user.Email,
"avatar": user.AvatarURL,
"level": user.Level,
"sprout_coins": user.SproutCoins,
"checkin_days": user.CheckInDays,
"checkin_streak": user.CheckInStreak,
},
})
}

View File

@@ -0,0 +1,127 @@
package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"infogenie-backend/config"
)
type AuthCenterUser struct {
Account string `json:"account"`
Username string `json:"username"`
Email string `json:"email"`
Level int `json:"level"`
SproutCoins float64 `json:"sproutCoins"`
AvatarURL string `json:"avatarUrl"`
WebsiteURL string `json:"websiteUrl"`
Bio string `json:"bio"`
CheckInDays int `json:"checkInDays"`
CheckInStreak int `json:"checkInStreak"`
}
type VerifyResponse struct {
Valid bool `json:"valid"`
User *AuthCenterUser `json:"user,omitempty"`
Error string `json:"error,omitempty"`
BanReason string `json:"banReason,omitempty"`
}
func verifyTokenWithAuthCenter(tokenStr string) (*VerifyResponse, error) {
body, _ := json.Marshal(map[string]string{"token": tokenStr})
url := config.Cfg.AuthCenter.APIURL + "/api/auth/verify"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Client", "infogenie")
req.Header.Set("X-Auth-Client-Name", "万象口袋")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求认证中心失败: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 {
return &VerifyResponse{Valid: false, Error: "invalid token"}, nil
}
var result VerifyResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("解析认证响应失败: %w", err)
}
return &result, nil
}
func extractToken(c *gin.Context) string {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return auth[7:]
}
return auth
}
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := extractToken(c)
if tokenStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "缺少认证token"})
c.Abort()
return
}
result, err := verifyTokenWithAuthCenter(tokenStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "认证服务暂时不可用"})
c.Abort()
return
}
if !result.Valid || result.User == nil {
msg := "Token无效或已过期"
if result.BanReason != "" {
msg = "账户已被封禁"
}
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": msg})
c.Abort()
return
}
c.Set("account", result.User.Account)
c.Set("username", result.User.Username)
c.Set("email", result.User.Email)
c.Set("sprout_coins", result.User.SproutCoins)
c.Set("auth_user", result.User)
c.Next()
}
}
func OptionalJWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr := extractToken(c)
if tokenStr != "" {
result, err := verifyTokenWithAuthCenter(tokenStr)
if err == nil && result.Valid && result.User != nil {
c.Set("account", result.User.Account)
c.Set("username", result.User.Username)
c.Set("email", result.User.Email)
c.Set("sprout_coins", result.User.SproutCoins)
c.Set("auth_user", result.User)
}
}
c.Next()
}
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
// CORS 宽松策略:放行任意 Origin由 gin-contrib/cors 回显请求 Origin便于前后端分离域名部署。
// 如需收紧,可改为仅白名单或仅允许 https://infogenie.shumengya.top 等。
func CORS() gin.HandlerFunc {
return cors.New(cors.Config{
AllowOriginFunc: func(origin string) bool {
return true
},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "Accept", "X-Site-Admin-Token"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
})
}

View File

@@ -0,0 +1,18 @@
package model
import "time"
// AI配置表用于存储各种AI提供商的配置
type AIConfig struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Provider string `gorm:"type:varchar(50);not null;uniqueIndex" json:"provider"` // deepseek, kimi, etc.
APIKey string `gorm:"type:varchar(255);not null" json:"api_key"`
APIBase string `gorm:"type:varchar(255);not null" json:"api_base"`
DefaultModel string `gorm:"type:varchar(100)" json:"default_model"`
Models string `gorm:"type:text" json:"models"` // JSON格式的模型列表
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AIConfig) TableName() string { return "ai_configs" }

View File

@@ -0,0 +1 @@
package model

View File

@@ -0,0 +1,8 @@
package model
// Site60sDisabled 记录在 60s API 列表中隐藏的功能项(仅存 feature_id与前端 Api60sConfig 中 item.id 一致)
type Site60sDisabled struct {
FeatureID string `gorm:"primaryKey;type:varchar(96);not null" json:"feature_id"`
}
func (Site60sDisabled) TableName() string { return "site_60s_disabled" }

View File

@@ -0,0 +1,9 @@
package model
// Site60sUpstream 单例行id=160s API 上游节点,仅管理员可改
type Site60sUpstream struct {
ID uint `gorm:"primaryKey" json:"id"`
SourceID string `gorm:"type:varchar(32);not null;default:self" json:"source_id"` // self | official
}
func (Site60sUpstream) TableName() string { return "site_60s_upstream" }

View File

@@ -0,0 +1,8 @@
package model
// SiteAIModelDisabled 记录在 AI 应用列表中隐藏的功能项(仅存 app_id与前端 StaticPageConfig 中 AI_MODEL_APPS 的索引或唯一标识对应)
type SiteAIModelDisabled struct {
AppID string `gorm:"primaryKey;type:varchar(96);not null" json:"app_id"`
}
func (SiteAIModelDisabled) TableName() string { return "site_ai_model_disabled" }

View File

@@ -0,0 +1,15 @@
package model
import "time"
// SiteAIRuntime 单例行id=1管理员配置的 AI 上游地址与密钥,优先于 ai_config.json
type SiteAIRuntime struct {
ID uint `gorm:"primaryKey" json:"id"`
APIBase string `gorm:"type:varchar(512)" json:"api_base"`
APIKey string `gorm:"type:varchar(2048)" json:"-"`
DefaultModel string `gorm:"type:varchar(120)" json:"default_model"`
DefaultProv string `gorm:"type:varchar(64)" json:"default_provider"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SiteAIRuntime) TableName() string { return "site_ai_runtime" }

View File

@@ -0,0 +1,92 @@
package router
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"infogenie-backend/internal/database"
"infogenie-backend/internal/handler"
"infogenie-backend/internal/middleware"
)
func Setup(r *gin.Engine) {
r.Use(middleware.CORS())
authH := handler.NewAuthHandler()
userH := handler.NewUserHandler()
aiH := handler.NewAIModelHandler()
siteH := handler.NewSiteConfigHandler()
aiRtH := handler.NewAIRuntimeHandler()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "万象口袋 后端 API 服务运行中",
"description": "提供AI模型应用接口用户认证由萌芽账户认证中心提供",
"version": "3.2.0-go",
"timestamp": time.Now().Format(time.RFC3339),
"endpoints": gin.H{
"auth": "/api/auth (via 萌芽认证中心)",
"user": "/api/user",
"aimodelapp": "/api/aimodelapp",
"site": "/api/site",
"admin_site": "/api/admin/site/*",
},
})
})
// 健康检查:实际检测数据库连接
r.GET("/api/health", func(c *gin.Context) {
dbStatus := "connected"
if database.DB != nil {
sqlDB, err := database.DB.DB()
if err != nil || sqlDB.Ping() != nil {
dbStatus = "disconnected"
}
} else {
dbStatus = "not_initialized"
}
c.JSON(http.StatusOK, gin.H{
"status": "running",
"database": dbStatus,
"timestamp": time.Now().Format(time.RFC3339),
})
})
auth := r.Group("/api/auth")
{
auth.GET("/check", middleware.OptionalJWTAuth(), authH.Check)
}
user := r.Group("/api/user", middleware.JWTAuth())
{
user.GET("/profile", userH.GetProfile)
}
// 站点公开配置(无需登录)
r.GET("/api/site/60s-disabled", siteH.Get60sDisabled)
r.GET("/api/site/60s-source", siteH.Get60sSource)
r.GET("/api/site/ai-model-disabled", siteH.GetAIModelDisabled)
r.PUT("/api/admin/site/60s-disabled", siteH.Put60sDisabled)
r.PUT("/api/admin/site/60s-source", siteH.Put60sSource)
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)
ai := r.Group("/api/aimodelapp")
{
ai.POST("/chat", middleware.JWTAuth(), aiH.Chat)
ai.POST("/name-analysis", middleware.JWTAuth(), aiH.NameAnalysis)
ai.POST("/variable-naming", middleware.JWTAuth(), aiH.VariableNaming)
ai.POST("/poetry", middleware.JWTAuth(), aiH.Poetry)
ai.POST("/translation", middleware.JWTAuth(), aiH.Translation)
ai.POST("/classical_conversion", middleware.JWTAuth(), aiH.ClassicalConversion)
ai.POST("/expression-maker", middleware.JWTAuth(), aiH.ExpressionMaker)
ai.POST("/linux-command", middleware.JWTAuth(), aiH.LinuxCommand)
ai.POST("/markdown_formatting", middleware.JWTAuth(), aiH.MarkdownFormatting)
ai.POST("/kinship-calculator", middleware.JWTAuth(), aiH.KinshipCalculator)
// models 端点添加认证保护
ai.GET("/models", middleware.JWTAuth(), aiH.GetModels)
}
}

View File

@@ -0,0 +1,213 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
// loadAIConfig 从数据库读取AI配置
func loadAIConfig(provider string) (apiKey, apiBase, defaultModel string, models []string, ok bool) {
if database.DB == nil {
return "", "", "", nil, false
}
var config model.AIConfig
if err := database.DB.Where("provider = ? AND is_enabled = ?", provider, true).First(&config).Error; err != nil {
return "", "", "", nil, false
}
// 解析models JSON
var modelList []string
if config.Models != "" {
if err := json.Unmarshal([]byte(config.Models), &modelList); err != nil {
// 如果解析失败,返回空的模型列表
modelList = []string{}
}
}
return config.APIKey, config.APIBase, config.DefaultModel, modelList, true
}
// loadRuntimeDeepSeek 读取管理员在后台配置的 DeepSeek 兼容接口OpenAI 格式),优先于 ai_config.json
func loadRuntimeDeepSeek() (apiBase, apiKey, defModel string, ok bool) {
if database.DB == nil {
return "", "", "", false
}
var row model.SiteAIRuntime
if err := database.DB.First(&row, 1).Error; err != nil {
return "", "", "", false
}
base := strings.TrimSpace(row.APIBase)
key := strings.TrimSpace(row.APIKey)
dm := strings.TrimSpace(row.DefaultModel)
if base != "" && key != "" {
return base, key, dm, true
}
return "", "", "", false
}
func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string, error) {
// 首先尝试从SiteAIRuntime读取配置向后兼容
if base, key, defModel, ok := loadRuntimeDeepSeek(); ok {
if model == "" {
model = defModel
}
if model == "" {
model = "deepseek-chat"
}
url := strings.TrimSuffix(base, "/") + "/chat/completions"
return callOpenAICompatible(url, key, model, messages, maxRetries, 90*time.Second)
}
// 从新的AI配置表读取
if apiKey, apiBase, defaultModel, models, ok := loadAIConfig("deepseek"); ok {
if model == "" {
model = defaultModel
}
if model == "" {
model = "deepseek-chat"
}
// 验证模型是否在允许列表中
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0] // 使用第一个允许的模型
}
}
url := strings.TrimSuffix(apiBase, "/") + "/chat/completions"
return callOpenAICompatible(url, apiKey, model, messages, maxRetries, 90*time.Second)
}
return "", fmt.Errorf("DeepSeek配置未设置请在管理员后台配置API Key和Base URL")
}
func CallKimi(messages []ChatMessage, model string) (string, error) {
// 从新的AI配置表读取
if apiKey, apiBase, defaultModel, models, ok := loadAIConfig("kimi"); ok {
if model == "" {
model = defaultModel
}
if model == "" {
model = "kimi-k2-0905-preview"
}
// 验证模型是否在允许列表中
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0] // 使用第一个允许的模型
}
}
url := strings.TrimSuffix(apiBase, "/") + "/v1/chat/completions"
return callOpenAICompatible(url, apiKey, model, messages, 1, 30*time.Second)
}
return "", fmt.Errorf("Kimi配置未设置请在管理员后台配置API Key和Base URL")
}
func callOpenAICompatible(url, apiKey, model string, messages []ChatMessage, maxRetries int, timeout time.Duration) (string, error) {
reqBody := chatRequest{
Model: model,
Messages: messages,
Temperature: 0.7,
MaxTokens: 2000,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
client := &http.Client{Timeout: timeout}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
lastErr = err
if attempt < maxRetries-1 {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(backoff)
continue
}
return "", fmt.Errorf("API调用异常已重试%d次: %w", maxRetries, err)
}
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
var result chatResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("解析响应失败: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("AI未返回有效内容")
}
return result.Choices[0].Message.Content, nil
}
lastErr = fmt.Errorf("API调用失败: %d - %s", resp.StatusCode, string(respBody))
if attempt < maxRetries-1 {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(backoff)
}
}
return "", lastErr
}
func CallAI(provider, model string, messages []ChatMessage) (string, error) {
switch provider {
case "deepseek":
return CallDeepSeek(messages, model, 3)
case "kimi":
return CallKimi(messages, model)
default:
return "", fmt.Errorf("不支持的AI提供商: %s目前支持的提供商: deepseek, kimi", provider)
}
}

View File

@@ -0,0 +1,65 @@
package service
// 邮件服务模块
// SSO 重构后,验证码发送/验证已由认证中心处理
// 此文件仅保留通用工具函数,如有需要可用于未来的邮件通知功能
import (
"crypto/tls"
"fmt"
"net/smtp"
"infogenie-backend/config"
)
// SendNotificationEmail 发送通知邮件(预留,供未来使用)
func SendNotificationEmail(to, subject, htmlBody string) error {
return sendSMTPMail(to, subject, htmlBody)
}
func sendSMTPMail(to, subject, htmlBody string) error {
cfg := config.Cfg.Mail
from := cfg.Username
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n",
from, to, subject)
msg := headers + htmlBody
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
tlsConfig := &tls.Config{
ServerName: cfg.Host,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS连接失败: %w", err)
}
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("SMTP客户端创建失败: %w", err)
}
defer client.Quit()
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP认证失败: %w", err)
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
return w.Close()
}