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
+53
View File
@@ -0,0 +1,53 @@
package repo
import (
"context"
"time"
"backend/internal/model"
"gorm.io/gorm"
)
type APIKeyRepository struct {
db *gorm.DB
}
func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository {
return &APIKeyRepository{db: db}
}
func (r *APIKeyRepository) ListByUserID(ctx context.Context, userID string) ([]model.APIKey, error) {
var keys []model.APIKey
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at asc").Find(&keys).Error; err != nil {
return nil, err
}
return keys, nil
}
func (r *APIKeyRepository) ReplaceForUser(ctx context.Context, userID string, key *model.APIKey) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.APIKey{}).Error; err != nil {
return err
}
return tx.Create(key).Error
})
}
func (r *APIKeyRepository) DeleteByUserID(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.APIKey{}).Error
}
func (r *APIKeyRepository) DeleteByID(ctx context.Context, userID, keyID string) error {
return r.db.WithContext(ctx).
Where("user_id = ? AND id = ?", userID, keyID).
Delete(&model.APIKey{}).Error
}
func (r *APIKeyRepository) Create(ctx context.Context, key *model.APIKey) error {
return r.db.WithContext(ctx).Create(key).Error
}
func (r *APIKeyRepository) TouchUsage(ctx context.Context, keyHash string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.APIKey{}).Where("key_hash = ?", keyHash).Update("last_used_at", now).Error
}
+134
View File
@@ -0,0 +1,134 @@
package repo
import (
"context"
"errors"
"time"
"backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ErrCDKBatchLimit is returned when a user tries to redeem a second code from
// the same marketing batch (one per user per batch).
var ErrCDKBatchLimit = errors.New("cdk marketing batch already redeemed by this user")
type CDKRepository struct {
db *gorm.DB
}
func NewCDKRepository(db *gorm.DB) *CDKRepository {
return &CDKRepository{db: db}
}
func (r *CDKRepository) List(ctx context.Context) ([]model.CDKCode, error) {
var items []model.CDKCode
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *CDKRepository) Stats(ctx context.Context) (map[string]any, error) {
var total, active, redeemed int64
if err := r.db.WithContext(ctx).Model(&model.CDKCode{}).Count(&total).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.CDKCode{}).Where("status = ?", "active").Count(&active).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.CDKCode{}).Where("status = ?", "redeemed").Count(&redeemed).Error; err != nil {
return nil, err
}
type sumRow struct {
Total *float64 `gorm:"column:total"`
}
var activeAmount, redeemedAmount sumRow
if err := r.db.WithContext(ctx).
Model(&model.CDKCode{}).
Select("SUM(amount) AS total").
Where("status = ?", "active").
Scan(&activeAmount).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).
Model(&model.CDKCode{}).
Select("SUM(amount) AS total").
Where("status = ?", "redeemed").
Scan(&redeemedAmount).Error; err != nil {
return nil, err
}
activeAmt := 0.0
if activeAmount.Total != nil {
activeAmt = *activeAmount.Total
}
redeemedAmt := 0.0
if redeemedAmount.Total != nil {
redeemedAmt = *redeemedAmount.Total
}
return map[string]any{
"total": total,
"active": active,
"redeemed": redeemed,
"active_amount": activeAmt,
"redeemed_amount": redeemedAmt,
}, nil
}
func (r *CDKRepository) CreateBatch(ctx context.Context, items []model.CDKCode) error {
return r.db.WithContext(ctx).Create(&items).Error
}
func (r *CDKRepository) Delete(ctx context.Context, code string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.CDKCode{}, "code = ?", code)
return res.RowsAffected, res.Error
}
func (r *CDKRepository) DeleteByCodes(ctx context.Context, codes []string) (int64, error) {
if len(codes) == 0 {
return 0, nil
}
res := r.db.WithContext(ctx).Delete(&model.CDKCode{}, "code IN ?", codes)
return res.RowsAffected, res.Error
}
func (r *CDKRepository) Redeem(ctx context.Context, code, userID string) (*model.CDKCode, error) {
var out *model.CDKCode
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item model.CDKCode
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "code = ?", code).Error; err != nil {
return err
}
if item.Status == "redeemed" {
return gorm.ErrDuplicatedKey
}
// Marketing codes: a user may redeem only ONE code per batch. The partial
// unique index (batch_id, redeemed_by) is the hard backstop against
// concurrent double-redeems; this check gives a friendly error first.
if item.Type == "marketing" && item.BatchID != "" {
var cnt int64
if err := tx.Model(&model.CDKCode{}).
Where("batch_id = ? AND type = 'marketing' AND redeemed_by = ?", item.BatchID, userID).
Count(&cnt).Error; err != nil {
return err
}
if cnt > 0 {
return ErrCDKBatchLimit
}
}
now := time.Now()
item.Status = "redeemed"
item.RedeemedBy = &userID
item.RedeemedAt = &now
if err := tx.Save(&item).Error; err != nil {
return err
}
out = &item
return nil
})
return out, err
}
+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
}
+69
View File
@@ -0,0 +1,69 @@
package repo
import (
"context"
"encoding/json"
"time"
"backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ModelRepository struct {
db *gorm.DB
}
func NewModelRepository(db *gorm.DB) *ModelRepository {
return &ModelRepository{db: db}
}
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
// back to newest-first so order stays stable for equal-weight models.
if err := r.db.WithContext(ctx).Order("weight desc, created_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.ModelConfig, error) {
var item model.ModelConfig
if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil {
return nil, err
}
return &item, nil
}
func JSONStrings(v datatypes.JSON) []string {
if len(v) == 0 {
return []string{}
}
var out []string
if err := json.Unmarshal([]byte(v), &out); err == nil {
return out
}
return []string{}
}
func (r *ModelRepository) Create(ctx context.Context, item *model.ModelConfig) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *ModelRepository) Update(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil {
return nil, err
}
var item model.ModelConfig
if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID)
return res.RowsAffected, res.Error
}
@@ -0,0 +1,73 @@
package repo
import (
"context"
"time"
"backend/internal/model"
"gorm.io/gorm"
)
type RefreshProfileRepository struct {
db *gorm.DB
}
func NewRefreshProfileRepository(db *gorm.DB) *RefreshProfileRepository {
return &RefreshProfileRepository{db: db}
}
func (r *RefreshProfileRepository) List(ctx context.Context) ([]model.RefreshProfile, error) {
var items []model.RefreshProfile
if err := r.db.WithContext(ctx).
Order("created_at desc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *RefreshProfileRepository) Get(ctx context.Context, id string) (*model.RefreshProfile, error) {
var item model.RefreshProfile
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *RefreshProfileRepository) Create(ctx context.Context, item *model.RefreshProfile) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *RefreshProfileRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.RefreshProfile, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).
Model(&model.RefreshProfile{}).
Where("id = ?", id).
Updates(patch).Error; err != nil {
return nil, err
}
return r.Get(ctx, id)
}
func (r *RefreshProfileRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id = ?", id).Error
}
func (r *RefreshProfileRepository) DeleteByIDs(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id IN ?", ids).Error
}
// ListDue returns enabled profiles whose next_retry_at has passed (or is unset,
// e.g. freshly imported). The background maintenance loop refreshes these.
func (r *RefreshProfileRepository) ListDue(ctx context.Context, now time.Time) ([]model.RefreshProfile, error) {
var items []model.RefreshProfile
if err := r.db.WithContext(ctx).
Where("enabled = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", true, now).
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
+99
View File
@@ -0,0 +1,99 @@
package repo
import (
"context"
"sort"
"strings"
"time"
"backend/internal/model"
"gorm.io/gorm"
)
type ShowcaseRepository struct {
db *gorm.DB
}
func NewShowcaseRepository(db *gorm.DB) *ShowcaseRepository {
return &ShowcaseRepository{db: db}
}
func (r *ShowcaseRepository) IsPublicFile(ctx context.Context, rel string) (bool, error) {
normalized := strings.TrimLeft(strings.TrimSpace(rel), "/")
if normalized == "" {
return false, nil
}
var count int64
if err := r.db.WithContext(ctx).
Model(&model.ShowcaseItem{}).
Where("image = ? OR image = ?", normalized, "/"+normalized).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// PublicFileSet returns the set of image keys referenced by any showcase item
// (normalized, no leading slash). The media-prune sweep uses it to never delete
// a file the homepage still shows, regardless of how old the file is.
func (r *ShowcaseRepository) PublicFileSet(ctx context.Context) (map[string]struct{}, error) {
var images []string
if err := r.db.WithContext(ctx).
Model(&model.ShowcaseItem{}).
Where("image <> ''").
Pluck("image", &images).Error; err != nil {
return nil, err
}
set := make(map[string]struct{}, len(images))
for _, img := range images {
n := strings.TrimLeft(strings.TrimSpace(img), "/")
if n != "" {
set[n] = struct{}{}
}
}
return set, nil
}
func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.ShowcaseItem, error) {
var items []model.ShowcaseItem
if err := r.db.WithContext(ctx).Find(&items).Error; err != nil {
return nil, err
}
grouped := map[string][]model.ShowcaseItem{
"hero": {},
"bento": {},
"work": {},
}
for _, item := range items {
grouped[item.Kind] = append(grouped[item.Kind], item)
}
for kind := range grouped {
sort.Slice(grouped[kind], func(i, j int) bool {
return grouped[kind][i].Weight > grouped[kind][j].Weight
})
}
return grouped, nil
}
func (r *ShowcaseRepository) Create(ctx context.Context, item *model.ShowcaseItem) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *ShowcaseRepository) Update(ctx context.Context, entryID string, patch map[string]any) (*model.ShowcaseItem, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).Model(&model.ShowcaseItem{}).Where("id = ?", entryID).Updates(patch).Error; err != nil {
return nil, err
}
var item model.ShowcaseItem
if err := r.db.WithContext(ctx).First(&item, "id = ?", entryID).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *ShowcaseRepository) Delete(ctx context.Context, entryID string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.ShowcaseItem{}, "id = ?", entryID)
return res.RowsAffected, res.Error
}
@@ -0,0 +1,96 @@
package repo
import (
"context"
"time"
"backend/internal/model"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const siteSettingCachePrefix = "setting:"
// siteSettingCacheTTL is a safety-net expiry; writes invalidate eagerly, so this
// only bounds staleness if an invalidation is ever missed (e.g. Redis blip).
const siteSettingCacheTTL = 5 * time.Minute
type SiteSettingRepository struct {
db *gorm.DB
cache *redis.Client
}
// NewSiteSettingRepository wires the config KV store. cache may be nil, in which
// case the repository transparently falls back to DB-only access.
func NewSiteSettingRepository(db *gorm.DB, cache *redis.Client) *SiteSettingRepository {
return &SiteSettingRepository{db: db, cache: cache}
}
func (r *SiteSettingRepository) cacheKey(key string) string {
return siteSettingCachePrefix + key
}
func (r *SiteSettingRepository) GetValue(ctx context.Context, key string) (string, error) {
if r.cache != nil {
if v, err := r.cache.Get(ctx, r.cacheKey(key)).Result(); err == nil {
return v, nil
}
// redis.Nil (miss) or any transient cache error -> fall through to DB.
}
value := ""
var setting model.SiteSetting
if err := r.db.WithContext(ctx).First(&setting, "key = ?", key).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return "", err
}
// Not found stays as "" — still cached below to absorb repeated misses.
} else {
value = setting.Value
}
if r.cache != nil {
_ = r.cache.Set(ctx, r.cacheKey(key), value, siteSettingCacheTTL).Err()
}
return value, nil
}
func (r *SiteSettingRepository) UpsertValue(ctx context.Context, key, value string) error {
if err := r.db.WithContext(ctx).Save(&model.SiteSetting{
Key: key,
Value: value,
}).Error; err != nil {
return err
}
r.invalidate(ctx, key)
return nil
}
func (r *SiteSettingRepository) UpsertValues(ctx context.Context, values map[string]string) error {
if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for key, value := range values {
if err := tx.Save(&model.SiteSetting{
Key: key,
Value: value,
}).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
for key := range values {
r.invalidate(ctx, key)
}
return nil
}
// invalidate drops the cached entry so the next read repopulates from the DB.
// Deleting (rather than overwriting) keeps writes simple and race-tolerant.
func (r *SiteSettingRepository) invalidate(ctx context.Context, key string) {
if r.cache == nil {
return
}
_ = r.cache.Del(ctx, r.cacheKey(key)).Err()
}
+390
View File
@@ -0,0 +1,390 @@
package repo
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type TokenRepository struct {
db *gorm.DB
}
func NewTokenRepository(db *gorm.DB) *TokenRepository {
return &TokenRepository{db: db}
}
func (r *TokenRepository) List(ctx context.Context) ([]model.TokenAccount, error) {
var items []model.TokenAccount
if err := r.db.WithContext(ctx).
Order("pool asc, created_at desc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *TokenRepository) ListByPool(ctx context.Context, pool string) ([]model.TokenAccount, error) {
var items []model.TokenAccount
if err := r.db.WithContext(ctx).
Where("pool = ?", pool).
Order("created_at desc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *TokenRepository) Get(ctx context.Context, pool, id string) (*model.TokenAccount, error) {
var item model.TokenAccount
if err := r.db.WithContext(ctx).
First(&item, "pool = ? AND id = ?", pool, id).Error; err != nil {
return nil, err
}
return &item, nil
}
// GetByPoolEmail finds an account in a pool by its account_email (the logical
// identity for import dedup). Returns (nil, nil) when none / email is blank.
func (r *TokenRepository) GetByPoolEmail(ctx context.Context, pool, email string) (*model.TokenAccount, error) {
email = strings.TrimSpace(email)
if email == "" {
return nil, nil
}
var item model.TokenAccount
err := r.db.WithContext(ctx).
Where("pool = ? AND account_email = ?", pool, email).
First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *TokenRepository) Create(ctx context.Context, item *model.TokenAccount) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *TokenRepository) Update(ctx context.Context, pool, id string, patch map[string]any) (*model.TokenAccount, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).
Model(&model.TokenAccount{}).
Where("pool = ? AND id = ?", pool, id).
Updates(patch).Error; err != nil {
return nil, err
}
return r.Get(ctx, pool, id)
}
// ReserveQuota atomically pre-deducts `amount` from an account's cached image
// token balance under a row lock, so concurrent picks of the same near-empty
// account can never over-commit it. Returns:
// - allowed=true, deducted=true: balance was known and ≥ amount → decremented.
// - allowed=true, deducted=false: balance unknown → allowed without a hold
// (benefit of the doubt; a post-render reconcile writes the real value).
// - allowed=false: balance known and < amount → caller should fail over.
// RefundQuota releases a hold made with deducted=true when the render fails.
func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amount int) (allowed, deducted bool, err error) {
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item model.TokenAccount
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
return e
}
rem, known := metaInt(item.Meta, "cached_quota_remaining")
if !known {
allowed, deducted = true, false
return nil
}
if rem < amount {
allowed, deducted = false, false
return nil
}
meta := cloneMeta(item.Meta)
meta["cached_quota_remaining"] = rem - amount
if e := tx.Model(&model.TokenAccount{}).
Where("pool = ? AND id = ?", pool, id).
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error; e != nil {
return e
}
allowed, deducted = true, true
return nil
})
return allowed, deducted, err
}
// RefundQuota atomically adds `amount` back to cached_quota_remaining (releasing a
// hold from a reservation whose render then failed). No-op if the balance is
// unknown. Row-locked like ReserveQuota.
func (r *TokenRepository) RefundQuota(ctx context.Context, pool, id string, amount int) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item model.TokenAccount
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
return e
}
rem, known := metaInt(item.Meta, "cached_quota_remaining")
if !known {
return nil
}
meta := cloneMeta(item.Meta)
meta["cached_quota_remaining"] = rem + amount
return tx.Model(&model.TokenAccount{}).
Where("pool = ? AND id = ?", pool, id).
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error
})
}
func cloneMeta(m datatypes.JSONMap) datatypes.JSONMap {
out := datatypes.JSONMap{}
for k, v := range m {
out[k] = v
}
return out
}
func metaInt(m datatypes.JSONMap, key string) (int, bool) {
if m == nil {
return 0, false
}
v, ok := m[key]
if !ok || v == nil {
return 0, false
}
switch x := v.(type) {
case int:
return x, true
case int64:
return int(x), true
case float64:
return int(x), true
case json.Number:
n, e := x.Int64()
if e != nil {
return 0, false
}
return int(n), true
case string:
n, e := strconv.Atoi(strings.TrimSpace(x))
if e != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
// TouchLastUsed stamps last_used_at at the moment a token is SELECTED, so the
// accounts view reflects an accurate "last used" time. Rotation order is driven
// by the in-memory strict round-robin cursor in the service layer (see
// V1Service.rotateRoundRobin), not by this timestamp.
func (r *TokenRepository) TouchLastUsed(ctx context.Context, id string) error {
return r.db.WithContext(ctx).
Model(&model.TokenAccount{}).
Where("id = ?", id).
Update("last_used_at", time.Now()).Error
}
// IncrementFail bumps an account's failure counters by one. Used to attribute
// an abandoned (purged) generation's failure back to the account it was using,
// since that generation never reached the normal markTokenFailure path.
func (r *TokenRepository) IncrementFail(ctx context.Context, id string) error {
return r.db.WithContext(ctx).
Model(&model.TokenAccount{}).
Where("id = ?", id).
Updates(map[string]any{
"fail_total": gorm.Expr("fail_total + 1"),
"fails": gorm.Expr("fails + 1"),
"updated_at": time.Now(),
}).Error
}
func (r *TokenRepository) Delete(ctx context.Context, pool, id string) (int64, error) {
res := r.db.WithContext(ctx).
Delete(&model.TokenAccount{}, "pool = ? AND id = ?", pool, id)
return res.RowsAffected, res.Error
}
// DeleteByIDs removes accounts by id across pools (ids are globally unique),
// for bulk delete. Returns the number of rows removed.
func (r *TokenRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
res := r.db.WithContext(ctx).Delete(&model.TokenAccount{}, "id IN ?", ids)
return res.RowsAffected, res.Error
}
// leonardoDailyTokens is the free-tier daily allowance restored at each reset.
// A paid account's true balance is reconciled on its next successful render.
const leonardoDailyTokens = 150
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed.
// Reset source: cached_quota_reset_after (upstream marker) first, else the
// quota_recover_at fallback stamped when the token was marked quota-exhausted.
// Mirrors Python TokenPool.recover_quota; returns the count reactivated.
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed and
// returns the accounts it recovered, so the caller can re-sync their real balance
// (the providers only sync quota when accessed).
func (r *TokenRepository) RecoverQuota(ctx context.Context) ([]model.TokenAccount, error) {
// Also pick up accounts that are only single-kind limited (image_limited /
// video_limited) — those keep status "active" and would otherwise never have
// their per-kind flag cleared. Adobe resets both kinds at once, so the shared
// reset time gates recovery for all of them.
var items []model.TokenAccount
if err := r.db.WithContext(ctx).
Where("status = ? OR image_limited = ? OR video_limited = ?", "quota", true, true).
Find(&items).Error; err != nil {
return nil, err
}
now := time.Now()
var recovered []model.TokenAccount
for i := range items {
t := &items[i]
// Runway's reset marker is the JWT expiry, not a quota-refresh time, and
// there's no way to refresh a bare JWT — so a runway account is never
// "recovered"; it's expired-to-dead by ExpireByReset instead.
if t.Pool == "runway" {
continue
}
reset := parseResetMarker(t.CachedQuotaResetAfter)
if reset == nil {
reset = t.QuotaRecoverAt
}
if reset == nil || now.Before(*reset) {
continue
}
patch := map[string]any{
"fails": 0,
"quota_recover_at": nil,
"image_limited": false,
"video_limited": false,
}
// Only flip status back to active if it was sunk to "quota" (both kinds
// limited); a single-kind limit left status untouched.
if t.Status == "quota" {
patch["status"] = "active"
}
// Leonardo's free tokens fully renew at each daily reset — restore the
// balance and advance the reset marker to the next 08:00 Beijing (== next
// UTC midnight), so the account is immediately usable instead of stuck at a
// stale 0. A paid account's real balance is corrected on its next render.
if t.Pool == "leonardo" || t.Pool == "krea" || t.Pool == "imagine" {
meta := cloneMeta(t.Meta)
if t.Pool == "leonardo" {
meta["cached_quota_remaining"] = leonardoDailyTokens
} else {
// Krea/Imagine balances re-sync from upstream (billing-data / v1/credit)
// on next probe — drop the stale value so the account isn't shown as
// empty after reset.
delete(meta, "cached_quota_remaining")
}
meta["cached_quota_at"] = int(now.Unix())
patch["meta"] = meta
patch["cached_quota_reset_after"] = time.Unix((now.Unix()/86400+1)*86400, 0).UTC().Format(time.RFC3339)
}
if _, err := r.Update(ctx, t.Pool, t.ID, patch); err != nil {
return recovered, err
}
recovered = append(recovered, *t)
}
return recovered, nil
}
// RollResetMarkers advances a stale (past) daily-reset marker to its next future
// occurrence — same time-of-day, +N whole days — for ACTIVE accounts of the given
// daily-reset pools, so the 恢复时间 column always shows the upcoming reset rather
// than yesterday's. Only active accounts are rolled: a 限额 account must keep its
// past marker so RecoverQuota can recover it (rolling it forward early would
// prevent recovery). Returns the number advanced.
func (r *TokenRepository) RollResetMarkers(ctx context.Context, pools []string) (int, error) {
var items []model.TokenAccount
if err := r.db.WithContext(ctx).
Where("pool IN ? AND dead = ? AND status = ? AND image_limited = ? AND video_limited = ? AND cached_quota_reset_after <> ''",
pools, false, "active", false, false).
Find(&items).Error; err != nil {
return 0, err
}
now := time.Now()
n := 0
for i := range items {
t := &items[i]
reset := parseResetMarker(t.CachedQuotaResetAfter)
if reset == nil || !reset.Before(now) {
continue // unparseable or already in the future
}
next := *reset
for !next.After(now) {
next = next.Add(24 * time.Hour)
}
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
"cached_quota_reset_after": next.UTC().Format(time.RFC3339),
}); err != nil {
return n, err
}
n++
}
return n, nil
}
// ExpireByReset marks accounts of a pool dead once their reset marker has passed.
// For runway the marker IS the JWT expiry and there's no refresh, so an expired
// token can only 401 — we proactively flip it to disabled+dead (the same end
// state a 401 would produce) instead of leaving a doomed account "active".
func (r *TokenRepository) ExpireByReset(ctx context.Context, pool string) (int, error) {
var items []model.TokenAccount
if err := r.db.WithContext(ctx).
Where("pool = ? AND dead = ?", pool, false).
Find(&items).Error; err != nil {
return 0, err
}
now := time.Now()
expired := 0
for i := range items {
t := &items[i]
reset := parseResetMarker(t.CachedQuotaResetAfter)
if reset == nil || now.Before(*reset) {
continue
}
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
"status": "disabled",
"dead": true,
}); err != nil {
return expired, err
}
expired++
}
return expired, nil
}
// parseResetMarker best-effort parses a quota reset marker into a time. Accepts
// epoch seconds (numeric string) or ISO-8601 (e.g. Adobe's available_until
// "2026-06-16T23:59:59.999Z"). Returns nil if unparseable.
func parseResetMarker(v string) *time.Time {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 946684800 {
t := time.Unix(int64(f), 0)
return &t
}
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.999Z07:00", "2006-01-02T15:04:05Z07:00"} {
if t, err := time.Parse(layout, v); err == nil {
return &t
}
}
return nil
}
+627
View File
@@ -0,0 +1,627 @@
package repo
import (
"context"
"errors"
"strings"
"time"
"backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserRepository struct {
db *gorm.DB
}
var ErrAlreadyCheckedInToday = errors.New("already checked in today")
type InviteStats struct {
InviteCount int64 `json:"invite_count"`
InviteEarned int `json:"invite_earned"`
}
type InviteRecord struct {
Name string `json:"name,omitempty"`
Inviter string `json:"inviter,omitempty"`
Invitee string `json:"invitee,omitempty"`
Reward int `json:"reward"`
RegisteredAt time.Time `json:"registered_at"`
CompletedAt *time.Time `json:"completed_at"`
Status string `json:"status"`
}
type InviteLogStats struct {
Total int64 `json:"total"`
Completed int64 `json:"completed"`
Pending int64 `json:"pending"`
RewardPaid int64 `json:"reward_paid"`
}
type CheckinResult struct {
Already bool `json:"already"`
Awarded int `json:"awarded"`
Streak int `json:"streak"`
Credits float64 `json:"credits"`
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetByID(ctx context.Context, userID string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByIdentifier(ctx context.Context, identifier string) (*model.User, error) {
ident := strings.TrimSpace(identifier)
if ident == "" {
return nil, gorm.ErrRecordNotFound
}
var user model.User
q := r.db.WithContext(ctx).Preload("APIKeys")
if strings.Contains(ident, "@") {
if err := q.First(&user, "email = ?", strings.ToLower(ident)).Error; err != nil {
return nil, err
}
return &user, nil
}
if err := q.First(&user, "LOWER(name) = ?", strings.ToLower(ident)).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).First(&user, "invite_code = ?", strings.ToUpper(strings.TrimSpace(code))).Error; err != nil {
return nil, err
}
return &user, nil
}
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 {
return nil, err
}
return users, nil
}
func (r *UserRepository) ExistsEmail(ctx context.Context, email, excludeUserID string) (bool, error) {
var count int64
q := r.db.WithContext(ctx).Model(&model.User{}).Where("email = ?", strings.ToLower(strings.TrimSpace(email)))
if strings.TrimSpace(excludeUserID) != "" {
q = q.Where("id <> ?", strings.TrimSpace(excludeUserID))
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) ExistsName(ctx context.Context, name, excludeUserID string) (bool, error) {
var count int64
q := r.db.WithContext(ctx).Model(&model.User{}).Where("LOWER(name) = ?", strings.ToLower(strings.TrimSpace(name)))
if strings.TrimSpace(excludeUserID) != "" {
q = q.Where("id <> ?", strings.TrimSpace(excludeUserID))
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*model.User, error) {
var apiKey model.APIKey
if err := r.db.WithContext(ctx).First(&apiKey, "key_hash = ?", keyHash).Error; err != nil {
return nil, err
}
var user model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", apiKey.UserID).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) TouchLogin(ctx context.Context, userID, ip string) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"last_login_at": now,
"last_login_ip": ip,
}).Error
}
func (r *UserRepository) HasAdmin(ctx context.Context) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("role = ?", "admin").
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) Stats(ctx context.Context) (map[string]any, error) {
var total, active, disabled, admins int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "active").Count(&active).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "disabled").Count(&disabled).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("role = ?", "admin").Count(&admins).Error; err != nil {
return nil, err
}
type sumRow struct {
Total *float64 `gorm:"column:total"`
}
var credits sumRow
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("SUM(credits) AS total").
Scan(&credits).Error; err != nil {
return nil, err
}
now := time.Now()
dayCut := now.Add(-24 * time.Hour)
weekCut := now.Add(-7 * 24 * time.Hour)
var new24h, new7d, active24h int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", dayCut).Count(&new24h).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", weekCut).Count(&new7d).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("last_login_at >= ?", dayCut).Count(&active24h).Error; err != nil {
return nil, err
}
creditsTotal := 0.0
if credits.Total != nil {
creditsTotal = *credits.Total
}
return map[string]any{
"total": total,
"active": active,
"disabled": disabled,
"admins": admins,
"credits_total": creditsTotal,
"new_24h": new24h,
"new_7d": new7d,
"active_24h": active24h,
}, nil
}
type CheckinStats struct {
TodayCount int64 `json:"today_count"`
MaxStreak int64 `json:"max_streak"`
}
// CheckinStats counts users who checked in today and the longest active streak —
// a single-query summary for the admin dashboard's 签到 card.
func (r *UserRepository) CheckinStats(ctx context.Context) (*CheckinStats, error) {
today := time.Now().Format("2006-01-02")
type row struct {
TodayCount int64 `gorm:"column:today_count"`
MaxStreak int64 `gorm:"column:max_streak"`
}
var out row
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("COUNT(*) FILTER (WHERE checkin_last = ?) AS today_count, COALESCE(MAX(checkin_streak), 0) AS max_streak", today).
Scan(&out).Error; err != nil {
return nil, err
}
return &CheckinStats{TodayCount: out.TodayCount, MaxStreak: out.MaxStreak}, nil
}
type InviteSummary struct {
Total int64 `json:"total"`
Completed int64 `json:"completed"`
}
// InviteSummary is a lightweight count of invited users (and how many have had
// their reward granted). Cheaper than AllInvites — no JOIN, no record list —
// for the dashboard which polls frequently.
func (r *UserRepository) InviteSummary(ctx context.Context) (*InviteSummary, error) {
type row struct {
Total int64 `gorm:"column:total"`
Completed int64 `gorm:"column:completed"`
}
var out row
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("COUNT(*) AS total, COUNT(*) FILTER (WHERE invite_reward_done) AS completed").
Where("invited_by IS NOT NULL AND invited_by <> ''").
Scan(&out).Error; err != nil {
return nil, err
}
return &InviteSummary{Total: out.Total, Completed: out.Completed}, nil
}
func (r *UserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *UserRepository) Update(ctx context.Context, userID string, patch map[string]any) (*model.User, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(patch).Error; err != nil {
return nil, err
}
return r.GetByID(ctx, userID)
}
func (r *UserRepository) Delete(ctx context.Context, userID string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", userID)
return res.RowsAffected, res.Error
}
func (r *UserRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
res := r.db.WithContext(ctx).Delete(&model.User{}, "id IN ?", ids)
return res.RowsAffected, res.Error
}
func (r *UserRepository) SetPasswordByEmail(ctx context.Context, email, passwordHash string) (*model.User, error) {
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("email = ?", strings.ToLower(strings.TrimSpace(email))).
Updates(map[string]any{
"password_hash": passwordHash,
"updated_at": time.Now(),
}).Error; err != nil {
return nil, err
}
var user model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "email = ?", strings.ToLower(strings.TrimSpace(email))).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) TouchAPIKeyUsage(ctx context.Context, keyHash string) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&model.APIKey{}).
Where("key_hash = ?", keyHash).
Update("last_used_at", now).Error
}
func (r *UserRepository) InviteStats(ctx context.Context, userID string, reward int) (*InviteStats, error) {
var inviteCount int64
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("invited_by = ?", userID).
Count(&inviteCount).Error; err != nil {
return nil, err
}
var rewardedCount int64
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("invited_by = ? AND invite_reward_done = ?", userID, true).
Count(&rewardedCount).Error; err != nil {
return nil, err
}
return &InviteStats{
InviteCount: inviteCount,
InviteEarned: int(rewardedCount) * reward,
}, nil
}
func (r *UserRepository) InviteList(ctx context.Context, userID string, reward int) ([]InviteRecord, error) {
type row struct {
Name string
CreatedAt time.Time
InviteRewardDone bool
InviteRewardAt *time.Time
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("name, created_at, invite_reward_done, invite_reward_at").
Where("invited_by = ?", userID).
Order("created_at desc").
Find(&rows).Error; err != nil {
return nil, err
}
out := make([]InviteRecord, 0, len(rows))
for _, item := range rows {
status := "pending"
rewardValue := 0
if item.InviteRewardDone {
status = "completed"
rewardValue = reward
}
name := strings.TrimSpace(item.Name)
if name == "" {
name = "—"
}
out = append(out, InviteRecord{
Name: name,
Reward: rewardValue,
RegisteredAt: item.CreatedAt,
CompletedAt: item.InviteRewardAt,
Status: status,
})
}
return out, nil
}
func (r *UserRepository) AllInvites(ctx context.Context, reward int) ([]InviteRecord, *InviteLogStats, error) {
type row struct {
InviterName string `gorm:"column:inviter_name"`
InviterEmail string `gorm:"column:inviter_email"`
InviteeName string `gorm:"column:invitee_name"`
InviteeEmail string `gorm:"column:invitee_email"`
CreatedAt time.Time `gorm:"column:created_at"`
InviteRewardDone bool `gorm:"column:invite_reward_done"`
InviteRewardAt *time.Time `gorm:"column:invite_reward_at"`
}
var rows []row
if err := r.db.WithContext(ctx).
Table("users AS invitee").
Select(`
inviter.name AS inviter_name,
inviter.email AS inviter_email,
invitee.name AS invitee_name,
invitee.email AS invitee_email,
invitee.created_at,
invitee.invite_reward_done,
invitee.invite_reward_at
`).
Joins("JOIN users AS inviter ON inviter.id = invitee.invited_by").
Order("invitee.created_at desc").
Scan(&rows).Error; err != nil {
return nil, nil, err
}
out := make([]InviteRecord, 0, len(rows))
stats := &InviteLogStats{}
for _, item := range rows {
stats.Total++
status := "pending"
rewardValue := 0
if item.InviteRewardDone {
status = "completed"
rewardValue = reward
stats.Completed++
stats.RewardPaid += int64(reward)
} else {
stats.Pending++
}
inviter := strings.TrimSpace(item.InviterName)
if inviter == "" {
inviter = strings.TrimSpace(item.InviterEmail)
}
invitee := strings.TrimSpace(item.InviteeName)
if invitee == "" {
invitee = strings.TrimSpace(item.InviteeEmail)
}
out = append(out, InviteRecord{
Inviter: inviter,
Invitee: invitee,
Reward: rewardValue,
RegisteredAt: item.CreatedAt,
CompletedAt: item.InviteRewardAt,
Status: status,
})
}
return out, stats, nil
}
func (r *UserRepository) DailyCheckin(ctx context.Context, userID string, reward int) (*CheckinResult, error) {
today := time.Now().Format("2006-01-02")
yesterday := time.Now().Add(-24 * time.Hour).Format("2006-01-02")
var result *CheckinResult
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
return err
}
if user.CheckinLast == today {
result = &CheckinResult{
Already: true,
Awarded: 0,
Streak: user.CheckinStreak,
Credits: user.Credits,
}
return ErrAlreadyCheckedInToday
}
streak := 1
if user.CheckinLast == yesterday {
streak = user.CheckinStreak + 1
}
credits := user.Credits + float64(reward)
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": credits,
"checkin_last": today,
"checkin_streak": streak,
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
result = &CheckinResult{
Already: false,
Awarded: reward,
Streak: streak,
Credits: credits,
}
return nil
})
if err != nil {
if errors.Is(err, ErrAlreadyCheckedInToday) {
return result, nil
}
return nil, err
}
return result, nil
}
func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta float64) (*model.User, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
return err
}
nextCredits := user.Credits + delta
if nextCredits < 0 {
nextCredits = 0
}
return tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": nextCredits,
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, err
}
return r.GetByID(ctx, userID)
}
// SetCredits sets a user's credit balance to an absolute (non-negative) value.
// The row is locked for the duration of the transaction so it stays consistent
// with concurrent AdjustCredits/TryDebitCredits operations.
func (r *UserRepository) SetCredits(ctx context.Context, userID string, value float64) (*model.User, error) {
if value < 0 {
value = 0
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": value,
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, err
}
return r.GetByID(ctx, userID)
}
func (r *UserRepository) TryDebitCredits(ctx context.Context, userID string, amount float64) (*model.User, bool, error) {
if amount <= 0 {
user, err := r.GetByID(ctx, userID)
return user, user != nil, err
}
var result *model.User
debited := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil {
return err
}
if user.Credits < amount {
result = &user
return nil
}
nextCredits := user.Credits - amount
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": nextCredits,
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
user.Credits = nextCredits
user.UpdatedAt = time.Now()
result = &user
debited = true
return nil
})
if err != nil {
return nil, false, err
}
return result, debited, nil
}
func (r *UserRepository) GrantInviteReward(ctx context.Context, inviteeUserID string, reward int) (bool, error) {
if reward <= 0 {
return false, nil
}
granted := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var invitee model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&invitee, "id = ?", inviteeUserID).Error; err != nil {
return err
}
if invitee.InvitedBy == nil || *invitee.InvitedBy == "" || invitee.InviteRewardDone {
return nil
}
var inviter model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&inviter, "id = ?", *invitee.InvitedBy).Error; err != nil {
return err
}
now := time.Now()
if err := tx.Model(&model.User{}).
Where("id = ?", invitee.ID).
Updates(map[string]any{
"invite_reward_done": true,
"invite_reward_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", inviter.ID).
Updates(map[string]any{
"credits": inviter.Credits + float64(reward),
"updated_at": now,
}).Error; err != nil {
return err
}
granted = true
return nil
})
if err != nil {
return false, err
}
return granted, nil
}