feat: 更新项目代码

This commit is contained in:
2026-04-01 22:03:57 +08:00
parent 1c81d4e6ea
commit 284b5a5260
53 changed files with 6240 additions and 22665 deletions

View File

@@ -53,5 +53,6 @@ func AutoMigrate() error {
&model.SiteAIRuntime{},
&model.Site60sUpstream{},
&model.SiteAIModelDisabled{},
&model.SiteFeatureCardClick{},
)
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"strings"
@@ -51,8 +52,14 @@ func validateTextLen(text string, label string) (string, error) {
return t, nil
}
// POST /api/aimodelapp/chat
func (h *AIModelHandler) Chat(c *gin.Context) {
type aiModelChatInput struct {
Messages []service.ChatMessage
Provider string
Model string
}
// bindAIModelChat 绑定并校验统一 chat / chat/stream 请求体;失败时已写入 JSON 响应。
func bindAIModelChat(c *gin.Context) (aiModelChatInput, bool) {
var req struct {
Messages []service.ChatMessage `json:"messages"`
Provider string `json:"provider"`
@@ -60,7 +67,7 @@ func (h *AIModelHandler) Chat(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求数据为空"})
return
return aiModelChatInput{}, false
}
if req.Provider == "" {
req.Provider = "deepseek"
@@ -68,44 +75,99 @@ func (h *AIModelHandler) Chat(c *gin.Context) {
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
return aiModelChatInput{}, false
}
if len(req.Messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息内容不能为空"})
return
return aiModelChatInput{}, false
}
if len(req.Messages) > maxChatMsgCount {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("消息数量不能超过 %d 条", maxChatMsgCount)})
return
return aiModelChatInput{}, false
}
// 校验每条消息的长度
for _, m := range req.Messages {
if len(m.Content) > maxInputLen {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("单条消息长度不能超过 %d 字符", maxInputLen)})
return
return aiModelChatInput{}, false
}
}
return aiModelChatInput{Messages: req.Messages, Provider: req.Provider, Model: req.Model}, true
}
content, err := service.CallAI(req.Provider, req.Model, req.Messages)
// POST /api/aimodelapp/chat
func (h *AIModelHandler) Chat(c *gin.Context) {
in, ok := bindAIModelChat(c)
if !ok {
return
}
content, err := service.CallAI(in.Provider, in.Model, in.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,
"provider": in.Provider,
"model": in.Model,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/chat/stream — SSE 透传上游 OpenAI 兼容流data: {...}\\n\\n / [DONE]
func (h *AIModelHandler) ChatStream(c *gin.Context) {
in, ok := bindAIModelChat(c)
if !ok {
return
}
upstream, statusCode, err := service.OpenAIChatStream(c.Request.Context(), in.Provider, in.Model, in.Messages)
if err != nil {
if statusCode == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": safeAIError(err)})
return
}
defer upstream.Close()
hdr := c.Writer.Header()
hdr.Set("Content-Type", "text/event-stream; charset=utf-8")
hdr.Set("Cache-Control", "no-cache")
hdr.Set("Connection", "keep-alive")
hdr.Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
_, _ = io.Copy(c.Writer, upstream)
return
}
buf := make([]byte, 8192)
for {
select {
case <-c.Request.Context().Done():
return
default:
}
n, rerr := upstream.Read(buf)
if n > 0 {
if _, werr := c.Writer.Write(buf[:n]); werr != nil {
return
}
flusher.Flush()
}
if rerr == io.EOF {
return
}
if rerr != nil {
return
}
}
}
// POST /api/aimodelapp/name-analysis
func (h *AIModelHandler) NameAnalysis(c *gin.Context) {
var req struct {

View File

@@ -0,0 +1,95 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
var featureCardSections = map[string]struct{}{
"60sapi": {},
"smallgame": {},
"toolbox": {},
"aimodel": {},
}
func normalizeFeatureSection(s string) (string, bool) {
key := strings.ToLower(strings.TrimSpace(s))
if key == "" {
return "", false
}
if _, ok := featureCardSections[key]; !ok {
return "", false
}
return key, true
}
func sanitizeFeatureItemID(raw string) (string, bool) {
id := strings.TrimSpace(raw)
if id == "" || len(id) > 128 {
return "", false
}
return id, true
}
// GetFeatureCardClicks 公开:按板块返回各功能 id 的点击次数(未出现过的 id 前端视为 0
func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
section, ok := normalizeFeatureSection(c.Query("section"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
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"})
return
}
counts := make(map[string]uint64, len(rows))
for _, r := range rows {
counts[r.ItemID] = r.ClickCount
}
c.JSON(http.StatusOK, gin.H{"section": section, "counts": counts})
}
type postFeatureCardClickBody struct {
Section string `json:"section"`
ItemID string `json:"item_id"`
}
// PostFeatureCardClickIncrement 公开:记录一次卡片点击并返回最新计数
func (h *SiteConfigHandler) PostFeatureCardClickIncrement(c *gin.Context) {
var body postFeatureCardClickBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
return
}
section, ok := normalizeFeatureSection(body.Section)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
return
}
itemID, ok := sanitizeFeatureItemID(body.ItemID)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_item_id"})
return
}
const q = `INSERT INTO site_feature_card_clicks (section, item_id, click_count, updated_at)
VALUES (?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()`
if err := database.DB.Exec(q, section, itemID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
var row model.SiteFeatureCardClick
if err := database.DB.Where("section = ? AND item_id = ?", section, itemID).First(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
c.JSON(http.StatusOK, gin.H{"section": section, "item_id": itemID, "count": row.ClickCount})
}

View File

@@ -131,6 +131,19 @@ func resolve60sUpstream(sourceID string) (id string, info sixtySrcInfo) {
return id, info
}
// EffectiveSixtyUpstream 返回当前站点配置的 60s API 根地址(读库;无记录或库不可用则回落默认 self
func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label string) {
sid := ""
if db != nil {
var row model.Site60sUpstream
if err := db.First(&row, 1).Error; err == nil {
sid = row.SourceID
}
}
id, info := resolve60sUpstream(sid)
return id, info.Base, info.Label
}
// Get60sSource 公开:当前站点使用的 60s 上游 base_url供静态页 iframe 传参)
func (h *SiteConfigHandler) Get60sSource(c *gin.Context) {
var row model.Site60sUpstream

View File

@@ -0,0 +1,13 @@
package model
import "time"
// SiteFeatureCardClick 四大板块功能卡片点击次数section + item_id 唯一)
type SiteFeatureCardClick struct {
Section string `gorm:"primaryKey;type:varchar(24);not null" json:"section"`
ItemID string `gorm:"primaryKey;type:varchar(128);not null" json:"item_id"`
ClickCount uint64 `gorm:"not null;default:0" json:"click_count"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SiteFeatureCardClick) TableName() string { return "site_feature_card_clicks" }

View File

@@ -1,7 +1,10 @@
package router
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -11,6 +14,31 @@ import (
"infogenie-backend/internal/middleware"
)
func probeSixtyPublicAPI(ctx context.Context, base string) (ok bool, httpStatus int, errMsg string, ms int64, probeURL string) {
b := strings.TrimSpace(base)
if b == "" {
return false, 0, "empty_base", 0, ""
}
probeURL = strings.TrimRight(b, "/") + "/v2/ip"
client := &http.Client{Timeout: 6 * time.Second}
t0 := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
if err != nil {
return false, 0, err.Error(), time.Since(t0).Milliseconds(), probeURL
}
resp, err := client.Do(req)
ms = time.Since(t0).Milliseconds()
if err != nil {
return false, 0, err.Error(), ms, probeURL
}
_ = resp.Body.Close()
ok = resp.StatusCode >= 200 && resp.StatusCode < 500
if !ok && errMsg == "" {
errMsg = fmt.Sprintf("http_%d", resp.StatusCode)
}
return ok, resp.StatusCode, "", ms, probeURL
}
func Setup(r *gin.Engine) {
r.Use(middleware.CORS())
@@ -24,33 +52,66 @@ func Setup(r *gin.Engine) {
c.JSON(http.StatusOK, gin.H{
"message": "万象口袋 后端 API 服务运行中",
"description": "提供AI模型应用接口用户认证由萌芽账户认证中心提供",
"version": "3.2.0-go",
"version": "3.3.0-go",
"timestamp": time.Now().Format(time.RFC3339),
"endpoints": gin.H{
"auth": "/api/auth (via 萌芽认证中心)",
"user": "/api/user",
"aimodelapp": "/api/aimodelapp",
"site": "/api/site",
"site": "/api/site (含 feature-card-clicks)",
"admin_site": "/api/admin/site/*",
},
})
})
// 健康检查:实际检测数据库连接
// 健康检查:数据库 Ping + 当前配置的 60s 上游轻量探测GET …/v2/ip
r.GET("/api/health", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
dbStatus := "connected"
if database.DB != nil {
sqlDB, err := database.DB.DB()
if err != nil || sqlDB.Ping() != nil {
if err != nil || sqlDB.PingContext(ctx) != nil {
dbStatus = "disconnected"
}
} else {
dbStatus = "not_initialized"
}
mysqlOK := dbStatus == "connected"
sid, sixtyBase, sixtyLabel := handler.EffectiveSixtyUpstream(database.DB)
sixtyOK, sixtyHTTP, sixtyErr, sixtyMs, probeURL := probeSixtyPublicAPI(ctx, sixtyBase)
if !sixtyOK && sixtyErr == "" && sixtyHTTP >= 500 {
sixtyErr = fmt.Sprintf("http_%d", sixtyHTTP)
}
overall := "running"
if !mysqlOK || !sixtyOK {
overall = "degraded"
}
c.JSON(http.StatusOK, gin.H{
"status": "running",
"database": dbStatus,
"status": overall,
"timestamp": time.Now().Format(time.RFC3339),
"database": dbStatus,
"mysql": gin.H{
"ok": mysqlOK,
"status": dbStatus,
},
"backend_api": gin.H{
"ok": true,
},
"sixty_api": gin.H{
"ok": sixtyOK,
"source_id": sid,
"base_url": sixtyBase,
"label": sixtyLabel,
"probe_url": probeURL,
"http_status": sixtyHTTP,
"latency_ms": sixtyMs,
"error": sixtyErr,
},
})
})
@@ -68,6 +129,8 @@ func Setup(r *gin.Engine) {
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.GET("/api/site/feature-card-clicks", siteH.GetFeatureCardClicks)
r.POST("/api/site/feature-card-clicks/increment", siteH.PostFeatureCardClickIncrement)
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)
@@ -77,6 +140,7 @@ func Setup(r *gin.Engine) {
ai := r.Group("/api/aimodelapp")
{
ai.POST("/chat", middleware.JWTAuth(), aiH.Chat)
ai.POST("/chat/stream", middleware.JWTAuth(), aiH.ChatStream)
ai.POST("/name-analysis", middleware.JWTAuth(), aiH.NameAnalysis)
ai.POST("/variable-naming", middleware.JWTAuth(), aiH.VariableNaming)
ai.POST("/poetry", middleware.JWTAuth(), aiH.Poetry)

View File

@@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -75,8 +76,8 @@ func loadRuntimeDeepSeek() (apiBase, apiKey, defModel string, ok bool) {
return "", "", "", false
}
func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string, error) {
// 首先尝试从SiteAIRuntime读取配置向后兼容
// openDeepSeekChatURL 解析 DeepSeek 兼容 /chat/completions 的完整 URL、密钥与最终落库模型名
func openDeepSeekChatURL(model string) (fullURL, apiKey, resolvedModel string, err error) {
if base, key, defModel, ok := loadRuntimeDeepSeek(); ok {
if model == "" {
model = defModel
@@ -84,65 +85,74 @@ func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string,
if model == "" {
model = "deepseek-chat"
}
url := strings.TrimSuffix(base, "/") + "/chat/completions"
return callOpenAICompatible(url, key, model, messages, maxRetries, 90*time.Second)
return strings.TrimSuffix(base, "/") + "/chat/completions", key, model, nil
}
// 从新的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] // 使用第一个允许的模型
apiKey, apiBase, defaultModel, models, ok := loadAIConfig("deepseek")
if !ok {
return "", "", "", fmt.Errorf("DeepSeek配置未设置请在管理员后台配置API Key和Base URL")
}
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
}
}
url := strings.TrimSuffix(apiBase, "/") + "/chat/completions"
return callOpenAICompatible(url, apiKey, model, messages, maxRetries, 90*time.Second)
if !allowed {
model = models[0]
}
}
return strings.TrimSuffix(apiBase, "/") + "/chat/completions", apiKey, model, nil
}
return "", fmt.Errorf("DeepSeek配置未设置请在管理员后台配置API Key和Base URL")
func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string, error) {
urlStr, key, m, err := openDeepSeekChatURL(model)
if err != nil {
return "", err
}
return callOpenAICompatible(urlStr, key, m, messages, maxRetries, 90*time.Second)
}
// openKimiChatURL 解析 Kimi /v1/chat/completions
func openKimiChatURL(model string) (fullURL, apiKey, resolvedModel string, err error) {
apiKey, apiBase, defaultModel, models, ok := loadAIConfig("kimi")
if !ok {
return "", "", "", fmt.Errorf("Kimi配置未设置请在管理员后台配置API Key和Base URL")
}
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]
}
}
return strings.TrimSuffix(apiBase, "/") + "/v1/chat/completions", apiKey, model, nil
}
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)
urlStr, key, m, err := openKimiChatURL(model)
if err != nil {
return "", err
}
return "", fmt.Errorf("Kimi配置未设置请在管理员后台配置API Key和Base URL")
return callOpenAICompatible(urlStr, key, m, messages, 1, 30*time.Second)
}
func callOpenAICompatible(url, apiKey, model string, messages []ChatMessage, maxRetries int, timeout time.Duration) (string, error) {
@@ -211,3 +221,49 @@ func CallAI(provider, model string, messages []ChatMessage) (string, error) {
return "", fmt.Errorf("不支持的AI提供商: %s目前支持的提供商: deepseek, kimi", provider)
}
}
// OpenAIChatStream 向上游发起 stream:true 的请求;返回的 ReadCloser 需由调用方 Close。statusCode 非 200 时 body 已读完并关闭rc 为 nil。
func OpenAIChatStream(ctx context.Context, provider, model string, messages []ChatMessage) (rc io.ReadCloser, statusCode int, err error) {
var urlStr, apiKey, m string
switch provider {
case "deepseek":
urlStr, apiKey, m, err = openDeepSeekChatURL(model)
case "kimi":
urlStr, apiKey, m, err = openKimiChatURL(model)
default:
return nil, 0, fmt.Errorf("不支持的AI提供商: %s", provider)
}
if err != nil {
return nil, 0, err
}
streamBody := map[string]interface{}{
"model": m,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2000,
"stream": true,
}
bodyBytes, jerr := json.Marshal(streamBody)
if jerr != nil {
return nil, 0, fmt.Errorf("序列化请求失败: %w", jerr)
}
req, rerr := http.NewRequestWithContext(ctx, "POST", urlStr, bytes.NewReader(bodyBytes))
if rerr != nil {
return nil, 0, rerr
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
client := &http.Client{}
resp, derr := client.Do(req)
if derr != nil {
return nil, 0, derr
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, resp.StatusCode, fmt.Errorf("%s", strings.TrimSpace(string(b)))
}
return resp.Body, http.StatusOK, nil
}

View File

@@ -0,0 +1,117 @@
# 万象口袋 — 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`**。