package service import ( "context" "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 tokens *repo.TokenRepository cdks *repo.CDKRepository store *storage.Client } func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client) *AdminReadService { return &AdminReadService{ cfg: cfg, users: users, models: models, events: events, settings: settings, tokens: tokens, cdks: cdks, store: store, } } 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 } counts, err := s.events.UserSuccessCounts(ctx) if err != nil { return nil, nil, err } for i := range users { meta := users[i].Notes _ = meta } stats, err := s.users.Stats(ctx) if err != nil { return nil, nil, err } stats["generation_counts"] = counts return users, stats, nil } func (s *AdminReadService) Models(ctx context.Context) ([]model.ModelConfig, error) { return s.models.List(ctx) } func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, error) { items, err := s.models.List(ctx) if err != nil { return nil, err } counts, err := s.events.ModelSuccessCounts(ctx) if err != nil { return nil, err } out := make([]map[string]any, 0, len(items)) for _, item := range items { out = append(out, map[string]any{ "id": item.ID, "type": item.Type, "name": item.Name, "provider": item.Provider, "enabled": item.Enabled, "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, "reference_mode": item.ReferenceMode, "weight": item.Weight, "generation_count": counts[item.ID], "created_at": item.CreatedAt, "updated_at": item.UpdatedAt, }) } return out, nil } func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, since *time.Time, userID, excludeSource, source string, hasFile bool) ([]model.EventLog, int64, *repo.EventStats, error) { items, total, err := s.events.List(ctx, repo.EventListFilter{ Limit: limit, Offset: offset, Kind: kind, Status: status, Since: since, UserID: userID, ExcludeSource: excludeSource, Source: source, HasFile: hasFile, }) 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 } // 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 } func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) { stats, err := s.events.Stats(ctx) if err != nil { return nil, err } recentFiles, _ := s.RecentImages(ctx, 24) files, fileStats, _ := s.scanGeneratedFiles(ctx) var size int64 if v, ok := fileStats["size_bytes"].(int64); ok { size = v } return map[string]any{ "generated_count": len(files), "generated_size_bytes": size, "recent": recentFiles, "avg_elapsed_ms": stats.AvgElapsedMS, "avg_elapsed_ms_24h": stats.AvgElapsedMS24, }, 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(-7 * 24 * time.Hour) 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 } // 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{ "day": day, "week": week, "prev_day_total": prevDay, "dau": dau, "wau": wau, "hourly": hourly, "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) } func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) { models, err := s.models.List(ctx) if err != nil { return nil, err } tokens, err := s.tokens.List(ctx) if err != nil { return nil, err } modelCounts := map[string]int{} for _, item := range models { modelCounts[item.Provider]++ } type aggregate struct { active int disabled int quota int } tokenCounts := map[string]*aggregate{} for _, item := range tokens { if _, ok := tokenCounts[item.Pool]; !ok { tokenCounts[item.Pool] = &aggregate{} } switch item.Status { case "active": tokenCounts[item.Pool].active++ case "quota": tokenCounts[item.Pool].quota++ default: tokenCounts[item.Pool].disabled++ } } providers := []struct { Name string Pool string Type string }{ {Name: "chatgpt", Pool: "chatgpt", Type: "openai"}, {Name: "adobe", Pool: "adobe", Type: "adobe"}, } out := make([]map[string]any, 0, len(providers)) for _, item := range providers { count := tokenCounts[item.Pool] if count == nil { count = &aggregate{} } out = append(out, map[string]any{ "name": item.Name, "token_pool": item.Pool, "type": item.Type, "model_count": modelCounts[item.Name], "tokens_total": count.active + count.disabled + count.quota, "tokens_active": count.active, "tokens_disabled": count.disabled, "tokens_quota": count.quota, }) } 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, stats, err := s.scanGeneratedFiles(ctx) if err != nil { return nil, 0, nil, err } filtered := make([]generatedFile, 0, len(allFiles)) for _, item := range allFiles { 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 } 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": "", } if event, ok := index[item.Name]; ok { row["prompt"] = event.Prompt row["model"] = event.Model row["resolution"] = event.Resolution row["ratio"] = event.Ratio row["duration"] = event.Duration } 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 } 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 } 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 }