fix: 修复 AI 服务商配置新增失败 (#222)

This commit is contained in:
chengma
2026-08-14 11:04:51 +08:00
parent 57fc8a3392
commit 01469f74b2
4 changed files with 216 additions and 1 deletions
+121
View File
@@ -0,0 +1,121 @@
package web
import (
"database/sql"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"cmautobuy/admin/internal/testutil"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
func TestRedirectAIConfig_区分成功和校验失败(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name, message, errorMessage, wantKey, wantValue string
}{
{name: "成功", message: "配置已保存", wantKey: "msg", wantValue: "配置已保存"},
{name: "校验失败", errorMessage: "Base URL 无效", wantKey: "error", wantValue: "Base URL 无效"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/settings/ai/save", nil)
redirectAIConfig(context, test.message, test.errorMessage)
if context.Writer.Status() != http.StatusSeeOther {
t.Fatalf("状态码=%d,期望 303", context.Writer.Status())
}
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil || location.Path != "/settings/ai" {
t.Fatalf("Location=%q err=%v", recorder.Header().Get("Location"), err)
}
if got := location.Query().Get(test.wantKey); got != test.wantValue {
t.Fatalf("%s=%q,期望 %q", test.wantKey, got, test.wantValue)
}
otherKey := "msg"
if test.wantKey == "msg" {
otherKey = "error"
}
if location.Query().Has(otherKey) {
t.Fatalf("成功和错误参数不能混用: %s", location.String())
}
})
}
}
func TestAIConfigSave_校验失败使用Error跳转(t *testing.T) {
context, recorder, _ := aiConfigSaveContext(t, url.Values{"confidence_threshold": {"not-a-number"}})
context.Set(currentUserKey, &model.User{Role: model.RoleAdmin, Status: model.UserActive})
(&Handler{}).AIConfigSave(context)
location, err := url.Parse(recorder.Header().Get("Location"))
if context.Writer.Status() != http.StatusSeeOther || err != nil || location.Query().Get("error") == "" || location.Query().Has("msg") {
t.Fatalf("校验失败跳转错误: code=%d location=%q err=%v", context.Writer.Status(), recorder.Header().Get("Location"), err)
}
}
func TestAIConfigSave_数据库异常返回500而非成功跳转(t *testing.T) {
db, err := sql.Open("mysql", "")
if err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
context, recorder, engine := aiConfigSaveContext(t, validAIConfigForm())
context.Set(currentUserKey, &model.User{UserID: "USR-1", Role: model.RoleAdmin, Status: model.UserActive})
engine.SetHTMLTemplate(template.Must(template.New("root").Parse(
`{{define "partials/error"}}{{.Message}}{{end}}`,
)))
(&Handler{db: db, aiPolicy: service.NewAIEndpointPolicy(nil)}).AIConfigSave(context)
if recorder.Code != http.StatusInternalServerError || recorder.Header().Get("Location") != "" {
t.Fatalf("数据库异常不能伪装成 303 成功: code=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
}
func TestAIConfigSave_真实MySQL新增后列表可见并使用Msg跳转(t *testing.T) {
db := testutil.OpenMySQL(t)
actor := model.User{UserID: "USR-WEB-AI", Username: "web-ai-admin", PasswordHash: "test-hash",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, actor); err != nil {
t.Fatal(err)
}
context, recorder, _ := aiConfigSaveContext(t, validAIConfigForm())
context.Set(currentUserKey, &actor)
(&Handler{db: db, aiPolicy: service.NewAIEndpointPolicy(nil)}).AIConfigSave(context)
location, err := url.Parse(recorder.Header().Get("Location"))
if context.Writer.Status() != http.StatusSeeOther || err != nil || location.Query().Get("msg") == "" || location.Query().Has("error") {
t.Fatalf("成功跳转错误: code=%d location=%q err=%v", context.Writer.Status(), recorder.Header().Get("Location"), err)
}
items, err := repository.ListAIProviders(db)
if err != nil || len(items) != 1 || items[0].Name != "测试主线路" {
t.Fatalf("跳转后应能查询新增配置: items=%+v err=%v", items, err)
}
}
func aiConfigSaveContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder, *gin.Engine) {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, engine := gin.CreateTestContext(recorder)
request := httptest.NewRequest(http.MethodPost, "/settings/ai/save", strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
context.Request = request
return context, recorder, engine
}
func validAIConfigForm() url.Values {
return url.Values{
"name": {"测试主线路"}, "base_url": {"https://api.example.com/v1"}, "model": {"model-1"},
"timeout_seconds": {"30"}, "max_concurrency": {"2"}, "confidence_threshold": {"85"},
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ 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,
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)
+62
View File
@@ -0,0 +1,62 @@
package repository
import (
"database/sql"
"errors"
"reflect"
"strings"
"testing"
"cmautobuy/admin/model"
)
type aiConfigCaptureExecer struct {
query string
args []any
}
func (c *aiConfigCaptureExecer) Exec(query string, args ...any) (sql.Result, error) {
c.query = query
c.args = append([]any(nil), args...)
return aiConfigResult(1), nil
}
func (c *aiConfigCaptureExecer) Query(string, ...any) (*sql.Rows, error) {
return nil, errors.New("本测试不应执行 Query")
}
func (c *aiConfigCaptureExecer) QueryRow(string, ...any) *sql.Row {
panic("本测试不应执行 QueryRow")
}
type aiConfigResult int64
func (r aiConfigResult) LastInsertId() (int64, error) { return int64(r), nil }
func (r aiConfigResult) RowsAffected() (int64, error) { return int64(r), nil }
func TestInsertAIProvider_占位符与字段参数一一对应(t *testing.T) {
item := model.AIProviderConfig{
ProviderID: "AIP-1", Name: "主线路", BaseURL: "https://api.example.com/v1",
Model: "model-1", TimeoutSeconds: 30, MaxConcurrency: 2, ConfidenceThresholdBPS: 8500,
CreatedByUserID: "USR-CREATE", UpdatedByUserID: "USR-UPDATE",
CreatedAt: "2026-08-14T00:00:00Z", UpdatedAt: "2026-08-14T00:01:00Z",
}
capture := &aiConfigCaptureExecer{}
if err := InsertAIProvider(capture, item); err != nil {
t.Fatal(err)
}
if got, want := strings.Count(capture.query, "?"), len(capture.args); got != want {
t.Fatalf("INSERT 占位符=%d,参数=%d;两者必须相同", got, want)
}
wantArgs := []any{item.ProviderID, item.Name, item.BaseURL, item.Model, item.TimeoutSeconds,
item.MaxConcurrency, item.ConfidenceThresholdBPS, item.CreatedByUserID,
item.UpdatedByUserID, item.CreatedAt, item.UpdatedAt}
if !reflect.DeepEqual(capture.args, wantArgs) {
t.Fatalf("INSERT 参数顺序错误\n实际: %#v\n期望: %#v", capture.args, wantArgs)
}
for _, fixed := range []string{"0", "'pending'"} {
if !strings.Contains(capture.query, fixed) {
t.Fatalf("INSERT 缺少固定初始值 %s", fixed)
}
}
}
+32
View File
@@ -9,6 +9,7 @@ import (
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestParseConfidenceThresholdBPS_精确转换(t *testing.T) {
@@ -25,6 +26,37 @@ func TestParseConfidenceThresholdBPS_精确转换(t *testing.T) {
}
}
func TestSaveAIProviderConfig_新增后可查询并与审计同事务(t *testing.T) {
db := newTestDB(t)
now := time.Date(2026, 8, 14, 1, 2, 3, 0, time.UTC)
actor := model.User{UserID: "USR-AI-CONFIG", Username: "ai-admin", PasswordHash: "test-hash",
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
if err := repository.CreateUser(db, actor); err != nil {
t.Fatal(err)
}
input := AIProviderInput{Name: "主线路", BaseURL: "https://api.example.com/v1", Model: "model-1",
TimeoutSeconds: 30, MaxConcurrency: 2, ConfidenceThresholdBPS: 8500}
providerID, err := SaveAIProviderConfig(db, &actor, input, NewAIEndpointPolicy(nil), now)
if err != nil || providerID == "" {
t.Fatalf("新增 AI 配置失败: id=%q err=%v", providerID, err)
}
items, err := repository.ListAIProviders(db)
if err != nil || len(items) != 1 || items[0].ProviderID != providerID || items[0].Name != input.Name {
t.Fatalf("新增后列表未回显配置: items=%+v err=%v", items, err)
}
var auditCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_provider_audits WHERE provider_id=? AND action='create'`, providerID).Scan(&auditCount); err != nil || auditCount != 1 {
t.Fatalf("创建审计数量=%d err=%v", auditCount, err)
}
if _, err := SaveAIProviderConfig(db, &actor, input, NewAIEndpointPolicy(nil), now.Add(time.Minute)); !IsValidationError(err) {
t.Fatalf("重名配置应返回可展示的校验错误: %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_provider_configs`).Scan(&auditCount); err != nil || auditCount != 1 {
t.Fatalf("失败请求不得留下半条配置: count=%d err=%v", auditCount, err)
}
}
func TestAIEndpointPolicy_阻止凭据和内网地址(t *testing.T) {
policy := NewAIEndpointPolicy(nil)
for _, raw := range []string{