Compare commits
2 Commits
5b765c8b5e
...
1522c87c74
| Author | SHA1 | Date | |
|---|---|---|---|
| 1522c87c74 | |||
| 5b401e29cb |
33
Makefile
33
Makefile
@@ -2,6 +2,7 @@
|
||||
backend-build backend-run backend-test backend-test-unit backend-test-integration backend-test-coverage \
|
||||
backend-lint backend-deps backend-generate \
|
||||
backend-migrate-up backend-migrate-down backend-migrate-status backend-migrate-create \
|
||||
test-mysql-up test-mysql-down test-mysql test-mysql-quick \
|
||||
frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint \
|
||||
desktop desktop-darwin desktop-windows desktop-linux package-macos
|
||||
|
||||
@@ -57,6 +58,38 @@ backend-migrate-create:
|
||||
cd backend && goose -dir migrations/sqlite create $$name sql; \
|
||||
cd backend && goose -dir migrations/mysql create $$name sql
|
||||
|
||||
# ============================================
|
||||
# MySQL 专项测试
|
||||
# ============================================
|
||||
|
||||
test-mysql-up:
|
||||
@echo "Starting MySQL test container..."
|
||||
cd backend/tests/mysql && docker-compose up -d
|
||||
@echo "Waiting for MySQL to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if docker exec nex-mysql-test mysqladmin ping -h localhost -u root -ptestpass --silent 2>/dev/null; then \
|
||||
echo "MySQL is ready!"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo "Waiting... ($$i/30)"; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "MySQL failed to start"; \
|
||||
exit 1
|
||||
|
||||
test-mysql-down:
|
||||
@echo "Stopping MySQL test container..."
|
||||
cd backend/tests/mysql && docker-compose down -v
|
||||
|
||||
test-mysql: test-mysql-up
|
||||
@echo "Running MySQL tests..."
|
||||
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
|
||||
$(MAKE) test-mysql-down
|
||||
|
||||
test-mysql-quick:
|
||||
@echo "Running MySQL tests (without container management)..."
|
||||
cd backend && go test -tags=mysql ./tests/mysql/... -v -count=1
|
||||
|
||||
# ============================================
|
||||
# 前端
|
||||
# ============================================
|
||||
|
||||
@@ -29,8 +29,8 @@ type Model struct {
|
||||
// UsageStats 用量统计
|
||||
type UsageStats struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ProviderID string `gorm:"not null;index" json:"provider_id"`
|
||||
ModelName string `gorm:"not null;index" json:"model_name"`
|
||||
ProviderID string `gorm:"not null;index;uniqueIndex:idx_provider_model_date" json:"provider_id"`
|
||||
ModelName string `gorm:"not null;index;uniqueIndex:idx_provider_model_date" json:"model_name"`
|
||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||
Date time.Time `gorm:"type:date;not null;uniqueIndex:idx_provider_model_date" json:"date"`
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/internal/domain"
|
||||
@@ -22,47 +22,43 @@ func (r *statsRepository) Record(providerID, modelName string) error {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
todayTime, _ := time.Parse("2006-01-02", today)
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var stats config.UsageStats
|
||||
err := tx.Where("provider_id = ? AND model_name = ? AND date = ?",
|
||||
providerID, modelName, todayTime).First(&stats).Error
|
||||
stats := config.UsageStats{
|
||||
ProviderID: providerID,
|
||||
ModelName: modelName,
|
||||
RequestCount: 1,
|
||||
Date: todayTime,
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
stats = config.UsageStats{
|
||||
ProviderID: providerID,
|
||||
ModelName: modelName,
|
||||
RequestCount: 1,
|
||||
Date: todayTime,
|
||||
}
|
||||
return tx.Create(&stats).Error
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&stats).Update("request_count", gorm.Expr("request_count + 1")).Error
|
||||
})
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "provider_id"},
|
||||
{Name: "model_name"},
|
||||
{Name: "date"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + 1"),
|
||||
}),
|
||||
}).Create(&stats).Error
|
||||
}
|
||||
|
||||
func (r *statsRepository) BatchUpdate(providerID, modelName string, date time.Time, delta int) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var stats config.UsageStats
|
||||
err := tx.Where("provider_id = ? AND model_name = ? AND date = ?",
|
||||
providerID, modelName, date).First(&stats).Error
|
||||
stats := config.UsageStats{
|
||||
ProviderID: providerID,
|
||||
ModelName: modelName,
|
||||
RequestCount: delta,
|
||||
Date: date,
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tx.Create(&config.UsageStats{
|
||||
ProviderID: providerID,
|
||||
ModelName: modelName,
|
||||
RequestCount: delta,
|
||||
Date: date,
|
||||
}).Error
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&stats).
|
||||
Update("request_count", gorm.Expr("request_count + ?", delta)).Error
|
||||
})
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "provider_id"},
|
||||
{Name: "model_name"},
|
||||
{Name: "date"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + ?", delta),
|
||||
}),
|
||||
}).Create(&stats).Error
|
||||
}
|
||||
|
||||
func (r *statsRepository) Query(providerID, modelName string, startDate, endDate *time.Time) ([]domain.UsageStats, error) {
|
||||
|
||||
158
backend/tests/mysql/concurrent_test.go
Normal file
158
backend/tests/mysql/concurrent_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
//go:build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
"nex/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestConcurrent_UsageStatsRecord(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
|
||||
providerID := "concurrent-test-provider"
|
||||
modelName := "gpt-4"
|
||||
|
||||
concurrency := 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrency)
|
||||
|
||||
errChan := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := statsRepo.Record(providerID, modelName)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
var errorCount int
|
||||
uniqueErrors := make(map[string]int)
|
||||
for err := range errChan {
|
||||
errorCount++
|
||||
uniqueErrors[err.Error()]++
|
||||
}
|
||||
|
||||
t.Logf("并发 %d 次,错误 %d 次", concurrency, errorCount)
|
||||
for errMsg, count := range uniqueErrors {
|
||||
t.Logf(" 错误: %s (出现 %d 次)", errMsg, count)
|
||||
}
|
||||
|
||||
var stats config.UsageStats
|
||||
err := db.Where("provider_id = ? AND model_name = ?", providerID, modelName).
|
||||
First(&stats).Error
|
||||
require.NoError(t, err, "应能查到 usage_stats 记录")
|
||||
|
||||
successCount := concurrency - errorCount
|
||||
t.Logf("成功次数: %d, 最终 request_count: %d", successCount, stats.RequestCount)
|
||||
|
||||
assert.Equal(t, concurrency, stats.RequestCount, "request_count 应等于并发数,无数据丢失或重复")
|
||||
}
|
||||
|
||||
func TestConcurrent_ProviderCreate(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
providerID := "concurrent-provider-id"
|
||||
concurrency := 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrency)
|
||||
|
||||
successCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
provider := config.Provider{
|
||||
ID: providerID,
|
||||
Name: "Concurrent Provider",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://test.com",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
err := db.Create(&provider).Error
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 1, successCount, "仅 1 个创建应成功")
|
||||
|
||||
var count int64
|
||||
db.Model(&config.Provider{}).Where("id = ?", providerID).Count(&count)
|
||||
assert.Equal(t, int64(1), count, "最终应有 1 条记录")
|
||||
}
|
||||
|
||||
func TestConcurrent_ModelCreate(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
provider := config.Provider{
|
||||
ID: "concurrent-model-provider",
|
||||
Name: "Test Provider",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://test.com",
|
||||
Enabled: true,
|
||||
}
|
||||
err := db.Create(&provider).Error
|
||||
require.NoError(t, err, "创建 provider 应成功")
|
||||
|
||||
modelName := "gpt-4-concurrent"
|
||||
concurrency := 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrency)
|
||||
|
||||
successCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
model := config.Model{
|
||||
ID: uuid.New().String(),
|
||||
ProviderID: provider.ID,
|
||||
ModelName: modelName,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
err := db.Create(&model).Error
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 1, successCount, "仅 1 个创建应成功")
|
||||
|
||||
var count int64
|
||||
db.Model(&config.Model{}).Where("provider_id = ? AND model_name = ?", provider.ID, modelName).Count(&count)
|
||||
assert.Equal(t, int64(1), count, "最终应有 1 条记录")
|
||||
}
|
||||
130
backend/tests/mysql/constraint_test.go
Normal file
130
backend/tests/mysql/constraint_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
//go:build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nex/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestConstraint_ForeignKeyEnforced(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
model := config.Model{
|
||||
ID: "test-model-id",
|
||||
ProviderID: "non-existent-provider",
|
||||
ModelName: "gpt-4",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
err := db.Create(&model).Error
|
||||
assert.Error(t, err, "创建 model 时 provider_id 不存在应失败")
|
||||
assert.Contains(t, err.Error(), "foreign key constraint", "错误应为外键约束错误")
|
||||
}
|
||||
|
||||
func TestConstraint_CascadeDelete(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
provider := config.Provider{
|
||||
ID: "test-provider-cascade",
|
||||
Name: "Test Provider",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://test.com",
|
||||
Enabled: true,
|
||||
}
|
||||
err := db.Create(&provider).Error
|
||||
require.NoError(t, err, "创建 provider 应成功")
|
||||
|
||||
model := config.Model{
|
||||
ID: "test-model-cascade",
|
||||
ProviderID: provider.ID,
|
||||
ModelName: "gpt-4",
|
||||
Enabled: true,
|
||||
}
|
||||
err = db.Create(&model).Error
|
||||
require.NoError(t, err, "创建 model 应成功")
|
||||
|
||||
err = db.Delete(&provider).Error
|
||||
require.NoError(t, err, "删除 provider 应成功")
|
||||
|
||||
var count int64
|
||||
err = db.Model(&config.Model{}).Where("provider_id = ?", provider.ID).Count(&count).Error
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), count, "删除 provider 后其 models 应被级联删除")
|
||||
}
|
||||
|
||||
func TestConstraint_UniqueProviderModel(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
provider := config.Provider{
|
||||
ID: "test-provider-unique",
|
||||
Name: "Test Provider",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://test.com",
|
||||
Enabled: true,
|
||||
}
|
||||
err := db.Create(&provider).Error
|
||||
require.NoError(t, err, "创建 provider 应成功")
|
||||
|
||||
model1 := config.Model{
|
||||
ID: "test-model-unique-1",
|
||||
ProviderID: provider.ID,
|
||||
ModelName: "gpt-4",
|
||||
Enabled: true,
|
||||
}
|
||||
err = db.Create(&model1).Error
|
||||
require.NoError(t, err, "创建第一个 model 应成功")
|
||||
|
||||
model2 := config.Model{
|
||||
ID: "test-model-unique-2",
|
||||
ProviderID: provider.ID,
|
||||
ModelName: "gpt-4",
|
||||
Enabled: true,
|
||||
}
|
||||
err = db.Create(&model2).Error
|
||||
assert.Error(t, err, "创建相同 (provider_id, model_name) 的 model 应失败")
|
||||
assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) ||
|
||||
(err != nil && (err.Error() == "Error 1062" || containsDuplicateError(err.Error()))),
|
||||
"错误应为唯一约束错误")
|
||||
}
|
||||
|
||||
func TestConstraint_UniqueUsageStats(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
todayTime, _ := time.Parse("2006-01-02", today)
|
||||
|
||||
providerID := "test-provider-unique-stats"
|
||||
|
||||
stats1 := config.UsageStats{
|
||||
ProviderID: providerID,
|
||||
ModelName: "gpt-4",
|
||||
RequestCount: 10,
|
||||
Date: todayTime,
|
||||
}
|
||||
err := db.Create(&stats1).Error
|
||||
require.NoError(t, err, "创建第一个 usage_stats 应成功")
|
||||
|
||||
stats2 := config.UsageStats{
|
||||
ProviderID: providerID,
|
||||
ModelName: "gpt-4",
|
||||
RequestCount: 20,
|
||||
Date: todayTime,
|
||||
}
|
||||
err = db.Create(&stats2).Error
|
||||
assert.Error(t, err, "创建相同 (provider_id, model_name, date) 的 usage_stats 应失败")
|
||||
assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey) ||
|
||||
(err != nil && (err.Error() == "Error 1062" || containsDuplicateError(err.Error()))),
|
||||
"错误应为唯一约束错误")
|
||||
}
|
||||
|
||||
func containsDuplicateError(errStr string) bool {
|
||||
return len(errStr) > 0 && (errStr[0:8] == "Error 10" || errStr[0:5] == "Dupli")
|
||||
}
|
||||
21
backend/tests/mysql/docker-compose.yml
Normal file
21
backend/tests/mysql/docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: nex-mysql-test
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: testpass
|
||||
MYSQL_DATABASE: nex_test
|
||||
MYSQL_USER: nex_test
|
||||
MYSQL_PASSWORD: testpass
|
||||
ports:
|
||||
- "13306:3306"
|
||||
tmpfs:
|
||||
- /var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
126
backend/tests/mysql/migration_test.go
Normal file
126
backend/tests/mysql/migration_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
//go:build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigration_TablesExist(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
var tables []string
|
||||
err := db.Raw("SHOW TABLES").Scan(&tables).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedTables := []string{"providers", "models", "usage_stats"}
|
||||
for _, expected := range expectedTables {
|
||||
assert.Contains(t, tables, expected, "表 %s 应存在", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration_TableColumns(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
t.Run("providers 表字段", func(t *testing.T) {
|
||||
var columns []struct {
|
||||
Field string
|
||||
Type string
|
||||
Null string
|
||||
}
|
||||
err := db.Raw("SHOW COLUMNS FROM providers").Scan(&columns).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
columnMap := make(map[string]string)
|
||||
for _, col := range columns {
|
||||
columnMap[col.Field] = col.Type
|
||||
}
|
||||
|
||||
assert.Contains(t, columnMap["id"], "varchar", "id 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["name"], "varchar", "name 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["api_key"], "varchar", "api_key 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["base_url"], "varchar", "base_url 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["protocol"], "varchar", "protocol 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["enabled"], "tinyint", "enabled 应为 TINYINT (BOOLEAN) 类型")
|
||||
assert.Contains(t, columnMap["created_at"], "datetime", "created_at 应为 DATETIME 类型")
|
||||
assert.Contains(t, columnMap["updated_at"], "datetime", "updated_at 应为 DATETIME 类型")
|
||||
})
|
||||
|
||||
t.Run("models 表字段", func(t *testing.T) {
|
||||
var columns []struct {
|
||||
Field string
|
||||
Type string
|
||||
}
|
||||
err := db.Raw("SHOW COLUMNS FROM models").Scan(&columns).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
columnMap := make(map[string]string)
|
||||
for _, col := range columns {
|
||||
columnMap[col.Field] = col.Type
|
||||
}
|
||||
|
||||
assert.Contains(t, columnMap["id"], "varchar", "id 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["provider_id"], "varchar", "provider_id 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["model_name"], "varchar", "model_name 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["enabled"], "tinyint", "enabled 应为 TINYINT (BOOLEAN) 类型")
|
||||
assert.Contains(t, columnMap["created_at"], "datetime", "created_at 应为 DATETIME 类型")
|
||||
})
|
||||
|
||||
t.Run("usage_stats 表字段", func(t *testing.T) {
|
||||
var columns []struct {
|
||||
Field string
|
||||
Type string
|
||||
}
|
||||
err := db.Raw("SHOW COLUMNS FROM usage_stats").Scan(&columns).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
columnMap := make(map[string]string)
|
||||
for _, col := range columns {
|
||||
columnMap[col.Field] = col.Type
|
||||
}
|
||||
|
||||
assert.Contains(t, columnMap["id"], "int", "id 应为 INT 类型")
|
||||
assert.Contains(t, columnMap["provider_id"], "varchar", "provider_id 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["model_name"], "varchar", "model_name 应为 VARCHAR 类型")
|
||||
assert.Contains(t, columnMap["request_count"], "int", "request_count 应为 INT 类型")
|
||||
assert.Contains(t, columnMap["date"], "date", "date 应为 DATE 类型")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigration_IndexesExist(t *testing.T) {
|
||||
db := SetupMySQLTestDB(t)
|
||||
|
||||
t.Run("models 表索引", func(t *testing.T) {
|
||||
var indexes []struct {
|
||||
KeyName string
|
||||
}
|
||||
err := db.Raw("SHOW INDEX FROM models").Scan(&indexes).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
indexMap := make(map[string]bool)
|
||||
for _, idx := range indexes {
|
||||
indexMap[idx.KeyName] = true
|
||||
}
|
||||
|
||||
assert.True(t, indexMap["idx_models_provider_id"], "idx_models_provider_id 索引应存在")
|
||||
assert.True(t, indexMap["idx_models_model_name"], "idx_models_model_name 索引应存在")
|
||||
})
|
||||
|
||||
t.Run("usage_stats 表索引", func(t *testing.T) {
|
||||
var indexes []struct {
|
||||
KeyName string
|
||||
}
|
||||
err := db.Raw("SHOW INDEX FROM usage_stats").Scan(&indexes).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
indexMap := make(map[string]bool)
|
||||
for _, idx := range indexes {
|
||||
indexMap[idx.KeyName] = true
|
||||
}
|
||||
|
||||
assert.True(t, indexMap["idx_usage_stats_provider_model_date"], "idx_usage_stats_provider_model_date 索引应存在")
|
||||
})
|
||||
}
|
||||
160
backend/tests/mysql/testhelper.go
Normal file
160
backend/tests/mysql/testhelper.go
Normal file
@@ -0,0 +1,160 @@
|
||||
//go:build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type MySQLTestConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
}
|
||||
|
||||
func getMySQLTestConfig() *MySQLTestConfig {
|
||||
return &MySQLTestConfig{
|
||||
Host: getEnvOrDefault("NEX_TEST_MYSQL_HOST", "localhost"),
|
||||
Port: getEnvOrDefaultInt("NEX_TEST_MYSQL_PORT", 13306),
|
||||
User: getEnvOrDefault("NEX_TEST_MYSQL_USER", "nex_test"),
|
||||
Password: getEnvOrDefault("NEX_TEST_MYSQL_PASSWORD", "testpass"),
|
||||
Database: getEnvOrDefault("NEX_TEST_MYSQL_DATABASE", "nex_test"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvOrDefaultInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
var intValue int
|
||||
if _, err := fmt.Sscanf(value, "%d", &intValue); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func SkipIfMySQLUnavailable(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
cfg := getMySQLTestConfig()
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Skipf("MySQL 不可用: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Skipf("MySQL 不可用: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func SetupMySQLTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
SkipIfMySQLUnavailable(t)
|
||||
|
||||
cfg := getMySQLTestConfig()
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err, "连接 MySQL 失败")
|
||||
|
||||
if err := runMigrations(db); err != nil {
|
||||
require.NoError(t, err, "运行迁移失败")
|
||||
}
|
||||
|
||||
if err := cleanupTables(db); err != nil {
|
||||
require.NoError(t, err, "清理表数据失败")
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
t.Cleanup(func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func cleanupTables(db *gorm.DB) error {
|
||||
if err := db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("TRUNCATE TABLE usage_stats").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("TRUNCATE TABLE models").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("TRUNCATE TABLE providers").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMigrations(db *gorm.DB) error {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrationsDir := getMigrationsDir()
|
||||
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
|
||||
}
|
||||
|
||||
goose.SetDialect("mysql")
|
||||
if err := goose.Up(sqlDB, migrationsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMigrationsDir() string {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "mysql")
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return "./migrations/mysql"
|
||||
}
|
||||
104
openspec/specs/mysql-testing/spec.md
Normal file
104
openspec/specs/mysql-testing/spec.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# MySQL Testing
|
||||
|
||||
## Purpose
|
||||
|
||||
提供 MySQL 数据库专项测试能力,验证迁移正确性、外键约束、并发写入等数据库特定行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: MySQL 测试环境可启动
|
||||
|
||||
系统 SHALL 提供 Docker Compose 配置以启动 MySQL 8.0 测试环境。
|
||||
|
||||
#### Scenario: 启动 MySQL 测试容器
|
||||
- **WHEN** 执行 `make test-mysql-up`
|
||||
- **THEN** 启动 MySQL 8.0 容器,端口 13306
|
||||
- **AND** 创建数据库 `nex_test`
|
||||
- **AND** 容器数据存储在内存盘(tmpfs)
|
||||
|
||||
#### Scenario: 销毁 MySQL 测试容器
|
||||
- **WHEN** 执行 `make test-mysql-down`
|
||||
- **THEN** 停止并删除容器
|
||||
- **AND** 所有数据被销毁
|
||||
|
||||
### Requirement: MySQL 测试可通过 build tag 控制
|
||||
|
||||
MySQL 测试 SHALL 使用 `// +build mysql` build tag,默认不运行。
|
||||
|
||||
#### Scenario: 默认测试不包含 MySQL 测试
|
||||
- **WHEN** 执行 `go test ./...`
|
||||
- **THEN** 不运行 `tests/mysql/` 下的测试
|
||||
|
||||
#### Scenario: 启用 MySQL 测试
|
||||
- **WHEN** 执行 `go test -tags=mysql ./tests/mysql/...`
|
||||
- **THEN** 运行所有 MySQL 测试
|
||||
|
||||
### Requirement: MySQL 迁移正确执行
|
||||
|
||||
MySQL 测试 SHALL 验证迁移脚本在 MySQL 环境下正确执行。
|
||||
|
||||
#### Scenario: 迁移创建所有表
|
||||
- **WHEN** 运行 MySQL 迁移
|
||||
- **THEN** 创建 `providers`、`models`、`usage_stats` 表
|
||||
- **AND** 字段类型符合 MySQL 迁移文件定义(VARCHAR、DATETIME(3)、BOOLEAN 等)
|
||||
- **AND** 索引 `idx_models_provider_id`、`idx_models_model_name`、`idx_usage_stats_provider_model_date` 创建成功
|
||||
|
||||
#### Scenario: 迁移可重复执行
|
||||
- **WHEN** 在已迁移的数据库上再次运行迁移
|
||||
- **THEN** 不报错,数据库状态不变
|
||||
|
||||
### Requirement: MySQL 外键约束生效
|
||||
|
||||
MySQL 测试 SHALL 验证外键约束行为符合预期。
|
||||
|
||||
#### Scenario: 外键约束阻止无效引用
|
||||
- **WHEN** 创建 model 时 `provider_id` 不存在
|
||||
- **THEN** 操作失败,返回外键约束错误
|
||||
|
||||
#### Scenario: 级联删除生效
|
||||
- **WHEN** 删除 provider
|
||||
- **THEN** 该 provider 的所有 models 被级联删除
|
||||
|
||||
### Requirement: MySQL UNIQUE 约束生效
|
||||
|
||||
MySQL 测试 SHALL 验证 UNIQUE 约束行为符合预期。
|
||||
|
||||
#### Scenario: models 表 UNIQUE 约束
|
||||
- **WHEN** 尝试创建相同 `(provider_id, model_name)` 组合的 model
|
||||
- **THEN** 操作失败,返回唯一约束错误
|
||||
|
||||
#### Scenario: usage_stats 表 UNIQUE 约束
|
||||
- **WHEN** 尝试创建相同 `(provider_id, model_name, date)` 组合的 usage_stats
|
||||
- **THEN** 操作失败,返回唯一约束错误
|
||||
|
||||
### Requirement: MySQL 并发写入正确
|
||||
|
||||
MySQL 测试 SHALL 验证并发写入不丢失数据。
|
||||
|
||||
#### Scenario: 并发记录 usage_stats
|
||||
- **WHEN** 10 个 goroutine 并发调用 `statsRepo.Record(providerID, modelName)`
|
||||
- **THEN** 最终 `request_count` 等于 10
|
||||
- **AND** 无数据丢失或重复
|
||||
|
||||
#### Scenario: 并发创建相同 provider
|
||||
- **WHEN** 10 个 goroutine 并发创建相同 ID 的 provider
|
||||
- **THEN** 仅 1 个成功,其他 9 个失败
|
||||
|
||||
#### Scenario: 并发创建相同 model
|
||||
- **WHEN** 10 个 goroutine 并发创建相同 `(provider_id, model_name)` 的 model
|
||||
- **THEN** 仅 1 个成功,其他 9 个失败
|
||||
|
||||
### Requirement: MySQL 测试命令完整
|
||||
|
||||
Makefile SHALL 提供完整的 MySQL 测试命令。
|
||||
|
||||
#### Scenario: 完整测试流程
|
||||
- **WHEN** 执行 `make test-mysql`
|
||||
- **THEN** 启动 Docker MySQL
|
||||
- **AND** 等待 MySQL 就绪
|
||||
- **AND** 运行所有 MySQL 测试
|
||||
- **AND** 销毁容器
|
||||
|
||||
#### Scenario: 快速测试(容器已运行)
|
||||
- **WHEN** 执行 `make test-mysql-quick`
|
||||
- **THEN** 直接运行测试,不管理容器生命周期
|
||||
@@ -93,7 +93,21 @@
|
||||
- **WHEN** 同时处理多个并发请求
|
||||
- **THEN** 网关 SHALL 使用原子操作正确增加每个请求的计数
|
||||
- **THEN** 不 SHALL 因并发写入而丢失统计
|
||||
- **THEN** SHALL 使用 StatsBuffer 的内存计数器
|
||||
- **THEN** SHALL 使用 upsert 操作保证原子性
|
||||
|
||||
#### Scenario: 并发调用 Record 方法
|
||||
|
||||
- **WHEN** 多个 goroutine 并发调用 StatsRepository.Record
|
||||
- **THEN** SHALL 使用 INSERT ... ON DUPLICATE KEY UPDATE (MySQL) 或 INSERT ... ON CONFLICT DO UPDATE (SQLite)
|
||||
- **THEN** SHALL 保证所有并发调用的计数都被正确累加
|
||||
- **THEN** 不 SHALL 因 UNIQUE 约束冲突而丢失数据
|
||||
|
||||
#### Scenario: 并发调用 BatchUpdate 方法
|
||||
|
||||
- **WHEN** 多个 goroutine 并发调用 StatsRepository.BatchUpdate
|
||||
- **THEN** SHALL 使用 upsert 操作保证原子性
|
||||
- **THEN** SHALL 正确累加所有 delta 值
|
||||
- **THEN** 不 SHALL 因并发写入而丢失统计
|
||||
|
||||
### Requirement: 使用 service 层处理业务逻辑
|
||||
|
||||
@@ -125,14 +139,14 @@ Service SHALL 通过 StatsRepository 访问数据。
|
||||
|
||||
- **WHEN** StatsBuffer 刷新统计
|
||||
- **THEN** SHALL 调用 StatsRepository.BatchUpdate
|
||||
- **THEN** SHALL 使用事务更新或创建统计记录
|
||||
- **THEN** SHALL 使用 upsert 操作更新或创建统计记录
|
||||
- **THEN** SHALL 支持增量更新(request_count + delta)
|
||||
|
||||
#### Scenario: 事务处理
|
||||
#### Scenario: upsert 操作
|
||||
|
||||
- **WHEN** 记录统计
|
||||
- **THEN** SHALL 在 repository 层使用数据库事务
|
||||
- **THEN** SHALL 确保并发安全
|
||||
- **THEN** SHALL 在 repository 层使用 upsert 操作
|
||||
- **THEN** SHALL 保证原子性和并发安全
|
||||
|
||||
### Requirement: 统计查询优化
|
||||
|
||||
@@ -168,11 +182,18 @@ StatsRepository SHALL 新增 BatchUpdate 方法支持批量增量更新。
|
||||
#### Scenario: BatchUpdate 更新现有记录
|
||||
|
||||
- **WHEN** 调用 BatchUpdate 且当日记录存在
|
||||
- **THEN** SHALL 使用事务更新 request_count = request_count + delta
|
||||
- **THEN** SHALL 使用 upsert 操作更新 request_count = request_count + delta
|
||||
- **THEN** SHALL 保证原子性,无竞态条件
|
||||
- **THEN** SHALL 不创建新记录
|
||||
|
||||
#### Scenario: BatchUpdate 创建新记录
|
||||
|
||||
- **WHEN** 调用 BatchUpdate 且当日记录不存在
|
||||
- **THEN** SHALL 创建新记录,request_count = delta
|
||||
- **THEN** SHALL 使用事务保证原子性
|
||||
- **THEN** SHALL 使用 upsert 操作保证原子性
|
||||
|
||||
#### Scenario: BatchUpdate 并发安全
|
||||
|
||||
- **WHEN** 多个 BatchUpdate 调用同时执行
|
||||
- **THEN** SHALL 保证所有 delta 都被正确累加
|
||||
- **THEN** SHALL 不因并发冲突而丢失数据
|
||||
|
||||
Reference in New Issue
Block a user