From 018da3f732561e9e65afd1c3005b14503a537061 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 14 Aug 2026 09:49:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=20AI=20=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/ai_config_template_test.go | 29 ++ admin/config.example.yaml | 10 + admin/config/config.go | 47 ++ admin/config/config_test.go | 15 + admin/handler/web/ai_config.go | 104 ++++ admin/handler/web/web.go | 18 +- admin/main.go | 10 +- admin/main_test.go | 31 ++ admin/model/model.go | 60 ++- admin/repository/ai_config.go | 167 +++++++ admin/repository/mysql_db.go | 105 +++- admin/repository/mysql_db_integration_test.go | 32 ++ admin/service/ai_config.go | 460 ++++++++++++++++++ admin/service/ai_config_test.go | 88 ++++ admin/service/ai_secret.go | 196 ++++++++ admin/service/ai_secret_test.go | 48 ++ admin/static/css/app.css | 10 + admin/templates/ai/list.html | 73 +++ admin/templates/partials/header.html | 1 + docs/admin/01-requirements.md | 8 + docs/admin/02-architecture.md | 13 +- docs/admin/03-data-model.md | 11 + docs/admin/05-ui-specification.md | 10 + docs/admin/06-quality-security.md | 8 + 24 files changed, 1546 insertions(+), 8 deletions(-) create mode 100644 admin/ai_config_template_test.go create mode 100644 admin/handler/web/ai_config.go create mode 100644 admin/repository/ai_config.go create mode 100644 admin/service/ai_config.go create mode 100644 admin/service/ai_config_test.go create mode 100644 admin/service/ai_secret.go create mode 100644 admin/service/ai_secret_test.go create mode 100644 admin/templates/ai/list.html diff --git a/admin/ai_config_template_test.go b/admin/ai_config_template_test.go new file mode 100644 index 0000000..4aaf708 --- /dev/null +++ b/admin/ai_config_template_test.go @@ -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) + } + } +} diff --git a/admin/config.example.yaml b/admin/config.example.yaml index f4f403b..a1c8ee3 100644 --- a/admin/config.example.yaml +++ b/admin/config.example.yaml @@ -44,6 +44,16 @@ integrations: # 留空表示商品目录接口禁用,不影响 Admin 页面和 Client 四接口。 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: # 顺运宝 ERP 地址。一般不用改,域名变了才改。 base_url: https://www.shunyunbaoerp.com diff --git a/admin/config/config.go b/admin/config/config.go index bbec85e..d0a981f 100644 --- a/admin/config/config.go +++ b/admin/config/config.go @@ -33,6 +33,8 @@ const ( databaseTLSCAEnv = "CMAUTOBUY_DB_TLS_CA" catalogSourceEnv = "CMAUTOBUY_CATALOG_SOURCE" catalogTokenEnv = "CMAUTOBUY_CATALOG_TOKEN" + aiSecretsPathEnv = "CMAUTOBUY_AI_SECRETS_PATH" + aiAllowedHostsEnv = "CMAUTOBUY_AI_ALLOWED_HOSTS" ) const ( @@ -322,6 +324,51 @@ type Config struct { Database DatabaseConfig `yaml:"database"` Syb SybConfig `yaml:"syb"` 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 的第三方数据接入配置。 diff --git a/admin/config/config_test.go b/admin/config/config_test.go index be70a06..e9b3728 100644 --- a/admin/config/config_test.go +++ b/admin/config/config_test.go @@ -327,3 +327,18 @@ func TestCatalogIntegrationConfig_弱Token拒绝且留空可禁用(t *testing.T) 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) + } +} diff --git a/admin/handler/web/ai_config.go b/admin/handler/web/ai_config.go new file mode 100644 index 0000000..b791a73 --- /dev/null +++ b/admin/handler/web/ai_config.go @@ -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) +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 618dd88..3d7f2ef 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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)。 diff --git a/admin/main.go b/admin/main.go index 5f377ce..e45d0e4 100644 --- a/admin/main.go +++ b/admin/main.go @@ -107,8 +107,14 @@ func newRouter(db *sql.DB) (*gin.Engine, error) { r.StaticFS("/static", http.FS(staticSub)) // 4. 路由 - web.Register(r, db, config.OnlineThreshold) // 给浏览器的页面 - api.Register(r, db) // 给 Client 的接口 + 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 的接口 catalogConfig, err := config.LoadCatalogIntegration() if err != nil { return nil, fmt.Errorf("读取商品目录接口配置失败: %w", err) diff --git a/admin/main_test.go b/admin/main_test.go index 5300cb4..e831f4d 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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) addAuth(pddRequest) @@ -608,6 +615,30 @@ func TestMainPagesReturnOK(t *testing.T) { if usersResponse.Code != http.StatusForbidden { 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{ {ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`}, {ClientID: "CLIENT-DRY", Name: "未就绪机", Capabilities: `{"purchase_mode":"dry_run"}`}, diff --git a/admin/model/model.go b/admin/model/model.go index 5f681ee..867126e 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -4,7 +4,10 @@ // 字段含义的权威定义在 docs/admin/03-data-model.md。 package model -import "time" +import ( + "fmt" + "time" +) // ---------- 时间 ---------- @@ -100,6 +103,61 @@ func (u User) StatusLabel() string { 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 // 的 SHA-256,不是 Token 原文。 type WebSession struct { diff --git a/admin/repository/ai_config.go b/admin/repository/ai_config.go new file mode 100644 index 0000000..173352f --- /dev/null +++ b/admin/repository/ai_config.go @@ -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) +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 99ea0c7..c126b72 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 19 +const mysqlSchemaVersion = 20 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 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 { 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) } +// 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 商品的店铺和图片。 func migrateMySQLV15(db *sql.DB) error { return backfillSybProductMetadata(db) @@ -1798,6 +1863,7 @@ func CheckMySQLSchema(db *sql.DB) error { "task_syb_sources", "task_sequences", "syb_allowed_shops", + "ai_provider_configs", "ai_provider_audits", } if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { return err @@ -1850,7 +1916,42 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV18Shape(db); err != nil { 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 { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 51e4fb9..dd244c9 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -953,6 +953,38 @@ func openMySQLMigrationTestDB(t *testing.T) *sql.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) { t.Helper() mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`) diff --git a/admin/service/ai_config.go b/admin/service/ai_config.go new file mode 100644 index 0000000..743d426 --- /dev/null +++ b/admin/service/ai_config.go @@ -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 +} diff --git a/admin/service/ai_config_test.go b/admin/service/ai_config_test.go new file mode 100644 index 0000000..0f66811 --- /dev/null +++ b/admin/service/ai_config_test.go @@ -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) + } + } +} diff --git a/admin/service/ai_secret.go b/admin/service/ai_secret.go new file mode 100644 index 0000000..24211cc --- /dev/null +++ b/admin/service/ai_secret.go @@ -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)) +} diff --git a/admin/service/ai_secret_test.go b/admin/service/ai_secret_test.go new file mode 100644 index 0000000..08e9cf9 --- /dev/null +++ b/admin/service/ai_secret_test.go @@ -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) + } + } +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index ceeb814..0bc4007 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -20,6 +20,16 @@ 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 { margin: 0; font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; diff --git a/admin/templates/ai/list.html b/admin/templates/ai/list.html new file mode 100644 index 0000000..066351a --- /dev/null +++ b/admin/templates/ai/list.html @@ -0,0 +1,73 @@ +{{define "ai/list"}} +{{template "header" .}} + +
+
+ + + + + + + + + + + + + + + 超时单位为秒;新增后还需要保存 API Key、测试连接并启用。 +
+
+ +{{if .Message}}

