1
0

feat: 实现分层架构,包含 domain、service、repository 和 pkg 层

- 新增 domain 层:model、provider、route、stats 实体
- 新增 service 层:models、providers、routing、stats 业务逻辑
- 新增 repository 层:models、providers、stats 数据访问
- 新增 pkg 工具包:errors、logger、validator
- 新增中间件:CORS、logging、recovery、request ID
- 新增数据库迁移:初始 schema 和索引
- 新增单元测试和集成测试
- 新增规范文档:config-management、database-migration、error-handling、layered-architecture、middleware-system、request-validation、structured-logging、test-coverage
- 移除 config 子包和 model_router(已迁移至分层架构)
This commit is contained in:
2026-04-16 00:47:20 +08:00
parent 915b004924
commit f18904af1e
77 changed files with 5727 additions and 1257 deletions

View File

@@ -0,0 +1,12 @@
package service
import "nex/backend/internal/domain"
// ModelService 模型服务接口
type ModelService interface {
Create(model *domain.Model) error
Get(id string) (*domain.Model, error)
List(providerID string) ([]domain.Model, error)
Update(id string, updates map[string]interface{}) error
Delete(id string) error
}

View File

@@ -0,0 +1,50 @@
package service
import (
appErrors "nex/backend/pkg/errors"
"nex/backend/internal/domain"
"nex/backend/internal/repository"
)
type modelService struct {
modelRepo repository.ModelRepository
providerRepo repository.ProviderRepository
}
func NewModelService(modelRepo repository.ModelRepository, providerRepo repository.ProviderRepository) ModelService {
return &modelService{modelRepo: modelRepo, providerRepo: providerRepo}
}
func (s *modelService) Create(model *domain.Model) error {
// Verify provider exists
_, err := s.providerRepo.GetByID(model.ProviderID)
if err != nil {
return appErrors.ErrProviderNotFound
}
model.Enabled = true
return s.modelRepo.Create(model)
}
func (s *modelService) Get(id string) (*domain.Model, error) {
return s.modelRepo.GetByID(id)
}
func (s *modelService) List(providerID string) ([]domain.Model, error) {
return s.modelRepo.List(providerID)
}
func (s *modelService) Update(id string, updates map[string]interface{}) error {
// If updating provider_id, verify new provider exists
if providerID, ok := updates["provider_id"].(string); ok {
_, err := s.providerRepo.GetByID(providerID)
if err != nil {
return appErrors.ErrProviderNotFound
}
}
return s.modelRepo.Update(id, updates)
}
func (s *modelService) Delete(id string) error {
return s.modelRepo.Delete(id)
}

View File

@@ -0,0 +1,12 @@
package service
import "nex/backend/internal/domain"
// ProviderService 供应商服务接口
type ProviderService interface {
Create(provider *domain.Provider) error
Get(id string, maskKey bool) (*domain.Provider, error)
List() ([]domain.Provider, error)
Update(id string, updates map[string]interface{}) error
Delete(id string) error
}

View File

@@ -0,0 +1,49 @@
package service
import (
"nex/backend/internal/domain"
"nex/backend/internal/repository"
)
type providerService struct {
providerRepo repository.ProviderRepository
}
func NewProviderService(providerRepo repository.ProviderRepository) ProviderService {
return &providerService{providerRepo: providerRepo}
}
func (s *providerService) Create(provider *domain.Provider) error {
provider.Enabled = true
return s.providerRepo.Create(provider)
}
func (s *providerService) Get(id string, maskKey bool) (*domain.Provider, error) {
provider, err := s.providerRepo.GetByID(id)
if err != nil {
return nil, err
}
if maskKey {
provider.MaskAPIKey()
}
return provider, nil
}
func (s *providerService) List() ([]domain.Provider, error) {
providers, err := s.providerRepo.List()
if err != nil {
return nil, err
}
for i := range providers {
providers[i].MaskAPIKey()
}
return providers, nil
}
func (s *providerService) Update(id string, updates map[string]interface{}) error {
return s.providerRepo.Update(id, updates)
}
func (s *providerService) Delete(id string) error {
return s.providerRepo.Delete(id)
}

View File

@@ -0,0 +1,8 @@
package service
import "nex/backend/internal/domain"
// RoutingService 路由服务接口
type RoutingService interface {
Route(modelName string) (*domain.RouteResult, error)
}

View File

@@ -0,0 +1,42 @@
package service
import (
appErrors "nex/backend/pkg/errors"
"nex/backend/internal/domain"
"nex/backend/internal/repository"
)
type routingService struct {
modelRepo repository.ModelRepository
providerRepo repository.ProviderRepository
}
func NewRoutingService(modelRepo repository.ModelRepository, providerRepo repository.ProviderRepository) RoutingService {
return &routingService{modelRepo: modelRepo, providerRepo: providerRepo}
}
func (s *routingService) Route(modelName string) (*domain.RouteResult, error) {
model, err := s.modelRepo.GetByModelName(modelName)
if err != nil {
return nil, appErrors.ErrModelNotFound
}
if !model.Enabled {
return nil, appErrors.ErrModelDisabled
}
provider, err := s.providerRepo.GetByID(model.ProviderID)
if err != nil {
return nil, appErrors.ErrProviderNotFound
}
if !provider.Enabled {
return nil, appErrors.ErrProviderDisabled
}
return &domain.RouteResult{
Provider: provider,
Model: model,
}, nil
}

