feat: 增加管理员 AI 模型配置 (#200)
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAIConfigTemplate_密钥不回显且表单语义完整(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("templates/ai/list.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := string(raw)
|
||||||
|
for _, want := range []string{
|
||||||
|
`action="/settings/ai/save"`, `action="/settings/ai/secret"`,
|
||||||
|
`action="/settings/ai/test"`, `action="/settings/ai/enable"`,
|
||||||
|
`type="password"`, `autocomplete="new-password"`, "连接测试", "置信度%",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(source, want) {
|
||||||
|
t.Errorf("AI 配置模板缺少 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{".APIKey", `value="{{.Secret`, `value="{{.API`} {
|
||||||
|
if strings.Contains(source, forbidden) {
|
||||||
|
t.Fatalf("AI 配置模板可能回显密钥字段 %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,16 @@ integrations:
|
|||||||
# 留空表示商品目录接口禁用,不影响 Admin 页面和 Client 四接口。
|
# 留空表示商品目录接口禁用,不影响 Admin 页面和 Client 四接口。
|
||||||
token: ""
|
token: ""
|
||||||
|
|
||||||
|
ai:
|
||||||
|
# AI API Key 只能写入这个独立密钥文件。生产建议通过
|
||||||
|
# CMAUTOBUY_AI_SECRETS_PATH 指向 /etc/cmautobuy/ai-secrets.yaml,权限 600。
|
||||||
|
# 留空时 Admin 正常启动,但不能保存密钥或测试 AI 连接。
|
||||||
|
secrets_path: ""
|
||||||
|
|
||||||
|
# 默认只允许公网 HTTPS 服务。确需访问私有模型端点时,必须由部署人员
|
||||||
|
# 在这里或 CMAUTOBUY_AI_ALLOWED_HOSTS 中按主机名显式允许,网页不能修改。
|
||||||
|
allowed_hosts: []
|
||||||
|
|
||||||
syb:
|
syb:
|
||||||
# 顺运宝 ERP 地址。一般不用改,域名变了才改。
|
# 顺运宝 ERP 地址。一般不用改,域名变了才改。
|
||||||
base_url: https://www.shunyunbaoerp.com
|
base_url: https://www.shunyunbaoerp.com
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const (
|
|||||||
databaseTLSCAEnv = "CMAUTOBUY_DB_TLS_CA"
|
databaseTLSCAEnv = "CMAUTOBUY_DB_TLS_CA"
|
||||||
catalogSourceEnv = "CMAUTOBUY_CATALOG_SOURCE"
|
catalogSourceEnv = "CMAUTOBUY_CATALOG_SOURCE"
|
||||||
catalogTokenEnv = "CMAUTOBUY_CATALOG_TOKEN"
|
catalogTokenEnv = "CMAUTOBUY_CATALOG_TOKEN"
|
||||||
|
aiSecretsPathEnv = "CMAUTOBUY_AI_SECRETS_PATH"
|
||||||
|
aiAllowedHostsEnv = "CMAUTOBUY_AI_ALLOWED_HOSTS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -322,6 +324,51 @@ type Config struct {
|
|||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
Syb SybConfig `yaml:"syb"`
|
Syb SybConfig `yaml:"syb"`
|
||||||
Integrations IntegrationsConfig `yaml:"integrations"`
|
Integrations IntegrationsConfig `yaml:"integrations"`
|
||||||
|
AI AIConfig `yaml:"ai"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIConfig 只保存部署级安全边界,不保存网页维护的服务商配置和 API Key。
|
||||||
|
type AIConfig struct {
|
||||||
|
SecretsPath string `yaml:"secrets_path"`
|
||||||
|
AllowedHosts []string `yaml:"allowed_hosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAIConfig 读取可选的 AI 密钥路径和私有端点允许列表。
|
||||||
|
// 未配置时 Admin 仍能启动,但网页不能保存密钥或测试连接。
|
||||||
|
func LoadAIConfig() (AIConfig, error) {
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return AIConfig{}, fmt.Errorf("无法确定 AI 配置路径: %w", err)
|
||||||
|
}
|
||||||
|
var cfg AIConfig
|
||||||
|
raw, readErr := os.ReadFile(path)
|
||||||
|
if readErr == nil {
|
||||||
|
parsed, parseErr := parseConfig(raw, path)
|
||||||
|
if parseErr != nil {
|
||||||
|
return AIConfig{}, parseErr
|
||||||
|
}
|
||||||
|
cfg = parsed.AI
|
||||||
|
} else if !os.IsNotExist(readErr) {
|
||||||
|
return AIConfig{}, fmt.Errorf("读取配置文件 %s 失败: %w", path, readErr)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv(aiSecretsPathEnv)); value != "" {
|
||||||
|
cfg.SecretsPath = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv(aiAllowedHostsEnv)); value != "" {
|
||||||
|
cfg.AllowedHosts = strings.Split(value, ",")
|
||||||
|
}
|
||||||
|
cfg.SecretsPath = strings.TrimSpace(cfg.SecretsPath)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
hosts := make([]string, 0, len(cfg.AllowedHosts))
|
||||||
|
for _, host := range cfg.AllowedHosts {
|
||||||
|
host = strings.ToLower(strings.TrimSpace(host))
|
||||||
|
if host != "" && !seen[host] {
|
||||||
|
seen[host] = true
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.AllowedHosts = hosts
|
||||||
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IntegrationsConfig 收拢所有非 Client 的第三方数据接入配置。
|
// IntegrationsConfig 收拢所有非 Client 的第三方数据接入配置。
|
||||||
|
|||||||
@@ -327,3 +327,18 @@ func TestCatalogIntegrationConfig_弱Token拒绝且留空可禁用(t *testing.T)
|
|||||||
t.Fatalf("弱 Token 应被拒绝:%v", err)
|
t.Fatalf("弱 Token 应被拒绝:%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_AI部署配置不包含APIKey(t *testing.T) {
|
||||||
|
cfg, err := loadFrom(t, `
|
||||||
|
ai:
|
||||||
|
secrets_path: "C:/secure/ai-secrets.yaml"
|
||||||
|
allowed_hosts:
|
||||||
|
- model.internal.example
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.AI.SecretsPath != "C:/secure/ai-secrets.yaml" || len(cfg.AI.AllowedHosts) != 1 {
|
||||||
|
t.Fatalf("AI 部署配置解析错误: %+v", cfg.AI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +16,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler 持有各页面共用的依赖。
|
// Handler 持有各页面共用的依赖。
|
||||||
@@ -25,14 +27,16 @@ type Handler struct {
|
|||||||
// 取「轮询周期 + 最长任务时长」,宽松一点,
|
// 取「轮询周期 + 最长任务时长」,宽松一点,
|
||||||
// 免得客户端执行长任务期间被误判成离线。
|
// 免得客户端执行长任务期间被误判成离线。
|
||||||
onlineThreshold time.Duration
|
onlineThreshold time.Duration
|
||||||
|
aiSecrets service.AISecretStore
|
||||||
|
aiPolicy service.AIEndpointPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 把五个模块的页面路由挂上去。
|
// Register 把五个模块的页面路由挂上去。
|
||||||
//
|
//
|
||||||
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
|
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
|
||||||
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
|
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
|
||||||
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecrets service.AISecretStore, aiPolicy service.AIEndpointPolicy) {
|
||||||
h := &Handler{db: db, onlineThreshold: onlineThreshold}
|
h := &Handler{db: db, onlineThreshold: onlineThreshold, aiSecrets: aiSecrets, aiPolicy: aiPolicy}
|
||||||
|
|
||||||
// CSRF 只挂在页面路由上。初始化和登录是公开页面,但 POST 仍要 CSRF。
|
// CSRF 只挂在页面路由上。初始化和登录是公开页面,但 POST 仍要 CSRF。
|
||||||
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
|
// 给 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 := pages.Group("/integrations", AdminRequired())
|
||||||
integrations.GET("/catalog", h.CatalogHistory)
|
integrations.GET("/catalog", h.CatalogHistory)
|
||||||
integrations.GET("/catalog/detail", h.CatalogHistoryDetail)
|
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)。
|
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。
|
||||||
|
|||||||
+7
-1
@@ -107,7 +107,13 @@ func newRouter(db *sql.DB) (*gin.Engine, error) {
|
|||||||
r.StaticFS("/static", http.FS(staticSub))
|
r.StaticFS("/static", http.FS(staticSub))
|
||||||
|
|
||||||
// 4. 路由
|
// 4. 路由
|
||||||
web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面
|
aiConfig, err := config.LoadAIConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 AI 部署配置失败: %w", err)
|
||||||
|
}
|
||||||
|
aiSecrets := service.NewFileAISecretStore(aiConfig.SecretsPath)
|
||||||
|
aiPolicy := service.NewAIEndpointPolicy(aiConfig.AllowedHosts)
|
||||||
|
web.Register(r, db, config.OnlineThreshold, aiSecrets, aiPolicy) // 给浏览器的页面
|
||||||
api.Register(r, db) // 给 Client 的接口
|
api.Register(r, db) // 给 Client 的接口
|
||||||
catalogConfig, err := config.LoadCatalogIntegration()
|
catalogConfig, err := config.LoadCatalogIntegration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -570,6 +570,13 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
aiSettingsRequest := httptest.NewRequest(http.MethodGet, "/settings/ai", nil)
|
||||||
|
addAuth(aiSettingsRequest)
|
||||||
|
aiSettingsResponse := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(aiSettingsResponse, aiSettingsRequest)
|
||||||
|
if aiSettingsResponse.Code != http.StatusOK || !strings.Contains(aiSettingsResponse.Body.String(), "AI 模型配置") {
|
||||||
|
t.Fatalf("管理员 GET /settings/ai = %d,响应:%s", aiSettingsResponse.Code, aiSettingsResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
pddRequest := httptest.NewRequest(http.MethodGet, "/pdd", nil)
|
pddRequest := httptest.NewRequest(http.MethodGet, "/pdd", nil)
|
||||||
addAuth(pddRequest)
|
addAuth(pddRequest)
|
||||||
@@ -608,6 +615,30 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
if usersResponse.Code != http.StatusForbidden {
|
if usersResponse.Code != http.StatusForbidden {
|
||||||
t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code)
|
t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code)
|
||||||
}
|
}
|
||||||
|
aiDeniedRequest := httptest.NewRequest(http.MethodGet, "/settings/ai", nil)
|
||||||
|
aiDeniedRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
|
||||||
|
aiDeniedResponse := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(aiDeniedResponse, aiDeniedRequest)
|
||||||
|
if aiDeniedResponse.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("采购员 GET /settings/ai = %d,期望 403", aiDeniedResponse.Code)
|
||||||
|
}
|
||||||
|
buyerPageRequest := httptest.NewRequest(http.MethodGet, "/shopee", nil)
|
||||||
|
buyerPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
|
||||||
|
buyerPageResponse := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(buyerPageResponse, buyerPageRequest)
|
||||||
|
buyerCSRF := findResponseCookie(t, buyerPageResponse, "cmautobuy_csrf")
|
||||||
|
for _, path := range []string{
|
||||||
|
"/settings/ai/save", "/settings/ai/secret", "/settings/ai/secret/clear",
|
||||||
|
"/settings/ai/test", "/settings/ai/enable", "/settings/ai/disable",
|
||||||
|
} {
|
||||||
|
request := postFormRequest(path, url.Values{"csrf_token": {buyerCSRF.Value}}, buyerCSRF)
|
||||||
|
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("采购员 POST %s = %d,期望 403", path, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, client := range []model.Client{
|
for _, client := range []model.Client{
|
||||||
{ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`},
|
{ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`},
|
||||||
{ClientID: "CLIENT-DRY", Name: "未就绪机", Capabilities: `{"purchase_mode":"dry_run"}`},
|
{ClientID: "CLIENT-DRY", Name: "未就绪机", Capabilities: `{"purchase_mode":"dry_run"}`},
|
||||||
|
|||||||
+59
-1
@@ -4,7 +4,10 @@
|
|||||||
// 字段含义的权威定义在 docs/admin/03-data-model.md。
|
// 字段含义的权威定义在 docs/admin/03-data-model.md。
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ---------- 时间 ----------
|
// ---------- 时间 ----------
|
||||||
|
|
||||||
@@ -100,6 +103,61 @@ func (u User) StatusLabel() string {
|
|||||||
return "禁用"
|
return "禁用"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AIProviderConfig 是一个 OpenAI 兼容服务商的非敏感配置。
|
||||||
|
// API Key 不属于这个结构,也绝不能进入数据库。
|
||||||
|
type AIProviderConfig struct {
|
||||||
|
ProviderID string
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
TimeoutSeconds int
|
||||||
|
MaxConcurrency int
|
||||||
|
ConfidenceThresholdBPS int
|
||||||
|
Enabled bool
|
||||||
|
LastTestStatus string
|
||||||
|
LastTestMessage string
|
||||||
|
LastTestedAt string
|
||||||
|
LastTestFingerprint string
|
||||||
|
CreatedByUserID string
|
||||||
|
UpdatedByUserID string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
SecretConfigured bool
|
||||||
|
SecretSuffix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c AIProviderConfig) StatusLabel() string {
|
||||||
|
if c.Enabled {
|
||||||
|
return "已启用"
|
||||||
|
}
|
||||||
|
return "已停用"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c AIProviderConfig) TestStatusLabel() string {
|
||||||
|
switch c.LastTestStatus {
|
||||||
|
case "succeeded":
|
||||||
|
return "连接正常"
|
||||||
|
case "failed":
|
||||||
|
return "连接失败"
|
||||||
|
default:
|
||||||
|
return "尚未测试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c AIProviderConfig) ConfidencePercent() string {
|
||||||
|
return fmt.Sprintf("%d.%02d", c.ConfidenceThresholdBPS/100, c.ConfidenceThresholdBPS%100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIProviderAudit 只记录非敏感配置变更;DetailsJSON 不得含 API Key。
|
||||||
|
type AIProviderAudit struct {
|
||||||
|
ID int64
|
||||||
|
ProviderID string
|
||||||
|
Action string
|
||||||
|
DetailsJSON string
|
||||||
|
ActorUserID string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
// WebSession 是服务端保存的网页登录状态。SessionHash 是浏览器随机 Token
|
// WebSession 是服务端保存的网页登录状态。SessionHash 是浏览器随机 Token
|
||||||
// 的 SHA-256,不是 Token 原文。
|
// 的 SHA-256,不是 Token 原文。
|
||||||
type WebSession struct {
|
type WebSession struct {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAIProviderNameExists = errors.New("AI 服务商名称已经存在")
|
||||||
|
|
||||||
|
func ListAIProviders(q Execer) ([]model.AIProviderConfig, error) {
|
||||||
|
rows, err := q.Query(`SELECT provider_id,name,base_url,model,timeout_seconds,max_concurrency,
|
||||||
|
confidence_threshold_bps,enabled,last_test_status,last_test_message,last_tested_at,
|
||||||
|
last_test_fingerprint,created_by_user_id,updated_by_user_id,created_at,updated_at
|
||||||
|
FROM ai_provider_configs ORDER BY enabled DESC,name,provider_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询 AI 服务商配置失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []model.AIProviderConfig
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanAIProvider(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAIProvider(q Execer, providerID string) (*model.AIProviderConfig, error) {
|
||||||
|
item, err := scanAIProvider(func(dest ...any) error {
|
||||||
|
return q.QueryRow(`SELECT provider_id,name,base_url,model,timeout_seconds,max_concurrency,
|
||||||
|
confidence_threshold_bps,enabled,last_test_status,last_test_message,last_tested_at,
|
||||||
|
last_test_fingerprint,created_by_user_id,updated_by_user_id,created_at,updated_at
|
||||||
|
FROM ai_provider_configs WHERE provider_id=?`, providerID).Scan(dest...)
|
||||||
|
})
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询 AI 服务商配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanValues func(dest ...any) error
|
||||||
|
|
||||||
|
func scanAIProvider(scan scanValues) (model.AIProviderConfig, error) {
|
||||||
|
var item model.AIProviderConfig
|
||||||
|
var enabled int
|
||||||
|
var lastMessage, lastTestedAt, fingerprint sql.NullString
|
||||||
|
err := scan(&item.ProviderID, &item.Name, &item.BaseURL, &item.Model, &item.TimeoutSeconds,
|
||||||
|
&item.MaxConcurrency, &item.ConfidenceThresholdBPS, &enabled, &item.LastTestStatus,
|
||||||
|
&lastMessage, &lastTestedAt, &fingerprint, &item.CreatedByUserID, &item.UpdatedByUserID,
|
||||||
|
&item.CreatedAt, &item.UpdatedAt)
|
||||||
|
item.Enabled = enabled == 1
|
||||||
|
item.LastTestMessage = lastMessage.String
|
||||||
|
item.LastTestedAt = lastTestedAt.String
|
||||||
|
item.LastTestFingerprint = fingerprint.String
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertAIProvider(q Execer, item model.AIProviderConfig) error {
|
||||||
|
_, err := q.Exec(`INSERT INTO ai_provider_configs
|
||||||
|
(provider_id,name,base_url,model,timeout_seconds,max_concurrency,confidence_threshold_bps,
|
||||||
|
enabled,last_test_status,created_by_user_id,updated_by_user_id,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,0,'pending',?,?,?,?,?)`, item.ProviderID, item.Name, item.BaseURL,
|
||||||
|
item.Model, item.TimeoutSeconds, item.MaxConcurrency, item.ConfidenceThresholdBPS,
|
||||||
|
item.CreatedByUserID, item.UpdatedByUserID, item.CreatedAt, item.UpdatedAt)
|
||||||
|
return aiProviderWriteError("新增 AI 服务商配置", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateAIProvider(q Execer, item model.AIProviderConfig) (bool, error) {
|
||||||
|
result, err := q.Exec(`UPDATE ai_provider_configs SET name=?,base_url=?,model=?,timeout_seconds=?,
|
||||||
|
max_concurrency=?,confidence_threshold_bps=?,last_test_status='pending',last_test_message=NULL,
|
||||||
|
last_tested_at=NULL,last_test_fingerprint=NULL,enabled=0,updated_by_user_id=?,updated_at=? WHERE provider_id=?`,
|
||||||
|
item.Name, item.BaseURL, item.Model, item.TimeoutSeconds, item.MaxConcurrency,
|
||||||
|
item.ConfidenceThresholdBPS, item.UpdatedByUserID, item.UpdatedAt, item.ProviderID)
|
||||||
|
if err != nil {
|
||||||
|
return false, aiProviderWriteError("更新 AI 服务商配置", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordAIProviderTest(q Execer, providerID, status, message, testedAt, fingerprint, actorID string) (bool, error) {
|
||||||
|
result, err := q.Exec(`UPDATE ai_provider_configs SET last_test_status=?,last_test_message=?,
|
||||||
|
last_tested_at=?,last_test_fingerprint=?,updated_by_user_id=?,updated_at=? WHERE provider_id=?`,
|
||||||
|
status, nullableText(message), testedAt, nullableText(fingerprint), actorID, testedAt, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("记录 AI 连接测试失败: %w", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvalidateAIProviderTest(q Execer, providerID, actorID, updatedAt string) (bool, error) {
|
||||||
|
result, err := q.Exec(`UPDATE ai_provider_configs SET last_test_status='pending',last_test_message=NULL,
|
||||||
|
last_tested_at=NULL,last_test_fingerprint=NULL,enabled=0,updated_by_user_id=?,updated_at=? WHERE provider_id=?`,
|
||||||
|
actorID, updatedAt, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("重置 AI 连接测试状态失败: %w", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func LockAIProviders(q Execer) error {
|
||||||
|
rows, err := q.Query(`SELECT provider_id FROM ai_provider_configs FOR UPDATE`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("锁定 AI 服务商配置失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var providerID string
|
||||||
|
if err := rows.Scan(&providerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnableAIProvider(q Execer, providerID, actorID, updatedAt string) (bool, error) {
|
||||||
|
if _, err := q.Exec(`UPDATE ai_provider_configs SET enabled=0,updated_by_user_id=?,updated_at=? WHERE enabled=1`, actorID, updatedAt); err != nil {
|
||||||
|
return false, fmt.Errorf("停用旧 AI 服务商失败: %w", err)
|
||||||
|
}
|
||||||
|
result, err := q.Exec(`UPDATE ai_provider_configs SET enabled=1,updated_by_user_id=?,updated_at=? WHERE provider_id=?`, actorID, updatedAt, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("启用 AI 服务商失败: %w", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DisableAIProvider(q Execer, providerID, actorID, updatedAt string) (bool, error) {
|
||||||
|
result, err := q.Exec(`UPDATE ai_provider_configs SET enabled=0,updated_by_user_id=?,updated_at=? WHERE provider_id=?`, actorID, updatedAt, providerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("停用 AI 服务商失败: %w", err)
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertAIProviderAudit(q Execer, audit model.AIProviderAudit) error {
|
||||||
|
_, err := q.Exec(`INSERT INTO ai_provider_audits(provider_id,action,details_json,actor_user_id,created_at)
|
||||||
|
VALUES(?,?,?,?,?)`, nullableText(audit.ProviderID), audit.Action, audit.DetailsJSON,
|
||||||
|
audit.ActorUserID, audit.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入 AI 配置审计失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiProviderWriteError(action string, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var mysqlErr *mysql.MySQLError
|
||||||
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
|
return ErrAIProviderNameExists
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s失败: %w", action, err)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"cmautobuy/admin/spec"
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 19
|
const mysqlSchemaVersion = 20
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||||
@@ -652,10 +652,75 @@ func MigrateMySQL(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 19, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 19, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
return fmt.Errorf("记录 MySQL schema v19 失败: %w", err)
|
return fmt.Errorf("记录 MySQL schema v19 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
current = 19
|
||||||
|
}
|
||||||
|
if current < 20 {
|
||||||
|
if err := migrateMySQLV20(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v20 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV20Shape(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v20 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 20, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v20 失败: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CheckMySQLSchema(db)
|
return CheckMySQLSchema(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateMySQLV20 保存 AI 服务商的非敏感配置和配置审计。
|
||||||
|
// API Key 只存在独立密钥文件中,不在这两张表里留列。
|
||||||
|
func migrateMySQLV20(db *sql.DB) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS ai_provider_configs (
|
||||||
|
provider_id VARCHAR(191) COLLATE utf8mb4_bin PRIMARY KEY,
|
||||||
|
name VARCHAR(191) NOT NULL,
|
||||||
|
base_url VARCHAR(2048) NOT NULL,
|
||||||
|
model VARCHAR(191) NOT NULL,
|
||||||
|
timeout_seconds INT NOT NULL,
|
||||||
|
max_concurrency INT NOT NULL,
|
||||||
|
confidence_threshold_bps INT NOT NULL,
|
||||||
|
enabled TINYINT NOT NULL DEFAULT 0,
|
||||||
|
active_slot TINYINT GENERATED ALWAYS AS (IF(enabled=1,1,NULL)) STORED,
|
||||||
|
last_test_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
last_test_message VARCHAR(500),
|
||||||
|
last_tested_at VARCHAR(35),
|
||||||
|
last_test_fingerprint CHAR(64) COLLATE utf8mb4_bin,
|
||||||
|
created_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
updated_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
created_at VARCHAR(35) NOT NULL,
|
||||||
|
updated_at VARCHAR(35) NOT NULL,
|
||||||
|
UNIQUE KEY uq_ai_provider_name (name),
|
||||||
|
UNIQUE KEY uq_ai_provider_active (active_slot),
|
||||||
|
KEY idx_ai_provider_updated (updated_at DESC,provider_id),
|
||||||
|
CONSTRAINT fk_ai_provider_created_by FOREIGN KEY (created_by_user_id) REFERENCES users(user_id),
|
||||||
|
CONSTRAINT fk_ai_provider_updated_by FOREIGN KEY (updated_by_user_id) REFERENCES users(user_id),
|
||||||
|
CONSTRAINT chk_ai_provider_enabled CHECK (enabled IN (0,1)),
|
||||||
|
CONSTRAINT chk_ai_provider_timeout CHECK (timeout_seconds BETWEEN 1 AND 120),
|
||||||
|
CONSTRAINT chk_ai_provider_concurrency CHECK (max_concurrency BETWEEN 1 AND 16),
|
||||||
|
CONSTRAINT chk_ai_provider_confidence CHECK (confidence_threshold_bps BETWEEN 0 AND 10000),
|
||||||
|
CONSTRAINT chk_ai_provider_test_status CHECK (last_test_status IN ('pending','succeeded','failed'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ai_provider_audits (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
provider_id VARCHAR(191) COLLATE utf8mb4_bin,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
details_json LONGTEXT NOT NULL,
|
||||||
|
actor_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
created_at VARCHAR(35) NOT NULL,
|
||||||
|
KEY idx_ai_provider_audit (provider_id,created_at DESC,id DESC),
|
||||||
|
CONSTRAINT fk_ai_audit_provider FOREIGN KEY (provider_id) REFERENCES ai_provider_configs(provider_id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_ai_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := db.Exec(statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// migrateMySQLV15 以低优先级补全历史 SYB 商品的店铺和图片。
|
// migrateMySQLV15 以低优先级补全历史 SYB 商品的店铺和图片。
|
||||||
func migrateMySQLV15(db *sql.DB) error {
|
func migrateMySQLV15(db *sql.DB) error {
|
||||||
return backfillSybProductMetadata(db)
|
return backfillSybProductMetadata(db)
|
||||||
@@ -1798,6 +1863,7 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
"task_syb_sources",
|
"task_syb_sources",
|
||||||
"task_sequences",
|
"task_sequences",
|
||||||
"syb_allowed_shops",
|
"syb_allowed_shops",
|
||||||
|
"ai_provider_configs", "ai_provider_audits",
|
||||||
}
|
}
|
||||||
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1850,7 +1916,42 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
if err := checkMySQLV18Shape(db); err != nil {
|
if err := checkMySQLV18Shape(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return checkMySQLV19Shape(db)
|
if err := checkMySQLV19Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkMySQLV20Shape(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLV20Shape(db *sql.DB) error {
|
||||||
|
if err := checkMySQLSchema(db, []string{"ai_provider_configs", "ai_provider_audits"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range []string{"provider_id", "base_url", "model", "timeout_seconds", "max_concurrency",
|
||||||
|
"confidence_threshold_bps", "enabled", "active_slot", "last_test_status", "last_test_fingerprint"} {
|
||||||
|
exists, err := mysqlColumnExists(db, "ai_provider_configs", name)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return fmt.Errorf("AI 服务商配置字段 %s 缺失: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range []struct{ kind, table, name string }{
|
||||||
|
{"index", "ai_provider_configs", "uq_ai_provider_name"},
|
||||||
|
{"index", "ai_provider_configs", "uq_ai_provider_active"},
|
||||||
|
{"constraint", "ai_provider_configs", "chk_ai_provider_test_status"},
|
||||||
|
{"constraint", "ai_provider_configs", "fk_ai_provider_created_by"},
|
||||||
|
{"constraint", "ai_provider_audits", "fk_ai_audit_actor"},
|
||||||
|
} {
|
||||||
|
var exists bool
|
||||||
|
var err error
|
||||||
|
if item.kind == "index" {
|
||||||
|
exists, err = mysqlIndexExists(db, item.table, item.name)
|
||||||
|
} else {
|
||||||
|
exists, err = mysqlConstraintExists(db, item.table, item.name)
|
||||||
|
}
|
||||||
|
if err != nil || !exists {
|
||||||
|
return fmt.Errorf("AI 配置%s %s.%s 缺失: %v", item.kind, item.table, item.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMySQLV15Shape(db *sql.DB) error {
|
func checkMySQLV15Shape(db *sql.DB) error {
|
||||||
|
|||||||
@@ -953,6 +953,38 @@ func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMySQLV20_AI配置首建重放与唯一启用(t *testing.T) {
|
||||||
|
db := openMySQLMigrationTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
cleanMySQLTestSchema(t, db)
|
||||||
|
defer cleanMySQLTestSchema(t, db)
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := MigrateMySQL(db); err != nil {
|
||||||
|
t.Fatalf("v20 重放失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV20Shape(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := "2026-08-14T00:00:00Z"
|
||||||
|
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
|
||||||
|
VALUES('AI-ADMIN','ai-admin','x','admin','active',?,?,?)`, now, now, now)
|
||||||
|
mustExec(t, db, `INSERT INTO ai_provider_configs(provider_id,name,base_url,model,timeout_seconds,
|
||||||
|
max_concurrency,confidence_threshold_bps,enabled,last_test_status,created_by_user_id,updated_by_user_id,created_at,updated_at)
|
||||||
|
VALUES('AI-1','一号','https://one.example/v1','m1',30,2,8500,1,'pending','AI-ADMIN','AI-ADMIN',?,?)`, now, now)
|
||||||
|
if _, err := db.Exec(`INSERT INTO ai_provider_configs(provider_id,name,base_url,model,timeout_seconds,
|
||||||
|
max_concurrency,confidence_threshold_bps,enabled,last_test_status,created_by_user_id,updated_by_user_id,created_at,updated_at)
|
||||||
|
VALUES('AI-2','二号','https://two.example/v1','m2',30,2,8500,1,'pending','AI-ADMIN','AI-ADMIN',?,?)`, now, now); err == nil {
|
||||||
|
t.Fatal("数据库必须拒绝同时启用两个 AI 服务商")
|
||||||
|
}
|
||||||
|
var secretColumns int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='ai_provider_configs' AND column_name IN ('api_key','secret','token')`).Scan(&secretColumns); err != nil || secretColumns != 0 {
|
||||||
|
t.Fatalf("AI 配置表不得含密钥列: count=%d err=%v", secretColumns, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func prepareMySQLV2(t *testing.T, db *sql.DB) {
|
func prepareMySQLV2(t *testing.T, db *sql.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`)
|
mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`)
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIProviderInput struct {
|
||||||
|
ProviderID string
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
TimeoutSeconds int
|
||||||
|
MaxConcurrency int
|
||||||
|
ConfidenceThresholdBPS int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIProviderListResult struct {
|
||||||
|
Items []model.AIProviderConfig
|
||||||
|
SecretStoreError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIEndpointPolicy struct {
|
||||||
|
allowedHosts map[string]bool
|
||||||
|
resolver *net.Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAIEndpointPolicy(allowedHosts []string) AIEndpointPolicy {
|
||||||
|
policy := AIEndpointPolicy{allowedHosts: map[string]bool{}, resolver: net.DefaultResolver}
|
||||||
|
for _, host := range allowedHosts {
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(host)); value != "" {
|
||||||
|
policy.allowedHosts[value] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAIProviderConfigs(db *sql.DB, actor *model.User, secrets AISecretStore) (AIProviderListResult, error) {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return AIProviderListResult{}, ErrAdminRequired
|
||||||
|
}
|
||||||
|
items, err := repository.ListAIProviders(db)
|
||||||
|
if err != nil {
|
||||||
|
return AIProviderListResult{}, err
|
||||||
|
}
|
||||||
|
result := AIProviderListResult{Items: items}
|
||||||
|
for i := range result.Items {
|
||||||
|
configured, suffix, statusErr := secrets.Status(result.Items[i].ProviderID)
|
||||||
|
if statusErr != nil {
|
||||||
|
result.SecretStoreError = statusErr.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result.Items[i].SecretConfigured = configured
|
||||||
|
result.Items[i].SecretSuffix = suffix
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveAIProviderConfig(db *sql.DB, actor *model.User, input AIProviderInput, policy AIEndpointPolicy, now time.Time) (string, error) {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return "", ErrAdminRequired
|
||||||
|
}
|
||||||
|
item, err := validateAIProviderInput(input, policy)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
item.ProviderID = strings.TrimSpace(input.ProviderID)
|
||||||
|
if item.ProviderID == "" {
|
||||||
|
item.ProviderID, err = randomID("AIP-", 16)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("生成 AI 服务商编号失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
at := now.UTC().Format(model.TimeLayout)
|
||||||
|
item.UpdatedByUserID, item.UpdatedAt = actor.UserID, at
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
action := "update"
|
||||||
|
details := map[string]any{"fields": []string{"name", "base_url", "model", "timeout_seconds", "max_concurrency", "confidence_threshold_bps"}}
|
||||||
|
if input.ProviderID == "" {
|
||||||
|
action = "create"
|
||||||
|
item.CreatedByUserID, item.CreatedAt = actor.UserID, at
|
||||||
|
if err := repository.InsertAIProvider(tx, item); err != nil {
|
||||||
|
return "", aiProviderValidationError(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
found, err := repository.UpdateAIProvider(tx, item)
|
||||||
|
if err != nil {
|
||||||
|
return "", aiProviderValidationError(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return "", &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := insertAIConfigAudit(tx, item.ProviderID, action, details, actor.UserID, at); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return item.ProviderID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetAIProviderSecret(db *sql.DB, actor *model.User, secrets AISecretStore, providerID, apiKey string, now time.Time) error {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return ErrAdminRequired
|
||||||
|
}
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if err := invalidateAIProviderForSecretChange(db, actor, providerID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := secrets.Set(providerID, apiKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return insertAIConfigAudit(db, providerID, "secret_replace", map[string]any{"secret": "configured"}, actor.UserID, now.UTC().Format(model.TimeLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearAIProviderSecret(db *sql.DB, actor *model.User, secrets AISecretStore, providerID string, now time.Time) error {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return ErrAdminRequired
|
||||||
|
}
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
if err := invalidateAIProviderForSecretChange(db, actor, providerID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := secrets.Clear(providerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return insertAIConfigAudit(db, providerID, "secret_clear", map[string]any{"secret": "cleared"}, actor.UserID, now.UTC().Format(model.TimeLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateAIProviderForSecretChange(db *sql.DB, actor *model.User, providerID string, now time.Time) error {
|
||||||
|
if providerID == "" {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商编号不能为空"}
|
||||||
|
}
|
||||||
|
found, err := repository.InvalidateAIProviderTest(db, providerID, actor.UserID, now.UTC().Format(model.TimeLayout))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIHTTPDoer interface {
|
||||||
|
Do(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIProviderConnection(ctx context.Context, db *sql.DB, actor *model.User, secrets AISecretStore,
|
||||||
|
providerID string, policy AIEndpointPolicy, client AIHTTPDoer, now time.Time) error {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return ErrAdminRequired
|
||||||
|
}
|
||||||
|
provider, err := repository.GetAIProvider(db, strings.TrimSpace(providerID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
secret, err := secrets.Get(provider.ProviderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return &validationError{field: "api_key", message: "请先保存 API Key"}
|
||||||
|
}
|
||||||
|
if err := policy.ValidateResolved(ctx, provider.BaseURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = NewSafeAIHTTPClient(policy, time.Duration(provider.TimeoutSeconds)*time.Second)
|
||||||
|
}
|
||||||
|
testErr := callAIHealthCheck(ctx, client, *provider, secret)
|
||||||
|
status, message, fingerprint := "succeeded", "连接正常", aiProviderFingerprint(*provider)
|
||||||
|
if testErr != nil {
|
||||||
|
status, message, fingerprint = "failed", safeAIError(testErr), ""
|
||||||
|
}
|
||||||
|
at := now.UTC().Format(model.TimeLayout)
|
||||||
|
found, recordErr := repository.RecordAIProviderTest(db, provider.ProviderID, status, message, at, fingerprint, actor.UserID)
|
||||||
|
if recordErr != nil {
|
||||||
|
return recordErr
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
if err := insertAIConfigAudit(db, provider.ProviderID, "test_"+status, map[string]any{"status": status}, actor.UserID, at); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if testErr != nil {
|
||||||
|
return &validationError{field: "connection", message: "连接测试失败:" + message}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnableAIProviderConfig(db *sql.DB, actor *model.User, secrets AISecretStore, providerID string, now time.Time) error {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return ErrAdminRequired
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err := repository.LockAIProviders(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
provider, err := repository.GetAIProvider(tx, strings.TrimSpace(providerID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
configured, _, err := secrets.Status(provider.ProviderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !configured {
|
||||||
|
return &validationError{field: "api_key", message: "请先保存 API Key 并测试连接"}
|
||||||
|
}
|
||||||
|
if provider.LastTestStatus != "succeeded" || provider.LastTestFingerprint != aiProviderFingerprint(*provider) {
|
||||||
|
return &validationError{field: "connection", message: "当前配置尚未通过连接测试,不能启用"}
|
||||||
|
}
|
||||||
|
at := now.UTC().Format(model.TimeLayout)
|
||||||
|
found, err := repository.EnableAIProvider(tx, provider.ProviderID, actor.UserID, at)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
if err := insertAIConfigAudit(tx, provider.ProviderID, "enable", map[string]any{"enabled": true}, actor.UserID, at); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DisableAIProviderConfig(db *sql.DB, actor *model.User, providerID string, now time.Time) error {
|
||||||
|
if actor == nil || !actor.IsAdmin() {
|
||||||
|
return ErrAdminRequired
|
||||||
|
}
|
||||||
|
at := now.UTC().Format(model.TimeLayout)
|
||||||
|
found, err := repository.DisableAIProvider(db, strings.TrimSpace(providerID), actor.UserID, at)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
|
||||||
|
}
|
||||||
|
return insertAIConfigAudit(db, strings.TrimSpace(providerID), "disable", map[string]any{"enabled": false}, actor.UserID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAIProviderInput(input AIProviderInput, policy AIEndpointPolicy) (model.AIProviderConfig, error) {
|
||||||
|
item := model.AIProviderConfig{Name: strings.TrimSpace(input.Name), Model: strings.TrimSpace(input.Model),
|
||||||
|
TimeoutSeconds: input.TimeoutSeconds, MaxConcurrency: input.MaxConcurrency,
|
||||||
|
ConfidenceThresholdBPS: input.ConfidenceThresholdBPS}
|
||||||
|
if item.Name == "" || utf8.RuneCountInString(item.Name) > 191 {
|
||||||
|
return item, &validationError{field: "name", message: "服务商名称不能为空且最多 191 个字符"}
|
||||||
|
}
|
||||||
|
if item.Model == "" || utf8.RuneCountInString(item.Model) > 191 {
|
||||||
|
return item, &validationError{field: "model", message: "模型名称不能为空且最多 191 个字符"}
|
||||||
|
}
|
||||||
|
baseURL, err := policy.ValidateSyntax(input.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
item.BaseURL = baseURL
|
||||||
|
if item.TimeoutSeconds < 1 || item.TimeoutSeconds > 120 {
|
||||||
|
return item, &validationError{field: "timeout_seconds", message: "超时秒数必须在 1 到 120 之间"}
|
||||||
|
}
|
||||||
|
if item.MaxConcurrency < 1 || item.MaxConcurrency > 16 {
|
||||||
|
return item, &validationError{field: "max_concurrency", message: "最大并发必须在 1 到 16 之间"}
|
||||||
|
}
|
||||||
|
if item.ConfidenceThresholdBPS < 0 || item.ConfidenceThresholdBPS > 10000 {
|
||||||
|
return item, &validationError{field: "confidence_threshold", message: "自动写入置信度必须在 0% 到 100% 之间"}
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConfidenceThresholdBPS 把页面百分比精确转换成基点,避免数据库存浮点数。
|
||||||
|
func ParseConfidenceThresholdBPS(raw string) (int, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
parts := strings.Split(raw, ".")
|
||||||
|
if raw == "" || len(parts) > 2 {
|
||||||
|
return 0, &validationError{field: "confidence_threshold", message: "置信度必须是 0 到 100 的数字,最多两位小数"}
|
||||||
|
}
|
||||||
|
whole, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil || whole < 0 || whole > 100 {
|
||||||
|
return 0, &validationError{field: "confidence_threshold", message: "置信度必须在 0% 到 100% 之间"}
|
||||||
|
}
|
||||||
|
fraction := 0
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if len(parts[1]) == 0 || len(parts[1]) > 2 {
|
||||||
|
return 0, &validationError{field: "confidence_threshold", message: "置信度最多保留两位小数"}
|
||||||
|
}
|
||||||
|
fraction, err = strconv.Atoi(parts[1] + strings.Repeat("0", 2-len(parts[1])))
|
||||||
|
if err != nil {
|
||||||
|
return 0, &validationError{field: "confidence_threshold", message: "置信度格式不正确"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if whole == 100 && fraction != 0 {
|
||||||
|
return 0, &validationError{field: "confidence_threshold", message: "置信度不能超过 100%"}
|
||||||
|
}
|
||||||
|
return whole*100 + fraction, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p AIEndpointPolicy) ValidateSyntax(raw string) (string, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||||
|
return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 HTTPS 地址"}
|
||||||
|
}
|
||||||
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return "", &validationError{field: "base_url", message: "Base URL 不能包含账号密码、查询参数或片段"}
|
||||||
|
}
|
||||||
|
host := strings.ToLower(parsed.Hostname())
|
||||||
|
if host == "localhost" || host == "metadata.google.internal" {
|
||||||
|
return "", &validationError{field: "base_url", message: "Base URL 指向了受保护的本机或元数据地址"}
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil && !p.allowedHosts[host] && !isPublicAIIP(ip) {
|
||||||
|
return "", &validationError{field: "base_url", message: "Base URL 指向私有或本机地址,必须由部署配置显式允许"}
|
||||||
|
}
|
||||||
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||||
|
parsed.RawPath = strings.TrimRight(parsed.RawPath, "/")
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p AIEndpointPolicy) ValidateResolved(ctx context.Context, raw string) error {
|
||||||
|
normalized, err := p.ValidateSyntax(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, _ := url.Parse(normalized)
|
||||||
|
host := strings.ToLower(parsed.Hostname())
|
||||||
|
if p.allowedHosts[host] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
addresses, err := p.resolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil || len(addresses) == 0 {
|
||||||
|
return &validationError{field: "base_url", message: "无法解析 AI 服务商地址"}
|
||||||
|
}
|
||||||
|
for _, address := range addresses {
|
||||||
|
if !isPublicAIIP(address.IP) {
|
||||||
|
return &validationError{field: "base_url", message: "AI 服务商域名解析到私有、本机或链路本地地址"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPublicAIIP(ip net.IP) bool {
|
||||||
|
return ip != nil && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() &&
|
||||||
|
!ip.IsLinkLocalMulticast() && !ip.IsUnspecified() && !ip.IsMulticast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSafeAIHTTPClient(policy AIEndpointPolicy, timeout time.Duration) *http.Client {
|
||||||
|
dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second}
|
||||||
|
// 不使用环境代理。否则实际拨号只会校验代理地址,目标地址可能绕过 SSRF 拨号校验。
|
||||||
|
transport := &http.Transport{ForceAttemptHTTP2: true,
|
||||||
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AI 服务商网络地址无效")
|
||||||
|
}
|
||||||
|
if !policy.allowedHosts[strings.ToLower(host)] {
|
||||||
|
addresses, err := policy.resolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil || len(addresses) == 0 {
|
||||||
|
return nil, fmt.Errorf("无法解析 AI 服务商地址")
|
||||||
|
}
|
||||||
|
for _, item := range addresses {
|
||||||
|
if !isPublicAIIP(item.IP) {
|
||||||
|
return nil, fmt.Errorf("AI 服务商地址不在允许范围")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, address)
|
||||||
|
}}
|
||||||
|
return &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 3 {
|
||||||
|
return fmt.Errorf("AI 服务商重定向次数过多")
|
||||||
|
}
|
||||||
|
return policy.ValidateResolved(req.Context(), req.URL.String())
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func callAIHealthCheck(ctx context.Context, client AIHTTPDoer, provider model.AIProviderConfig, secret string) error {
|
||||||
|
payload, _ := json.Marshal(map[string]any{"model": provider.Model, "messages": []map[string]string{{"role": "user", "content": "Reply with OK."}}, "max_tokens": 1, "temperature": 0})
|
||||||
|
endpoint := strings.TrimRight(provider.BaseURL, "/") + "/chat/completions"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("准备连接测试失败")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("请求失败")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 32<<10))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("服务返回 HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiProviderFingerprint(provider model.AIProviderConfig) string {
|
||||||
|
value := strings.Join([]string{provider.BaseURL, provider.Model, strconv.Itoa(provider.TimeoutSeconds),
|
||||||
|
strconv.Itoa(provider.MaxConcurrency), strconv.Itoa(provider.ConfidenceThresholdBPS)}, "\x00")
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertAIConfigAudit(q repository.Execer, providerID, action string, details any, actorID, at string) error {
|
||||||
|
raw, err := json.Marshal(details)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("准备 AI 配置审计失败: %w", err)
|
||||||
|
}
|
||||||
|
return repository.InsertAIProviderAudit(q, model.AIProviderAudit{ProviderID: providerID, Action: action,
|
||||||
|
DetailsJSON: string(raw), ActorUserID: actorID, CreatedAt: at})
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiProviderValidationError(err error) error {
|
||||||
|
if errors.Is(err, repository.ErrAIProviderNameExists) {
|
||||||
|
return &validationError{field: "name", message: "服务商名称已经存在"}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeAIError(err error) string {
|
||||||
|
message := strings.TrimSpace(err.Error())
|
||||||
|
if message == "" {
|
||||||
|
message = "未知错误"
|
||||||
|
}
|
||||||
|
runes := []rune(message)
|
||||||
|
if len(runes) > 200 {
|
||||||
|
message = string(runes[:200])
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmautobuy/admin/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseConfidenceThresholdBPS_精确转换(t *testing.T) {
|
||||||
|
for raw, want := range map[string]int{"0": 0, "85": 8500, "85.5": 8550, "99.99": 9999, "100.00": 10000} {
|
||||||
|
got, err := ParseConfidenceThresholdBPS(raw)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("%q => %d,%v,期望 %d", raw, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range []string{"", "-1", "100.01", "1.234", "abc"} {
|
||||||
|
if _, err := ParseConfidenceThresholdBPS(raw); err == nil {
|
||||||
|
t.Errorf("非法置信度 %q 应被拒绝", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIEndpointPolicy_阻止凭据和内网地址(t *testing.T) {
|
||||||
|
policy := NewAIEndpointPolicy(nil)
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://api.example.com/v1", "https://user:pass@api.example.com/v1",
|
||||||
|
"https://127.0.0.1/v1", "https://169.254.169.254/latest", "https://localhost/v1",
|
||||||
|
} {
|
||||||
|
if _, err := policy.ValidateSyntax(raw); err == nil {
|
||||||
|
t.Errorf("危险地址 %q 应被拒绝", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := policy.ValidateSyntax("https://api.example.com/v1/")
|
||||||
|
if err != nil || got != "https://api.example.com/v1" {
|
||||||
|
t.Fatalf("公网 HTTPS 地址应通过并去掉尾斜杠: %q %v", got, err)
|
||||||
|
}
|
||||||
|
allowed := NewAIEndpointPolicy([]string{"10.0.0.8"})
|
||||||
|
if _, err := allowed.ValidateSyntax("https://10.0.0.8/v1"); err != nil {
|
||||||
|
t.Fatalf("部署允许的私有端点应通过: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeAIHTTPClient_不使用环境代理(t *testing.T) {
|
||||||
|
client := NewSafeAIHTTPClient(NewAIEndpointPolicy(nil), time.Second)
|
||||||
|
transport, ok := client.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Transport 类型 = %T", client.Transport)
|
||||||
|
}
|
||||||
|
if transport.Proxy != nil {
|
||||||
|
t.Fatal("AI HTTP 客户端不能使用环境代理,否则目标地址会绕过拨号阶段的 SSRF 校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingAIHTTPDoer struct {
|
||||||
|
request *http.Request
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *recordingAIHTTPDoer) Do(request *http.Request) (*http.Response, error) {
|
||||||
|
d.request = request
|
||||||
|
return &http.Response{StatusCode: d.status, Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallAIHealthCheck_最小请求且错误不泄露密钥(t *testing.T) {
|
||||||
|
doer := &recordingAIHTTPDoer{status: http.StatusUnauthorized}
|
||||||
|
const secret = "test-secret-never-log"
|
||||||
|
err := callAIHealthCheck(context.Background(), doer, model.AIProviderConfig{
|
||||||
|
BaseURL: "https://api.example.com/v1", Model: "test-model",
|
||||||
|
}, secret)
|
||||||
|
if err == nil || strings.Contains(err.Error(), secret) || !strings.Contains(err.Error(), "401") {
|
||||||
|
t.Fatalf("错误必须脱敏且保留 HTTP 状态: %v", err)
|
||||||
|
}
|
||||||
|
if got := doer.request.Header.Get("Authorization"); got != "Bearer "+secret {
|
||||||
|
t.Fatalf("测试请求未使用密钥: %q", got)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(doer.request.Body)
|
||||||
|
text := string(body)
|
||||||
|
for _, forbidden := range []string{"order_no", "syb_id", "address", secret} {
|
||||||
|
if strings.Contains(text, forbidden) {
|
||||||
|
t.Fatalf("最小测试请求包含业务数据或密钥 %q: %s", forbidden, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AISecretStore 把 AI API Key 隔离在数据库、data 和发布目录之外。
|
||||||
|
type AISecretStore interface {
|
||||||
|
Get(providerID string) (string, error)
|
||||||
|
Set(providerID, apiKey string) error
|
||||||
|
Clear(providerID string) error
|
||||||
|
Status(providerID string) (configured bool, suffix string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiSecretFile struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Providers map[string]string `yaml:"providers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileAISecretStore 使用同目录临时文件 + fsync + 原子替换保存密钥。
|
||||||
|
type FileAISecretStore struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileAISecretStore(path string) *FileAISecretStore {
|
||||||
|
return &FileAISecretStore{path: strings.TrimSpace(path)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) Get(providerID string) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
data, err := s.readLocked()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return data.Providers[strings.TrimSpace(providerID)], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) Status(providerID string) (bool, string, error) {
|
||||||
|
secret, err := s.Get(providerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
runes := []rune(secret)
|
||||||
|
start := len(runes) - 4
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
return true, "••••" + string(runes[start:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) Set(providerID, apiKey string) error {
|
||||||
|
providerID = strings.TrimSpace(providerID)
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if providerID == "" {
|
||||||
|
return fmt.Errorf("服务商编号不能为空")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(apiKey) < 8 {
|
||||||
|
return fmt.Errorf("API Key 至少需要 8 个字符")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
data, err := s.readLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data.Providers[providerID] = apiKey
|
||||||
|
return s.writeLocked(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) Clear(providerID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
data, err := s.readLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(data.Providers, strings.TrimSpace(providerID))
|
||||||
|
return s.writeLocked(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) readLocked() (aiSecretFile, error) {
|
||||||
|
result := aiSecretFile{Version: 1, Providers: map[string]string{}}
|
||||||
|
path, err := s.safePath()
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("读取 AI 密钥文件失败")
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &result); err != nil {
|
||||||
|
return aiSecretFile{}, fmt.Errorf("解析 AI 密钥文件失败")
|
||||||
|
}
|
||||||
|
if result.Providers == nil {
|
||||||
|
result.Providers = map[string]string{}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) writeLocked(data aiSecretFile) error {
|
||||||
|
path, err := s.safePath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("准备 AI 密钥目录失败")
|
||||||
|
}
|
||||||
|
raw, err := yaml.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("编码 AI 密钥文件失败")
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".ai-secrets-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建 AI 密钥临时文件失败")
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if err := tmp.Chmod(0o600); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("设置 AI 密钥临时文件权限失败")
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(raw); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("写入 AI 密钥临时文件失败")
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("同步 AI 密钥临时文件失败")
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("关闭 AI 密钥临时文件失败")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return fmt.Errorf("替换 AI 密钥文件失败")
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("设置 AI 密钥文件权限失败")
|
||||||
|
}
|
||||||
|
if dir, err := os.Open(filepath.Dir(path)); err == nil {
|
||||||
|
_ = dir.Sync()
|
||||||
|
_ = dir.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileAISecretStore) safePath() (string, error) {
|
||||||
|
if s == nil || s.path == "" {
|
||||||
|
return "", fmt.Errorf("未配置 CMAUTOBUY_AI_SECRETS_PATH,不能保存或读取 AI 密钥")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(s.path) {
|
||||||
|
return "", fmt.Errorf("AI 密钥文件必须使用绝对路径")
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(s.path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析 AI 密钥文件路径失败")
|
||||||
|
}
|
||||||
|
for _, root := range protectedAISecretRoots() {
|
||||||
|
if pathWithin(abs, root) {
|
||||||
|
return "", fmt.Errorf("AI 密钥文件不能位于仓库、data 或发布目录内")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectedAISecretRoots() []string {
|
||||||
|
var roots []string
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
roots = append(roots, cwd)
|
||||||
|
}
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
roots = append(roots, filepath.Dir(exe))
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathWithin(path, root string) bool {
|
||||||
|
path, root = filepath.Clean(path), filepath.Clean(root)
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileAISecretStore_原子保存且不回显明文(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "ai-secrets.yaml")
|
||||||
|
store := NewFileAISecretStore(path)
|
||||||
|
const secret = "test-key-not-for-production"
|
||||||
|
if err := store.Set("AI-1", secret); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configured, suffix, err := store.Status("AI-1")
|
||||||
|
if err != nil || !configured || !strings.HasSuffix(suffix, "tion") || strings.Contains(suffix, secret) {
|
||||||
|
t.Fatalf("密钥状态不安全: configured=%v suffix=%q err=%v", configured, suffix, err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil || !strings.Contains(string(raw), secret) {
|
||||||
|
t.Fatalf("独立密钥文件没有保存测试值: %v", err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
info, _ := os.Stat(path)
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("密钥权限 = %o,期望 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := store.Clear("AI-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configured, _, err = store.Status("AI-1")
|
||||||
|
if err != nil || configured {
|
||||||
|
t.Fatalf("清除密钥失败: configured=%v err=%v", configured, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileAISecretStore_拒绝仓库内和相对路径(t *testing.T) {
|
||||||
|
for _, path := range []string{"relative.yaml", filepath.Join("data", "ai.yaml")} {
|
||||||
|
store := NewFileAISecretStore(path)
|
||||||
|
if err := store.Set("AI-1", "12345678"); err == nil {
|
||||||
|
t.Fatalf("路径 %q 应被拒绝", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,16 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-config-table { min-width: 1320px; }
|
||||||
|
.ai-config-table input { max-width: 100%; }
|
||||||
|
.ai-config-endpoint { min-width: 300px; }
|
||||||
|
.ai-config-params { min-width: 130px; }
|
||||||
|
.ai-config-params input { width: 88px; display: block; margin-bottom: 4px; }
|
||||||
|
.ai-config-table td { vertical-align: top; }
|
||||||
|
.ai-config-table p { margin: 0 0 6px; }
|
||||||
|
.ai-config-toolbar input[type="url"] { min-width: 220px; }
|
||||||
|
.input-short { width: 70px; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
|
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{{define "ai/list"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
|
||||||
|
<div class="toolbar ai-config-toolbar">
|
||||||
|
<form class="inline grow" method="post" action="/settings/ai/save">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<label for="ai-name">服务商</label>
|
||||||
|
<input id="ai-name" name="name" maxlength="191" required placeholder="例如:主模型">
|
||||||
|
<label for="ai-base-url">Base URL</label>
|
||||||
|
<input id="ai-base-url" name="base_url" type="url" required placeholder="https://api.example.com/v1">
|
||||||
|
<label for="ai-model">模型</label>
|
||||||
|
<input id="ai-model" name="model" maxlength="191" required placeholder="模型名称">
|
||||||
|
<label for="ai-timeout">超时</label>
|
||||||
|
<input id="ai-timeout" class="input-short" name="timeout_seconds" type="number" min="1" max="120" value="30" required aria-describedby="ai-create-help">
|
||||||
|
<label for="ai-concurrency">并发</label>
|
||||||
|
<input id="ai-concurrency" class="input-short" name="max_concurrency" type="number" min="1" max="16" value="2" required>
|
||||||
|
<label for="ai-threshold">置信度%</label>
|
||||||
|
<input id="ai-threshold" class="input-short" name="confidence_threshold" type="number" min="0" max="100" step="0.01" value="85" required>
|
||||||
|
<button class="primary" type="submit">新增</button>
|
||||||
|
<small id="ai-create-help" class="visually-hidden">超时单位为秒;新增后还需要保存 API Key、测试连接并启用。</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Message}}<p class="hint" role="status">{{.Message}}</p>{{end}}
|
||||||
|
{{if .Error}}<p class="missing" role="alert">{{.Error}}</p>{{end}}
|
||||||
|
{{if .SecretStoreError}}<p class="missing" role="alert">密钥存储不可用:{{.SecretStoreError}}</p>{{end}}
|
||||||
|
<p class="hint">API Key 只写入部署指定的独立 600 权限密钥文件。页面永不回显明文;修改普通配置或密钥后必须重新测试才能启用。</p>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="ai-config-table">
|
||||||
|
<thead><tr><th>服务商</th><th>接口与模型</th><th>运行参数</th><th>API Key</th><th>连接测试</th><th>状态与操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td><label class="visually-hidden" for="ai-name-{{.ProviderID}}">服务商名称</label><input id="ai-name-{{.ProviderID}}" name="name" value="{{.Name}}" maxlength="191" required form="ai-edit-{{.ProviderID}}"></td>
|
||||||
|
<td class="ai-config-endpoint">
|
||||||
|
<label for="ai-url-{{.ProviderID}}">Base URL</label><input id="ai-url-{{.ProviderID}}" name="base_url" type="url" value="{{.BaseURL}}" required form="ai-edit-{{.ProviderID}}">
|
||||||
|
<label for="ai-model-{{.ProviderID}}">模型</label><input id="ai-model-{{.ProviderID}}" name="model" value="{{.Model}}" maxlength="191" required form="ai-edit-{{.ProviderID}}">
|
||||||
|
</td>
|
||||||
|
<td class="ai-config-params">
|
||||||
|
<label for="ai-timeout-{{.ProviderID}}">超时秒</label><input id="ai-timeout-{{.ProviderID}}" name="timeout_seconds" type="number" min="1" max="120" value="{{.TimeoutSeconds}}" required form="ai-edit-{{.ProviderID}}">
|
||||||
|
<label for="ai-concurrency-{{.ProviderID}}">最大并发</label><input id="ai-concurrency-{{.ProviderID}}" name="max_concurrency" type="number" min="1" max="16" value="{{.MaxConcurrency}}" required form="ai-edit-{{.ProviderID}}">
|
||||||
|
<label for="ai-threshold-{{.ProviderID}}">置信度%</label><input id="ai-threshold-{{.ProviderID}}" name="confidence_threshold" type="number" min="0" max="100" step="0.01" value="{{.ConfidencePercent}}" required form="ai-edit-{{.ProviderID}}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p>{{if .SecretConfigured}}已配置:{{.SecretSuffix}}{{else}}未配置{{end}}</p>
|
||||||
|
<form class="form-stack" method="post" action="/settings/ai/secret">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="provider_id" value="{{.ProviderID}}">
|
||||||
|
<label for="ai-key-{{.ProviderID}}">替换 API Key</label><input id="ai-key-{{.ProviderID}}" name="api_key" type="password" minlength="8" required autocomplete="new-password">
|
||||||
|
<button type="submit">保存密钥</button>
|
||||||
|
</form>
|
||||||
|
{{if .SecretConfigured}}<form class="inline" method="post" action="/settings/ai/secret/clear" data-confirm-submit="确定清除“{{.Name}}”的 API Key 吗?该服务商会立即停用。">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="provider_id" value="{{.ProviderID}}"><button class="danger" type="submit">清除</button>
|
||||||
|
</form>{{end}}
|
||||||
|
</td>
|
||||||
|
<td><strong>{{.TestStatusLabel}}</strong>{{if .LastTestMessage}}<p>{{.LastTestMessage}}</p>{{end}}{{if .LastTestedAt}}<small>{{.LastTestedAt}}</small>{{end}}
|
||||||
|
<form class="inline" method="post" action="/settings/ai/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="provider_id" value="{{.ProviderID}}"><button type="submit">测试连接</button></form>
|
||||||
|
</td>
|
||||||
|
<td><p><strong>{{.StatusLabel}}</strong></p>
|
||||||
|
<form id="ai-edit-{{.ProviderID}}" class="inline" method="post" action="/settings/ai/save"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="provider_id" value="{{.ProviderID}}"><button type="submit">保存配置</button></form>
|
||||||
|
{{if .Enabled}}<form class="inline" method="post" action="/settings/ai/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="provider_id" value="{{.ProviderID}}"><button type="submit">停用</button></form>
|
||||||
|
{{else}}<form class="inline" method="post" action="/settings/ai/enable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="provider_id" value="{{.ProviderID}}"><button class="primary" type="submit">启用</button></form>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr class="empty"><td colspan="6">还没有 AI 服务商。先新增非敏感配置,再保存密钥并测试连接。</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="statusbar"><span class="grow"></span><span>共 {{len .Rows}} 个服务商</span></div>
|
||||||
|
{{template "footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
{{if and .CurrentUser .CurrentUser.IsAdmin}}
|
{{if and .CurrentUser .CurrentUser.IsAdmin}}
|
||||||
<a href="/shops" data-module-root="/shops" class="{{if eq .Active "shops"}}active{{end}}">店铺管理</a>
|
<a href="/shops" data-module-root="/shops" class="{{if eq .Active "shops"}}active{{end}}">店铺管理</a>
|
||||||
<a href="/users" data-module-root="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>
|
<a href="/users" data-module-root="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>
|
||||||
|
<a href="/settings/ai" data-module-root="/settings/ai" class="{{if eq .Active "ai-settings"}}active{{end}}">AI 配置</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .CurrentUser}}
|
{{if .CurrentUser}}
|
||||||
<span class="nav-user">{{.CurrentUser.Username}}</span>
|
<span class="nav-user">{{.CurrentUser.Username}}</span>
|
||||||
|
|||||||
@@ -400,6 +400,14 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
|
|||||||
|
|
||||||
**底部状态条:** 在线 / 离线数量统计。
|
**底部状态条:** 在线 / 离线数量统计。
|
||||||
|
|
||||||
|
### 4.6 AI 规格批量匹配(#199)
|
||||||
|
|
||||||
|
- AI 集成保留在 Admin 内部,不增加独立微服务;MVP 使用 OpenAI 兼容协议。
|
||||||
|
- 只有管理员能够维护服务商、模型和部署级密钥;采购员只能发起规格匹配。
|
||||||
|
- API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。
|
||||||
|
- 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。
|
||||||
|
- AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。
|
||||||
|
|
||||||
## 5. 创建采购任务的校验
|
## 5. 创建采购任务的校验
|
||||||
|
|
||||||
`[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么:
|
`[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么:
|
||||||
|
|||||||
@@ -252,7 +252,18 @@ handler 返回批次 JSON;管理员在统一导入记录页查看摘要
|
|||||||
Web 登录中间件也不覆盖 `/api/v1/client/*`。
|
Web 登录中间件也不覆盖 `/api/v1/client/*`。
|
||||||
- 详见 [04 Client 接口实现](04-client-api.md)。
|
- 详见 [04 Client 接口实现](04-client-api.md)。
|
||||||
|
|
||||||
## 9. 相关文档
|
## 9. AI 配置与密钥边界
|
||||||
|
|
||||||
|
AI 服务商的普通配置和非敏感审计由 `repository` 写入 MySQL。API Key 通过
|
||||||
|
`service.AISecretStore` 写入部署指定的独立文件;该文件必须使用绝对路径,不能位于
|
||||||
|
仓库、`data/` 或 release 目录,生产建议为 `/etc/cmautobuy/ai-secrets.yaml` 且权限为
|
||||||
|
`600`。页面只得到“是否配置”和固定掩码尾号。
|
||||||
|
|
||||||
|
外部模型调用位于独立 HTTP 客户端边界:只允许 HTTPS,拒绝 URL 内凭据、环回、链路
|
||||||
|
本地、云元数据和未显式允许的私网地址;重定向和实际拨号也执行相同检查。部署级
|
||||||
|
`allowed_hosts` 是私有模型端点的唯一例外入口,网页不能修改。
|
||||||
|
|
||||||
|
## 10. 相关文档
|
||||||
|
|
||||||
- [上手指南](00-getting-started.md)
|
- [上手指南](00-getting-started.md)
|
||||||
- [术语表](00-glossary.md)
|
- [术语表](00-glossary.md)
|
||||||
|
|||||||
@@ -974,3 +974,14 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
|||||||
- 相同来源和批次号携带不同请求哈希时记录冲突并返回 HTTP 409。
|
- 相同来源和批次号携带不同请求哈希时记录冲突并返回 HTTP 409。
|
||||||
- `response_body` 只保存可安全重放的结果摘要,不得包含凭据或原始商品数据。
|
- `response_body` 只保存可安全重放的结果摘要,不得包含凭据或原始商品数据。
|
||||||
- `source_observed_at` 用于拒绝较旧数据覆盖较新数据;缺席记录绝不代表删除。
|
- `source_observed_at` 用于拒绝较旧数据覆盖较新数据;缺席记录绝不代表删除。
|
||||||
|
|
||||||
|
## 15. AI 服务商配置(MySQL v20)
|
||||||
|
|
||||||
|
`ai_provider_configs` 只保存非敏感配置:服务商名称、HTTPS Base URL、模型、超时、最大
|
||||||
|
并发、自动写入置信度基点、启用状态和最近连接测试摘要。`active_slot` 是生成列并带唯一
|
||||||
|
索引,数据库层保证任一时刻最多一个启用项。普通配置或密钥发生变化时,当前服务商会
|
||||||
|
停用并把测试状态重置为 `pending`,必须重新测试后才能启用。
|
||||||
|
|
||||||
|
`ai_provider_audits` 追加记录创建、修改、测试、启停以及密钥替换/清除动作,只保存字段
|
||||||
|
名和非敏感状态。两张表都没有 API Key、Token 或 Secret 列;API Key 只存在部署指定的
|
||||||
|
独立密钥文件。
|
||||||
|
|||||||
@@ -907,6 +907,16 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
|
|||||||
- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。
|
- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。
|
||||||
- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。
|
- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。
|
||||||
|
|
||||||
|
### 8.4 AI 模型配置页
|
||||||
|
|
||||||
|
“AI 配置”只在管理员导航中显示,采购员直接访问页面或写接口也必须返回 403。页面采用
|
||||||
|
高密度表格:一行一个服务商,显示接口、模型、运行参数、密钥状态、连接测试和启用状态。
|
||||||
|
所有输入使用可见 `label`;保存、测试、启停和清除均为带 CSRF 的普通 POST 表单。
|
||||||
|
|
||||||
|
API Key 使用密码输入框,只允许替换或清除。已保存值显示固定掩码和尾四位,不提供查看、
|
||||||
|
复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一
|
||||||
|
个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。
|
||||||
|
|
||||||
## 9. 反馈方式
|
## 9. 反馈方式
|
||||||
|
|
||||||
| 场景 | 怎么反馈 |
|
| 场景 | 怎么反馈 |
|
||||||
|
|||||||
@@ -260,3 +260,11 @@ CMAutoBuyAdmin/
|
|||||||
4. 变更不泄露敏感信息;
|
4. 变更不泄露敏感信息;
|
||||||
5. Gitea 工单更新最终结果和提交哈希;
|
5. Gitea 工单更新最终结果和提交哈希;
|
||||||
6. 完成记录归档到 `docs/task`。
|
6. 完成记录归档到 `docs/task`。
|
||||||
|
|
||||||
|
## 11. AI 外部调用安全
|
||||||
|
|
||||||
|
- API Key 不得进入数据库、`data/`、日志、页面响应、测试快照、工单或归档;测试只使用假密钥。
|
||||||
|
- 密钥文件使用同目录临时文件、`fsync`、原子替换和 `600` 权限,失败时旧文件保持可用。
|
||||||
|
- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。
|
||||||
|
- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。
|
||||||
|
- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。
|
||||||
|
|||||||
Reference in New Issue
Block a user