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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user