From 97fd72e27342545507a935a5cfa92464066e7d64 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 29 May 2026 19:39:41 +0800 Subject: [PATCH] feat(portal): add logical group catalog api --- internal/app/http_api.go | 15 +++ internal/app/portal_api.go | 194 ++++++++++++++++++++++++++++++++ internal/app/portal_api_test.go | 180 +++++++++++++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 internal/app/portal_api.go create mode 100644 internal/app/portal_api_test.go diff --git a/internal/app/http_api.go b/internal/app/http_api.go index b75c297d..ba6670b4 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -31,6 +31,9 @@ type ActionSet struct { GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error) ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error) GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error) + ListPortalLogicalGroups func(context.Context) ([]PortalLogicalGroupInfo, error) + GetPortalLogicalGroup func(context.Context, string) (PortalLogicalGroupInfo, error) + ListPortalLogicalGroupModels func(context.Context, string) ([]PortalLogicalGroupModel, error) CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error) ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error) GetLogicalGroup func(context.Context, string) (LogicalGroupInfo, error) @@ -333,6 +336,15 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha mux.HandleFunc("POST /api/admin/session/logout", func(w http.ResponseWriter, r *http.Request) { handleAdminSessionLogout(w, r) }) + mux.HandleFunc("GET /api/portal/logical-groups", func(w http.ResponseWriter, r *http.Request) { + handleListPortalLogicalGroups(w, r, actions.ListPortalLogicalGroups) + }) + mux.HandleFunc("GET /api/portal/logical-groups/{groupID}", func(w http.ResponseWriter, r *http.Request) { + handleGetPortalLogicalGroup(w, r, actions.GetPortalLogicalGroup) + }) + mux.HandleFunc("GET /api/portal/logical-groups/{groupID}/models", func(w http.ResponseWriter, r *http.Request) { + handleListPortalLogicalGroupModels(w, r, actions.ListPortalLogicalGroupModels) + }) mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun) }))) @@ -1274,6 +1286,9 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN), ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN), GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN), + ListPortalLogicalGroups: buildListPortalLogicalGroupsAction(sqliteDSN), + GetPortalLogicalGroup: buildGetPortalLogicalGroupAction(sqliteDSN), + ListPortalLogicalGroupModels: buildListPortalLogicalGroupModelsAction(sqliteDSN), CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN), ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN), GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN), diff --git a/internal/app/portal_api.go b/internal/app/portal_api.go new file mode 100644 index 00000000..08062f79 --- /dev/null +++ b/internal/app/portal_api.go @@ -0,0 +1,194 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "strings" + + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +type PortalLogicalGroupInfo struct { + LogicalGroupID string `json:"logical_group_id"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + StickyMode string `json:"sticky_mode,omitempty"` + RoutePolicy string `json:"route_policy,omitempty"` + PublicModels []PortalLogicalGroupModel `json:"public_models,omitempty"` + RouteCount int `json:"route_count"` + ActiveRouteCount int `json:"active_route_count"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type PortalLogicalGroupModel struct { + PublicModel string `json:"public_model"` + Status string `json:"status,omitempty"` +} + +func handleListPortalLogicalGroups(w http.ResponseWriter, r *http.Request, fn func(context.Context) ([]PortalLogicalGroupInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-portal-logical-groups action is not configured"}) + return + } + groups, err := fn(r.Context()) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{"logical_groups": groups}) +} + +func handleGetPortalLogicalGroup(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (PortalLogicalGroupInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-portal-logical-group action is not configured"}) + return + } + group, err := fn(r.Context(), strings.TrimSpace(r.PathValue("groupID"))) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{"logical_group": group}) +} + +func handleListPortalLogicalGroupModels(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) ([]PortalLogicalGroupModel, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-portal-logical-group-models action is not configured"}) + return + } + models, err := fn(r.Context(), strings.TrimSpace(r.PathValue("groupID"))) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{"models": models}) +} + +func buildListPortalLogicalGroupsAction(sqliteDSN string) func(context.Context) ([]PortalLogicalGroupInfo, error) { + return func(ctx context.Context) ([]PortalLogicalGroupInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return nil, err + } + defer store.Close() + + rows, err := store.LogicalGroups().List(ctx) + if err != nil { + return nil, err + } + items := make([]PortalLogicalGroupInfo, 0, len(rows)) + for _, row := range rows { + if !isPortalLogicalGroupVisible(row.Status) { + continue + } + item, buildErr := buildPortalLogicalGroupInfo(ctx, store, row) + if buildErr != nil { + return nil, buildErr + } + items = append(items, item) + } + return items, nil + } +} + +func buildGetPortalLogicalGroupAction(sqliteDSN string) func(context.Context, string) (PortalLogicalGroupInfo, error) { + return func(ctx context.Context, logicalGroupID string) (PortalLogicalGroupInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return PortalLogicalGroupInfo{}, err + } + defer store.Close() + + group, err := getLogicalGroupRow(ctx, store, logicalGroupID) + if err != nil { + return PortalLogicalGroupInfo{}, err + } + if !isPortalLogicalGroupVisible(group.Status) { + return PortalLogicalGroupInfo{}, fmt.Errorf("logical group %q not found", strings.TrimSpace(logicalGroupID)) + } + return buildPortalLogicalGroupInfo(ctx, store, group) + } +} + +func buildListPortalLogicalGroupModelsAction(sqliteDSN string) func(context.Context, string) ([]PortalLogicalGroupModel, error) { + return func(ctx context.Context, logicalGroupID string) ([]PortalLogicalGroupModel, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return nil, err + } + defer store.Close() + + group, err := getLogicalGroupRow(ctx, store, logicalGroupID) + if err != nil { + return nil, err + } + if !isPortalLogicalGroupVisible(group.Status) { + return nil, fmt.Errorf("logical group %q not found", strings.TrimSpace(logicalGroupID)) + } + rows, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, group.LogicalGroupID) + if err != nil { + return nil, err + } + return portalLogicalGroupModelsFromRows(rows), nil + } +} + +func buildPortalLogicalGroupInfo(ctx context.Context, store *sqlite.DB, group sqlite.LogicalGroup) (PortalLogicalGroupInfo, error) { + modelRows, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, group.LogicalGroupID) + if err != nil { + return PortalLogicalGroupInfo{}, err + } + routeRows, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID) + if err != nil { + return PortalLogicalGroupInfo{}, err + } + + routeCount := 0 + activeRouteCount := 0 + for _, route := range routeRows { + routeCount++ + if strings.EqualFold(strings.TrimSpace(route.Status), "active") { + activeRouteCount++ + } + } + + return PortalLogicalGroupInfo{ + LogicalGroupID: group.LogicalGroupID, + DisplayName: group.DisplayName, + Description: group.Description, + Status: group.Status, + StickyMode: group.StickyMode, + RoutePolicy: group.RoutePolicy, + PublicModels: portalLogicalGroupModelsFromRows(modelRows), + RouteCount: routeCount, + ActiveRouteCount: activeRouteCount, + CreatedAt: group.CreatedAt, + UpdatedAt: group.UpdatedAt, + }, nil +} + +func portalLogicalGroupModelsFromRows(rows []sqlite.LogicalGroupModel) []PortalLogicalGroupModel { + result := make([]PortalLogicalGroupModel, 0, len(rows)) + for _, row := range rows { + if !isPortalLogicalGroupModelVisible(row.Status) { + continue + } + result = append(result, PortalLogicalGroupModel{ + PublicModel: row.PublicModel, + Status: row.Status, + }) + } + return result +} + +func isPortalLogicalGroupVisible(status string) bool { + return strings.EqualFold(strings.TrimSpace(status), "active") +} + +func isPortalLogicalGroupModelVisible(status string) bool { + status = strings.TrimSpace(status) + return status == "" || strings.EqualFold(status, "active") +} diff --git a/internal/app/portal_api_test.go b/internal/app/portal_api_test.go new file mode 100644 index 00000000..625ea3fc --- /dev/null +++ b/internal/app/portal_api_test.go @@ -0,0 +1,180 @@ +package app + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +func TestAPIListPortalLogicalGroups(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListPortalLogicalGroups: func(_ context.Context) ([]PortalLogicalGroupInfo, error) { + return []PortalLogicalGroupInfo{{ + LogicalGroupID: "gpt-shared", + DisplayName: "GPT Shared", + Status: "active", + RouteCount: 2, + ActiveRouteCount: 1, + PublicModels: []PortalLogicalGroupModel{ + {PublicModel: "gpt-5.4", Status: "active"}, + }, + }}, nil + }, + }) + + request := httptestRequest(t, "GET", "/api/portal/logical-groups", nil, "") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, 200) + var listPayload struct { + LogicalGroups []PortalLogicalGroupInfo `json:"logical_groups"` + } + if err := json.Unmarshal(response.Body().Bytes(), &listPayload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(listPayload.LogicalGroups) != 1 || listPayload.LogicalGroups[0].LogicalGroupID != "gpt-shared" || listPayload.LogicalGroups[0].RouteCount != 2 { + t.Fatalf("portal logical groups payload = %+v", listPayload) + } +} + +func TestAPIGetPortalLogicalGroupModels(t *testing.T) { + handler := NewAPIHandler("secret-token", ActionSet{ + ListPortalLogicalGroupModels: func(_ context.Context, logicalGroupID string) ([]PortalLogicalGroupModel, error) { + if logicalGroupID != "gpt-shared" { + t.Fatalf("logicalGroupID = %q, want gpt-shared", logicalGroupID) + } + return []PortalLogicalGroupModel{{PublicModel: "gpt-5.4", Status: "active"}}, nil + }, + }) + + request := httptestRequest(t, "GET", "/api/portal/logical-groups/gpt-shared/models", nil, "") + response := httptestRecorder(handler, request) + assertStatusCode(t, response, 200) + var modelsPayload struct { + Models []PortalLogicalGroupModel `json:"models"` + } + if err := json.Unmarshal(response.Body().Bytes(), &modelsPayload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(modelsPayload.Models) != 1 || modelsPayload.Models[0].PublicModel != "gpt-5.4" { + t.Fatalf("portal models payload = %+v", modelsPayload) + } +} + +func TestNewActionSetPortalLogicalGroups(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "portal-logical-groups.db") + dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000" + actions := NewActionSet(dsn) + ctx := context.Background() + + store, err := sqlite.Open(ctx, dsn) + if err != nil { + t.Fatalf("sqlite.Open() error = %v", err) + } + defer store.Close() + + if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{ + LogicalGroupID: "gpt-shared", + DisplayName: "GPT Shared", + Status: "active", + Description: "Public GPT product", + RoutePolicy: "priority", + StickyMode: "conversation_preferred", + ConversationTTLSeconds: 7200, + UserModelTTLSeconds: 1800, + FailoverThreshold: 2, + CooldownSeconds: 600, + }); err != nil { + t.Fatalf("LogicalGroups().Create(active) error = %v", err) + } + if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{ + LogicalGroupID: "disabled-group", + DisplayName: "Disabled Group", + Status: "disabled", + }); err != nil { + t.Fatalf("LogicalGroups().Create(disabled) error = %v", err) + } + if _, err := store.LogicalGroupModels().Create(ctx, sqlite.LogicalGroupModel{ + LogicalGroupID: "gpt-shared", + PublicModel: "gpt-5.4", + Status: "active", + }); err != nil { + t.Fatalf("LogicalGroupModels().Create(active) error = %v", err) + } + if _, err := store.LogicalGroupModels().Create(ctx, sqlite.LogicalGroupModel{ + LogicalGroupID: "gpt-shared", + PublicModel: "gpt-hidden", + Status: "disabled", + }); err != nil { + t.Fatalf("LogicalGroupModels().Create(disabled) error = %v", err) + } + if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{ + RouteID: "asxs", + LogicalGroupID: "gpt-shared", + Name: "ASXS", + Status: "active", + Priority: 10, + Weight: 100, + ShadowGroupID: "9", + ShadowHostID: "remote43", + UpstreamBaseURLHint: "https://api.asxs.top/v1", + }); err != nil { + t.Fatalf("LogicalGroupRoutes().Create(active) error = %v", err) + } + if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{ + RouteID: "backup", + LogicalGroupID: "gpt-shared", + Name: "Backup", + Status: "cooldown", + Priority: 20, + Weight: 100, + ShadowGroupID: "9", + ShadowHostID: "remote43", + UpstreamBaseURLHint: "https://backup.example.com", + }); err != nil { + t.Fatalf("LogicalGroupRoutes().Create(cooldown) error = %v", err) + } + + listed, err := actions.ListPortalLogicalGroups(ctx) + if err != nil { + t.Fatalf("ListPortalLogicalGroups() error = %v", err) + } + if len(listed) != 1 || listed[0].LogicalGroupID != "gpt-shared" { + t.Fatalf("ListPortalLogicalGroups() = %+v", listed) + } + if listed[0].RouteCount != 2 || listed[0].ActiveRouteCount != 1 { + t.Fatalf("ListPortalLogicalGroups() route counts = %+v", listed[0]) + } + if len(listed[0].PublicModels) != 1 || listed[0].PublicModels[0].PublicModel != "gpt-5.4" { + t.Fatalf("ListPortalLogicalGroups() public models = %+v", listed[0].PublicModels) + } + + group, err := actions.GetPortalLogicalGroup(ctx, "gpt-shared") + if err != nil { + t.Fatalf("GetPortalLogicalGroup() error = %v", err) + } + if group.DisplayName != "GPT Shared" || group.RoutePolicy != "priority" { + t.Fatalf("GetPortalLogicalGroup() = %+v", group) + } + + models, err := actions.ListPortalLogicalGroupModels(ctx, "gpt-shared") + if err != nil { + t.Fatalf("ListPortalLogicalGroupModels() error = %v", err) + } + if len(models) != 1 || models[0].PublicModel != "gpt-5.4" { + t.Fatalf("ListPortalLogicalGroupModels() = %+v", models) + } + + request := httptestRequest(t, "GET", "/api/portal/logical-groups/gpt-shared", nil, "") + response := httptestRecorder(NewAPIHandler("secret-token", actions), request) + assertStatusCode(t, response, 200) + var payload map[string]PortalLogicalGroupInfo + if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if payload["logical_group"].LogicalGroupID != "gpt-shared" { + t.Fatalf("portal logical group payload = %+v", payload) + } +}