feat: 增加管理员 AI 模型配置 (#200)

This commit is contained in:
chengma
2026-08-14 09:49:11 +08:00
parent ad44f83ea6
commit 018da3f732
24 changed files with 1546 additions and 8 deletions
+104
View File
@@ -0,0 +1,104 @@
package web
import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
func (h *Handler) AIConfigList(c *gin.Context) {
result, err := service.ListAIProviderConfigs(h.db, currentUser(c), h.aiSecrets)
if err != nil {
fail(c, http.StatusInternalServerError, "读取 AI 模型配置失败,配置没有被改动。")
return
}
c.HTML(http.StatusOK, "ai/list", page(c, "ai-settings", "AI 模型配置", gin.H{
"Rows": result.Items, "SecretStoreError": result.SecretStoreError,
"Message": c.Query("msg"), "Error": c.Query("error"),
}))
}
func (h *Handler) AIConfigSave(c *gin.Context) {
timeoutSeconds, _ := strconv.Atoi(c.PostForm("timeout_seconds"))
maxConcurrency, _ := strconv.Atoi(c.PostForm("max_concurrency"))
threshold, err := service.ParseConfidenceThresholdBPS(c.PostForm("confidence_threshold"))
if err != nil {
redirectAIConfig(c, "", err.Error())
return
}
_, err = service.SaveAIProviderConfig(h.db, currentUser(c), service.AIProviderInput{
ProviderID: c.PostForm("provider_id"), Name: c.PostForm("name"), BaseURL: c.PostForm("base_url"),
Model: c.PostForm("model"), TimeoutSeconds: timeoutSeconds, MaxConcurrency: maxConcurrency,
ConfidenceThresholdBPS: threshold,
}, h.aiPolicy, time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectAIConfig(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "保存 AI 服务商失败,原有效配置保持不变。")
return
}
redirectAIConfig(c, "AI 服务商配置已保存;请保存密钥并测试连接后再启用", "")
}
func (h *Handler) AIConfigSecretSave(c *gin.Context) {
if err := service.SetAIProviderSecret(h.db, currentUser(c), h.aiSecrets, c.PostForm("provider_id"), c.PostForm("api_key"), time.Now()); err != nil {
redirectAIConfig(c, "", err.Error())
return
}
redirectAIConfig(c, "API Key 已安全替换;当前服务商已停用,请重新测试后启用", "")
}
func (h *Handler) AIConfigSecretClear(c *gin.Context) {
if err := service.ClearAIProviderSecret(h.db, currentUser(c), h.aiSecrets, c.PostForm("provider_id"), time.Now()); err != nil {
redirectAIConfig(c, "", err.Error())
return
}
redirectAIConfig(c, "API Key 已清除,服务商已停用", "")
}
func (h *Handler) AIConfigTest(c *gin.Context) {
if err := service.TestAIProviderConnection(c.Request.Context(), h.db, currentUser(c), h.aiSecrets,
c.PostForm("provider_id"), h.aiPolicy, nil, time.Now()); err != nil {
redirectAIConfig(c, "", err.Error())
return
}
redirectAIConfig(c, "连接测试成功;现在可以启用这个服务商", "")
}
func (h *Handler) AIConfigEnable(c *gin.Context) {
if err := service.EnableAIProviderConfig(h.db, currentUser(c), h.aiSecrets, c.PostForm("provider_id"), time.Now()); err != nil {
redirectAIConfig(c, "", err.Error())
return
}
redirectAIConfig(c, "AI 服务商已启用,新批次将使用这份配置", "")
}
func (h *Handler) AIConfigDisable(c *gin.Context) {
if err := service.DisableAIProviderConfig(h.db, currentUser(c), c.PostForm("provider_id"), time.Now()); err != nil {
redirectAIConfig(c, "", err.Error())
return
}
redirectAIConfig(c, "AI 服务商已停用,不会影响已经启动的批次", "")
}
func redirectAIConfig(c *gin.Context, message, errorMessage string) {
values := url.Values{}
if message != "" {
values.Set("msg", message)
}
if errorMessage != "" {
values.Set("error", errorMessage)
}
target := "/settings/ai"
if encoded := values.Encode(); encoded != "" {
target += "?" + encoded
}
c.Redirect(http.StatusSeeOther, target)
}
+16 -2
View File
@@ -16,6 +16,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
// Handler 持有各页面共用的依赖。
@@ -25,14 +27,16 @@ type Handler struct {
// 取「轮询周期 + 最长任务时长」,宽松一点,
// 免得客户端执行长任务期间被误判成离线。
onlineThreshold time.Duration
aiSecrets service.AISecretStore
aiPolicy service.AIEndpointPolicy
}
// Register 把五个模块的页面路由挂上去。
//
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
h := &Handler{db: db, onlineThreshold: onlineThreshold}
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecrets service.AISecretStore, aiPolicy service.AIEndpointPolicy) {
h := &Handler{db: db, onlineThreshold: onlineThreshold, aiSecrets: aiSecrets, aiPolicy: aiPolicy}
// CSRF 只挂在页面路由上。初始化和登录是公开页面,但 POST 仍要 CSRF。
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
@@ -120,6 +124,16 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
integrations := pages.Group("/integrations", AdminRequired())
integrations.GET("/catalog", h.CatalogHistory)
integrations.GET("/catalog/detail", h.CatalogHistoryDetail)
// 8. AI 模型配置:密钥和系统级服务商配置仅管理员可见。
aiSettings := pages.Group("/settings/ai", AdminRequired())
aiSettings.GET("", h.AIConfigList)
aiSettings.POST("/save", h.AIConfigSave)
aiSettings.POST("/secret", h.AIConfigSecretSave)
aiSettings.POST("/secret/clear", h.AIConfigSecretClear)
aiSettings.POST("/test", h.AIConfigTest)
aiSettings.POST("/enable", h.AIConfigEnable)
aiSettings.POST("/disable", h.AIConfigDisable)
}
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。