feat(routing): add logical group admin api

This commit is contained in:
phamnazage-jpg
2026-05-28 15:57:34 +08:00
parent 361a93270f
commit 2818892255
9 changed files with 2312 additions and 63 deletions

View File

@@ -20,19 +20,23 @@ type execQuerier interface {
}
type Queries struct {
Hosts *HostsRepo
Packs *PacksRepo
Providers *ProvidersRepo
ProviderDrafts *ProviderDraftsRepo
ImportBatches *ImportBatchesRepo
ImportBatchItems *ImportBatchItemsRepo
ImportRuns *ImportRunsRepo
ImportRunItems *ImportRunItemsRepo
ImportRunEvents *ImportRunItemEventsRepo
ManagedResources *ManagedResourcesRepo
ProbeResults *ProbeResultsRepo
AccessClosures *AccessClosureRecordsRepo
ReconcileRuns *ReconcileRunsRepo
Hosts *HostsRepo
Packs *PacksRepo
Providers *ProvidersRepo
LogicalGroups *LogicalGroupsRepo
LogicalGroupModels *LogicalGroupModelsRepo
LogicalGroupRoutes *LogicalGroupRoutesRepo
LogicalGroupRouteModels *LogicalGroupRouteModelsRepo
ProviderDrafts *ProviderDraftsRepo
ImportBatches *ImportBatchesRepo
ImportBatchItems *ImportBatchItemsRepo
ImportRuns *ImportRunsRepo
ImportRunItems *ImportRunItemsRepo
ImportRunEvents *ImportRunItemEventsRepo
ManagedResources *ManagedResourcesRepo
ProbeResults *ProbeResultsRepo
AccessClosures *AccessClosureRecordsRepo
ReconcileRuns *ReconcileRunsRepo
}
type DB struct {
@@ -92,6 +96,22 @@ func (db *DB) Providers() *ProvidersRepo {
return db.queries.Providers
}
func (db *DB) LogicalGroups() *LogicalGroupsRepo {
return db.queries.LogicalGroups
}
func (db *DB) LogicalGroupModels() *LogicalGroupModelsRepo {
return db.queries.LogicalGroupModels
}
func (db *DB) LogicalGroupRoutes() *LogicalGroupRoutesRepo {
return db.queries.LogicalGroupRoutes
}
func (db *DB) LogicalGroupRouteModels() *LogicalGroupRouteModelsRepo {
return db.queries.LogicalGroupRouteModels
}
func (db *DB) ProviderDrafts() *ProviderDraftsRepo {
return db.queries.ProviderDrafts
}
@@ -161,19 +181,23 @@ func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error {
func newQueries(db execQuerier) *Queries {
return &Queries{
Hosts: newHostsRepo(db),
Packs: newPacksRepo(db),
Providers: newProvidersRepo(db),
ProviderDrafts: newProviderDraftsRepo(db),
ImportBatches: newImportBatchesRepo(db),
ImportBatchItems: newImportBatchItemsRepo(db),
ImportRuns: newImportRunsRepo(db),
ImportRunItems: newImportRunItemsRepo(db),
ImportRunEvents: newImportRunItemEventsRepo(db),
ManagedResources: newManagedResourcesRepo(db),
ProbeResults: newProbeResultsRepo(db),
AccessClosures: newAccessClosureRecordsRepo(db),
ReconcileRuns: newReconcileRunsRepo(db),
Hosts: newHostsRepo(db),
Packs: newPacksRepo(db),
Providers: newProvidersRepo(db),
LogicalGroups: newLogicalGroupsRepo(db),
LogicalGroupModels: newLogicalGroupModelsRepo(db),
LogicalGroupRoutes: newLogicalGroupRoutesRepo(db),
LogicalGroupRouteModels: newLogicalGroupRouteModelsRepo(db),
ProviderDrafts: newProviderDraftsRepo(db),
ImportBatches: newImportBatchesRepo(db),
ImportBatchItems: newImportBatchItemsRepo(db),
ImportRuns: newImportRunsRepo(db),
ImportRunItems: newImportRunItemsRepo(db),
ImportRunEvents: newImportRunItemEventsRepo(db),
ManagedResources: newManagedResourcesRepo(db),
ProbeResults: newProbeResultsRepo(db),
AccessClosures: newAccessClosureRecordsRepo(db),
ReconcileRuns: newReconcileRunsRepo(db),
}
}

View File

@@ -0,0 +1,136 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type LogicalGroupModel struct {
ID int64
LogicalGroupID string
PublicModel string
Status string
CreatedAt string
UpdatedAt string
}
type LogicalGroupModelsRepo struct {
db execQuerier
}
func newLogicalGroupModelsRepo(db execQuerier) *LogicalGroupModelsRepo {
return &LogicalGroupModelsRepo{db: db}
}
func (r *LogicalGroupModelsRepo) Create(ctx context.Context, model LogicalGroupModel) (int64, error) {
model, err := normalizeLogicalGroupModel(model)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO logical_group_models (logical_group_id, public_model, status)
VALUES (?, ?, ?)`,
model.LogicalGroupID,
model.PublicModel,
model.Status,
)
if err != nil {
return 0, fmt.Errorf("insert logical group model %q/%q: %w", model.LogicalGroupID, model.PublicModel, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted logical group model id for %q/%q: %w", model.LogicalGroupID, model.PublicModel, err)
}
return id, nil
}
func (r *LogicalGroupModelsRepo) ListByLogicalGroupID(ctx context.Context, logicalGroupID string) ([]LogicalGroupModel, error) {
logicalGroupID = strings.TrimSpace(logicalGroupID)
if logicalGroupID == "" {
return nil, fmt.Errorf("logical_group_id is required")
}
rows, err := r.db.QueryContext(
ctx,
`SELECT id, logical_group_id, public_model, status, created_at, updated_at
FROM logical_group_models
WHERE logical_group_id = ?
ORDER BY id ASC`,
logicalGroupID,
)
if err != nil {
return nil, fmt.Errorf("list logical group models for %q: %w", logicalGroupID, err)
}
defer rows.Close()
models := make([]LogicalGroupModel, 0)
for rows.Next() {
var model LogicalGroupModel
if err := rows.Scan(
&model.ID,
&model.LogicalGroupID,
&model.PublicModel,
&model.Status,
&model.CreatedAt,
&model.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan logical group model: %w", err)
}
models = append(models, model)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate logical group models for %q: %w", logicalGroupID, err)
}
return models, nil
}
func (r *LogicalGroupModelsRepo) DeleteByLogicalGroupIDAndModel(ctx context.Context, logicalGroupID string, publicModel string) error {
logicalGroupID = strings.TrimSpace(logicalGroupID)
publicModel = strings.TrimSpace(publicModel)
if logicalGroupID == "" {
return fmt.Errorf("logical_group_id is required")
}
if publicModel == "" {
return fmt.Errorf("public_model is required")
}
result, err := r.db.ExecContext(
ctx,
`DELETE FROM logical_group_models
WHERE logical_group_id = ? AND public_model = ?`,
logicalGroupID,
publicModel,
)
if err != nil {
return fmt.Errorf("delete logical group model %q/%q: %w", logicalGroupID, publicModel, err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read deleted logical group model rows for %q/%q: %w", logicalGroupID, publicModel, err)
}
if affected == 0 {
return fmt.Errorf("logical group model %q/%q not found", logicalGroupID, publicModel)
}
return nil
}
func normalizeLogicalGroupModel(model LogicalGroupModel) (LogicalGroupModel, error) {
model.LogicalGroupID = strings.TrimSpace(model.LogicalGroupID)
model.PublicModel = strings.TrimSpace(model.PublicModel)
model.Status = strings.TrimSpace(model.Status)
switch {
case model.LogicalGroupID == "":
return LogicalGroupModel{}, fmt.Errorf("logical_group_id is required")
case model.PublicModel == "":
return LogicalGroupModel{}, fmt.Errorf("public_model is required")
}
if model.Status == "" {
model.Status = "active"
}
return model, nil
}

View File

@@ -0,0 +1,113 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type LogicalGroupRouteModel struct {
ID int64
RouteID string
PublicModel string
ShadowModel string
Status string
CreatedAt string
UpdatedAt string
}
type LogicalGroupRouteModelsRepo struct {
db execQuerier
}
func newLogicalGroupRouteModelsRepo(db execQuerier) *LogicalGroupRouteModelsRepo {
return &LogicalGroupRouteModelsRepo{db: db}
}
func (r *LogicalGroupRouteModelsRepo) Create(ctx context.Context, model LogicalGroupRouteModel) (int64, error) {
model, err := normalizeLogicalGroupRouteModel(model)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO logical_group_route_models (route_id, public_model, shadow_model, status)
VALUES (?, ?, ?, ?)`,
model.RouteID,
model.PublicModel,
model.ShadowModel,
model.Status,
)
if err != nil {
return 0, fmt.Errorf("insert logical group route model %q/%q: %w", model.RouteID, model.PublicModel, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted logical group route model id for %q/%q: %w", model.RouteID, model.PublicModel, err)
}
return id, nil
}
func (r *LogicalGroupRouteModelsRepo) ListByRouteID(ctx context.Context, routeID string) ([]LogicalGroupRouteModel, error) {
routeID = strings.TrimSpace(routeID)
if routeID == "" {
return nil, fmt.Errorf("route_id is required")
}
rows, err := r.db.QueryContext(
ctx,
`SELECT id, route_id, public_model, shadow_model, status, created_at, updated_at
FROM logical_group_route_models
WHERE route_id = ?
ORDER BY id ASC`,
routeID,
)
if err != nil {
return nil, fmt.Errorf("list logical group route models for %q: %w", routeID, err)
}
defer rows.Close()
models := make([]LogicalGroupRouteModel, 0)
for rows.Next() {
var model LogicalGroupRouteModel
if err := rows.Scan(
&model.ID,
&model.RouteID,
&model.PublicModel,
&model.ShadowModel,
&model.Status,
&model.CreatedAt,
&model.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan logical group route model: %w", err)
}
models = append(models, model)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate logical group route models for %q: %w", routeID, err)
}
return models, nil
}
func normalizeLogicalGroupRouteModel(model LogicalGroupRouteModel) (LogicalGroupRouteModel, error) {
model.RouteID = strings.TrimSpace(model.RouteID)
model.PublicModel = strings.TrimSpace(model.PublicModel)
model.ShadowModel = strings.TrimSpace(model.ShadowModel)
model.Status = strings.TrimSpace(model.Status)
switch {
case model.RouteID == "":
return LogicalGroupRouteModel{}, fmt.Errorf("route_id is required")
case model.PublicModel == "":
return LogicalGroupRouteModel{}, fmt.Errorf("public_model is required")
}
if model.ShadowModel == "" {
model.ShadowModel = model.PublicModel
}
if model.Status == "" {
model.Status = "active"
}
return model, nil
}

View File

@@ -0,0 +1,238 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type LogicalGroupRoute struct {
ID int64
RouteID string
LogicalGroupID string
Name string
Status string
Priority int
Weight int
ShadowGroupID string
ShadowHostID string
UpstreamBaseURLHint string
CooldownUntil string
CreatedAt string
UpdatedAt string
}
type LogicalGroupRoutesRepo struct {
db execQuerier
}
func newLogicalGroupRoutesRepo(db execQuerier) *LogicalGroupRoutesRepo {
return &LogicalGroupRoutesRepo{db: db}
}
func (r *LogicalGroupRoutesRepo) Create(ctx context.Context, route LogicalGroupRoute) (int64, error) {
route, err := normalizeLogicalGroupRoute(route)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO logical_group_routes (
route_id,
logical_group_id,
name,
status,
priority,
weight,
shadow_group_id,
shadow_host_id,
upstream_base_url_hint,
cooldown_until
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
route.RouteID,
route.LogicalGroupID,
route.Name,
route.Status,
route.Priority,
route.Weight,
route.ShadowGroupID,
route.ShadowHostID,
route.UpstreamBaseURLHint,
route.CooldownUntil,
)
if err != nil {
return 0, fmt.Errorf("insert logical group route %q: %w", route.RouteID, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted logical group route id for %q: %w", route.RouteID, err)
}
return id, nil
}
func (r *LogicalGroupRoutesRepo) GetByRouteID(ctx context.Context, routeID string) (LogicalGroupRoute, error) {
routeID = strings.TrimSpace(routeID)
if routeID == "" {
return LogicalGroupRoute{}, fmt.Errorf("route_id is required")
}
var route LogicalGroupRoute
if err := r.db.QueryRowContext(
ctx,
`SELECT id, route_id, logical_group_id, name, status, priority, weight, shadow_group_id, shadow_host_id, upstream_base_url_hint, cooldown_until, created_at, updated_at
FROM logical_group_routes
WHERE route_id = ?`,
routeID,
).Scan(
&route.ID,
&route.RouteID,
&route.LogicalGroupID,
&route.Name,
&route.Status,
&route.Priority,
&route.Weight,
&route.ShadowGroupID,
&route.ShadowHostID,
&route.UpstreamBaseURLHint,
&route.CooldownUntil,
&route.CreatedAt,
&route.UpdatedAt,
); err != nil {
return LogicalGroupRoute{}, err
}
return route, nil
}
func (r *LogicalGroupRoutesRepo) ListByLogicalGroupID(ctx context.Context, logicalGroupID string) ([]LogicalGroupRoute, error) {
logicalGroupID = strings.TrimSpace(logicalGroupID)
if logicalGroupID == "" {
return nil, fmt.Errorf("logical_group_id is required")
}
rows, err := r.db.QueryContext(
ctx,
`SELECT id, route_id, logical_group_id, name, status, priority, weight, shadow_group_id, shadow_host_id, upstream_base_url_hint, cooldown_until, created_at, updated_at
FROM logical_group_routes
WHERE logical_group_id = ?
ORDER BY priority ASC, id ASC`,
logicalGroupID,
)
if err != nil {
return nil, fmt.Errorf("list logical group routes for %q: %w", logicalGroupID, err)
}
defer rows.Close()
routes := make([]LogicalGroupRoute, 0)
for rows.Next() {
var route LogicalGroupRoute
if err := rows.Scan(
&route.ID,
&route.RouteID,
&route.LogicalGroupID,
&route.Name,
&route.Status,
&route.Priority,
&route.Weight,
&route.ShadowGroupID,
&route.ShadowHostID,
&route.UpstreamBaseURLHint,
&route.CooldownUntil,
&route.CreatedAt,
&route.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan logical group route: %w", err)
}
routes = append(routes, route)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate logical group routes for %q: %w", logicalGroupID, err)
}
return routes, nil
}
func (r *LogicalGroupRoutesRepo) UpdateByRouteID(ctx context.Context, route LogicalGroupRoute) error {
route, err := normalizeLogicalGroupRoute(route)
if err != nil {
return err
}
result, err := r.db.ExecContext(
ctx,
`UPDATE logical_group_routes
SET logical_group_id = ?, name = ?, status = ?, priority = ?, weight = ?, shadow_group_id = ?, shadow_host_id = ?, upstream_base_url_hint = ?, cooldown_until = ?, updated_at = CURRENT_TIMESTAMP
WHERE route_id = ?`,
route.LogicalGroupID,
route.Name,
route.Status,
route.Priority,
route.Weight,
route.ShadowGroupID,
route.ShadowHostID,
route.UpstreamBaseURLHint,
route.CooldownUntil,
route.RouteID,
)
if err != nil {
return fmt.Errorf("update logical group route %q: %w", route.RouteID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read updated logical group route rows for %q: %w", route.RouteID, err)
}
if affected == 0 {
return fmt.Errorf("logical group route %q not found", route.RouteID)
}
return nil
}
func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID string) error {
routeID = strings.TrimSpace(routeID)
if routeID == "" {
return fmt.Errorf("route_id is required")
}
result, err := r.db.ExecContext(ctx, `DELETE FROM logical_group_routes WHERE route_id = ?`, routeID)
if err != nil {
return fmt.Errorf("delete logical group route %q: %w", routeID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read deleted logical group route rows for %q: %w", routeID, err)
}
if affected == 0 {
return fmt.Errorf("logical group route %q not found", routeID)
}
return nil
}
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {
route.RouteID = strings.TrimSpace(route.RouteID)
route.LogicalGroupID = strings.TrimSpace(route.LogicalGroupID)
route.Name = strings.TrimSpace(route.Name)
route.Status = strings.TrimSpace(route.Status)
route.ShadowGroupID = strings.TrimSpace(route.ShadowGroupID)
route.ShadowHostID = strings.TrimSpace(route.ShadowHostID)
route.UpstreamBaseURLHint = strings.TrimSpace(route.UpstreamBaseURLHint)
route.CooldownUntil = strings.TrimSpace(route.CooldownUntil)
switch {
case route.RouteID == "":
return LogicalGroupRoute{}, fmt.Errorf("route_id is required")
case route.LogicalGroupID == "":
return LogicalGroupRoute{}, fmt.Errorf("logical_group_id is required")
case route.Name == "":
return LogicalGroupRoute{}, fmt.Errorf("name is required")
case route.Status == "":
return LogicalGroupRoute{}, fmt.Errorf("status is required")
case route.ShadowGroupID == "":
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
case route.ShadowHostID == "":
return LogicalGroupRoute{}, fmt.Errorf("shadow_host_id is required")
}
if route.Weight <= 0 {
route.Weight = 100
}
return route, nil
}

View File

@@ -0,0 +1,249 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
const (
defaultLogicalGroupRoutePolicy = "priority"
defaultLogicalGroupStickyMode = "conversation_preferred"
defaultConversationTTLSeconds = 7200
defaultUserModelTTLSeconds = 1800
defaultFailoverThreshold = 2
defaultCooldownSeconds = 600
)
type LogicalGroup struct {
ID int64
LogicalGroupID string
DisplayName string
Status string
Description string
RoutePolicy string
StickyMode string
ConversationTTLSeconds int
UserModelTTLSeconds int
FailoverThreshold int
CooldownSeconds int
CreatedAt string
UpdatedAt string
}
type LogicalGroupsRepo struct {
db execQuerier
}
func newLogicalGroupsRepo(db execQuerier) *LogicalGroupsRepo {
return &LogicalGroupsRepo{db: db}
}
func (r *LogicalGroupsRepo) Create(ctx context.Context, group LogicalGroup) (int64, error) {
group, err := normalizeLogicalGroup(group)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO logical_groups (
logical_group_id,
display_name,
status,
description,
route_policy,
sticky_mode,
conversation_ttl_seconds,
user_model_ttl_seconds,
failover_threshold,
cooldown_seconds
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
group.LogicalGroupID,
group.DisplayName,
group.Status,
group.Description,
group.RoutePolicy,
group.StickyMode,
group.ConversationTTLSeconds,
group.UserModelTTLSeconds,
group.FailoverThreshold,
group.CooldownSeconds,
)
if err != nil {
return 0, fmt.Errorf("insert logical group %q: %w", group.LogicalGroupID, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted logical group id for %q: %w", group.LogicalGroupID, err)
}
return id, nil
}
func (r *LogicalGroupsRepo) GetByLogicalGroupID(ctx context.Context, logicalGroupID string) (LogicalGroup, error) {
logicalGroupID = strings.TrimSpace(logicalGroupID)
if logicalGroupID == "" {
return LogicalGroup{}, fmt.Errorf("logical_group_id is required")
}
var group LogicalGroup
if err := r.db.QueryRowContext(
ctx,
`SELECT id, logical_group_id, display_name, status, description, route_policy, sticky_mode, conversation_ttl_seconds, user_model_ttl_seconds, failover_threshold, cooldown_seconds, created_at, updated_at
FROM logical_groups
WHERE logical_group_id = ?`,
logicalGroupID,
).Scan(
&group.ID,
&group.LogicalGroupID,
&group.DisplayName,
&group.Status,
&group.Description,
&group.RoutePolicy,
&group.StickyMode,
&group.ConversationTTLSeconds,
&group.UserModelTTLSeconds,
&group.FailoverThreshold,
&group.CooldownSeconds,
&group.CreatedAt,
&group.UpdatedAt,
); err != nil {
return LogicalGroup{}, err
}
return group, nil
}
func (r *LogicalGroupsRepo) List(ctx context.Context) ([]LogicalGroup, error) {
rows, err := r.db.QueryContext(
ctx,
`SELECT id, logical_group_id, display_name, status, description, route_policy, sticky_mode, conversation_ttl_seconds, user_model_ttl_seconds, failover_threshold, cooldown_seconds, created_at, updated_at
FROM logical_groups
ORDER BY id ASC`,
)
if err != nil {
return nil, fmt.Errorf("list logical groups: %w", err)
}
defer rows.Close()
groups := make([]LogicalGroup, 0)
for rows.Next() {
var group LogicalGroup
if err := rows.Scan(
&group.ID,
&group.LogicalGroupID,
&group.DisplayName,
&group.Status,
&group.Description,
&group.RoutePolicy,
&group.StickyMode,
&group.ConversationTTLSeconds,
&group.UserModelTTLSeconds,
&group.FailoverThreshold,
&group.CooldownSeconds,
&group.CreatedAt,
&group.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan logical group: %w", err)
}
groups = append(groups, group)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate logical groups: %w", err)
}
return groups, nil
}
func (r *LogicalGroupsRepo) UpdateByLogicalGroupID(ctx context.Context, group LogicalGroup) error {
group, err := normalizeLogicalGroup(group)
if err != nil {
return err
}
result, err := r.db.ExecContext(
ctx,
`UPDATE logical_groups
SET display_name = ?, status = ?, description = ?, route_policy = ?, sticky_mode = ?, conversation_ttl_seconds = ?, user_model_ttl_seconds = ?, failover_threshold = ?, cooldown_seconds = ?, updated_at = CURRENT_TIMESTAMP
WHERE logical_group_id = ?`,
group.DisplayName,
group.Status,
group.Description,
group.RoutePolicy,
group.StickyMode,
group.ConversationTTLSeconds,
group.UserModelTTLSeconds,
group.FailoverThreshold,
group.CooldownSeconds,
group.LogicalGroupID,
)
if err != nil {
return fmt.Errorf("update logical group %q: %w", group.LogicalGroupID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read updated logical group rows for %q: %w", group.LogicalGroupID, err)
}
if affected == 0 {
return fmt.Errorf("logical group %q not found", group.LogicalGroupID)
}
return nil
}
func (r *LogicalGroupsRepo) DeleteByLogicalGroupID(ctx context.Context, logicalGroupID string) error {
logicalGroupID = strings.TrimSpace(logicalGroupID)
if logicalGroupID == "" {
return fmt.Errorf("logical_group_id is required")
}
result, err := r.db.ExecContext(ctx, `DELETE FROM logical_groups WHERE logical_group_id = ?`, logicalGroupID)
if err != nil {
return fmt.Errorf("delete logical group %q: %w", logicalGroupID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read deleted logical group rows for %q: %w", logicalGroupID, err)
}
if affected == 0 {
return fmt.Errorf("logical group %q not found", logicalGroupID)
}
return nil
}
func normalizeLogicalGroup(group LogicalGroup) (LogicalGroup, error) {
group.LogicalGroupID = strings.TrimSpace(group.LogicalGroupID)
group.DisplayName = strings.TrimSpace(group.DisplayName)
group.Status = strings.TrimSpace(group.Status)
group.Description = strings.TrimSpace(group.Description)
group.RoutePolicy = strings.TrimSpace(group.RoutePolicy)
group.StickyMode = strings.TrimSpace(group.StickyMode)
switch {
case group.LogicalGroupID == "":
return LogicalGroup{}, fmt.Errorf("logical_group_id is required")
case group.DisplayName == "":
return LogicalGroup{}, fmt.Errorf("display_name is required")
case group.Status == "":
return LogicalGroup{}, fmt.Errorf("status is required")
}
if group.RoutePolicy == "" {
group.RoutePolicy = defaultLogicalGroupRoutePolicy
}
if group.StickyMode == "" {
group.StickyMode = defaultLogicalGroupStickyMode
}
if group.ConversationTTLSeconds <= 0 {
group.ConversationTTLSeconds = defaultConversationTTLSeconds
}
if group.UserModelTTLSeconds <= 0 {
group.UserModelTTLSeconds = defaultUserModelTTLSeconds
}
if group.FailoverThreshold <= 0 {
group.FailoverThreshold = defaultFailoverThreshold
}
if group.CooldownSeconds <= 0 {
group.CooldownSeconds = defaultCooldownSeconds
}
return group, nil
}

View File

@@ -0,0 +1,308 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"testing"
)
func TestLogicalGroupsRepoCreateGetUpdateDelete(t *testing.T) {
store := openTestDB(t)
ctx := context.Background()
id, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
Description: "shared group",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id <= 0 {
t.Fatalf("Create() id = %d, want positive", id)
}
group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("GetByLogicalGroupID() error = %v", err)
}
if group.RoutePolicy != defaultLogicalGroupRoutePolicy {
t.Fatalf("RoutePolicy = %q, want %q", group.RoutePolicy, defaultLogicalGroupRoutePolicy)
}
if group.StickyMode != defaultLogicalGroupStickyMode {
t.Fatalf("StickyMode = %q, want %q", group.StickyMode, defaultLogicalGroupStickyMode)
}
if err := store.LogicalGroups().UpdateByLogicalGroupID(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared Updated",
Status: "paused",
Description: "updated",
RoutePolicy: "priority",
StickyMode: "user_preferred",
ConversationTTLSeconds: 3600,
UserModelTTLSeconds: 900,
FailoverThreshold: 3,
CooldownSeconds: 120,
}); err != nil {
t.Fatalf("UpdateByLogicalGroupID() error = %v", err)
}
updated, err := store.LogicalGroups().GetByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("GetByLogicalGroupID(updated) error = %v", err)
}
if updated.DisplayName != "GPT Shared Updated" || updated.Status != "paused" {
t.Fatalf("updated group = %+v, want updated fields", updated)
}
if err := store.LogicalGroups().DeleteByLogicalGroupID(ctx, "gpt-shared"); err != nil {
t.Fatalf("DeleteByLogicalGroupID() error = %v", err)
}
_, err = store.LogicalGroups().GetByLogicalGroupID(ctx, "gpt-shared")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByLogicalGroupID() after delete error = %v, want sql.ErrNoRows", err)
}
}
func TestLogicalGroupsRepoList(t *testing.T) {
store := openTestDB(t)
ctx := context.Background()
for _, group := range []LogicalGroup{
{LogicalGroupID: "group-a", DisplayName: "Group A", Status: "active"},
{LogicalGroupID: "group-b", DisplayName: "Group B", Status: "active"},
} {
if _, err := store.LogicalGroups().Create(ctx, group); err != nil {
t.Fatalf("Create(%q) error = %v", group.LogicalGroupID, err)
}
}
groups, err := store.LogicalGroups().List(ctx)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(groups) != 2 {
t.Fatalf("List() len = %d, want 2", len(groups))
}
if groups[0].LogicalGroupID != "group-a" || groups[1].LogicalGroupID != "group-b" {
t.Fatalf("List() = %+v, want insertion order", groups)
}
}
func TestLogicalGroupModelsRepoCreateListDelete(t *testing.T) {
store := openTestDBWithFK(t)
ctx := context.Background()
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupModels().Create(ctx, LogicalGroupModel{
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
}); err != nil {
t.Fatalf("LogicalGroupModels().Create() error = %v", err)
}
models, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("ListByLogicalGroupID() error = %v", err)
}
if len(models) != 1 || models[0].PublicModel != "gpt-5.4" {
t.Fatalf("ListByLogicalGroupID() = %+v, want gpt-5.4", models)
}
if err := store.LogicalGroupModels().DeleteByLogicalGroupIDAndModel(ctx, "gpt-shared", "gpt-5.4"); err != nil {
t.Fatalf("DeleteByLogicalGroupIDAndModel() error = %v", err)
}
models, err = store.LogicalGroupModels().ListByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("ListByLogicalGroupID() after delete error = %v", err)
}
if len(models) != 0 {
t.Fatalf("ListByLogicalGroupID() after delete len = %d, want 0", len(models))
}
}
func TestLogicalGroupRoutesRepoCreateGetListUpdateDelete(t *testing.T) {
store := openTestDBWithFK(t)
ctx := context.Background()
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
RouteID: "asxs",
LogicalGroupID: "gpt-shared",
Name: "ASXS",
Status: "active",
Priority: 10,
ShadowGroupID: "gpt-shared__asxs",
ShadowHostID: "remote43",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
route, err := store.LogicalGroupRoutes().GetByRouteID(ctx, "asxs")
if err != nil {
t.Fatalf("GetByRouteID() error = %v", err)
}
if route.Weight != 100 {
t.Fatalf("Weight = %d, want 100", route.Weight)
}
routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("ListByLogicalGroupID() error = %v", err)
}
if len(routes) != 1 || routes[0].RouteID != "asxs" {
t.Fatalf("ListByLogicalGroupID() = %+v, want route asxs", routes)
}
if err := store.LogicalGroupRoutes().UpdateByRouteID(ctx, LogicalGroupRoute{
RouteID: "asxs",
LogicalGroupID: "gpt-shared",
Name: "ASXS Updated",
Status: "degraded",
Priority: 20,
Weight: 80,
ShadowGroupID: "gpt-shared__asxs",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://api.asxs.top/v1",
CooldownUntil: "2026-05-28T16:00:00Z",
}); err != nil {
t.Fatalf("UpdateByRouteID() error = %v", err)
}
updated, err := store.LogicalGroupRoutes().GetByRouteID(ctx, "asxs")
if err != nil {
t.Fatalf("GetByRouteID(updated) error = %v", err)
}
if updated.Name != "ASXS Updated" || updated.Status != "degraded" || updated.Weight != 80 {
t.Fatalf("updated route = %+v, want updated fields", updated)
}
if err := store.LogicalGroupRoutes().DeleteByRouteID(ctx, "asxs"); err != nil {
t.Fatalf("DeleteByRouteID() error = %v", err)
}
_, err = store.LogicalGroupRoutes().GetByRouteID(ctx, "asxs")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByRouteID() after delete error = %v, want sql.ErrNoRows", err)
}
}
func TestLogicalGroupRouteModelsRepoCreateList(t *testing.T) {
store := openTestDBWithFK(t)
ctx := context.Background()
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
RouteID: "codex2api",
LogicalGroupID: "gpt-shared",
Name: "Codex2API",
Status: "active",
Priority: 20,
ShadowGroupID: "gpt-shared__codex2api",
ShadowHostID: "remote43",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
if _, err := store.LogicalGroupRouteModels().Create(ctx, LogicalGroupRouteModel{
RouteID: "codex2api",
PublicModel: "gpt-5.4",
}); err != nil {
t.Fatalf("LogicalGroupRouteModels().Create() error = %v", err)
}
models, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, "codex2api")
if err != nil {
t.Fatalf("ListByRouteID() error = %v", err)
}
if len(models) != 1 {
t.Fatalf("ListByRouteID() len = %d, want 1", len(models))
}
if models[0].ShadowModel != "gpt-5.4" {
t.Fatalf("ShadowModel = %q, want default public model", models[0].ShadowModel)
}
}
func TestLogicalGroupReposEnforceForeignKeysAndCascadeDelete(t *testing.T) {
store := openTestDBWithFK(t)
ctx := context.Background()
if _, err := store.LogicalGroupModels().Create(ctx, LogicalGroupModel{
LogicalGroupID: "missing-group",
PublicModel: "gpt-5.4",
}); err == nil {
t.Fatal("LogicalGroupModels().Create() error = nil, want foreign key failure")
}
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupModels().Create(ctx, LogicalGroupModel{
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
}); err != nil {
t.Fatalf("LogicalGroupModels().Create() error = %v", err)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
RouteID: "asxs",
LogicalGroupID: "gpt-shared",
Name: "ASXS",
Status: "active",
Priority: 10,
ShadowGroupID: "gpt-shared__asxs",
ShadowHostID: "remote43",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
if _, err := store.LogicalGroupRouteModels().Create(ctx, LogicalGroupRouteModel{
RouteID: "asxs",
PublicModel: "gpt-5.4",
}); err != nil {
t.Fatalf("LogicalGroupRouteModels().Create() error = %v", err)
}
if err := store.LogicalGroups().DeleteByLogicalGroupID(ctx, "gpt-shared"); err != nil {
t.Fatalf("DeleteByLogicalGroupID() error = %v", err)
}
models, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("ListByLogicalGroupID() after cascade error = %v", err)
}
if len(models) != 0 {
t.Fatalf("models after cascade len = %d, want 0", len(models))
}
routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, "gpt-shared")
if err != nil {
t.Fatalf("ListByLogicalGroupID(routes) after cascade error = %v", err)
}
if len(routes) != 0 {
t.Fatalf("routes after cascade len = %d, want 0", len(routes))
}
}