refactor(api): split Provider into ProviderGroup and APIKey models

Restructure the provider management system by separating the monolithic
Provider model into two distinct entities:

- ProviderGroup: defines shared upstream configuration (type, base_url,
  google settings, models, status)
- APIKey: represents individual credentials within a group (api_key,
  weight, status, auto_ban, ban settings)

This change also updates:
- Binding model to reference GroupID instead of RouteGroup string
- All CRUD handlers for the new provider-group and api-key endpoints
- Sync service to rebuild provider snapshots from joined tables
- Model registry to aggregate capabilities across group/key pairs
- Access handler to validate namespace existence and subset constraints
- Migration importer to handle the new schema structure
- All related tests to use the new model relationships

BREAKING CHANGE: Provider API endpoints replaced with /provider-groups
and /api-keys endpoints; Binding.RouteGroup replaced with Binding.GroupID
This commit is contained in:
zenfun
2025-12-24 02:15:52 +08:00
parent cd5616dc26
commit dea8363e41
27 changed files with 1222 additions and 1625 deletions

View File

@@ -258,14 +258,45 @@ func (i *Importer) importMasters(items []Master, summary *ImportSummary) (map[st
}
func (i *Importer) importProviders(items []Provider, summary *ImportSummary) error {
groupCache := make(map[string]model.ProviderGroup)
for _, item := range items {
name := strings.TrimSpace(item.Name)
if name == "" {
summary.Warnings = append(summary.Warnings, "skip provider with empty name")
groupName := normalizeGroup(item.PrimaryGroup)
if strings.TrimSpace(groupName) == "" {
groupName = "default"
}
group, ok := groupCache[groupName]
if !ok {
var existing model.ProviderGroup
err := i.db.Where("name = ?", groupName).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
group = existing
} else {
group = model.ProviderGroup{
Name: groupName,
Type: strings.TrimSpace(item.Type),
BaseURL: strings.TrimSpace(item.BaseURL),
Models: strings.Join(item.Models, ","),
Status: normalizeStatus(item.Status, "active"),
}
if !i.opts.DryRun {
if err := i.db.Create(&group).Error; err != nil {
return err
}
}
}
groupCache[groupName] = group
}
apiKey := strings.TrimSpace(item.APIKey)
if apiKey == "" {
summary.Warnings = append(summary.Warnings, "skip api key with empty api_key")
continue
}
var existing model.Provider
err := i.db.Where("name = ?", name).First(&existing).Error
var existingKey model.APIKey
err := i.db.Where("group_id = ? AND api_key = ?", group.ID, apiKey).First(&existingKey).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
@@ -277,16 +308,11 @@ func (i *Importer) importProviders(items []Provider, summary *ImportSummary) err
continue
}
update := map[string]any{
"type": strings.TrimSpace(item.Type),
"base_url": strings.TrimSpace(item.BaseURL),
"api_key": strings.TrimSpace(item.APIKey),
"group": normalizeGroup(item.PrimaryGroup),
"models": strings.Join(item.Models, ","),
"weight": resolveWeight(item.Weight, item.Priority),
"status": normalizeProviderStatus(item.Status),
"auto_ban": item.AutoBan,
}
if err := i.db.Model(&existing).Updates(update).Error; err != nil {
if err := i.db.Model(&existingKey).Updates(update).Error; err != nil {
return err
}
summary.ProvidersUpdated++
@@ -301,18 +327,14 @@ func (i *Importer) importProviders(items []Provider, summary *ImportSummary) err
continue
}
provider := model.Provider{
Name: name,
Type: strings.TrimSpace(item.Type),
BaseURL: strings.TrimSpace(item.BaseURL),
APIKey: strings.TrimSpace(item.APIKey),
Group: normalizeGroup(item.PrimaryGroup),
Models: strings.Join(item.Models, ","),
key := model.APIKey{
GroupID: group.ID,
APIKey: apiKey,
Weight: resolveWeight(item.Weight, item.Priority),
Status: normalizeProviderStatus(item.Status),
AutoBan: item.AutoBan,
}
if err := i.db.Create(&provider).Error; err != nil {
if err := i.db.Create(&key).Error; err != nil {
return err
}
summary.ProvidersCreated++
@@ -420,8 +442,14 @@ func (i *Importer) importBindings(items []Binding, summary *ImportSummary) error
summary.Warnings = append(summary.Warnings, "skip binding with empty model")
continue
}
groupName := normalizeGroup(item.RouteGroup)
var group model.ProviderGroup
if err := i.db.Where("name = ?", groupName).First(&group).Error; err != nil {
summary.Warnings = append(summary.Warnings, "skip binding with missing provider group: "+groupName)
continue
}
var existing model.Binding
err := i.db.Where("namespace = ? AND public_model = ?", ns, publicModel).First(&existing).Error
err := i.db.Where("namespace = ? AND public_model = ? AND group_id = ?", ns, publicModel, group.ID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
@@ -433,7 +461,8 @@ func (i *Importer) importBindings(items []Binding, summary *ImportSummary) error
continue
}
update := map[string]any{
"route_group": normalizeGroup(item.RouteGroup),
"group_id": group.ID,
"weight": 1,
"selector_type": "exact",
"selector_value": publicModel,
"status": normalizeStatus(item.Status, "active"),
@@ -456,7 +485,8 @@ func (i *Importer) importBindings(items []Binding, summary *ImportSummary) error
binding := model.Binding{
Namespace: ns,
PublicModel: publicModel,
RouteGroup: normalizeGroup(item.RouteGroup),
GroupID: group.ID,
Weight: 1,
SelectorType: "exact",
SelectorValue: publicModel,
Status: normalizeStatus(item.Status, "active"),

View File

@@ -92,7 +92,7 @@ type Key struct {
// Binding represents an EZ-API binding (optional, from abilities).
type Binding struct {
Namespace string `json:"namespace"`
RouteGroup string `json:"route_group"`
RouteGroup string `json:"route_group"` // provider group name
Model string `json:"model"`
Status string `json:"status"`
}