feat: 支持管理端查看生成媒体 (#72)

This commit is contained in:
ila
2026-08-27 10:05:16 +08:00
parent e3d2782978
commit 71c7142f0c
21 changed files with 752 additions and 13 deletions
+176
View File
@@ -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: &note, 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"))
}
}
+68
View File
@@ -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
+4
View File
@@ -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) {
+171 -1
View File
@@ -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 {
+32
View File
@@ -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"`
+6
View File
@@ -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)
})
+12 -5
View File
@@ -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"`
}
+4
View File
@@ -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
+1
View File
@@ -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
+3
View File
@@ -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=