From 71c7142f0c7c67f4528a409a63cdda7ef2a09533 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 27 Aug 2026 10:05:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E6=9F=A5=E7=9C=8B=E7=94=9F=E6=88=90=E5=AA=92=E4=BD=93?= =?UTF-8?q?=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-ui/src/api/chorus/index.js | 2 + admin-ui/src/utils/request.js | 3 +- .../src/views/chorus/generations/index.vue | 89 ++++++++- .../unit/chorus/generation-media.spec.js | 28 +++ admin/app/chorus/generation_media_test.go | 176 ++++++++++++++++++ admin/app/chorus/router.go | 68 +++++++ admin/app/chorus/router_test.go | 4 + admin/app/chorus/service.go | 172 ++++++++++++++++- admin/app/chorus/types.go | 32 ++++ admin/cmd/server.go | 6 + admin/config/extend.go | 17 +- admin/config/settings.example.yml | 4 + admin/go.mod | 1 + admin/go.sum | 3 + internal/core/storage/storage.go | 8 +- internal/platform/storage/local.go | 20 ++ internal/platform/storage/local_test.go | 39 ++++ .../000010_admin_generation_media.down.sql | 33 ++++ .../000010_admin_generation_media.up.sql | 29 +++ migrations/migrations_test.go | 25 +++ migrations/mysql_integration_test.go | 6 + 21 files changed, 752 insertions(+), 13 deletions(-) create mode 100644 admin-ui/tests/unit/chorus/generation-media.spec.js create mode 100644 admin/app/chorus/generation_media_test.go create mode 100644 migrations/000010_admin_generation_media.down.sql create mode 100644 migrations/000010_admin_generation_media.up.sql diff --git a/admin-ui/src/api/chorus/index.js b/admin-ui/src/api/chorus/index.js index b76ba50..0da4ad6 100644 --- a/admin-ui/src/api/chorus/index.js +++ b/admin-ui/src/api/chorus/index.js @@ -25,6 +25,8 @@ export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' }) export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }}) export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' }) +export const getGeneration = id => request({ url: `${base}/generations/${id}`, method: 'get' }) +export const getGenerationMedia = url => request({ url, method: 'get', responseType: 'blob' }) export const listAPIKeys = params => request({ url: `${base}/api-keys`, method: 'get', params }) export const getAPIKey = id => request({ url: `${base}/api-keys/${id}`, method: 'get' }) diff --git a/admin-ui/src/utils/request.js b/admin-ui/src/utils/request.js index a17485a..3bcd2de 100644 --- a/admin-ui/src/utils/request.js +++ b/admin-ui/src/utils/request.js @@ -42,8 +42,9 @@ service.interceptors.response.use( * Determine the request status by custom code * Here is just an example * You can also judge the status by HTTP Status Code - */ + */ response => { + if (response.config.responseType === 'blob') return response.data const code = response.data.code if (code === 401) { store.dispatch('user/resetToken') diff --git a/admin-ui/src/views/chorus/generations/index.vue b/admin-ui/src/views/chorus/generations/index.vue index e8f3577..b155b6d 100644 --- a/admin-ui/src/views/chorus/generations/index.vue +++ b/admin-ui/src/views/chorus/generations/index.vue @@ -12,27 +12,108 @@

任务 #{{ selected.id }}

{{ statusText(selected.status) }}
用户账号
{{ selected.username }}({{ selected.display_name }})
生成类型
{{ kindText(selected.kind) }}
Provider 尝试
{{ selected.provider_attempt_count }}
总耗时
{{ selectedTiming.total }}
排队耗时
{{ selectedTiming.queue }}
处理耗时
{{ selectedTiming.processing }}
上游耗时
{{ selectedTiming.upstream }}
提交时间
{{ formatTime(selected.created_at) }}
开始时间
{{ formatTime(selected.started_at) }}
完成时间
{{ formatTime(selected.completed_at) }}
{{ selected.rendered_prompt || '-' }}
+ + +

尝试记录

{{ attempt.presentation.label }} · {{ attempt.presentation.title }}

{{ attempt.presentation.status }} · {{ attempt.presentation.duration }} · {{ attempt.error_message }}

尚无尝试记录。

+
diff --git a/admin-ui/tests/unit/chorus/generation-media.spec.js b/admin-ui/tests/unit/chorus/generation-media.spec.js new file mode 100644 index 0000000..1092df9 --- /dev/null +++ b/admin-ui/tests/unit/chorus/generation-media.spec.js @@ -0,0 +1,28 @@ +import fs from 'fs' +import path from 'path' +import request from '@/utils/request' +import { getGeneration, getGenerationMedia } from '@/api/chorus' + +jest.mock('@/utils/request', () => jest.fn()) + +describe('Chorus Admin generation media', () => { + beforeEach(() => request.mockClear()) + + test('loads detail and media through authenticated API requests', () => { + getGeneration(9) + expect(request).toHaveBeenCalledWith({ url: '/api/v1/chorus/generations/9', method: 'get' }) + getGenerationMedia('/api/v1/chorus/generations/9/outputs/12/thumbnail') + expect(request).toHaveBeenCalledWith({ url: '/api/v1/chorus/generations/9/outputs/12/thumbnail', method: 'get', responseType: 'blob' }) + }) + + test('uses blob URLs and explicit loading, empty, and error states', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../../src/views/chorus/generations/index.vue'), 'utf8') + expect(source).toContain('URL.createObjectURL(blob)') + expect(source).toContain('URL.revokeObjectURL') + expect(source).toContain('提示词原图') + expect(source).toContain('本任务没有提示词原图') + expect(source).toContain('本任务尚无生成结果') + expect(source).toContain('详情加载失败') + expect(source).not.toContain('Admin-Token') + }) +}) diff --git a/admin/app/chorus/generation_media_test.go b/admin/app/chorus/generation_media_test.go new file mode 100644 index 0000000..d89c66b --- /dev/null +++ b/admin/app/chorus/generation_media_test.go @@ -0,0 +1,176 @@ +package chorus + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type testMediaObject struct { + data []byte + object corestorage.Object +} + +type testMediaReader map[string]testMediaObject + +func (r testMediaReader) Open(_ context.Context, key string) (io.ReadCloser, corestorage.Object, error) { + item, ok := r[key] + if !ok { + return nil, corestorage.Object{}, errors.New("missing synthetic object") + } + return io.NopCloser(bytes.NewReader(item.data)), item.object, nil +} + +func newGenerationMediaService(t *testing.T) (*Service, testMediaReader) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, status TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`, + `CREATE TABLE generations (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, provider_model_id INTEGER, route_pool_id INTEGER, route_pool_version INTEGER, prompt_template_id INTEGER, route_snapshot JSON, role_rule TEXT, provider_attempt_count INTEGER NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, idempotency_key TEXT NOT NULL, user_prompt TEXT NOT NULL, rendered_prompt TEXT NOT NULL, attempts JSON NOT NULL, attempt_count INTEGER NOT NULL, error_code TEXT, error_message TEXT, lease_owner TEXT, lease_token TEXT, lease_until DATETIME, available_at DATETIME NOT NULL, started_at DATETIME, completed_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`, + `CREATE TABLE generation_inputs (id INTEGER PRIMARY KEY, generation_id INTEGER NOT NULL, position INTEGER NOT NULL, role TEXT NOT NULL, note TEXT, original_name TEXT NOT NULL, mime_type TEXT NOT NULL, storage_key TEXT NOT NULL, size_bytes INTEGER NOT NULL, width INTEGER, height INTEGER, created_at DATETIME NOT NULL)`, + `CREATE TABLE generation_outputs (id INTEGER PRIMARY KEY, generation_id INTEGER NOT NULL, kind TEXT NOT NULL, text_content TEXT, storage_key TEXT, thumbnail_storage_key TEXT, mime_type TEXT, size_bytes INTEGER, width INTEGER, height INTEGER, created_at DATETIME NOT NULL)`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + now := time.Now().UTC() + user := model.User{ID: 7, Username: "synthetic-user", Email: "synthetic@example.invalid", PasswordHash: "unused", DisplayName: "Synthetic", Status: "active", CreatedAt: now, UpdatedAt: now} + generation := model.Generation{ID: 9, UserID: user.ID, Kind: model.GenerationKind("image"), Status: model.GenerationStatus("succeeded"), IdempotencyKey: "synthetic-generation", UserPrompt: "synthetic prompt", RenderedPrompt: "rendered synthetic prompt", Attempts: json.RawMessage(`[]`), AvailableAt: now, CreatedAt: now, UpdatedAt: now} + note := "reference" + input := model.GenerationInput{ID: 11, GenerationID: generation.ID, Role: model.InputRole("reference"), Note: ¬e, OriginalName: "reference.png", MIMEType: "image/png", StorageKey: "private/input-key", SizeBytes: 5, CreatedAt: now} + outputKey, thumbnailKey, mimeType, textOutput := "private/output-key", "private/thumbnail-key", "image/png", "synthetic text result" + size := uint64(6) + outputs := []model.GenerationOutput{ + {ID: 12, GenerationID: generation.ID, Kind: model.GenerationKind("image"), StorageKey: &outputKey, ThumbnailStorageKey: &thumbnailKey, MIMEType: &mimeType, SizeBytes: &size, CreatedAt: now}, + {ID: 13, GenerationID: generation.ID, Kind: model.GenerationKind("text"), TextContent: &textOutput, CreatedAt: now}, + } + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&generation).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&input).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&outputs).Error; err != nil { + t.Fatal(err) + } + reader := testMediaReader{ + input.StorageKey: {data: []byte("input"), object: corestorage.Object{Key: input.StorageKey, OwnerID: user.ID, GenerationID: generation.ID, ContentType: input.MIMEType, Size: 5}}, + outputKey: {data: []byte("output"), object: corestorage.Object{Key: outputKey, OwnerID: user.ID, GenerationID: generation.ID, ContentType: mimeType, Size: 6}}, + thumbnailKey: {data: []byte("thumb"), object: corestorage.Object{Key: thumbnailKey, OwnerID: user.ID, GenerationID: generation.ID, ContentType: mimeType, Size: 5}}, + } + service, err := NewService(db, Config{Storage: reader}) + if err != nil { + t.Fatal(err) + } + return service, reader +} + +func TestGenerationDetailExposesMediaURLsWithoutStorageKeys(t *testing.T) { + service, _ := newGenerationMediaService(t) + detail, err := service.Generation(context.Background(), 9) + if err != nil { + t.Fatal(err) + } + if len(detail.Inputs) != 1 || detail.Inputs[0].URL != "/api/v1/chorus/generations/9/inputs/11" { + t.Fatalf("inputs=%#v", detail.Inputs) + } + if len(detail.Outputs) != 2 || detail.Outputs[0].ThumbnailURL == "" || detail.Outputs[1].Text == nil { + t.Fatalf("outputs=%#v", detail.Outputs) + } + payload, err := json.Marshal(detail) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"private/input-key", "private/output-key", "private/thumbnail-key"} { + if bytes.Contains(payload, []byte(secret)) { + t.Fatalf("detail leaked storage key %q: %s", secret, payload) + } + } +} + +func TestGenerationMediaRequiresMatchingDatabaseAndObjectOwnership(t *testing.T) { + service, reader := newGenerationMediaService(t) + input, object, name, err := service.OpenGenerationInput(context.Background(), 9, 11) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(input) + input.Close() + if string(data) != "input" || object.OwnerID != 7 || name != "reference.png" { + t.Fatalf("input=%q object=%#v name=%q", data, object, name) + } + output, _, err := service.OpenGenerationOutput(context.Background(), 9, 12, true) + if err != nil { + t.Fatal(err) + } + data, _ = io.ReadAll(output) + output.Close() + if string(data) != "thumb" { + t.Fatalf("thumbnail=%q", data) + } + if _, _, _, err := service.OpenGenerationInput(context.Background(), 10, 11); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-generation input error=%v", err) + } + item := reader["private/output-key"] + item.object.OwnerID = 99 + reader["private/output-key"] = item + if _, _, err := service.OpenGenerationOutput(context.Background(), 9, 12, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("mismatched owner error=%v", err) + } + item = reader["private/thumbnail-key"] + item.object.ContentType = "text/html" + reader["private/thumbnail-key"] = item + if _, _, err := service.OpenGenerationOutput(context.Background(), 9, 12, true); !errors.Is(err, ErrNotFound) { + t.Fatalf("unsafe thumbnail MIME error=%v", err) + } +} + +func TestGenerationMediaRouteStreamsAuthorizedObjectWithSafeHeaders(t *testing.T) { + service, _ := newGenerationMediaService(t) + gin.SetMode(gin.TestMode) + engine := gin.New() + authentication := func(c *gin.Context) { + c.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.IdentityKey: float64(7)}) + c.Next() + } + RegisterWithService(engine.Group("/api/v1"), authentication, func(c *gin.Context) { c.Next() }, service) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/chorus/generations/9/inputs/11", nil) + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != "input" { + t.Fatalf("status=%d body=%q", response.Code, response.Body.String()) + } + for key, want := range map[string]string{ + "Cache-Control": "no-store", "Pragma": "no-cache", "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'", "Content-Type": "image/png", + } { + if got := response.Header().Get(key); got != want { + t.Errorf("%s=%q, want %q", key, got, want) + } + } + if !strings.Contains(response.Header().Get("Content-Disposition"), "reference.png") { + t.Fatalf("Content-Disposition=%q", response.Header().Get("Content-Disposition")) + } +} diff --git a/admin/app/chorus/router.go b/admin/app/chorus/router.go index 0fbdb3d..ec0763c 100644 --- a/admin/app/chorus/router.go +++ b/admin/app/chorus/router.go @@ -2,8 +2,12 @@ package chorus import ( "errors" + "io" + "mime" "net/http" + "path" "strconv" + "strings" "git.ilapage.cn/OPC/chorus/admin/common/middleware" "github.com/gin-gonic/gin" @@ -128,6 +132,13 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc r.GET("/generations", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) { return service.Generations(c.Request.Context()) })) + r.GET("/generations/:id", withID(factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64) (any, error) { + adminNoStore(c) + return service.Generation(c.Request.Context(), id) + })) + r.GET("/generations/:id/inputs/:inputID", generationMedia(factory, "input")) + r.GET("/generations/:id/outputs/:outputID", generationMedia(factory, "output")) + r.GET("/generations/:id/outputs/:outputID/thumbnail", generationMedia(factory, "thumbnail")) r.GET("/api-keys", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) { adminNoStore(c) filter, err := apiKeyFilter(c) @@ -146,6 +157,63 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc })) } +func generationMedia(factory serviceFactory, resource string) gin.HandlerFunc { + return func(c *gin.Context) { + service, _, _, ok := requestScope(c, factory) + if !ok { + return + } + generationID, err := pathID(c, "id") + if err != nil { + respond(c, nil, err) + return + } + resourceName := "outputID" + if resource == "input" { + resourceName = "inputID" + } + resourceID, err := pathID(c, resourceName) + if err != nil { + respond(c, nil, err) + return + } + + adminNoStore(c) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "default-src 'none'") + if resource == "input" { + reader, object, name, openErr := service.OpenGenerationInput(c.Request.Context(), generationID, resourceID) + if openErr != nil { + respond(c, nil, openErr) + return + } + defer reader.Close() + filename := path.Base(strings.ReplaceAll(name, "\\", "/")) + if filename == "." || filename == "/" || filename == "" { + filename = "input" + } + c.Header("Content-Type", object.ContentType) + c.Header("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename})) + c.Header("Content-Length", strconv.FormatInt(object.Size, 10)) + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, reader) + return + } + + reader, object, openErr := service.OpenGenerationOutput(c.Request.Context(), generationID, resourceID, resource == "thumbnail") + if openErr != nil { + respond(c, nil, openErr) + return + } + defer reader.Close() + c.Header("Content-Type", object.ContentType) + c.Header("Content-Disposition", "inline") + c.Header("Content-Length", strconv.FormatInt(object.Size, 10)) + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, reader) + } +} + func apiKeyFilter(c *gin.Context) (APIKeyFilter, error) { filter := APIKeyFilter{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: 1, PageSize: 20} var err error diff --git a/admin/app/chorus/router_test.go b/admin/app/chorus/router_test.go index 3fc95ee..3a397a9 100644 --- a/admin/app/chorus/router_test.go +++ b/admin/app/chorus/router_test.go @@ -39,6 +39,10 @@ func TestChorusRoutesRequireAuthenticationAuthorizationAndHaveNoDeleteEndpoint(t {name: "API keys unauthenticated", method: http.MethodGet, path: "/api/v1/chorus/api-keys", want: http.StatusUnauthorized}, {name: "API keys not authorized", method: http.MethodGet, path: "/api/v1/chorus/api-keys", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, {name: "API key revoke not authorized", method: http.MethodPost, path: "/api/v1/chorus/api-keys/1/revoke", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, + {name: "generation detail unauthenticated", method: http.MethodGet, path: "/api/v1/chorus/generations/1", want: http.StatusUnauthorized}, + {name: "generation input not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/inputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, + {name: "generation output not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, + {name: "generation thumbnail not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1/thumbnail", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden}, {name: "delete is not exposed", method: http.MethodDelete, path: "/api/v1/chorus/providers/1", headers: map[string]string{"Authorization": "Bearer test", "X-Chorus-Role": "operator"}, want: http.StatusNotFound}, } { t.Run(test.name, func(t *testing.T) { diff --git a/admin/app/chorus/service.go b/admin/app/chorus/service.go index 4719460..3b48dae 100644 --- a/admin/app/chorus/service.go +++ b/admin/app/chorus/service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "net/url" "slices" @@ -13,6 +14,7 @@ import ( "git.ilapage.cn/OPC/chorus/internal/core/model" "git.ilapage.cn/OPC/chorus/internal/core/provider" + corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -46,6 +48,7 @@ type Config struct { MaxResponseBytes int64 Now func() time.Time Probe ProbeRunner + Storage corestorage.Reader } type Service struct { @@ -55,6 +58,7 @@ type Service struct { maxResponseBytes int64 now func() time.Time probe ProbeRunner + storage corestorage.Reader } func NewService(db *gorm.DB, cfg Config) (*Service, error) { @@ -70,7 +74,7 @@ func NewService(db *gorm.DB, cfg Config) (*Service, error) { return &Service{ db: db, allowConnectivityChecks: cfg.AllowConnectivityChecks, connectivityCooldown: cfg.ConnectivityCooldown, maxResponseBytes: cfg.MaxResponseBytes, - now: cfg.Now, probe: cfg.Probe, + now: cfg.Now, probe: cfg.Probe, storage: cfg.Storage, }, nil } @@ -816,6 +820,172 @@ func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) { return items, nil } +func (s *Service) Generation(ctx context.Context, id uint64) (GenerationDetailView, error) { + var row generationRow + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + return GenerationDetailView{}, translateNotFound(err) + } + item, err := s.generationView(ctx, row) + if err != nil { + return GenerationDetailView{}, err + } + + var inputRows []model.GenerationInput + if err := s.db.WithContext(ctx).Where("generation_id = ?", id).Order("position, id").Find(&inputRows).Error; err != nil { + return GenerationDetailView{}, fmt.Errorf("list generation inputs: %w", err) + } + inputs := make([]GenerationInputView, 0, len(inputRows)) + for _, input := range inputRows { + inputs = append(inputs, GenerationInputView{ + ID: input.ID, Position: input.Position, Role: input.Role, Note: input.Note, Name: input.OriginalName, + MIMEType: input.MIMEType, SizeBytes: input.SizeBytes, Width: input.Width, Height: input.Height, + URL: fmt.Sprintf("/api/v1/chorus/generations/%d/inputs/%d", id, input.ID), + }) + } + + var outputRows []model.GenerationOutput + if err := s.db.WithContext(ctx).Where("generation_id = ?", id).Order("id").Find(&outputRows).Error; err != nil { + return GenerationDetailView{}, fmt.Errorf("list generation outputs: %w", err) + } + outputs := make([]GenerationOutputView, 0, len(outputRows)) + for _, output := range outputRows { + view := GenerationOutputView{ + ID: output.ID, Kind: output.Kind, Text: output.TextContent, MIMEType: output.MIMEType, + SizeBytes: output.SizeBytes, Width: output.Width, Height: output.Height, CreatedAt: output.CreatedAt, + } + if output.StorageKey != nil { + view.URL = fmt.Sprintf("/api/v1/chorus/generations/%d/outputs/%d", id, output.ID) + } + if output.ThumbnailStorageKey != nil { + view.ThumbnailURL = fmt.Sprintf("/api/v1/chorus/generations/%d/outputs/%d/thumbnail", id, output.ID) + } + outputs = append(outputs, view) + } + return GenerationDetailView{GenerationView: item, Inputs: inputs, Outputs: outputs}, nil +} + +func (s *Service) generationView(ctx context.Context, row generationRow) (GenerationView, error) { + var user portalUserRow + if err := s.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return GenerationView{}, fmt.Errorf("get generation user: %w", err) + } + attempts := parseGenerationAttempts(row.Attempts) + models := make(map[uint64]providerModelRow) + providers := make(map[uint64]providerRow) + modelIDs := make([]uint64, 0, len(attempts)) + seen := make(map[uint64]struct{}, len(attempts)) + for _, attempt := range attempts { + if attempt.ProviderModelID != 0 { + if _, ok := seen[attempt.ProviderModelID]; !ok { + seen[attempt.ProviderModelID] = struct{}{} + modelIDs = append(modelIDs, attempt.ProviderModelID) + } + } + } + if len(modelIDs) > 0 { + var modelRows []providerModelRow + if err := s.db.WithContext(ctx).Where("id IN ?", modelIDs).Find(&modelRows).Error; err != nil { + return GenerationView{}, fmt.Errorf("get generation provider models: %w", err) + } + providerIDs := make([]uint64, 0, len(modelRows)) + for _, modelRow := range modelRows { + models[modelRow.ID] = modelRow + providerIDs = append(providerIDs, modelRow.ProviderID) + } + var providerRows []providerRow + if len(providerIDs) > 0 { + if err := s.db.WithContext(ctx).Where("id IN ?", providerIDs).Find(&providerRows).Error; err != nil { + return GenerationView{}, fmt.Errorf("get generation providers: %w", err) + } + for _, providerRow := range providerRows { + providers[providerRow.ID] = providerRow + } + } + } + username := user.Username + if username == "" { + username = fmt.Sprintf("user_%d", row.UserID) + } + item := GenerationView{ + ID: row.ID, UserID: row.UserID, Username: username, DisplayName: user.DisplayName, Status: row.Status, + Kind: row.Kind, RenderedPrompt: truncate(row.RenderedPrompt, 1024), Attempts: enrichGenerationAttempts(attempts, models, providers), + ProviderAttemptCnt: row.ProviderAttemptCount, CreatedAt: row.CreatedAt, StartedAt: row.StartedAt, CompletedAt: row.CompletedAt, + } + if row.ErrorCode != nil { + item.ErrorCode = *row.ErrorCode + } + if row.ErrorMessage != nil { + item.ErrorMessage = truncate(*row.ErrorMessage, 256) + } + return item, nil +} + +func (s *Service) OpenGenerationInput(ctx context.Context, generationID, inputID uint64) (io.ReadCloser, corestorage.Object, string, error) { + if s.storage == nil { + return nil, corestorage.Object{}, "", ErrNotFound + } + var generation generationRow + if err := s.db.WithContext(ctx).Select("id", "user_id").First(&generation, generationID).Error; err != nil { + return nil, corestorage.Object{}, "", translateNotFound(err) + } + var input model.GenerationInput + if err := s.db.WithContext(ctx).Where("id = ? AND generation_id = ?", inputID, generationID).First(&input).Error; err != nil { + return nil, corestorage.Object{}, "", translateNotFound(err) + } + reader, object, err := s.storage.Open(ctx, input.StorageKey) + if err != nil { + return nil, corestorage.Object{}, "", ErrNotFound + } + if object.OwnerID != generation.UserID || object.GenerationID != generationID || !strings.EqualFold(object.ContentType, input.MIMEType) || !isSafeAdminImageMIME(object.ContentType) { + reader.Close() + return nil, corestorage.Object{}, "", ErrNotFound + } + return reader, object, input.OriginalName, nil +} + +func (s *Service) OpenGenerationOutput(ctx context.Context, generationID, outputID uint64, thumbnail bool) (io.ReadCloser, corestorage.Object, error) { + if s.storage == nil { + return nil, corestorage.Object{}, ErrNotFound + } + var generation generationRow + if err := s.db.WithContext(ctx).Select("id", "user_id").First(&generation, generationID).Error; err != nil { + return nil, corestorage.Object{}, translateNotFound(err) + } + var output model.GenerationOutput + if err := s.db.WithContext(ctx).Where("id = ? AND generation_id = ?", outputID, generationID).First(&output).Error; err != nil { + return nil, corestorage.Object{}, translateNotFound(err) + } + key := output.StorageKey + if thumbnail { + key = output.ThumbnailStorageKey + } + if key == nil { + return nil, corestorage.Object{}, ErrNotFound + } + reader, object, err := s.storage.Open(ctx, *key) + if err != nil { + return nil, corestorage.Object{}, ErrNotFound + } + if object.OwnerID != generation.UserID || object.GenerationID != generationID || !isSafeAdminImageMIME(object.ContentType) { + reader.Close() + return nil, corestorage.Object{}, ErrNotFound + } + if !thumbnail && (output.MIMEType == nil || !strings.EqualFold(object.ContentType, *output.MIMEType)) { + reader.Close() + return nil, corestorage.Object{}, ErrNotFound + } + return reader, object, nil +} + +func isSafeAdminImageMIME(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "image/png", "image/jpeg", "image/webp": + return true + default: + return false + } +} + func parseGenerationAttempts(raw json.RawMessage) []model.Attempt { var attempts []model.Attempt if len(raw) == 0 || json.Unmarshal(raw, &attempts) != nil { diff --git a/admin/app/chorus/types.go b/admin/app/chorus/types.go index 9314aa0..5ce57ea 100644 --- a/admin/app/chorus/types.go +++ b/admin/app/chorus/types.go @@ -190,6 +190,38 @@ type GenerationView struct { CompletedAt *time.Time `json:"completed_at,omitempty"` } +type GenerationDetailView struct { + GenerationView + Inputs []GenerationInputView `json:"inputs"` + Outputs []GenerationOutputView `json:"outputs"` +} + +type GenerationInputView struct { + ID uint64 `json:"id"` + Position uint32 `json:"position"` + Role model.InputRole `json:"role"` + Note *string `json:"note,omitempty"` + Name string `json:"name"` + MIMEType string `json:"mime_type"` + SizeBytes uint64 `json:"size_bytes"` + Width *uint32 `json:"width,omitempty"` + Height *uint32 `json:"height,omitempty"` + URL string `json:"url"` +} + +type GenerationOutputView struct { + ID uint64 `json:"id"` + Kind model.GenerationKind `json:"kind"` + Text *string `json:"text,omitempty"` + MIMEType *string `json:"mime_type,omitempty"` + SizeBytes *uint64 `json:"size_bytes,omitempty"` + Width *uint32 `json:"width,omitempty"` + Height *uint32 `json:"height,omitempty"` + CreatedAt time.Time `json:"created_at"` + URL string `json:"url,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` +} + type GenerationAttemptView struct { model.Attempt ProviderName string `json:"provider_name,omitempty"` diff --git a/admin/cmd/server.go b/admin/cmd/server.go index 8199351..b5d941d 100644 --- a/admin/cmd/server.go +++ b/admin/cmd/server.go @@ -19,6 +19,7 @@ import ( ext "git.ilapage.cn/OPC/chorus/admin/config" sharedconfig "git.ilapage.cn/OPC/chorus/internal/config" safehttp "git.ilapage.cn/OPC/chorus/internal/platform/http" + platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage" "github.com/gin-gonic/gin" "github.com/go-admin-team/go-admin-core/config/source/file" "github.com/go-admin-team/go-admin-core/sdk" @@ -66,6 +67,11 @@ func setup(path string) error { if err != nil { return err } + mediaStorage, err := platformstorage.NewLocalReader(ext.ExtConfig.Chorus.StorageRoot) + if err != nil { + return fmt.Errorf("configure read-only Chorus storage: %w", err) + } + serviceConfig.config.Storage = mediaStorage chorus.Configure(func(db *gorm.DB) (*chorus.Service, error) { return chorus.NewService(db, serviceConfig.config) }) diff --git a/admin/config/extend.go b/admin/config/extend.go index bd06e03..2a7a9be 100644 --- a/admin/config/extend.go +++ b/admin/config/extend.go @@ -3,14 +3,21 @@ package config var ExtConfig Extend // Extend 扩展配置 -// extend: -// demo: -// name: demo-name +// +// extend: +// demo: +// name: demo-name +// // 使用方法: config.ExtConfig......即可!! type Extend struct { - AMap AMap // 这里配置对应配置文件的结构即可 + AMap AMap `yaml:"amap"` + Chorus Chorus `yaml:"chorus"` } type AMap struct { - Key string + Key string `yaml:"key"` +} + +type Chorus struct { + StorageRoot string `yaml:"storage_root"` } diff --git a/admin/config/settings.example.yml b/admin/config/settings.example.yml index 9641866..799739d 100644 --- a/admin/config/settings.example.yml +++ b/admin/config/settings.example.yml @@ -21,3 +21,7 @@ settings: source: user:password@tcp(127.0.0.1:3308)/chorus?charset=utf8mb4&parseTime=True&loc=Local cache: memory: '' + extend: + chorus: + # Read-only access to the same protected storage root used by Portal. + storage_root: ../var/storage diff --git a/admin/go.mod b/admin/go.mod index 9811e69..6514a6c 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -50,6 +50,7 @@ require ( github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect github.com/cloudwego/base64x v0.1.7 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect diff --git a/admin/go.sum b/admin/go.sum index 3ab7223..9fb7bb6 100644 --- a/admin/go.sum +++ b/admin/go.sum @@ -105,6 +105,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -577,6 +579,7 @@ golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xi golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index e600116..695582d 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -21,8 +21,12 @@ type PutRequest struct { Source io.Reader } -type Store interface { - Put(ctx context.Context, request PutRequest) (Object, error) +type Reader interface { Open(ctx context.Context, key string) (io.ReadCloser, Object, error) +} + +type Store interface { + Reader + Put(ctx context.Context, request PutRequest) (Object, error) Delete(ctx context.Context, key string) error } diff --git a/internal/platform/storage/local.go b/internal/platform/storage/local.go index 41f213f..eb14e6c 100644 --- a/internal/platform/storage/local.go +++ b/internal/platform/storage/local.go @@ -99,6 +99,26 @@ func NewLocal(config Config) (*Local, error) { }, nil } +// NewLocalReader opens an existing local store without enabling its write path. +func NewLocalReader(root string) (*Local, error) { + if strings.TrimSpace(root) == "" { + return nil, fmt.Errorf("local storage root is required") + } + resolved, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve storage root: %w", err) + } + resolved, err = filepath.EvalSymlinks(resolved) + if err != nil { + return nil, fmt.Errorf("resolve storage root links: %w", err) + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("local storage root is unavailable") + } + return &Local{root: resolved}, nil +} + func (s *Local) Put(ctx context.Context, request corestorage.PutRequest) (object corestorage.Object, err error) { if request.OwnerID == 0 || request.GenerationID == 0 || strings.TrimSpace(request.ContentType) == "" || request.Source == nil { return corestorage.Object{}, ErrInvalidMetadata diff --git a/internal/platform/storage/local_test.go b/internal/platform/storage/local_test.go index 4fd94d3..aea5314 100644 --- a/internal/platform/storage/local_test.go +++ b/internal/platform/storage/local_test.go @@ -75,6 +75,45 @@ func TestLocalPutOpenAtomicMetadataAndTraversal(t *testing.T) { } } +func TestNewLocalReaderOpensExistingObjectsWithoutWriteSetup(t *testing.T) { + root := t.TempDir() + store, err := NewLocal(Config{ + Root: root, MaxObjectBytes: 1024, MaxImagePixels: 1_000_000, ThumbnailMaxSide: 256, + AllowedImageMIME: map[string]bool{"image/png": true}, + }) + if err != nil { + t.Fatal(err) + } + object, err := store.Put(context.Background(), corestorage.PutRequest{ + Key: "users/7/generations/9/input", OwnerID: 7, GenerationID: 9, + ContentType: "text/plain", Source: bytes.NewBufferString("read-only object"), + }) + if err != nil { + t.Fatal(err) + } + + readerStore, err := NewLocalReader(root) + if err != nil { + t.Fatal(err) + } + reader, metadata, err := readerStore.Open(context.Background(), object.Key) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if string(data) != "read-only object" || metadata.OwnerID != 7 || metadata.GenerationID != 9 { + t.Fatalf("data=%q metadata=%#v", data, metadata) + } + + if _, err := NewLocalReader(filepath.Join(root, "missing")); err == nil { + t.Fatal("NewLocalReader accepted a missing root") + } +} + func TestLocalFailureCleansTemporaryFiles(t *testing.T) { store := newTestStore(t, 4, 1_000_000) _, err := store.Put(context.Background(), corestorage.PutRequest{ diff --git a/migrations/000010_admin_generation_media.down.sql b/migrations/000010_admin_generation_media.down.sql new file mode 100644 index 0000000..85740e0 --- /dev/null +++ b/migrations/000010_admin_generation_media.down.sql @@ -0,0 +1,33 @@ +DELETE casbin +FROM sys_casbin_rule casbin +JOIN sys_api api + ON casbin.ptype = 'p' + AND casbin.v0 = 'chorus_operator' + AND casbin.v1 = api.path + AND casbin.v2 = api.action +WHERE api.handle IN ( + 'chorus.generations.get', + 'chorus.generations.input.read', + 'chorus.generations.output.read', + 'chorus.generations.output.thumbnail' +); + +DELETE menu_api +FROM sys_menu_api_rule menu_api +JOIN sys_api api ON api.id = menu_api.sys_api_id +JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id +WHERE menu.path = '/chorus/generations' + AND api.handle IN ( + 'chorus.generations.get', + 'chorus.generations.input.read', + 'chorus.generations.output.read', + 'chorus.generations.output.thumbnail' + ); + +DELETE FROM sys_api +WHERE handle IN ( + 'chorus.generations.get', + 'chorus.generations.input.read', + 'chorus.generations.output.read', + 'chorus.generations.output.thumbnail' +); diff --git a/migrations/000010_admin_generation_media.up.sql b/migrations/000010_admin_generation_media.up.sql new file mode 100644 index 0000000..464eaa1 --- /dev/null +++ b/migrations/000010_admin_generation_media.up.sql @@ -0,0 +1,29 @@ +INSERT INTO sys_api (handle, title, path, type, action) +VALUES + ('chorus.generations.get', 'Get generation detail', '/api/v1/chorus/generations/:id', 'BUS', 'GET'), + ('chorus.generations.input.read', 'Read generation input', '/api/v1/chorus/generations/:id/inputs/:inputID', 'BUS', 'GET'), + ('chorus.generations.output.read', 'Read generation output', '/api/v1/chorus/generations/:id/outputs/:outputID', 'BUS', 'GET'), + ('chorus.generations.output.thumbnail', 'Read generation output thumbnail', '/api/v1/chorus/generations/:id/outputs/:outputID/thumbnail', 'BUS', 'GET') +ON DUPLICATE KEY UPDATE + title = VALUES(title), type = VALUES(type), deleted_at = NULL; + +INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id) +SELECT menu.menu_id, api.id +FROM sys_menu menu +JOIN sys_api api ON api.handle IN ( + 'chorus.generations.get', + 'chorus.generations.input.read', + 'chorus.generations.output.read', + 'chorus.generations.output.thumbnail' +) +WHERE menu.path = '/chorus/generations'; + +INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', 'chorus_operator', path, action, '', '', '' +FROM sys_api +WHERE handle IN ( + 'chorus.generations.get', + 'chorus.generations.input.read', + 'chorus.generations.output.read', + 'chorus.generations.output.thumbnail' +); diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index 54ebd58..07e7303 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -31,6 +31,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { "000007_admin_navigation_localization.up.sql", "000008_portal_username_login.up.sql", "000009_admin_grouped_navigation.up.sql", + "000010_admin_generation_media.up.sql", } slices.Sort(upFiles) if !slices.Equal(upFiles, wantFiles) { @@ -62,6 +63,30 @@ func TestMigrationPairsAndProductionTables(t *testing.T) { } } +func TestAdminGenerationMediaMigrationContracts(t *testing.T) { + up, err := os.ReadFile("000010_admin_generation_media.up.sql") + if err != nil { + t.Fatal(err) + } + down, err := os.ReadFile("000010_admin_generation_media.down.sql") + if err != nil { + t.Fatal(err) + } + for _, required := range []string{ + "chorus.generations.get", "chorus.generations.input.read", "chorus.generations.output.read", + "chorus.generations.output.thumbnail", "sys_menu_api_rule", "sys_casbin_rule", "/chorus/generations", + } { + if !strings.Contains(string(up), required) || !strings.Contains(string(down), required) { + t.Errorf("generation media migration is missing reversible contract %s", required) + } + } + for _, forbidden := range []string{"generation_inputs", "generation_outputs", "storage_key", "AutoMigrate"} { + if strings.Contains(string(up), forbidden) || strings.Contains(string(down), forbidden) { + t.Errorf("generation media permission migration must not touch %s", forbidden) + } + } +} + func TestAdminGroupedNavigationMigrationContracts(t *testing.T) { read := func(name string) string { t.Helper() diff --git a/migrations/mysql_integration_test.go b/migrations/mysql_integration_test.go index cd5e308..4c978ea 100644 --- a/migrations/mysql_integration_test.go +++ b/migrations/mysql_integration_test.go @@ -155,7 +155,12 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) { assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.menus.%' AND action <> 'GET'`, 0) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.apis.%' AND action <> 'GET'`, 0) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.login-logs.%' AND action <> 'GET'`, 0) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 LIKE '/api/v1/chorus/generations/%' AND v2 = 'GET'`, 4) + runMigrate("down", "1") + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 0) + assertGroupedAdminNavigation(t, ctx, db) runMigrate("down", "1") assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus'`, 1) assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system')`, 0) @@ -199,6 +204,7 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) { assertColumnExists(t, ctx, db, "users", "username", true) assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1) assertGroupedAdminNavigation(t, ctx, db) + assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4) } func assertGroupedAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) {