Files
image2api/backend/internal/service/admin_read.go
T
chiyiandClaude Opus 4.8 1137d0bd1d feat: 画图台并发重构 + 品牌定制 + 用户备注 + provider/UI 多项修复
画图台(并发出图):
- 不再锁定 UI:点「生成」开独立任务,可连续多次并发
- 结果网格一行5个、最多10张,进行中/成功/失败状态回显,刷新保留进行中
- 生图张数 1/2/3/4,各自独立计费出卡
- 点图=参考图(单张替换/多张替换末位);首尾帧模型点视频=抓末帧设为首帧,否则放大
- /logs 新增 statuses=pending,success 服务端过滤(status IN 专用 SQL)

品牌定制(设置→网站):
- 自定义 Logo 图片 + 子标题(公开页头部 + 管理侧栏)
- 邮件验证码标题改用站点名:{title} 邮箱验证码

提示词复制:
- 去掉复制按钮,点提示词文字即复制(预览/后台日志/图片管理/画图记录),统一弹「指令已复制」
- 新增 utils/clipboard.js:execCommand 回退,非安全上下文(http/IP)也能复制

用户管理:列表加「备注」列,新建/编辑可填改备注(默认空)

provider 修复:
- grok 401 正确判死封号(markTokenFailure 漏了 grok 池)
- grok 视频支持 15s
- custom 上游报错去敏感(抹掉上游 URL/IP,改英文短描述)
- custom 去掉额度耗尽锁定:429/欠费当临时错误,账号保持 active

UI/其它:
- 展示位弹窗浅色主题适配(tab 选中高亮、输入框边框)— 主题变量 + 中心补丁
- 自定义模型:时长可填任意秒数 + 15s 预设
- 首页设置/卡密弹窗去固定高度与滚动条
- 顶部菜单「记录」→「图片」
- 下线 Flow provider(代码移除)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:11:22 +08:00

571 lines
16 KiB
Go

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
}
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) ModelsView(ctx context.Context) ([]map[string]any, error) {
items, err := s.models.List(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": 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, 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,
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(-3 * 24 * time.Hour) // "week" key = last 3 days (per admin request)
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{
"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)
}
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"},
{Name: "runway", Pool: "runway", Type: "runway"},
{Name: "leonardo", Pool: "leonardo", Type: "leonardo"},
{Name: "krea", Pool: "krea", Type: "krea"},
{Name: "imagine", Pool: "imagine", Type: "imagine"},
{Name: "grok", Pool: "grok", Type: "grok"},
}
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
}