feat(*): 创作与运维 - 新增图片尺寸预设和系统性能面板并优化图片组动画
This commit is contained in:
1 parent
df94c3bdf1
commit
bd639745ac
41 files changed
+2785
-353
No files matched your search
@@ -0,0 +1,346 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/buildinfo"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var systemProcessStartedAt = time.Now()
|
||||
|
||||
const systemPerformanceTimeout = 3 * time.Second
|
||||
|
||||
type AdminSystemPerformance struct {
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
Status string `json:"status"`
|
||||
Host SystemPerformanceHost `json:"host"`
|
||||
Memory SystemPerformanceMemory `json:"memory"`
|
||||
Disk SystemPerformanceDisk `json:"disk"`
|
||||
Database repository.DatabaseRuntimeStats `json:"database"`
|
||||
Redis SystemPerformanceRedis `json:"redis"`
|
||||
Build buildinfo.Info `json:"build"`
|
||||
}
|
||||
|
||||
type SystemPerformanceHost struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
CPUCores int `json:"cpuCores"`
|
||||
GOMAXPROCS int `json:"gomaxprocs"`
|
||||
ProcessID int `json:"processId"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
ActiveWorkerTasks int64 `json:"activeWorkerTasks"`
|
||||
LoadAverage [3]float64 `json:"loadAverage"`
|
||||
LoadAverageAvailable bool `json:"loadAverageAvailable"`
|
||||
}
|
||||
|
||||
type SystemPerformanceMemory struct {
|
||||
SystemAvailable bool `json:"systemAvailable"`
|
||||
TotalBytes uint64 `json:"totalBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
AvailableBytes uint64 `json:"availableBytes"`
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
HeapAllocBytes uint64 `json:"heapAllocBytes"`
|
||||
HeapSysBytes uint64 `json:"heapSysBytes"`
|
||||
SysBytes uint64 `json:"sysBytes"`
|
||||
HeapObjects uint64 `json:"heapObjects"`
|
||||
GCCount uint32 `json:"gcCount"`
|
||||
LastGCAt *time.Time `json:"lastGCAt,omitempty"`
|
||||
}
|
||||
|
||||
type SystemPerformanceDisk struct {
|
||||
Available bool `json:"available"`
|
||||
Writable bool `json:"writable"`
|
||||
TotalBytes uint64 `json:"totalBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
FreeBytes uint64 `json:"freeBytes"`
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
}
|
||||
|
||||
type SystemPerformanceRedisPool struct {
|
||||
Hits uint32 `json:"hits"`
|
||||
Misses uint32 `json:"misses"`
|
||||
Timeouts uint32 `json:"timeouts"`
|
||||
TotalConnections uint32 `json:"totalConnections"`
|
||||
IdleConnections uint32 `json:"idleConnections"`
|
||||
StaleConnections uint32 `json:"staleConnections"`
|
||||
PendingRequests uint32 `json:"pendingRequests"`
|
||||
}
|
||||
|
||||
type SystemPerformanceCacheGroup struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Keys int64 `json:"keys"`
|
||||
Clearable bool `json:"clearable"`
|
||||
}
|
||||
|
||||
type SystemPerformanceRedis struct {
|
||||
Configured bool `json:"configured"`
|
||||
Connected bool `json:"connected"`
|
||||
Mode string `json:"mode"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
||||
UsedMemoryBytes int64 `json:"usedMemoryBytes,omitempty"`
|
||||
PeakMemoryBytes int64 `json:"peakMemoryBytes,omitempty"`
|
||||
MaxMemoryBytes int64 `json:"maxMemoryBytes,omitempty"`
|
||||
MaxMemoryPolicy string `json:"maxMemoryPolicy,omitempty"`
|
||||
Keys int64 `json:"keys,omitempty"`
|
||||
Clients int64 `json:"clients,omitempty"`
|
||||
BlockedClients int64 `json:"blockedClients,omitempty"`
|
||||
OpsPerSecond int64 `json:"opsPerSecond,omitempty"`
|
||||
HitRate float64 `json:"hitRate,omitempty"`
|
||||
ExpiredKeys int64 `json:"expiredKeys,omitempty"`
|
||||
EvictedKeys int64 `json:"evictedKeys,omitempty"`
|
||||
Pool SystemPerformanceRedisPool `json:"pool"`
|
||||
CacheGroups []SystemPerformanceCacheGroup `json:"cacheGroups"`
|
||||
StatusMessage string `json:"statusMessage,omitempty"`
|
||||
}
|
||||
|
||||
type AdminCacheClearRequest struct {
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type AdminCacheClearGroupResult struct {
|
||||
ID string `json:"id"`
|
||||
DeletedKeys int64 `json:"deletedKeys"`
|
||||
}
|
||||
|
||||
type AdminCacheClearResult struct {
|
||||
Scope string `json:"scope"`
|
||||
DeletedKeys int64 `json:"deletedKeys"`
|
||||
MemoryEntries int `json:"memoryEntries"`
|
||||
Groups []AdminCacheClearGroupResult `json:"groups"`
|
||||
ClearedAt time.Time `json:"clearedAt"`
|
||||
}
|
||||
|
||||
type runtimeCacheDefinition struct {
|
||||
id, label, pattern string
|
||||
}
|
||||
|
||||
var runtimeCacheDefinitions = []runtimeCacheDefinition{
|
||||
{id: "rateLimits", label: "请求频控", pattern: "canvas:rate:*"},
|
||||
{id: "circuitFailures", label: "渠道失败计数", pattern: "canvas:circuit:failures:*"},
|
||||
{id: "circuitOpen", label: "渠道熔断状态", pattern: "canvas:circuit:open:*"},
|
||||
{id: "routeHealth", label: "线路临时禁用", pattern: "canvas:logical-route-health:*"},
|
||||
}
|
||||
|
||||
func (s *Service) AdminSystemPerformance(ctx context.Context, actor *model.User) (*AdminSystemPerformance, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, systemPerformanceTimeout)
|
||||
defer cancel()
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
load, loadAvailable := platformLoadAverage()
|
||||
memory := collectSystemMemory()
|
||||
disk := collectSystemDisk(s.dataDir)
|
||||
databaseStats, databaseErr := s.repo.DatabaseRuntimeStats(ctx)
|
||||
redisStats := s.collectRedisPerformance(ctx)
|
||||
status := "healthy"
|
||||
if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) {
|
||||
status = "degraded"
|
||||
}
|
||||
return &AdminSystemPerformance{
|
||||
CollectedAt: time.Now(), Status: status,
|
||||
Host: SystemPerformanceHost{Hostname: hostname, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCores: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ProcessID: os.Getpid(), UptimeSeconds: int64(time.Since(systemProcessStartedAt).Seconds()), Goroutines: runtime.NumGoroutine(), ActiveWorkerTasks: s.ActiveWorkerTasks(), LoadAverage: load, LoadAverageAvailable: loadAvailable},
|
||||
Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Build: buildinfo.Current(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func collectSystemMemory() SystemPerformanceMemory {
|
||||
var stats runtime.MemStats
|
||||
runtime.ReadMemStats(&stats)
|
||||
total, available, ok := platformMemory()
|
||||
used := uint64(0)
|
||||
percentage := float64(0)
|
||||
if ok && total >= available {
|
||||
used = total - available
|
||||
if total > 0 {
|
||||
percentage = float64(used) / float64(total) * 100
|
||||
}
|
||||
}
|
||||
result := SystemPerformanceMemory{SystemAvailable: ok, TotalBytes: total, UsedBytes: used, AvailableBytes: available, UsagePercent: percentage, HeapAllocBytes: stats.HeapAlloc, HeapSysBytes: stats.HeapSys, SysBytes: stats.Sys, HeapObjects: stats.HeapObjects, GCCount: stats.NumGC}
|
||||
if stats.LastGC > 0 {
|
||||
last := time.Unix(0, int64(stats.LastGC))
|
||||
result.LastGCAt = &last
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) collectRedisPerformance(ctx context.Context) SystemPerformanceRedis {
|
||||
result := SystemPerformanceRedis{Mode: "local", StatusMessage: "未配置 Redis,当前使用单实例本地协调", CacheGroups: make([]SystemPerformanceCacheGroup, 0, len(runtimeCacheDefinitions))}
|
||||
if s.coordinator == nil {
|
||||
result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Clearable: true})
|
||||
return result
|
||||
}
|
||||
if s.coordinator.redis == nil {
|
||||
s.coordinator.localMu.Lock()
|
||||
localRateKeys := int64(len(s.coordinator.localRate))
|
||||
s.coordinator.localMu.Unlock()
|
||||
result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Keys: localRateKeys, Clearable: true})
|
||||
return result
|
||||
}
|
||||
result.Configured, result.Mode = true, "redis"
|
||||
started := time.Now()
|
||||
if err := s.coordinator.redis.Ping(ctx).Err(); err != nil {
|
||||
result.StatusMessage = "Redis 暂时不可用"
|
||||
return result
|
||||
}
|
||||
result.Connected = true
|
||||
result.LatencyMs = max(1, time.Since(started).Milliseconds())
|
||||
result.StatusMessage = "Redis 连接正常"
|
||||
if size, err := s.coordinator.redis.DBSize(ctx).Result(); err == nil {
|
||||
result.Keys = size
|
||||
}
|
||||
if info, err := s.coordinator.redis.Info(ctx, "server", "clients", "memory", "stats").Result(); err == nil {
|
||||
values := parseRedisInfo(info)
|
||||
result.Version = values["redis_version"]
|
||||
result.UptimeSeconds = redisInfoInt(values, "uptime_in_seconds")
|
||||
result.UsedMemoryBytes = redisInfoInt(values, "used_memory")
|
||||
result.PeakMemoryBytes = redisInfoInt(values, "used_memory_peak")
|
||||
result.MaxMemoryBytes = redisInfoInt(values, "maxmemory")
|
||||
result.MaxMemoryPolicy = values["maxmemory_policy"]
|
||||
result.Clients = redisInfoInt(values, "connected_clients")
|
||||
result.BlockedClients = redisInfoInt(values, "blocked_clients")
|
||||
result.OpsPerSecond = redisInfoInt(values, "instantaneous_ops_per_sec")
|
||||
result.ExpiredKeys = redisInfoInt(values, "expired_keys")
|
||||
result.EvictedKeys = redisInfoInt(values, "evicted_keys")
|
||||
hits, misses := redisInfoInt(values, "keyspace_hits"), redisInfoInt(values, "keyspace_misses")
|
||||
if hits+misses > 0 {
|
||||
result.HitRate = float64(hits) / float64(hits+misses) * 100
|
||||
}
|
||||
}
|
||||
pool := s.coordinator.redis.PoolStats()
|
||||
result.Pool = SystemPerformanceRedisPool{Hits: pool.Hits, Misses: pool.Misses, Timeouts: pool.Timeouts, TotalConnections: pool.TotalConns, IdleConnections: pool.IdleConns, StaleConnections: pool.StaleConns, PendingRequests: pool.PendingRequests}
|
||||
for _, definition := range runtimeCacheDefinitions {
|
||||
count, _ := scanRedisKeyCount(ctx, s.coordinator.redis, definition.pattern)
|
||||
result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: definition.id, Label: definition.label, Keys: count, Clearable: true})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) ClearAdminRuntimeCache(ctx context.Context, actor *model.User, request AdminCacheClearRequest) (*AdminCacheClearResult, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(request.Scope) != "runtime" {
|
||||
return nil, BadAuthRequest("仅支持清理运行时缓存")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
result := &AdminCacheClearResult{Scope: "runtime", ClearedAt: time.Now(), Groups: make([]AdminCacheClearGroupResult, 0, len(runtimeCacheDefinitions))}
|
||||
if s.coordinator != nil {
|
||||
if s.coordinator.redis != nil {
|
||||
for _, definition := range runtimeCacheDefinitions {
|
||||
deleted, err := deleteRedisKeysByPattern(ctx, s.coordinator.redis, definition.pattern)
|
||||
if err != nil {
|
||||
return nil, errors.New("清理 Redis 运行时缓存失败")
|
||||
}
|
||||
result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: definition.id, DeletedKeys: deleted})
|
||||
result.DeletedKeys += deleted
|
||||
}
|
||||
} else {
|
||||
s.coordinator.localMu.Lock()
|
||||
count := len(s.coordinator.localRate)
|
||||
s.coordinator.localRate = map[string]localRateEntry{}
|
||||
s.coordinator.localMu.Unlock()
|
||||
result.DeletedKeys += int64(count)
|
||||
result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: "rateLimits", DeletedKeys: int64(count)})
|
||||
}
|
||||
}
|
||||
s.initReadCaches()
|
||||
result.MemoryEntries += s.concurrencyReadCache.clearAndCount()
|
||||
result.MemoryEntries += s.textReplayReadCache.clearAndCount()
|
||||
result.MemoryEntries += s.routeVersionReadCache.clearAndCount()
|
||||
s.routeHealthMu.Lock()
|
||||
result.MemoryEntries += len(s.routeHealthBlocked)
|
||||
s.routeHealthBlocked = make(map[string]time.Time)
|
||||
s.routeHealthMu.Unlock()
|
||||
// 与路由目录刷新保持同一加锁顺序,避免清理动作和回源刷新并发写入重试状态。
|
||||
s.routeCatalogRefreshMu.Lock()
|
||||
s.routeCatalogMu.Lock()
|
||||
if s.routeCatalog != nil {
|
||||
result.MemoryEntries++
|
||||
}
|
||||
s.routeCatalog = nil
|
||||
s.routeCatalogVersion = 0
|
||||
s.routeCatalogRetryAt = time.Time{}
|
||||
s.routeCatalogRefreshError = nil
|
||||
s.routeCatalogMu.Unlock()
|
||||
s.routeCatalogRefreshMu.Unlock()
|
||||
if err := s.appendAdminAudit(actor, "system.runtime_cache.clear", "system", "runtime-cache", "清理安全运行时缓存", map[string]any{"deletedKeys": result.DeletedKeys, "memoryEntries": result.MemoryEntries}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseRedisInfo(info string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if found {
|
||||
values[key] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func redisInfoInt(values map[string]string, key string) int64 {
|
||||
value, _ := strconv.ParseInt(values[key], 10, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func scanRedisKeyCount(ctx context.Context, client *redis.Client, pattern string) (int64, error) {
|
||||
var cursor uint64
|
||||
var total int64
|
||||
for {
|
||||
keys, next, err := client.Scan(ctx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += int64(len(keys))
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRedisKeysByPattern(ctx context.Context, client *redis.Client, pattern string) (int64, error) {
|
||||
var cursor uint64
|
||||
var deleted int64
|
||||
for {
|
||||
keys, next, err := client.Scan(ctx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
count, err := client.Unlink(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted += count
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
return deleted, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAdminSystemPerformanceRequiresAdmin(t *testing.T) {
|
||||
svc := &Service{}
|
||||
user := &model.User{ID: "user-1", Role: model.UserRoleUser}
|
||||
|
||||
if _, err := svc.AdminSystemPerformance(context.Background(), user); authStatus(err) != 403 {
|
||||
t.Fatalf("AdminSystemPerformance() error = %#v, want 403", err)
|
||||
}
|
||||
if _, err := svc.ClearAdminRuntimeCache(context.Background(), user, AdminCacheClearRequest{Scope: "runtime"}); authStatus(err) != 403 {
|
||||
t.Fatalf("ClearAdminRuntimeCache() error = %#v, want 403", err)
|
||||
}
|
||||
if _, err := svc.AdminSystemPerformance(context.Background(), nil); authStatus(err) != 401 {
|
||||
t.Fatalf("AdminSystemPerformance(nil) error = %#v, want 401", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSystemPerformanceReportsSQLiteWithoutRedis(t *testing.T) {
|
||||
svc, db := newSystemPerformanceTestService(t)
|
||||
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
|
||||
result, err := svc.AdminSystemPerformance(context.Background(), admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "healthy" {
|
||||
t.Fatalf("status = %q, want healthy", result.Status)
|
||||
}
|
||||
if result.Database.Driver != "sqlite" || !result.Database.Connected || result.Database.Postgres != nil {
|
||||
t.Fatalf("database = %#v", result.Database)
|
||||
}
|
||||
if result.Database.DatabaseBytes <= 0 {
|
||||
t.Fatalf("database bytes = %d, want > 0", result.Database.DatabaseBytes)
|
||||
}
|
||||
if result.Database.Schema.Expected != database.CurrentSchemaVersion {
|
||||
t.Fatalf("schema = %#v", result.Database.Schema)
|
||||
}
|
||||
if result.Redis.Configured || result.Redis.Connected || result.Redis.Mode != "local" {
|
||||
t.Fatalf("redis = %#v", result.Redis)
|
||||
}
|
||||
if len(result.Redis.CacheGroups) != 1 || result.Redis.CacheGroups[0].ID != "rateLimits" {
|
||||
t.Fatalf("cache groups = %#v", result.Redis.CacheGroups)
|
||||
}
|
||||
if !result.Disk.Available || !result.Disk.Writable {
|
||||
t.Fatalf("disk = %#v", result.Disk)
|
||||
}
|
||||
if result.Host.CPUCores < 1 || result.Host.GOMAXPROCS < 1 || result.Host.ProcessID < 1 {
|
||||
t.Fatalf("host = %#v", result.Host)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats := sqlDB.Stats(); result.Database.Pool.OpenConnections != stats.OpenConnections {
|
||||
t.Fatalf("pool open connections = %d, want %d", result.Database.Pool.OpenConnections, stats.OpenConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing.T) {
|
||||
svc, db := newSystemPerformanceTestService(t)
|
||||
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
svc.coordinator.localRate["user-1"] = localRateEntry{started: time.Now(), count: 2}
|
||||
|
||||
if _, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "all"}); authStatus(err) != 400 {
|
||||
t.Fatalf("unknown scope error = %#v, want 400", err)
|
||||
}
|
||||
if len(svc.coordinator.localRate) != 1 {
|
||||
t.Fatal("invalid scope changed local runtime cache")
|
||||
}
|
||||
|
||||
svc.initReadCaches()
|
||||
if _, err := svc.routeVersionReadCache.get(context.Background(), "route-version", func(context.Context) (int64, int, error) {
|
||||
return 7, 256, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.routeHealthBlocked["route-1"] = time.Now().Add(time.Minute)
|
||||
svc.routeCatalog = &routeCatalogSnapshot{}
|
||||
svc.routeCatalogRetryAt = time.Now().Add(time.Minute)
|
||||
svc.routeCatalogRefreshError = errors.New("cached refresh failure")
|
||||
|
||||
result, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "runtime"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.DeletedKeys != 1 || len(svc.coordinator.localRate) != 0 {
|
||||
t.Fatalf("local clear result = %#v, remaining = %d", result, len(svc.coordinator.localRate))
|
||||
}
|
||||
if result.MemoryEntries != 3 {
|
||||
t.Fatalf("memory entries = %d, want 3", result.MemoryEntries)
|
||||
}
|
||||
if svc.routeCatalog != nil || !svc.routeCatalogRetryAt.IsZero() || svc.routeCatalogRefreshError != nil || len(svc.routeHealthBlocked) != 0 {
|
||||
t.Fatalf("route caches were not reset")
|
||||
}
|
||||
var audit model.AdminAuditEvent
|
||||
if err := db.Where("action = ?", "system.runtime_cache.clear").First(&audit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audit.ActorUserID != admin.ID || audit.TargetID != "runtime-cache" {
|
||||
t.Fatalf("audit = %#v", audit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAdminRuntimeCacheUsesRedisWhitelist(t *testing.T) {
|
||||
client := startSystemPerformanceRedis(t)
|
||||
svc, db := newSystemPerformanceTestService(t)
|
||||
svc.coordinator = &runtimeCoordinator{redis: client, instanceID: "system-performance-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}
|
||||
admin := &model.User{ID: "admin-redis", Role: model.UserRoleAdmin}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
clearable := []string{
|
||||
"canvas:rate:user-1",
|
||||
"canvas:circuit:failures:channel-1",
|
||||
"canvas:circuit:open:channel-1",
|
||||
"canvas:logical-route-health:route-1",
|
||||
}
|
||||
preserved := []string{
|
||||
"canvas:slots:channel:channel-1",
|
||||
"canvas:logical-model-route-catalog:version",
|
||||
"canvas:task:task-1",
|
||||
}
|
||||
for _, key := range append(append([]string{}, clearable...), preserved...) {
|
||||
if err := client.Set(ctx, key, "1", 0).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
before := svc.collectRedisPerformance(ctx)
|
||||
if !before.Configured || !before.Connected || before.Mode != "redis" || before.Keys != int64(len(clearable)+len(preserved)) {
|
||||
t.Fatalf("redis performance = %#v", before)
|
||||
}
|
||||
|
||||
result, err := svc.ClearAdminRuntimeCache(ctx, admin, AdminCacheClearRequest{Scope: "runtime"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.DeletedKeys != int64(len(clearable)) || len(result.Groups) != len(runtimeCacheDefinitions) {
|
||||
t.Fatalf("clear result = %#v", result)
|
||||
}
|
||||
if remaining, err := client.Exists(ctx, clearable...).Result(); err != nil || remaining != 0 {
|
||||
t.Fatalf("clearable keys remaining = %d, err = %v", remaining, err)
|
||||
}
|
||||
if remaining, err := client.Exists(ctx, preserved...).Result(); err != nil || remaining != int64(len(preserved)) {
|
||||
t.Fatalf("preserved keys remaining = %d, err = %v", remaining, err)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "system.runtime_cache.clear").Count(&auditCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("audit count = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func newSystemPerformanceTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "system-performance.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.AdminAuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
return &Service{
|
||||
repo: repository.New(db),
|
||||
dataDir: dataDir,
|
||||
coordinator: &runtimeCoordinator{instanceID: "local-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}},
|
||||
routeCatalogTTL: 30 * time.Second,
|
||||
routeCatalogMaxStale: 5 * time.Minute,
|
||||
routeHealthBlocked: map[string]time.Time{},
|
||||
}, db
|
||||
}
|
||||
|
||||
func startSystemPerformanceRedis(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
binary, err := exec.LookPath("redis-server")
|
||||
if err != nil {
|
||||
t.Skip("redis-server unavailable; Redis cache whitelist integration not run")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "canvas-performance-redis-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
socket := filepath.Join(dir, "r.sock")
|
||||
cmd := exec.Command(binary, "--port", "0", "--unixsocket", socket, "--unixsocketperm", "700", "--save", "", "--appendonly", "no", "--dir", dir)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
client := redis.NewClient(&redis.Options{Network: "unix", Addr: socket, MaxRetries: -1, ContextTimeoutEnabled: true})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for client.Ping(ctx).Err() != nil {
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("temporary Redis did not start")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func authStatus(err error) int {
|
||||
var authErr *AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
return authErr.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImageSizePresetsValidationAndRoundTrip(t *testing.T) {
|
||||
profile := DefaultImageCapabilityConfig("openai-image", "test")
|
||||
profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"1824x1024", "1920x1080"}, Default: "1824x1024", Presets: []ImageSizePreset{
|
||||
{Tier: "1k", Ratio: "16:9", Size: "1824x1024", Width: 1824, Height: 1024},
|
||||
{Tier: "2k", Ratio: "16:9", Size: "1920x1080", Width: 1920, Height: 1080},
|
||||
}}
|
||||
if err := validateImageCapabilityConfig(profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored ImageCapabilityConfig
|
||||
if err := json.Unmarshal(data, &restored); err != nil || len(restored.Size.Presets) != 2 || restored.Size.Presets[1].Size != "1920x1080" {
|
||||
t.Fatalf("presets did not survive JSON round trip: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(*ImageCapabilityConfig)
|
||||
want string
|
||||
}{
|
||||
{"invalid tier", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Tier = "8k" }, "档位"},
|
||||
{"zero ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "0:9" }, "比例"},
|
||||
{"mismatched ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "9:16" }, "不一致"},
|
||||
{"tiny size", func(p *ImageCapabilityConfig) {
|
||||
p.Size.Presets[0].Width = 16
|
||||
p.Size.Presets[0].Height = 16
|
||||
p.Size.Presets[0].Size = "16x16"
|
||||
}, "像素"},
|
||||
{"size mismatch", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Size = "1024x1024" }, "像素"},
|
||||
{"not supported", func(p *ImageCapabilityConfig) { p.Size.Values = []string{"1824x1024"} }, "支持值"},
|
||||
{"duplicate equivalent ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[1].Tier = "1k"; p.Size.Presets[1].Ratio = "32:18" }, "重复"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var p ImageCapabilityConfig
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.edit(&p)
|
||||
if err := validateImageCapabilityConfig(&p); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected %s, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) {
|
||||
profile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image")
|
||||
profile.Quality.Default = "1k"
|
||||
profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1", Presets: []ImageSizePreset{
|
||||
{Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024},
|
||||
{Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536},
|
||||
}}
|
||||
if err := validateImageCapabilityConfig(profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile.Quality.Default = "2k"
|
||||
if err := validateImageCapabilityConfig(profile); err == nil || !strings.Contains(err.Error(), "默认图片分辨率") {
|
||||
t.Fatalf("expected invalid default pair error, got %v", err)
|
||||
}
|
||||
profile.Quality.Default = "1k"
|
||||
input := canvasGenerationInput{Config: providerConfig{Size: "1:1", Quality: "2k"}}
|
||||
if err := validateImageTask(profile, input); err == nil || !strings.Contains(err.Error(), "当前分辨率") {
|
||||
t.Fatalf("expected pair validation error, got %v", err)
|
||||
}
|
||||
input.Config.Size = "16:9"
|
||||
if err := validateImageTask(profile, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile.Size.AllowCustom = true
|
||||
input.Config.Size = "1:1"
|
||||
if err := validateImageTask(profile, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,19 @@ type ImageReferenceConfig struct {
|
||||
}
|
||||
|
||||
type ImageSizeConfig struct {
|
||||
Parameter string `json:"parameter"`
|
||||
Values []string `json:"values"`
|
||||
Default string `json:"default"`
|
||||
AllowCustom bool `json:"allowCustom"`
|
||||
Parameter string `json:"parameter"`
|
||||
Values []string `json:"values"`
|
||||
Default string `json:"default"`
|
||||
AllowCustom bool `json:"allowCustom"`
|
||||
Presets []ImageSizePreset `json:"presets,omitempty"`
|
||||
}
|
||||
|
||||
type ImageSizePreset struct {
|
||||
Tier string `json:"tier"`
|
||||
Ratio string `json:"ratio"`
|
||||
Size string `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type ImageQualityConfig struct {
|
||||
@@ -494,6 +503,7 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error {
|
||||
switch value.Size.Parameter {
|
||||
case "none":
|
||||
value.Size.Values = []string{}
|
||||
value.Size.Presets = nil
|
||||
value.Size.Default = "auto"
|
||||
value.Size.AllowCustom = false
|
||||
case "size", "aspect_ratio":
|
||||
@@ -506,6 +516,48 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error {
|
||||
default:
|
||||
return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio")
|
||||
}
|
||||
seenPresets := make(map[string]bool)
|
||||
for _, preset := range value.Size.Presets {
|
||||
if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" {
|
||||
return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K")
|
||||
}
|
||||
parts := strings.Split(preset.Ratio, ":")
|
||||
if len(parts) != 2 {
|
||||
return BadAuthRequest("图片预设比例格式无效")
|
||||
}
|
||||
w, ew := strconv.Atoi(parts[0])
|
||||
h, eh := strconv.Atoi(parts[1])
|
||||
if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 {
|
||||
return BadAuthRequest("图片预设比例无效")
|
||||
}
|
||||
if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) {
|
||||
return BadAuthRequest("图片预设像素尺寸无效")
|
||||
}
|
||||
// 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。
|
||||
difference := preset.Width*h - preset.Height*w
|
||||
if difference < 0 {
|
||||
difference = -difference
|
||||
}
|
||||
if difference*1000 > preset.Height*w*25 {
|
||||
return BadAuthRequest("图片预设像素尺寸与宽高比不一致")
|
||||
}
|
||||
a, b := w, h
|
||||
for b != 0 {
|
||||
a, b = b, a%b
|
||||
}
|
||||
key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a)
|
||||
if seenPresets[key] {
|
||||
return BadAuthRequest("图片尺寸预设重复")
|
||||
}
|
||||
seenPresets[key] = true
|
||||
requestValue := preset.Size
|
||||
if value.Size.Parameter == "aspect_ratio" {
|
||||
requestValue = preset.Ratio
|
||||
}
|
||||
if !containsCapabilityString(value.Size.Values, requestValue) {
|
||||
return BadAuthRequest("图片预设必须包含在尺寸支持值中")
|
||||
}
|
||||
}
|
||||
if value.Quality.Supported {
|
||||
if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) {
|
||||
return BadAuthRequest("请配置图片质量支持值和默认值")
|
||||
@@ -514,6 +566,9 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error {
|
||||
value.Quality.Values = []string{}
|
||||
value.Quality.Default = "auto"
|
||||
}
|
||||
if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil {
|
||||
return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中")
|
||||
}
|
||||
if !value.TransparentBackground.Supported {
|
||||
value.TransparentBackground.Default = false
|
||||
}
|
||||
@@ -770,6 +825,9 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
|
||||
if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) {
|
||||
return BadAuthRequest("图片质量不在当前模型支持范围内")
|
||||
}
|
||||
if err := validateImagePresetSelection(profile, firstNonEmpty(input.Config.Quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
|
||||
if err == nil && count > profile.MaxOutputs {
|
||||
return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs))
|
||||
@@ -777,6 +835,35 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error {
|
||||
if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" {
|
||||
return nil
|
||||
}
|
||||
tier := imageResolutionTier(quality)
|
||||
if tier == "" {
|
||||
return nil
|
||||
}
|
||||
for _, preset := range profile.Size.Presets {
|
||||
if preset.Tier == tier && preset.Ratio == ratio {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return BadAuthRequest("当前分辨率不支持所选图片宽高比")
|
||||
}
|
||||
|
||||
func imageResolutionTier(quality string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(quality)) {
|
||||
case "1k", "low":
|
||||
return "1k"
|
||||
case "2k", "medium":
|
||||
return "2k"
|
||||
case "4k", "high":
|
||||
return "4k"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func validateWorkflowProviderPromptLength(input canvasGenerationInput) error {
|
||||
profile := input.Config.CapabilityConfig
|
||||
if input.Mode != "video" || profile == nil || profile.Video == nil {
|
||||
|
||||
@@ -139,9 +139,15 @@ func (c *boundedReadCache[K, V]) remove(e *readCacheEntry[K, V]) {
|
||||
}
|
||||
|
||||
func (c *boundedReadCache[K, V]) clear() {
|
||||
c.clearAndCount()
|
||||
}
|
||||
|
||||
func (c *boundedReadCache[K, V]) clearAndCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
count := len(c.entries)
|
||||
c.entries = make(map[K]*readCacheEntry[K, V])
|
||||
c.lru.Init()
|
||||
c.bytes = 0
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build darwin
|
||||
|
||||
package service
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func platformMemory() (uint64, uint64, bool) {
|
||||
total, err := unix.SysctlUint64("hw.memsize")
|
||||
if err != nil || total == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// hw.memsize 只能稳定提供物理内存总量;没有可靠可用量时不要把 available=0
|
||||
// 误算成 100% 占用,页面会明确降级展示 Go 进程堆内存。
|
||||
return total, 0, false
|
||||
}
|
||||
|
||||
func platformLoadAverage() ([3]float64, bool) {
|
||||
return [3]float64{}, false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build linux
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func platformMemory() (uint64, uint64, bool) {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
values := map[string]uint64{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
value, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
values[strings.TrimSuffix(fields[0], ":")] = value * 1024
|
||||
}
|
||||
total, available := values["MemTotal"], values["MemAvailable"]
|
||||
return total, available, total > 0
|
||||
}
|
||||
|
||||
func platformLoadAverage() ([3]float64, bool) {
|
||||
var result [3]float64
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return result, false
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return result, false
|
||||
}
|
||||
for index := range 3 {
|
||||
result[index], _ = strconv.ParseFloat(fields[index], 64)
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux && !darwin
|
||||
|
||||
package service
|
||||
|
||||
func platformMemory() (uint64, uint64, bool) { return 0, 0, false }
|
||||
func platformLoadAverage() ([3]float64, bool) { return [3]float64{}, false }
|
||||
func collectSystemDisk(string) SystemPerformanceDisk { return SystemPerformanceDisk{} }
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func collectSystemDisk(path string) SystemPerformanceDisk {
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
path, _ = filepath.Abs(path)
|
||||
var stats syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stats); err != nil {
|
||||
return SystemPerformanceDisk{}
|
||||
}
|
||||
blockSize := uint64(stats.Bsize)
|
||||
total := stats.Blocks * blockSize
|
||||
free := stats.Bavail * blockSize
|
||||
used := total - stats.Bfree*blockSize
|
||||
usage := float64(0)
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
writable := false
|
||||
if file, err := os.CreateTemp(path, ".system-performance-write-check-"); err == nil {
|
||||
writable = true
|
||||
name := file.Name()
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
}
|
||||
return SystemPerformanceDisk{Available: true, Writable: writable, TotalBytes: total, UsedBytes: used, FreeBytes: free, UsagePercent: usage}
|
||||
}
|
||||
Reference in new issue
Block a user