View File

@@ -0,0 +1,245 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"nex/backend/internal/config"
"nex/backend/internal/domain"
"nex/backend/internal/repository"
)
func setupServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
dir := t.TempDir()
db, err := gorm.Open(sqlite.Open(dir+"/test.db"), &gorm.Config{})
require.NoError(t, err)
err = db.AutoMigrate(&config.Provider{}, &config.Model{}, &config.UsageStats{})
require.NoError(t, err)
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
})
return db
}
// ============ ProviderService 测试 ============
func TestProviderService_Create(t *testing.T) {
db := setupServiceTestDB(t)
repo := repository.NewProviderRepository(db)
svc := NewProviderService(repo)
provider := &domain.Provider{
ID: "test-p", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com",
}
err := svc.Create(provider)
require.NoError(t, err)
assert.True(t, provider.Enabled)
}
func TestProviderService_Get_MaskKey(t *testing.T) {
db := setupServiceTestDB(t)
repo := repository.NewProviderRepository(db)
svc := NewProviderService(repo)
svc.Create(&domain.Provider{
ID: "p1", Name: "Test", APIKey: "sk-long-api-key-12345", BaseURL: "https://test.com",
})
result, err := svc.Get("p1", true)
require.NoError(t, err)
assert.Equal(t, "***2345", result.APIKey)
result, err = svc.Get("p1", false)
require.NoError(t, err)
assert.Equal(t, "sk-long-api-key-12345", result.APIKey)
}
func TestProviderService_List(t *testing.T) {
db := setupServiceTestDB(t)
repo := repository.NewProviderRepository(db)
svc := NewProviderService(repo)
svc.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key123", BaseURL: "https://a.com"})
svc.Create(&domain.Provider{ID: "p2", Name: "P2", APIKey: "key456", BaseURL: "https://b.com"})
providers, err := svc.List()
require.NoError(t, err)
assert.Len(t, providers, 2)
assert.Contains(t, providers[0].APIKey, "***")
}
func TestProviderService_Delete(t *testing.T) {
db := setupServiceTestDB(t)
repo := repository.NewProviderRepository(db)
svc := NewProviderService(repo)
svc.Create(&domain.Provider{ID: "p1", Name: "Test", APIKey: "key", BaseURL: "https://test.com"})
err := svc.Delete("p1")
require.NoError(t, err)
_, err = svc.Get("p1", false)
assert.Error(t, err)
}
// ============ ModelService 测试 ============
func TestModelService_Create(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewModelService(modelRepo, providerRepo)
providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com"})
model := &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4"}
err := svc.Create(model)
require.NoError(t, err)
assert.True(t, model.Enabled)
}
func TestModelService_Create_ProviderNotFound(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewModelService(modelRepo, providerRepo)
model := &domain.Model{ID: "m1", ProviderID: "nonexistent", ModelName: "gpt-4"}
err := svc.Create(model)
assert.Error(t, err)
}
func TestModelService_List(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewModelService(modelRepo, providerRepo)
providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com"})
svc.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4"})
svc.Create(&domain.Model{ID: "m2", ProviderID: "p1", ModelName: "gpt-3.5"})
models, err := svc.List("p1")
require.NoError(t, err)
assert.Len(t, models, 2)
}
// ============ RoutingService 测试 ============
func TestRoutingService_Route(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewRoutingService(modelRepo, providerRepo)
providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com", Enabled: true})
modelRepo.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true})
result, err := svc.Route("gpt-4")
require.NoError(t, err)
assert.Equal(t, "p1", result.Provider.ID)
assert.Equal(t, "gpt-4", result.Model.ModelName)
}
func TestRoutingService_Route_ModelNotFound(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewRoutingService(modelRepo, providerRepo)
_, err := svc.Route("nonexistent-model")
assert.Error(t, err)
}
func TestRoutingService_Route_ModelDisabled(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewRoutingService(modelRepo, providerRepo)
providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com", Enabled: true})
// 先创建启用的模型,然后通过 Update 禁用
modelRepo.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true})
modelRepo.Update("m1", map[string]interface{}{"enabled": false})
_, err := svc.Route("gpt-4")
assert.Error(t, err)
}
func TestRoutingService_Route_ProviderDisabled(t *testing.T) {
db := setupServiceTestDB(t)
providerRepo := repository.NewProviderRepository(db)
modelRepo := repository.NewModelRepository(db)
svc := NewRoutingService(modelRepo, providerRepo)
// 先创建启用的 provider然后禁用
providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com", Enabled: true})
providerRepo.Update("p1", map[string]interface{}{"enabled": false})
modelRepo.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true})
_, err := svc.Route("gpt-4")
assert.Error(t, err)
}
// ============ StatsService 测试 ============
func TestStatsService_RecordAndGet(t *testing.T) {
db := setupServiceTestDB(t)
statsRepo := repository.NewStatsRepository(db)
svc := NewStatsService(statsRepo)
err := svc.Record("p1", "gpt-4")
require.NoError(t, err)
stats, err := svc.Get("p1", "", nil, nil)
require.NoError(t, err)
assert.Len(t, stats, 1)
}
func TestStatsService_Aggregate_ByProvider(t *testing.T) {
statsRepo := repository.NewStatsRepository(nil)
svc := NewStatsService(statsRepo)
stats := []domain.UsageStats{
{ProviderID: "p1", ModelName: "gpt-4", RequestCount: 10},
{ProviderID: "p1", ModelName: "gpt-3.5", RequestCount: 5},
{ProviderID: "p2", ModelName: "claude-3", RequestCount: 8},
}
result := svc.Aggregate(stats, "provider")
assert.Len(t, result, 2)
p1Count := 0
p2Count := 0
for _, r := range result {
if r["provider_id"] == "p1" {
p1Count = r["request_count"].(int)
}
if r["provider_id"] == "p2" {
p2Count = r["request_count"].(int)
}
}
assert.Equal(t, 15, p1Count)
assert.Equal(t, 8, p2Count)
}
func TestStatsService_Aggregate_ByDate(t *testing.T) {
statsRepo := repository.NewStatsRepository(nil)
svc := NewStatsService(statsRepo)
stats := []domain.UsageStats{
{ProviderID: "p1", RequestCount: 10},
{ProviderID: "p2", RequestCount: 5},
}
result := svc.Aggregate(stats, "date")
assert.Len(t, result, 1)
assert.Equal(t, 15, result[0]["request_count"])
}

