feat(portal): add logical group catalog api

This commit is contained in:
phamnazage-jpg
2026-05-29 19:39:41 +08:00
parent e3be48da71
commit 97fd72e273
3 changed files with 389 additions and 0 deletions

View File

@@ -31,6 +31,9 @@ type ActionSet struct {
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error) GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error) ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, 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) CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error)
ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error) ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error)
GetLogicalGroup func(context.Context, string) (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) { mux.HandleFunc("POST /api/admin/session/logout", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionLogout(w, r) 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) { mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun) handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
}))) })))
@@ -1274,6 +1286,9 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN), GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN), ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN), GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
ListPortalLogicalGroups: buildListPortalLogicalGroupsAction(sqliteDSN),
GetPortalLogicalGroup: buildGetPortalLogicalGroupAction(sqliteDSN),
ListPortalLogicalGroupModels: buildListPortalLogicalGroupModelsAction(sqliteDSN),
CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN), CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN),
ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN), ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN),
GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN), GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN),

194
internal/app/portal_api.go Normal file
View File

@@ -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")
}

View File

@@ -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)
}
}