{{.Message}}

{{end}} +{{if .Error}}{{end}} +{{if .SecretStoreError}}{{end}} +

API Key 只写入部署指定的独立 600 权限密钥文件。页面永不回显明文;修改普通配置或密钥后必须重新测试才能启用。

+ +
+ + + + {{range .Rows}} + + + + + + + + + {{else}} + + {{end}} + +
服务商接口与模型运行参数API Key连接测试状态与操作
+ + + + + + + +

{{if .SecretConfigured}}已配置:{{.SecretSuffix}}{{else}}未配置{{end}}

+
+ + + +
+ {{if .SecretConfigured}}
+ +
{{end}} +
{{.TestStatusLabel}}{{if .LastTestMessage}}

{{.LastTestMessage}}

{{end}}{{if .LastTestedAt}}{{.LastTestedAt}}{{end}} +
+

{{.StatusLabel}}

+
+ {{if .Enabled}}
+ {{else}}
{{end}} +
还没有 AI 服务商。先新增非敏感配置,再保存密钥并测试连接。
+
+
共 {{len .Rows}} 个服务商
+{{template "footer" .}} +{{end}} diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index 69aa735..a86cb44 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -18,6 +18,7 @@ {{if and .CurrentUser .CurrentUser.IsAdmin}} 店铺管理 用户管理 + AI 配置 {{end}} {{if .CurrentUser}} {{.CurrentUser.Username}} diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 736283a..9efcf3e 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -400,6 +400,14 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin **底部状态条:** 在线 / 离线数量统计。 +### 4.6 AI 规格批量匹配(#199) + +- AI 集成保留在 Admin 内部,不增加独立微服务;MVP 使用 OpenAI 兼容协议。 +- 只有管理员能够维护服务商、模型和部署级密钥;采购员只能发起规格匹配。 +- API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。 +- 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。 +- AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。 + ## 5. 创建采购任务的校验 `[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么: diff --git a/docs/admin/02-architecture.md b/docs/admin/02-architecture.md index b9a57ab..f7f4300 100644 --- a/docs/admin/02-architecture.md +++ b/docs/admin/02-architecture.md @@ -252,7 +252,18 @@ handler 返回批次 JSON;管理员在统一导入记录页查看摘要 Web 登录中间件也不覆盖 `/api/v1/client/*`。 - 详见 [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-glossary.md) diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index cafcf9a..30dab49 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -974,3 +974,14 @@ CREATE UNIQUE INDEX idx_client_assignment_current - 相同来源和批次号携带不同请求哈希时记录冲突并返回 HTTP 409。 - `response_body` 只保存可安全重放的结果摘要,不得包含凭据或原始商品数据。 - `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 只存在部署指定的 +独立密钥文件。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 6a52d77..c44941f 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -907,6 +907,16 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD - 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。 - 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。 +### 8.4 AI 模型配置页 + +“AI 配置”只在管理员导航中显示,采购员直接访问页面或写接口也必须返回 403。页面采用 +高密度表格:一行一个服务商,显示接口、模型、运行参数、密钥状态、连接测试和启用状态。 +所有输入使用可见 `label`;保存、测试、启停和清除均为带 CSRF 的普通 POST 表单。 + +API Key 使用密码输入框,只允许替换或清除。已保存值显示固定掩码和尾四位,不提供查看、 +复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一 +个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。 + ## 9. 反馈方式 | 场景 | 怎么反馈 | diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 1f1db7d..060f208 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -260,3 +260,11 @@ CMAutoBuyAdmin/ 4. 变更不泄露敏感信息; 5. Gitea 工单更新最终结果和提交哈希; 6. 完成记录归档到 `docs/task`。 + +## 11. AI 外部调用安全 + +- API Key 不得进入数据库、`data/`、日志、页面响应、测试快照、工单或归档;测试只使用假密钥。 +- 密钥文件使用同目录临时文件、`fsync`、原子替换和 `600` 权限,失败时旧文件保持可用。 +- 连接测试只发送模型名和最小无业务文本,不发送订单号、店铺账号、用户、地址或 Client 信息。 +- Base URL、DNS 解析、实际拨号和重定向都要执行 SSRF 校验;私有地址只能由部署级允许列表开放。 +- 测试失败、超时和非 2xx 响应不得记录 Authorization、完整请求或完整响应。