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)。
+7 -1
View File
@@ -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 {
+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>
+8
View File
@@ -400,6 +400,14 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
**底部状态条:** 在线 / 离线数量统计。
### 4.6 AI 规格批量匹配(#199)
- AI 集成保留在 Admin 内部,不增加独立微服务;MVP 使用 OpenAI 兼容协议。
- 只有管理员能够维护服务商、模型和部署级密钥;采购员只能发起规格匹配。
- API Key 不进入数据库、`data/`、日志或 HTTP 响应,保存后只显示固定掩码和尾四位。
- 模型只能选择服务端提供的当前可购买候选;不确定、超时或硬校验失败时保持人工处理。
- AI 匹配不会创建采购任务,不改变 Client 接口,也不放宽真实采购门禁。
## 5. 创建采购任务的校验
`[必须]` 下面任何一条不满足就不允许创建,并明确告诉操作员缺什么:
+12 -1
View File
@@ -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)
+11
View File
@@ -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 只存在部署指定的
独立密钥文件。
+10
View File
@@ -907,6 +907,16 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
- 采购员仍由管理员在用户管理页重置密码;本阶段不提供采购员自助改密和忘记密码入口。
- 弹窗继续遵守公共关闭规则:取消、关闭按钮、Esc 和直接点击遮罩可关闭,从密码框拖选到遮罩不会误关闭。
### 8.4 AI 模型配置页
“AI 配置”只在管理员导航中显示,采购员直接访问页面或写接口也必须返回 403。页面采用
高密度表格:一行一个服务商,显示接口、模型、运行参数、密钥状态、连接测试和启用状态。
所有输入使用可见 `label`;保存、测试、启停和清除均为带 CSRF 的普通 POST 表单。
API Key 使用密码输入框,只允许替换或清除。已保存值显示固定掩码和尾四位,不提供查看、
复制或下载明文的入口。修改普通配置或密钥后明确提示“需要重新测试”;测试失败保留上一
个已启用服务商,错误消息只说明原因和恢复动作,不展示响应正文。
## 9. 反馈方式
| 场景 | 怎么反馈 |
+8
View File
@@ -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、完整请求或完整响应。