View File

@@ -0,0 +1,14 @@
package service
import (
"time"
"nex/backend/internal/domain"
)
// StatsService 统计服务接口
type StatsService interface {
Record(providerID, modelName string) error
Get(providerID, modelName string, startDate, endDate *time.Time) ([]domain.UsageStats, error)
Aggregate(stats []domain.UsageStats, groupBy string) []map[string]interface{}
}

View File

@@ -0,0 +1,85 @@
package service
import (
"time"
"nex/backend/internal/domain"
"nex/backend/internal/repository"
)
type statsService struct {
statsRepo repository.StatsRepository
}
func NewStatsService(statsRepo repository.StatsRepository) StatsService {
return &statsService{statsRepo: statsRepo}
}
func (s *statsService) Record(providerID, modelName string) error {
return s.statsRepo.Record(providerID, modelName)
}
func (s *statsService) Get(providerID, modelName string, startDate, endDate *time.Time) ([]domain.UsageStats, error) {
return s.statsRepo.Query(providerID, modelName, startDate, endDate)
}
func (s *statsService) Aggregate(stats []domain.UsageStats, groupBy string) []map[string]interface{} {
switch groupBy {
case "provider":
return s.aggregateByProvider(stats)
case "model":
return s.aggregateByModel(stats)
case "date":
return s.aggregateByDate(stats)
default:
return s.aggregateByProvider(stats)
}
}
func (s *statsService) aggregateByProvider(stats []domain.UsageStats) []map[string]interface{} {
aggregated := make(map[string]int)
for _, stat := range stats {
aggregated[stat.ProviderID] += stat.RequestCount
}
result := make([]map[string]interface{}, 0, len(aggregated))
for providerID, count := range aggregated {
result = append(result, map[string]interface{}{
"provider_id": providerID,
"request_count": count,
})
}
return result
}
func (s *statsService) aggregateByModel(stats []domain.UsageStats) []map[string]interface{} {
aggregated := make(map[string]int)
for _, stat := range stats {
key := stat.ProviderID + "/" + stat.ModelName
aggregated[key] += stat.RequestCount
}
result := make([]map[string]interface{}, 0, len(aggregated))
for key, count := range aggregated {
result = append(result, map[string]interface{}{
"provider_id": key[:len(key)/2],
"model_name": key[len(key)/2+1:],
"request_count": count,
})
}
return result
}
func (s *statsService) aggregateByDate(stats []domain.UsageStats) []map[string]interface{} {
aggregated := make(map[string]int)
for _, stat := range stats {
key := stat.Date.Format("2006-01-02")
aggregated[key] += stat.RequestCount
}
result := make([]map[string]interface{}, 0, len(aggregated))
for date, count := range aggregated {
result = append(result, map[string]interface{}{
"date": date,
"request_count": count,
})
}
return result
}