后端新增 /logs?kw=:命中提示词/模型/错误,或命中用户 (名称/邮箱/ID) 的记录均保留;前端两个搜索框合并为一个。 首页导航恢复 ✦ 品牌小星标,与其他页一致。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
744 lines
23 KiB
Go
744 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"backend/internal/config"
|
|
"backend/internal/model"
|
|
"backend/internal/repo"
|
|
"backend/internal/storage"
|
|
)
|
|
|
|
type AdminReadService struct {
|
|
cfg *config.Config
|
|
users *repo.UserRepository
|
|
models *repo.ModelRepository
|
|
events *repo.EventRepository
|
|
settings *repo.SiteSettingRepository
|
|
channels *repo.ChannelRepository
|
|
cdks *repo.CDKRepository
|
|
store *storage.Client
|
|
showcase *repo.ShowcaseRepository
|
|
}
|
|
|
|
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, channels *repo.ChannelRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
|
return &AdminReadService{
|
|
cfg: cfg,
|
|
users: users,
|
|
models: models,
|
|
events: events,
|
|
settings: settings,
|
|
channels: channels,
|
|
cdks: cdks,
|
|
store: store,
|
|
showcase: showcase,
|
|
}
|
|
}
|
|
|
|
// showcaseFileList returns the homepage showcase image keys (no leading slash).
|
|
// User-facing galleries and the admin image manager hide these files — they
|
|
// belong to the public landing page, not to anyone's personal works.
|
|
func (s *AdminReadService) showcaseFileList(ctx context.Context) []string {
|
|
if s.showcase == nil {
|
|
return nil
|
|
}
|
|
set, err := s.showcase.PublicFileSet(ctx)
|
|
if err != nil || len(set) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(set))
|
|
for k := range set {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]any, error) {
|
|
users, err := s.users.List(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
stats, err := s.users.Stats(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// Per-user generation count now comes from the persistent users.generation_count
|
|
// column (set in the handler from each user object), not a log COUNT.
|
|
return users, stats, nil
|
|
}
|
|
|
|
func (s *AdminReadService) Models(ctx context.Context) ([]model.ModelConfig, error) {
|
|
return s.models.List(ctx)
|
|
}
|
|
|
|
func (s *AdminReadService) ModelNameMap(ctx context.Context) (map[string]string, error) {
|
|
return s.models.NameMap(ctx)
|
|
}
|
|
|
|
func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, error) {
|
|
items, err := s.models.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
routable, _ := s.channels.RoutableModelIDs(ctx)
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
_, bound := routable[item.ID]
|
|
out = append(out, map[string]any{
|
|
"id": item.ID,
|
|
"alias": item.Alias,
|
|
"type": item.Type,
|
|
"name": item.Name,
|
|
"provider": item.Provider,
|
|
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
|
|
"sellable": bound && ModelFullyPriced(&item),
|
|
"ratios": repo.JSONStrings(item.Ratios),
|
|
"prices": map[string]any(item.Prices),
|
|
"resolutions": repo.JSONStrings(item.Resolutions),
|
|
"image_to_image": item.ImageToImage,
|
|
"duration_prices": map[string]any(item.DurationPrices),
|
|
"prices_agent": map[string]any(item.PricesAgent),
|
|
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
|
"durations": repo.JSONStrings(item.Durations),
|
|
"max_reference_images": item.MaxReferenceImages,
|
|
"max_reference_videos": item.MaxReferenceVideos,
|
|
"max_reference_audios": item.MaxReferenceAudios,
|
|
"duration_min": item.DurationMin,
|
|
"duration_max": item.DurationMax,
|
|
"max_ref_video_seconds": item.MaxRefVideoSeconds,
|
|
"max_ref_audio_seconds": item.MaxRefAudioSeconds,
|
|
"reference_mode": item.ReferenceMode,
|
|
"weight": item.Weight,
|
|
"generation_count": item.GenerationCount,
|
|
"created_at": item.CreatedAt,
|
|
"updated_at": item.UpdatedAt,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
|
return s.logs(ctx, limit, offset, kind, status, statuses, since, userID, userIDs, nil, query, excludeSource, source, hasFile, excludeShowcase, mediaOnly)
|
|
}
|
|
|
|
// LogsKeyword 单搜索框:keyword 命中 提示词/模型/错误,或 user_id 在 kwUserIDs 内(名称/邮箱/ID 命中)即保留。
|
|
func (s *AdminReadService) LogsKeyword(ctx context.Context, limit, offset int, kind, status string, since *time.Time, keyword string, kwUserIDs []string, source string) ([]model.EventLog, int64, *repo.EventStats, error) {
|
|
return s.logs(ctx, limit, offset, kind, status, nil, since, "", nil, kwUserIDs, keyword, "", source, false, false, false)
|
|
}
|
|
|
|
func (s *AdminReadService) logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs, kwUserIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
|
var excludeFiles []string
|
|
if excludeShowcase {
|
|
excludeFiles = s.showcaseFileList(ctx)
|
|
}
|
|
items, total, err := s.events.List(ctx, repo.EventListFilter{
|
|
Limit: limit,
|
|
Offset: offset,
|
|
Kind: kind,
|
|
Status: status,
|
|
Statuses: statuses,
|
|
Since: since,
|
|
UserID: userID,
|
|
UserIDs: userIDs,
|
|
Query: query,
|
|
KeywordUserIDs: kwUserIDs,
|
|
ExcludeSource: excludeSource,
|
|
Source: source,
|
|
HasFile: hasFile,
|
|
ExcludeFiles: excludeFiles,
|
|
MediaOnly: mediaOnly,
|
|
})
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
// 用户自己的日志(userID 非空)→ 按本人统计;管理员全站视图 → 全站统计。
|
|
var stats *repo.EventStats
|
|
if userID != "" {
|
|
stats, err = s.events.StatsByUser(ctx, userID)
|
|
} else {
|
|
stats, err = s.events.Stats(ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
return items, total, stats, nil
|
|
}
|
|
|
|
// MatchUserIDs resolves an admin 用户搜索 term to the set of user ids whose
|
|
// name, email or id contains the term (case-insensitive). Returns a non-nil,
|
|
// possibly empty slice — an empty slice means "no user matched" and the caller
|
|
// should return zero rows rather than dropping the filter.
|
|
func (s *AdminReadService) MatchUserIDs(ctx context.Context, term string) ([]string, error) {
|
|
term = strings.ToLower(strings.TrimSpace(term))
|
|
if term == "" {
|
|
return nil, nil
|
|
}
|
|
users, err := s.users.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []string{}
|
|
for _, u := range users {
|
|
if strings.Contains(strings.ToLower(u.Name), term) ||
|
|
strings.Contains(strings.ToLower(u.Email), term) ||
|
|
strings.Contains(strings.ToLower(u.ID), term) {
|
|
out = append(out, u.ID)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UserNameMap builds an id -> display name lookup (name, else email, else id)
|
|
// used to annotate admin log rows with user_name (mirrors admin.py:584-596).
|
|
func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string, error) {
|
|
users, err := s.users.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]string, len(users))
|
|
for _, u := range users {
|
|
name := strings.TrimSpace(u.Name)
|
|
if name == "" {
|
|
name = strings.TrimSpace(u.Email)
|
|
}
|
|
if name == "" {
|
|
name = u.ID
|
|
}
|
|
out[u.ID] = name
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AccountNameMap builds a channel id -> display name lookup used to annotate
|
|
// log rows with which channel fulfilled each generation (event_logs.account_id
|
|
// now stores the channel id; account_email carries a denormalized name stamp).
|
|
func (s *AdminReadService) AccountNameMap(ctx context.Context) (map[string]string, error) {
|
|
if s.channels == nil {
|
|
return map[string]string{}, nil
|
|
}
|
|
channels, err := s.channels.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]string, len(channels))
|
|
for _, ch := range channels {
|
|
label := strings.TrimSpace(ch.Name)
|
|
if label == "" {
|
|
label = ch.ID
|
|
}
|
|
out[ch.ID] = label
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) {
|
|
stats, err := s.events.Stats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files, _, _ := s.scanGeneratedFiles(ctx)
|
|
// Exclude homepage showcase media so 已生成作品 matches the 作品管理 total.
|
|
pinned := map[string]struct{}{}
|
|
if s.showcase != nil {
|
|
if set, perr := s.showcase.PublicFileSet(ctx); perr == nil {
|
|
pinned = set
|
|
}
|
|
}
|
|
var count int
|
|
var size int64
|
|
for _, f := range files {
|
|
if _, ok := pinned[strings.TrimLeft(f.Name, "/")]; ok {
|
|
continue
|
|
}
|
|
count++
|
|
size += f.Size
|
|
}
|
|
// Aggregate-only: `recent` (owner dirs + filenames) is served via
|
|
// StatsRecent behind an admin session — it must not leak publicly.
|
|
return map[string]any{
|
|
"generated_count": count,
|
|
"generated_size_bytes": size,
|
|
"avg_elapsed_ms": stats.AvgElapsedMS,
|
|
"avg_elapsed_ms_24h": stats.AvgElapsedMS24,
|
|
}, nil
|
|
}
|
|
|
|
// StatsRecent backs the admin image picker; the route lives in the
|
|
// RequireAdminSession group because entries carry per-user owner names.
|
|
func (s *AdminReadService) StatsRecent(ctx context.Context) (map[string]any, error) {
|
|
recentFiles, err := s.RecentImages(ctx, 24)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"recent": recentFiles}, nil
|
|
}
|
|
|
|
// Dashboard assembles the admin overview's analytics entirely server-side
|
|
// (event windows, hourly trend, top models/failures/spenders) plus CDK / invite
|
|
// / checkin summaries. This replaces the old client-side math over the last 200
|
|
// logs, which silently undercounted week/DAU/trend once volume grew.
|
|
func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error) {
|
|
now := time.Now()
|
|
dayCut := now.Add(-24 * time.Hour)
|
|
weekCut := now.Add(-3 * 24 * time.Hour) // "week" key = last 3 days (per admin request)
|
|
// "today" = since local midnight (Asia/Shanghai, the app's business tz), not a
|
|
// rolling 24h — so 今日消耗/生成 resets at 00:00 the way the admin expects.
|
|
loc, lerr := time.LoadLocation("Asia/Shanghai")
|
|
if lerr != nil {
|
|
loc = time.Local
|
|
}
|
|
nowLocal := now.In(loc)
|
|
todayCut := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc)
|
|
|
|
today, err := s.events.WindowStats(ctx, todayCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
todayDau, err := s.events.DistinctUsersSince(ctx, todayCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
day, err := s.events.WindowStats(ctx, dayCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
week, err := s.events.WindowStats(ctx, weekCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prevDay, err := s.events.CountBetween(ctx, now.Add(-48*time.Hour), dayCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dau, err := s.events.DistinctUsersSince(ctx, dayCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wau, err := s.events.DistinctUsersSince(ctx, weekCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hourly, err := s.events.HourlyBuckets(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// All-time persistent counters (total/success/failed/image/video/api) — these
|
|
// survive log retention/clearing, unlike the windowed day/week stats.
|
|
lifetime, err := s.events.Counters(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Per-window top-N analytics so the frontend can toggle 24h / 7d without a
|
|
// re-fetch (the lists are small — top 6 / top 5).
|
|
nameByID, err := s.UserNameMap(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
analytics := func(since time.Time) (map[string]any, error) {
|
|
models, err := s.events.ModelUsageSince(ctx, since, 6)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
failures, err := s.events.TopFailures(ctx, since, 5)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users, err := s.events.TopUserSpend(ctx, since, 6)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range users {
|
|
if users[i].UserID == "" {
|
|
users[i].Name = "匿名"
|
|
} else if name, ok := nameByID[users[i].UserID]; ok {
|
|
users[i].Name = name
|
|
} else {
|
|
users[i].Name = users[i].UserID
|
|
}
|
|
}
|
|
return map[string]any{"models": models, "failures": failures, "top_users": users}, nil
|
|
}
|
|
dayAnalytics, err := analytics(dayCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
weekAnalytics, err := analytics(weekCut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cdkStats, err := s.cdks.Stats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inviteReward := parseIntSetting(s.mustSetting(ctx, "credits.invite_reward"), 3)
|
|
inviteSummary, err := s.users.InviteSummary(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
checkinReward := parseIntSetting(s.mustSetting(ctx, "credits.checkin_reward"), 3)
|
|
checkin, err := s.users.CheckinStats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return map[string]any{
|
|
"today": today,
|
|
"today_dau": todayDau,
|
|
"day": day,
|
|
"week": week,
|
|
"prev_day_total": prevDay,
|
|
"dau": dau,
|
|
"wau": wau,
|
|
"hourly": hourly,
|
|
"lifetime": lifetime,
|
|
"analytics": map[string]any{
|
|
"day": dayAnalytics,
|
|
"week": weekAnalytics,
|
|
},
|
|
"cdk": cdkStats,
|
|
"invites": map[string]any{
|
|
"total": inviteSummary.Total,
|
|
"completed": inviteSummary.Completed,
|
|
"reward": inviteReward,
|
|
"reward_paid": inviteSummary.Completed * int64(inviteReward),
|
|
},
|
|
"checkin": map[string]any{
|
|
"today": checkin.TodayCount,
|
|
"max_streak": checkin.MaxStreak,
|
|
"reward": checkinReward,
|
|
"awarded_today": checkin.TodayCount * int64(checkinReward),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// mustSetting reads a site setting value, returning "" on error so the caller's
|
|
// parseIntSetting default kicks in (a missing reward setting shouldn't 500 the
|
|
// whole dashboard).
|
|
func (s *AdminReadService) mustSetting(ctx context.Context, key string) string {
|
|
v, err := s.settings.GetValue(ctx, key)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *repo.InviteLogStats, error) {
|
|
rewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
reward := parseIntSetting(rewardRaw, 3)
|
|
return s.users.AllInvites(ctx, reward)
|
|
}
|
|
|
|
// Providers is now a channel summary (the account pools are gone): one row per
|
|
// channel with its bound-model count and local usage stats.
|
|
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
|
|
if s.channels == nil {
|
|
return []map[string]any{}, nil
|
|
}
|
|
channels, err := s.channels.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]map[string]any, 0, len(channels))
|
|
for _, ch := range channels {
|
|
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, ch.ID)
|
|
out = append(out, map[string]any{
|
|
"id": ch.ID,
|
|
"name": ch.Name,
|
|
"base_url": ch.BaseURL,
|
|
"enabled": ch.Enabled,
|
|
"concurrency": ch.Concurrency,
|
|
"weight": ch.Weight,
|
|
"model_count": len(modelIDs),
|
|
"success_total": ch.SuccessTotal,
|
|
"used_credits": ch.UsedCredits,
|
|
"type": "vivid",
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *AdminReadService) Images(ctx context.Context, limit, offset int, kind string) ([]map[string]any, int, map[string]any, error) {
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
allFiles, _, err := s.scanGeneratedFiles(ctx)
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
// Homepage showcase media never shows in the image manager — it's public
|
|
// landing-page content, managed on the 首页内容 page instead.
|
|
pinned := map[string]struct{}{}
|
|
if s.showcase != nil {
|
|
if set, perr := s.showcase.PublicFileSet(ctx); perr == nil {
|
|
pinned = set
|
|
}
|
|
}
|
|
// KPI stats must reflect what the manager actually shows, so recompute them
|
|
// after dropping showcase files (independent of the kind tab filter).
|
|
stats := map[string]any{"total": 0, "image": 0, "video": 0, "size_bytes": int64(0)}
|
|
filtered := make([]generatedFile, 0, len(allFiles))
|
|
for _, item := range allFiles {
|
|
if _, ok := pinned[strings.TrimLeft(item.Name, "/")]; ok {
|
|
continue
|
|
}
|
|
if _, ok := stats[item.Kind]; ok {
|
|
stats[item.Kind] = stats[item.Kind].(int) + 1
|
|
}
|
|
stats["total"] = stats["total"].(int) + 1
|
|
stats["size_bytes"] = stats["size_bytes"].(int64) + item.Size
|
|
if kind == "" || item.Kind == kind {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
sort.SliceStable(filtered, func(i, j int) bool {
|
|
return filtered[i].MTime > filtered[j].MTime
|
|
})
|
|
total := len(filtered)
|
|
if offset > total {
|
|
offset = total
|
|
}
|
|
end := offset + limit
|
|
if end > total {
|
|
end = total
|
|
}
|
|
page := filtered[offset:end]
|
|
index, err := s.eventIndexByFile(ctx)
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
nameMap, err := s.models.NameMap(ctx)
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
out := make([]map[string]any, 0, len(page))
|
|
for _, item := range page {
|
|
row := map[string]any{
|
|
"name": item.Name,
|
|
"size": item.Size,
|
|
"mtime": item.MTime,
|
|
"kind": item.Kind,
|
|
"prompt": "",
|
|
"model": "",
|
|
"resolution": "",
|
|
"ratio": "",
|
|
"duration": "",
|
|
"deai": false,
|
|
}
|
|
if event, ok := index[item.Name]; ok {
|
|
row["prompt"] = event.Prompt
|
|
if eff, ok := nameMap[event.Model]; ok && eff != "" {
|
|
row["model"] = eff
|
|
} else {
|
|
row["model"] = event.Model
|
|
}
|
|
row["resolution"] = event.Resolution
|
|
row["ratio"] = event.Ratio
|
|
row["duration"] = event.Duration
|
|
row["deai"] = event.DeAI
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out, total, stats, nil
|
|
}
|
|
|
|
func (s *AdminReadService) RecentImages(ctx context.Context, limit int) ([]map[string]any, error) {
|
|
if limit <= 0 {
|
|
limit = 24
|
|
}
|
|
allFiles, _, err := s.scanGeneratedFiles(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(allFiles, func(i, j int) bool {
|
|
return allFiles[i].MTime > allFiles[j].MTime
|
|
})
|
|
if len(allFiles) > limit {
|
|
allFiles = allFiles[:limit]
|
|
}
|
|
out := make([]map[string]any, 0, len(allFiles))
|
|
for _, item := range allFiles {
|
|
out = append(out, map[string]any{
|
|
"name": item.Name,
|
|
"size": item.Size,
|
|
"mtime": item.MTime,
|
|
"kind": item.Kind,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// RecentImagesOwned lists the most-recent generated images under a single owner
|
|
// directory (used by the showcase picker so an admin sees only their OWN images).
|
|
func (s *AdminReadService) RecentImagesOwned(ctx context.Context, owner string, limit int) ([]map[string]any, error) {
|
|
if limit <= 0 {
|
|
limit = 24
|
|
}
|
|
owner = strings.TrimSpace(owner)
|
|
if owner == "" || s.store == nil || !s.store.Configured() {
|
|
return []map[string]any{}, nil
|
|
}
|
|
objs, err := s.store.List(ctx, owner+"/")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files := make([]generatedFile, 0, len(objs))
|
|
for _, o := range objs {
|
|
if isReferenceFile(o.Key) {
|
|
continue
|
|
}
|
|
kind := mediaKind(o.Key)
|
|
if kind == "" {
|
|
continue
|
|
}
|
|
files = append(files, generatedFile{Name: o.Key, Size: o.Size, MTime: o.LastModified.Unix(), Kind: kind})
|
|
}
|
|
sort.SliceStable(files, func(i, j int) bool { return files[i].MTime > files[j].MTime })
|
|
if len(files) > limit {
|
|
files = files[:limit]
|
|
}
|
|
out := make([]map[string]any, 0, len(files))
|
|
for _, f := range files {
|
|
out = append(out, map[string]any{"name": f.Name, "size": f.Size, "mtime": f.MTime, "kind": f.Kind})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteOwnedFile removes a generated media object (and its thumbnail) that
|
|
// lives under the given owner directory, then blanks the file reference on the
|
|
// matching log rows so galleries and the 画图台 grid stop showing it. The owner
|
|
// prefix check keeps a user from deleting anyone else's files.
|
|
func (s *AdminReadService) DeleteOwnedFile(ctx context.Context, owner, rel string) error {
|
|
owner = strings.TrimSpace(owner)
|
|
rel = strings.TrimLeft(strings.TrimSpace(rel), "/")
|
|
if owner == "" {
|
|
return errors.New("invalid file")
|
|
}
|
|
if !strings.HasPrefix(rel, owner+"/") {
|
|
return errors.New("file not owned by caller")
|
|
}
|
|
return s.DeleteFile(ctx, rel)
|
|
}
|
|
|
|
// DeleteFile removes any generated media object (and its derived stills) and
|
|
// blanks the log rows referencing it. Admin 图片管理 delete — no owner check.
|
|
func (s *AdminReadService) DeleteFile(ctx context.Context, rel string) error {
|
|
rel = strings.TrimLeft(strings.TrimSpace(rel), "/")
|
|
if rel == "" || strings.Contains(rel, "..") {
|
|
return errors.New("invalid file")
|
|
}
|
|
if s.store == nil || !s.store.Configured() {
|
|
return errors.New("storage not configured")
|
|
}
|
|
if err := s.store.Delete(ctx, rel); err != nil {
|
|
return err
|
|
}
|
|
// Best-effort derived stills; old files may not have them.
|
|
_ = s.store.Delete(ctx, ThumbKey(rel))
|
|
_ = s.store.Delete(ctx, LastFrameKey(rel))
|
|
if _, err := s.events.ClearFiles(ctx, []string{rel}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminReadService) eventIndexByFile(ctx context.Context) (map[string]model.EventLog, error) {
|
|
items, err := s.events.RecentByFile(ctx, 10000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]model.EventLog, len(items))
|
|
for _, item := range items {
|
|
if item.File == "" {
|
|
continue
|
|
}
|
|
if _, ok := out[item.File]; ok {
|
|
continue
|
|
}
|
|
out[item.File] = item
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type generatedFile struct {
|
|
Name string
|
|
Size int64
|
|
MTime int64
|
|
Kind string
|
|
}
|
|
|
|
// mediaKind classifies an object key by extension (image / video / "" = skip).
|
|
func mediaKind(name string) string {
|
|
i := strings.LastIndex(name, ".")
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
switch strings.ToLower(name[i+1:]) {
|
|
case "png", "jpg", "jpeg", "webp", "gif":
|
|
return "image"
|
|
case "mp4", "webm", "mov":
|
|
return "video"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// isReferenceFile reports whether a key is an uploaded reference image (named
|
|
// "...-ref-..."), so the gallery / picker can skip them — only generated outputs
|
|
// are listed.
|
|
func isReferenceFile(name string) bool {
|
|
return strings.Contains(name, "-ref-")
|
|
}
|
|
|
|
// scanGeneratedFiles lists media objects from RustFS (replacing the old local
|
|
// directory walk). Keys ARE the relative paths the rest of the app expects.
|
|
func (s *AdminReadService) scanGeneratedFiles(ctx context.Context) ([]generatedFile, map[string]any, error) {
|
|
stats := map[string]any{"total": 0, "image": 0, "video": 0, "size_bytes": int64(0)}
|
|
if s.store == nil || !s.store.Configured() {
|
|
return nil, stats, nil
|
|
}
|
|
objs, err := s.store.List(ctx, "")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
out := make([]generatedFile, 0, len(objs))
|
|
for _, o := range objs {
|
|
if isReferenceFile(o.Key) {
|
|
continue // reference uploads are not generated outputs — hide from gallery
|
|
}
|
|
if IsThumbKey(o.Key) || IsLastFrameKey(o.Key) {
|
|
continue // thumbnails / last-frame stills are derived — only originals are listed
|
|
}
|
|
kind := mediaKind(o.Key)
|
|
if kind == "" {
|
|
continue
|
|
}
|
|
stats[kind] = stats[kind].(int) + 1
|
|
stats["total"] = stats["total"].(int) + 1
|
|
stats["size_bytes"] = stats["size_bytes"].(int64) + o.Size
|
|
out = append(out, generatedFile{
|
|
Name: o.Key,
|
|
Size: o.Size,
|
|
MTime: o.LastModified.Unix(),
|
|
Kind: kind,
|
|
})
|
|
}
|
|
return out, stats, nil
|
|
}
|