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 四接口。
|
||||
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
|
||||
|
||||
@@ -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 的第三方数据接入配置。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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)。
|
||||
|
||||
+7
-1
@@ -107,7 +107,13 @@ func newRouter(db *sql.DB) (*gin.Engine, error) {
|
||||
r.StaticFS("/static", http.FS(staticSub))
|
||||
|
||||
// 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 的接口
|
||||
catalogConfig, err := config.LoadCatalogIntegration()
|
||||
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)
|
||||
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"}`},
|
||||
|
||||
+59
-1
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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}}
|
||||
<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="/settings/ai" data-module-root="/settings/ai" class="{{if eq .Active "ai-settings"}}active{{end}}">AI 配置</a>
|
||||
{{end}}
|
||||
{{if .CurrentUser}}
|
||||
<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. 创建采购任务的校验
|
||||
|
||||
`[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 只存在部署指定的
|
||||
独立密钥文件。
|
||||
|
||||
@@ -907,6 +907,16 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
|
||||
- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。
|
||||
- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。
|
||||
|
||||
### 8.4 AI 模型配置页
|
||||
|
||||
“AI 配置”只在管理员导航中显示,采购员直接访问页面或写接口也必须返回 403。页面采用
|
||||
高密度表格:一行一个服务商,显示接口、模型、运行参数、密钥状态、连接测试和启用状态。
|
||||
所有输入使用可见 `label`;保存、测试、启停和清除均为带 CSRF 的普通 POST 表单。
|
||||
|
||||
API Key 使用密码输入框,只允许替换或清除。已保存值显示固定掩码和尾四位,不提供查看、
|
||||
复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一
|
||||
个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。
|
||||
|
||||
## 9. 反馈方式
|
||||
|
||||
| 场景 | 怎么反馈 |
|
||||
|
||||
@@ -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、完整请求或完整响应。
|
||||
|
||||
Reference in New Issue
Block a user