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:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+650
View File
@@ -0,0 +1,650 @@
package repo
import (
"context"
"errors"
"strings"
"time"
"backend/internal/model"
"gorm.io/gorm"
)
type EventRepository struct {
db *gorm.DB
}
type EventListFilter struct {
Limit int
Offset int
Kind string
Status string
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)
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
}
type EventStats struct {
Total int64 `json:"total"`
Success int64 `json:"success"`
Failed int64 `json:"failed"`
Pending int64 `json:"pending"`
AvgElapsedMS *int `json:"avg_elapsed_ms"`
AvgElapsedMS24 *int `json:"avg_elapsed_ms_24h"`
}
func NewEventRepository(db *gorm.DB) *EventRepository {
return &EventRepository{db: db}
}
func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]model.EventLog, int64, error) {
q := r.db.WithContext(ctx).Model(&model.EventLog{})
if filter.Kind != "" {
q = q.Where("kind = ?", filter.Kind)
}
if filter.Status != "" {
q = q.Where("status = ?", filter.Status)
}
if filter.Since != nil {
q = q.Where("ts > ?", *filter.Since)
}
if filter.UserID != "" {
q = q.Where("user_id = ?", filter.UserID)
}
if filter.ExcludeSource != "" {
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
}
if filter.Source != "" {
q = q.Where("source = ?", filter.Source)
}
if filter.HasFile {
q = q.Where("file <> ''")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.EventLog
if err := q.Order("ts desc").
Limit(filter.Limit).
Offset(filter.Offset).
Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *EventRepository) Stats(ctx context.Context) (*EventStats, error) {
stats := &EventStats{}
if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Count(&stats.Total).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("status = ?", "success").Count(&stats.Success).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("status = ?", "failed").Count(&stats.Failed).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("status = ?", "pending").Count(&stats.Pending).Error; err != nil {
return nil, err
}
type avgRow struct {
Avg *float64 `gorm:"column:avg"`
}
var all avgRow
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("AVG(elapsed_ms) AS avg").
Where("status = ? AND elapsed_ms > 0", "success").
Scan(&all).Error; err != nil {
return nil, err
}
if all.Avg != nil {
v := int(*all.Avg + 0.5)
stats.AvgElapsedMS = &v
}
var recent avgRow
cutoff := time.Now().Add(-24 * time.Hour)
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("AVG(elapsed_ms) AS avg").
Where("status = ? AND elapsed_ms > 0 AND ts >= ?", "success", cutoff).
Scan(&recent).Error; err != nil {
return nil, err
}
if recent.Avg != nil {
v := int(*recent.Avg + 0.5)
stats.AvgElapsedMS24 = &v
}
return stats, nil
}
// StatsByUser returns total / success / failed / pending counts scoped to a
// single user — for the customer-facing 生成日志 (/mylogs) KPI strip, so it
// reflects the caller's own history, not the whole site.
func (r *EventRepository) StatsByUser(ctx context.Context, userID string) (*EventStats, error) {
stats := &EventStats{}
q := func() *gorm.DB {
return r.db.WithContext(ctx).Model(&model.EventLog{}).Where("user_id = ?", userID)
}
if err := q().Count(&stats.Total).Error; err != nil {
return nil, err
}
if err := q().Where("status = ?", "success").Count(&stats.Success).Error; err != nil {
return nil, err
}
if err := q().Where("status = ?", "failed").Count(&stats.Failed).Error; err != nil {
return nil, err
}
if err := q().Where("status = ?", "pending").Count(&stats.Pending).Error; err != nil {
return nil, err
}
return stats, nil
}
// ---------------------------------------------------------------------------
// Dashboard aggregates — server-side GROUP BY / FILTER so the admin overview
// no longer derives 7-day / DAU / trend / top-N numbers client-side from the
// last 200 logs (which silently undercounts once volume passes that window).
// ---------------------------------------------------------------------------
// DashboardWindow is a single time-window aggregate (e.g. last 24h / 7d).
type DashboardWindow struct {
Total int64 `json:"total"`
Success int64 `json:"success"`
Failed int64 `json:"failed"`
Pending int64 `json:"pending"`
Image int64 `json:"image"`
Video int64 `json:"video"`
API int64 `json:"api"` // source = 'v1' (OpenAI-compatible key)
Web int64 `json:"web"` // everything else (web / playground)
Spent float64 `json:"spent"`
}
type ModelUsage struct {
Model string `json:"model"`
Count int64 `json:"count"`
AvgMS *int `json:"avg_ms"`
}
type FailureReason struct {
Reason string `json:"reason"`
Count int64 `json:"count"`
}
type UserSpend struct {
UserID string `json:"user_id"`
Name string `json:"name"` // resolved by the service from user_id
Count int64 `json:"count"`
Spent float64 `json:"spent"`
}
type HourBucket struct {
Image int64 `json:"image"`
Video int64 `json:"video"`
}
// WindowStats rolls up counts + spend over a single window in one query.
func (r *EventRepository) WindowStats(ctx context.Context, since time.Time) (*DashboardWindow, error) {
type row struct {
Total int64 `gorm:"column:total"`
Success int64 `gorm:"column:success"`
Failed int64 `gorm:"column:failed"`
Pending int64 `gorm:"column:pending"`
Image int64 `gorm:"column:image"`
Video int64 `gorm:"column:video"`
API int64 `gorm:"column:api"`
Spent float64 `gorm:"column:spent"`
}
var out row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select(`
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'success') AS success,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE kind = 'image') AS image,
COUNT(*) FILTER (WHERE kind = 'video') AS video,
COUNT(*) FILTER (WHERE source = 'v1') AS api,
COALESCE(SUM(cost) FILTER (WHERE status = 'success'), 0) AS spent`).
Where("ts >= ?", since).
Scan(&out).Error; err != nil {
return nil, err
}
return &DashboardWindow{
Total: out.Total, Success: out.Success, Failed: out.Failed, Pending: out.Pending,
Image: out.Image, Video: out.Video, API: out.API, Web: out.Total - out.API,
Spent: out.Spent,
}, nil
}
// CountBetween counts events in (start, end] — used for the prev-24h delta.
func (r *EventRepository) CountBetween(ctx context.Context, start, end time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
Where("ts > ? AND ts <= ?", start, end).Count(&n).Error
return n, err
}
// DistinctUsersSince counts distinct (non-empty) user_ids active since `since`.
func (r *EventRepository) DistinctUsersSince(ctx context.Context, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
Where("ts >= ? AND user_id <> ''", since).
Distinct("user_id").Count(&n).Error
return n, err
}
// HourlyBuckets returns 24 oldest→newest buckets (image/video split) for the
// last 24h trend chart.
func (r *EventRepository) HourlyBuckets(ctx context.Context) ([24]HourBucket, error) {
var out [24]HourBucket
type hourRow struct {
HoursAgo int `gorm:"column:hours_ago"`
Image int64 `gorm:"column:image"`
Video int64 `gorm:"column:video"`
}
var rows []hourRow
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select(`
FLOOR(EXTRACT(EPOCH FROM (NOW() - ts)) / 3600)::int AS hours_ago,
COUNT(*) FILTER (WHERE kind = 'video') AS video,
COUNT(*) FILTER (WHERE kind <> 'video') AS image`).
Where("ts >= NOW() - INTERVAL '24 hours'").
Group("hours_ago").
Scan(&rows).Error; err != nil {
return out, err
}
for _, hr := range rows {
if hr.HoursAgo < 0 || hr.HoursAgo >= 24 {
continue
}
out[23-hr.HoursAgo] = HourBucket{Image: hr.Image, Video: hr.Video}
}
return out, nil
}
// ModelUsageSince returns the top models by volume since `since`, with the
// success-only average latency.
func (r *EventRepository) ModelUsageSince(ctx context.Context, since time.Time, limit int) ([]ModelUsage, error) {
if limit <= 0 {
limit = 6
}
type row struct {
Model string `gorm:"column:model"`
Count int64 `gorm:"column:count"`
Avg *float64 `gorm:"column:avg_ms"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select(`
model,
COUNT(*) AS count,
AVG(elapsed_ms) FILTER (WHERE status = 'success' AND elapsed_ms > 0) AS avg_ms`).
Where("ts >= ? AND model <> ''", since).
Group("model").
Order("count DESC").
Limit(limit).
Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]ModelUsage, 0, len(rows))
for _, item := range rows {
var avg *int
if item.Avg != nil {
v := int(*item.Avg + 0.5)
avg = &v
}
out = append(out, ModelUsage{Model: item.Model, Count: item.Count, AvgMS: avg})
}
return out, nil
}
// TopFailures groups failed events by (truncated) error reason since `since`.
func (r *EventRepository) TopFailures(ctx context.Context, since time.Time, limit int) ([]FailureReason, error) {
if limit <= 0 {
limit = 5
}
var out []FailureReason
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select(`
LEFT(COALESCE(NULLIF(error, ''), '未知错误'), 60) AS reason,
COUNT(*) AS count`).
Where("ts >= ? AND status = 'failed'", since).
Group("reason").
Order("count DESC").
Limit(limit).
Scan(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// TopUserSpend ranks users by credits spent on SUCCESSFUL generations since
// `since`. Names are resolved by the caller (UserID -> display name).
func (r *EventRepository) TopUserSpend(ctx context.Context, since time.Time, limit int) ([]UserSpend, error) {
if limit <= 0 {
limit = 6
}
var out []UserSpend
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select(`
user_id,
COUNT(*) AS count,
COALESCE(SUM(cost), 0) AS spent`).
Where("ts >= ? AND status = 'success'", since).
Group("user_id").
Order("spent DESC").
Limit(limit).
Scan(&out).Error; err != nil {
return nil, err
}
return out, nil
}
func (r *EventRepository) PurgeOlderThan(ctx context.Context, maxAge time.Duration) (int64, error) {
if maxAge <= 0 {
return 0, nil
}
cutoff := time.Now().Add(-maxAge)
result := r.db.WithContext(ctx).Where("ts < ?", cutoff).Delete(&model.EventLog{})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// ClearFiles blanks the `file` column on any event rows that point at one of the
// given relative paths. Called after media retention deletes the files on disk so
// the log views don't dangle a 404 image — an emptied `file` reads as "no preview"
// ("—" in admin logs; hidden in the customer records page).
func (r *EventRepository) ClearFiles(ctx context.Context, relPaths []string) (int64, error) {
if len(relPaths) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Where("file IN ?", relPaths).
Updates(map[string]any{"file": "", "updated_at": time.Now()})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// ClearRefFiles blanks the ref_files paths on one event (called after a
// successful generation once the reference images are deleted from storage, so no
// dangling reference_urls remain). The `refs` COUNT is kept for the log record.
func (r *EventRepository) ClearRefFiles(ctx context.Context, eventID string) error {
return r.db.WithContext(ctx).
Model(&model.EventLog{}).
Where("id = ?", eventID).
Update("ref_files", nil).Error
}
// StaleEvent identifies a purged pending event so the caller can refund the
// credits debited up-front AND attribute the failure to the account the
// (now-abandoned) generation was using.
type StaleEvent struct {
ID string `gorm:"column:id"`
UserID string `gorm:"column:user_id"`
AccountID string `gorm:"column:account_id"`
Cost float64 `gorm:"column:cost"`
}
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
// the caller can refund their up-front charge. A stuck pending row otherwise
// blocks the per-user generation gate (PendingByUser) forever AND silently eats
// the user's credits (the charge happens at submit; the normal failure-refund
// path never runs for a process-restart orphan). Mirrors Python purge_stale.
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge time.Duration) ([]StaleEvent, error) {
if maxAge <= 0 {
maxAge = 600 * time.Second
}
cutoff := time.Now().Add(-maxAge)
var stale []StaleEvent
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
// sweep can't double-count (the UPDATE in the same tx removes them from
// the pending set).
if err := tx.Model(&model.EventLog{}).
Where("status = ? AND ts < ?", "pending", cutoff).
Select("id", "user_id", "account_id", "cost").
Scan(&stale).Error; err != nil {
return err
}
if len(stale) == 0 {
return nil
}
return tx.Model(&model.EventLog{}).
Where("status = ? AND ts < ?", "pending", cutoff).
Updates(map[string]any{
"status": "failed",
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, err
}
return stale, nil
}
func (r *EventRepository) Create(ctx context.Context, item *model.EventLog) error {
return r.db.WithContext(ctx).Create(item).Error
}
// GetByID fetches a single event (nil, nil when not found). Used by the async
// /v1/videos job to look up status / the stored upstream URL.
func (r *EventRepository) GetByID(ctx context.Context, id string) (*model.EventLog, error) {
var e model.EventLog
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &e, nil
}
// 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).
Model(&model.EventLog{}).
Where("id = ?", eventID).
Updates(map[string]any{
"status": "success",
"file": fileURL,
"error": "",
"elapsed_ms": elapsedMS,
"updated_at": time.Now(),
}).Error
}
func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error {
patch := map[string]any{
"status": status,
"elapsed_ms": elapsedMS,
"updated_at": time.Now(),
}
if strings.TrimSpace(errMsg) != "" {
patch["error"] = strings.TrimSpace(errMsg)
} else if status == "success" {
// A late-completing generation (one the maintenance sweep had already
// stamped "abandoned") must shed that stale error, or the row reads as
// "成功 + abandoned" at once.
patch["error"] = ""
}
return r.db.WithContext(ctx).
Model(&model.EventLog{}).
Where("id = ?", eventID).
Updates(patch).Error
}
// MarkRefunded atomically claims the right to refund this event exactly once:
// it flips refunded false→true and returns true ONLY for the caller that won the
// race. Both the normal failure path and the abandoned-purge sweep call this
// before crediting, so a generation can never be refunded twice.
func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (bool, error) {
res := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Where("id = ? AND refunded = ?", eventID, false).
Updates(map[string]any{"refunded": true, "updated_at": time.Now()})
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected == 1, nil
}
// SetAccount stamps which provider account is fulfilling an in-flight event.
// Called when generation commits to a token, so the accounts view can count
// pending events per account and an abandoned-event purge can attribute back.
func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID string) error {
return r.db.WithContext(ctx).
Model(&model.EventLog{}).
Where("id = ?", eventID).
Update("account_id", accountID).Error
}
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for
// the accounts view's live "in-flight" column.
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
type row struct {
AccountID string `gorm:"column:account_id"`
Count int64 `gorm:"column:count"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("account_id, COUNT(*) AS count").
Where("status = ? AND account_id <> ''", "pending").
Group("account_id").
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, item := range rows {
out[item.AccountID] = item.Count
}
return out, nil
}
func (r *EventRepository) RecentByFile(ctx context.Context, limit int) ([]model.EventLog, error) {
if limit <= 0 {
limit = 1000
}
var items []model.EventLog
if err := r.db.WithContext(ctx).
Where("file <> ''").
Order("ts desc").
Limit(limit).
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *EventRepository) ModelSuccessCounts(ctx context.Context) (map[string]int64, error) {
type row struct {
Model string `gorm:"column:model"`
Count int64 `gorm:"column:count"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("model, COUNT(*) AS count").
Where("status = ? AND model <> ''", "success").
Group("model").
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, item := range rows {
out[item.Model] = item.Count
}
return out, nil
}
func (r *EventRepository) UserSuccessCounts(ctx context.Context) (map[string]int64, error) {
type row struct {
UserID string `gorm:"column:user_id"`
Count int64 `gorm:"column:count"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("user_id, COUNT(*) AS count").
Where("status = ? AND user_id <> ''", "success").
Group("user_id").
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, item := range rows {
out[item.UserID] = item.Count
}
return out, nil
}
func (r *EventRepository) DeleteAll(ctx context.Context) (int64, error) {
result := r.db.WithContext(ctx).Where("1 = 1").Delete(&model.EventLog{})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func (r *EventRepository) DeletePending(ctx context.Context) (int64, error) {
result := r.db.WithContext(ctx).Where("status = ?", "pending").Delete(&model.EventLog{})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// LatestByUser / PendingByUser take onlySource: when non-empty they match ONLY
// that source. The playground passes "user" so it echoes ONLY the user's own
// web generations — never admin model-tests ("admin") or API-key calls ("v1").
func (r *EventRepository) LatestByUser(ctx context.Context, userID, onlySource string) (*model.EventLog, error) {
var item model.EventLog
q := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("user_id = ?", userID)
if strings.TrimSpace(onlySource) != "" {
q = q.Where("source = ?", strings.TrimSpace(onlySource))
}
if err := q.Order("ts desc").First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *EventRepository) PendingByUser(ctx context.Context, userID, onlySource string) (*model.EventLog, error) {
var item model.EventLog
q := r.db.WithContext(ctx).Model(&model.EventLog{}).
Where("user_id = ? AND status = ?", userID, "pending")
if strings.TrimSpace(onlySource) != "" {
q = q.Where("source = ?", strings.TrimSpace(onlySource))
}
if err := q.Order("ts desc").First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}