Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user