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

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