diff --git a/backend/internal/bootstrap/seed.go b/backend/internal/bootstrap/seed.go index ba87a4c..0fc6bfb 100644 --- a/backend/internal/bootstrap/seed.go +++ b/backend/internal/bootstrap/seed.go @@ -10,6 +10,8 @@ import ( func seedDefaults(ctx context.Context, db *gorm.DB) error { defaults := []model.SiteSetting{ {Key: "site.title", Value: "Vivid"}, + {Key: "site.logo", Value: ""}, + {Key: "site.subtitle", Value: ""}, {Key: "contact.qq", Value: "1114639355"}, {Key: "contact.qq_link", Value: "https://qm.qq.com/q/ItgCcNA7ac"}, {Key: "contact.qq_group", Value: "1106849765"}, @@ -47,5 +49,35 @@ func seedDefaults(ctx context.Context, db *gorm.DB) error { return err } } + // One-time backfill of the persistent per-model generation counter from + // historical success logs, so the admin "次数" keeps its running total when we + // switch it off the (retention-pruned) event_log. Only touches models still at + // 0, so it never double-counts after the first run; increments take over next. + if err := db.WithContext(ctx).Exec( + `UPDATE model_configs m SET generation_count = COALESCE( + (SELECT COUNT(*) FROM event_logs e WHERE e.model = m.id AND e.status = 'success'), 0) + WHERE m.generation_count = 0`).Error; err != nil { + return err + } + // Same one-time backfill for the per-user generation counter. + if err := db.WithContext(ctx).Exec( + `UPDATE users u SET generation_count = COALESCE( + (SELECT COUNT(*) FROM event_logs e WHERE e.user_id = u.id AND e.status = 'success'), 0) + WHERE u.generation_count = 0`).Error; err != nil { + return err + } + // Seed the dashboard lifetime counters from logs ONCE (only when empty), so the + // all-time cards start from real history then track forward via the hooks. + var counterRows int64 + if err := db.WithContext(ctx).Model(&model.StatCounter{}).Count(&counterRows).Error; err == nil && counterRows == 0 { + _ = db.WithContext(ctx).Exec(`INSERT INTO stat_counters (key, value, updated_at) + SELECT 'total', COUNT(*), now() FROM event_logs + UNION ALL SELECT 'success', COUNT(*) FILTER (WHERE status='success'), now() FROM event_logs + UNION ALL SELECT 'failed', COUNT(*) FILTER (WHERE status='failed'), now() FROM event_logs + UNION ALL SELECT 'image', COUNT(*) FILTER (WHERE kind='image'), now() FROM event_logs + UNION ALL SELECT 'video', COUNT(*) FILTER (WHERE kind='video'), now() FROM event_logs + UNION ALL SELECT 'api', COUNT(*) FILTER (WHERE source='v1'), now() FROM event_logs + ON CONFLICT (key) DO NOTHING`).Error + } return nil } diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index 16761c2..2d7b3ad 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -26,16 +26,11 @@ func (h *AdminReadHandler) Users(c *gin.Context) { } out := make([]gin.H, 0, len(users)) - generationCounts := map[string]int64{} - if raw, ok := stats["generation_counts"].(map[string]int64); ok { - generationCounts = raw - } for _, user := range users { row := userPublic(user) - row["generation_count"] = generationCounts[user.ID] + row["generation_count"] = user.GenerationCount out = append(out, row) } - delete(stats, "generation_counts") c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats}) } @@ -61,7 +56,7 @@ func (h *AdminReadHandler) Logs(c *gin.Context) { } } - items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, since, "", "", c.Query("source"), false) + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", "", c.Query("source"), false) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) return diff --git a/backend/internal/http/handler/site.go b/backend/internal/http/handler/site.go index 9766f48..d9182a1 100644 --- a/backend/internal/http/handler/site.go +++ b/backend/internal/http/handler/site.go @@ -21,5 +21,11 @@ func (h *SiteHandler) Public(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site"}) return } - c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())}) + ctx := c.Request.Context() + c.JSON(http.StatusOK, gin.H{ + "title": title, + "logo": h.site.Logo(ctx), + "subtitle": h.site.Subtitle(ctx), + "contact": h.site.Contact(ctx), + }) } diff --git a/backend/internal/http/handler/site_settings.go b/backend/internal/http/handler/site_settings.go index eecbd98..0e579ec 100644 --- a/backend/internal/http/handler/site_settings.go +++ b/backend/internal/http/handler/site_settings.go @@ -17,18 +17,27 @@ func NewSiteSettingsHandler(site *service.SiteService) *SiteSettingsHandler { } func (h *SiteSettingsHandler) Get(c *gin.Context) { - title, err := h.site.Title(c.Request.Context()) + ctx := c.Request.Context() + title, err := h.site.Title(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site settings"}) return } - c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())}) + c.JSON(http.StatusOK, gin.H{ + "title": title, + "logo": h.site.Logo(ctx), + "subtitle": h.site.Subtitle(ctx), + "contact": h.site.Contact(ctx), + }) } func (h *SiteSettingsHandler) Put(c *gin.Context) { + ctx := c.Request.Context() var body struct { - Title string `json:"title"` - Contact service.Contact `json:"contact"` + Title string `json:"title"` + Logo string `json:"logo"` + Subtitle string `json:"subtitle"` + Contact service.Contact `json:"contact"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) @@ -39,14 +48,21 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"detail": "网页主标题不能为空"}) return } - updated, err := h.site.SetTitle(c.Request.Context(), title) + updated, err := h.site.SetTitle(ctx, title) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"}) return } - if err := h.site.SetContact(c.Request.Context(), body.Contact); err != nil { + if err := h.site.SetBranding(ctx, body.Logo, body.Subtitle); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save branding"}) + return + } + if err := h.site.SetContact(ctx, body.Contact); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save contact info"}) return } - c.JSON(http.StatusOK, gin.H{"ok": true, "data": gin.H{"title": updated, "contact": h.site.Contact(c.Request.Context())}}) + c.JSON(http.StatusOK, gin.H{"ok": true, "data": gin.H{ + "title": updated, "logo": h.site.Logo(ctx), "subtitle": h.site.Subtitle(ctx), + "contact": h.site.Contact(ctx), + }}) } diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 33b2379..390664b 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -3,6 +3,7 @@ package handler import ( "errors" "net/http" + "strings" "backend/internal/service" "github.com/gin-gonic/gin" @@ -176,6 +177,16 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) { offset := parseInt(c.Query("offset"), 0) kind := c.Query("kind") status := c.Query("status") + // statuses=pending,success → status IN (...). Used by the 画图台 grid so it can + // fetch exactly the rows it shows (进行中 + 成功) in one query, server-side. + var statuses []string + if s := strings.TrimSpace(c.Query("statuses")); s != "" { + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + statuses = append(statuses, p) + } + } + } // Secure-by-default: always scope to the caller's OWN records. This endpoint // serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal // records must NOT see other users' work. Only an admin who explicitly opts @@ -195,7 +206,7 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) { // rows with real media (success + stored file), not failed/pending events. hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true" - items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, userID, excludeSource, source, hasFile) + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, excludeSource, source, hasFile) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) return @@ -430,7 +441,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "type": "video", "ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"}, "resolutions": []string{"720p"}, - "durations": []string{"6s", "10s"}, + "durations": []string{"6s", "10s", "15s"}, "max_reference_images": 6, "reference_mode": "asset", "description": "Grok Imagine video (文/图生视频)", diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 4e5fa0b..c15fc6e 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -21,6 +21,7 @@ type User struct { InviteRewardAt *time.Time CheckinLast string `gorm:"size:32"` CheckinStreak int `gorm:"not null;default:0"` + GenerationCount int64 `gorm:"not null;default:0"` LastLoginAt *time.Time LastLoginIP string `gorm:"size:128"` CreatedAt time.Time @@ -111,6 +112,10 @@ type ModelConfig struct { // weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall // back to created_at desc. Default 0. Weight int `gorm:"not null;default:0;index"` + // GenerationCount is a persistent success counter, incremented once per + // successful generation. Independent of the event_log (which is subject to + // retention / manual clearing), so the admin "次数" is a true running total. + GenerationCount int64 `gorm:"not null;default:0"` CreatedAt time.Time UpdatedAt time.Time } @@ -197,5 +202,15 @@ func AutoMigrateModels() []any { &TokenAccount{}, &RefreshProfile{}, &SiteSetting{}, + &StatCounter{}, } } + +// StatCounter is a persistent monotonic counter (key → value), independent of the +// event_log (which is retention-pruned / clearable). Used for the dashboard +// cumulative cards (total/success/failed/image/video/api) so they never reset. +type StatCounter struct { + Key string `gorm:"primaryKey;size:64"` + Value int64 `gorm:"not null;default:0"` + UpdatedAt time.Time +} diff --git a/backend/internal/provider/custom/client.go b/backend/internal/provider/custom/client.go index 146f673..e4cdad5 100644 --- a/backend/internal/provider/custom/client.go +++ b/backend/internal/provider/custom/client.go @@ -15,6 +15,7 @@ import ( "io" "mime/multipart" "net/http" + "net/url" "strings" "time" ) @@ -29,6 +30,32 @@ type Client struct{} func NewClient() *Client { return &Client{} } +// sanitizeErr strips the upstream URL/host from a network error so a user's +// private upstream URL never leaks into the event log / API response. +func sanitizeErr(err error) string { + if err == nil { + return "" + } + s := err.Error() + switch { + case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"): + return "request timeout" + case strings.Contains(s, "connection refused"): + return "connection refused" + case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "): + return "cannot reach upstream" + case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"): + return "TLS error" + case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"): + return "connection reset" + } + var ue *url.Error + if errors.As(err, &ue) { + return strings.ToLower(ue.Op) + " upstream failed" + } + return "upstream request failed" +} + func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute} } // GenerateImage calls the upstream OpenAI image API. With reference images it @@ -82,7 +109,7 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) @@ -145,7 +172,7 @@ func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prom case "failed", "error", "canceled", "cancelled": reason := stringValue(job["error"]) if isCreditError(reason) { - return nil, "", fmt.Errorf("%w: %s", ErrQuotaExhausted, clip([]byte(reason), 160)) + return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160)) } return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160)) } @@ -171,7 +198,7 @@ func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body [] } resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) @@ -194,7 +221,7 @@ func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, erro req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -234,7 +261,7 @@ func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) { req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil) resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() return io.ReadAll(resp.Body) @@ -249,12 +276,14 @@ func mapStatus(status int, body []byte) error { case status == 401 || status == 403: return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160)) case status == 429: - return fmt.Errorf("%w: 429 %s", ErrQuotaExhausted, clip(body, 160)) + // Custom upstreams have NO "quota exhausted" lock — a 429 is just rate + // limiting, treated as a temporary error (fail over, account stays active). + return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160)) case status >= 500: return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160)) default: if isCreditError(string(body)) { - return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(body, 160)) + return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160)) } return fmt.Errorf("custom: %d %s", status, clip(body, 160)) } diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index 3ed89b2..963a356 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -44,7 +44,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio, if strings.TrimSpace(resolution) == "" { resolution = "720p" } - if seconds != 6 && seconds != 10 { + if seconds != 6 && seconds != 10 && seconds != 15 { seconds = 10 } diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go index 0ec5090..036460c 100644 --- a/backend/internal/repo/event_repo.go +++ b/backend/internal/repo/event_repo.go @@ -18,7 +18,8 @@ type EventListFilter struct { Limit int Offset int Kind string - Status string + Status string // single status (status = ?) + Statuses []string // multiple statuses (status IN (?)) — used by the 画图台 grid Since *time.Time UserID string ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page) @@ -47,6 +48,9 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m if filter.Status != "" { q = q.Where("status = ?", filter.Status) } + if len(filter.Statuses) > 0 { + q = q.Where("status IN ?", filter.Statuses) + } if filter.Since != nil { q = q.Where("ts > ?", *filter.Since) } @@ -443,7 +447,49 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge time.Duration) } func (r *EventRepository) Create(ctx context.Context, item *model.EventLog) error { - return r.db.WithContext(ctx).Create(item).Error + if err := r.db.WithContext(ctx).Create(item).Error; err != nil { + return err + } + // Persistent cumulative counters (survive log retention/clearing): every + // created event bumps total + its kind + (api source). + deltas := map[string]int64{"total": 1} + if item.Kind == "video" { + deltas["video"] = 1 + } else if item.Kind == "image" { + deltas["image"] = 1 + } + if item.Source == "v1" { + deltas["api"] = 1 + } + r.incrCounters(ctx, deltas) + return nil +} + +// incrCounters upserts monotonic counters (stat_counters). Best-effort: a counter +// failure must never fail the generation, so errors are swallowed. +func (r *EventRepository) incrCounters(ctx context.Context, deltas map[string]int64) { + for k, n := range deltas { + if n == 0 { + continue + } + _ = r.db.WithContext(ctx).Exec( + `INSERT INTO stat_counters (key, value, updated_at) VALUES (?, ?, now()) + ON CONFLICT (key) DO UPDATE SET value = stat_counters.value + EXCLUDED.value, updated_at = now()`, + k, n).Error + } +} + +// Counters returns all persistent counters as key→value. +func (r *EventRepository) Counters(ctx context.Context) (map[string]int64, error) { + var rows []model.StatCounter + if err := r.db.WithContext(ctx).Find(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]int64, len(rows)) + for _, x := range rows { + out[x.Key] = x.Value + } + return out, nil } // GetByID fetches a single event (nil, nil when not found). Used by the async @@ -462,16 +508,22 @@ func (r *EventRepository) GetByID(ctx context.Context, id string) (*model.EventL // MarkVideoReady completes an async video job: status=success, file=upstream URL // (proxied on /content — never persisted), elapsed. func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL string, elapsedMS int) error { - return r.db.WithContext(ctx). + // Guard on a real transition (status <> success) so the success counter is + // incremented exactly once even if this fires twice / concurrently. + res := r.db.WithContext(ctx). Model(&model.EventLog{}). - Where("id = ?", eventID). + Where("id = ? AND status <> ?", eventID, "success"). Updates(map[string]any{ "status": "success", "file": fileURL, "error": "", "elapsed_ms": elapsedMS, "updated_at": time.Now(), - }).Error + }) + if res.Error == nil && res.RowsAffected > 0 { + r.incrCounters(ctx, map[string]int64{"success": 1}) + } + return res.Error } func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error { @@ -488,10 +540,20 @@ func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, err // "成功 + abandoned" at once. patch["error"] = "" } - return r.db.WithContext(ctx). + // Guard on a real transition so the success/failed counters increment exactly + // once per event even under a duplicate/concurrent terminal status update. + res := r.db.WithContext(ctx). Model(&model.EventLog{}). - Where("id = ?", eventID). - Updates(patch).Error + Where("id = ? AND status <> ?", eventID, status). + Updates(patch) + if res.Error == nil && res.RowsAffected > 0 { + if status == "success" { + r.incrCounters(ctx, map[string]int64{"success": 1}) + } else if status == "failed" { + r.incrCounters(ctx, map[string]int64{"failed": 1}) + } + } + return res.Error } // MarkRefunded atomically claims the right to refund this event exactly once: diff --git a/backend/internal/repo/model_repo.go b/backend/internal/repo/model_repo.go index c24bd35..ce99fcd 100644 --- a/backend/internal/repo/model_repo.go +++ b/backend/internal/repo/model_repo.go @@ -18,6 +18,14 @@ func NewModelRepository(db *gorm.DB) *ModelRepository { return &ModelRepository{db: db} } +// IncrementGenerationCount bumps a model's persistent success counter by 1. +// Best-effort: a missing model id is a no-op (0 rows affected, no error). +func (r *ModelRepository) IncrementGenerationCount(ctx context.Context, modelID string) error { + return r.db.WithContext(ctx).Model(&model.ModelConfig{}). + Where("id = ?", modelID). + UpdateColumn("generation_count", gorm.Expr("generation_count + 1")).Error +} + func (r *ModelRepository) List(ctx context.Context) ([]model.ModelConfig, error) { var items []model.ModelConfig // Higher weight floats to the top of the dropdown / admin list; ties fall diff --git a/backend/internal/repo/user_repo.go b/backend/internal/repo/user_repo.go index a8348c7..cff3027 100644 --- a/backend/internal/repo/user_repo.go +++ b/backend/internal/repo/user_repo.go @@ -87,6 +87,16 @@ func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*mod return &user, nil } +// IncrementGenerationCount bumps a user's persistent success counter by 1. +func (r *UserRepository) IncrementGenerationCount(ctx context.Context, userID string) error { + if userID == "" { + return nil + } + return r.db.WithContext(ctx).Model(&model.User{}). + Where("id = ?", userID). + UpdateColumn("generation_count", gorm.Expr("generation_count + 1")).Error +} + func (r *UserRepository) List(ctx context.Context) ([]model.User, error) { var users []model.User if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil { diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index ebd5087..b6f62e8 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -41,19 +41,12 @@ func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string] 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 + // 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 } @@ -66,10 +59,6 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er 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{ @@ -89,7 +78,7 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er "max_reference_images": item.MaxReferenceImages, "reference_mode": item.ReferenceMode, "weight": item.Weight, - "generation_count": counts[item.ID], + "generation_count": item.GenerationCount, "created_at": item.CreatedAt, "updated_at": item.UpdatedAt, }) @@ -97,12 +86,13 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er 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) { +func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []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, + Statuses: statuses, Since: since, UserID: userID, ExcludeSource: excludeSource, @@ -173,7 +163,7 @@ func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) { 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) + weekCut := now.Add(-3 * 24 * time.Hour) // "week" key = last 3 days (per admin request) day, err := s.events.WindowStats(ctx, dayCut) if err != nil { @@ -199,6 +189,12 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error 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). @@ -263,6 +259,7 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error "dau": dau, "wau": wau, "hourly": hourly, + "lifetime": lifetime, "analytics": map[string]any{ "day": dayAnalytics, "week": weekAnalytics, diff --git a/backend/internal/service/app_settings.go b/backend/internal/service/app_settings.go index 4f837fa..2d5a999 100644 --- a/backend/internal/service/app_settings.go +++ b/backend/internal/service/app_settings.go @@ -358,6 +358,12 @@ func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, er if err != nil { return SMTPConfig{}, err } + // The verification-email subject uses the site title, e.g. "