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
+568
View File
@@ -0,0 +1,568 @@
package service
import (
"context"
"sort"
"strings"
"time"
"backend/internal/config"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/storage"
)
type AdminReadService struct {
cfg *config.Config
users *repo.UserRepository
models *repo.ModelRepository
events *repo.EventRepository
settings *repo.SiteSettingRepository
tokens *repo.TokenRepository
cdks *repo.CDKRepository
store *storage.Client
}
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client) *AdminReadService {
return &AdminReadService{
cfg: cfg,
users: users,
models: models,
events: events,
settings: settings,
tokens: tokens,
cdks: cdks,
store: store,
}
}
func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]any, error) {
users, err := s.users.List(ctx)
if err != nil {
return nil, nil, err
}
counts, err := s.events.UserSuccessCounts(ctx)
if err != nil {
return nil, nil, err
}
for i := range users {
meta := users[i].Notes
_ = meta
}
stats, err := s.users.Stats(ctx)
if err != nil {
return nil, nil, err
}
stats["generation_counts"] = counts
return users, stats, nil
}
func (s *AdminReadService) Models(ctx context.Context) ([]model.ModelConfig, error) {
return s.models.List(ctx)
}
func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, error) {
items, err := s.models.List(ctx)
if err != nil {
return nil, err
}
counts, err := s.events.ModelSuccessCounts(ctx)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, map[string]any{
"id": item.ID,
"type": item.Type,
"name": item.Name,
"provider": item.Provider,
"enabled": item.Enabled,
"ratios": repo.JSONStrings(item.Ratios),
"prices": map[string]any(item.Prices),
"resolutions": repo.JSONStrings(item.Resolutions),
"image_to_image": item.ImageToImage,
"duration_prices": map[string]any(item.DurationPrices),
"prices_agent": map[string]any(item.PricesAgent),
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
"durations": repo.JSONStrings(item.Durations),
"max_reference_images": item.MaxReferenceImages,
"reference_mode": item.ReferenceMode,
"weight": item.Weight,
"generation_count": counts[item.ID],
"created_at": item.CreatedAt,
"updated_at": item.UpdatedAt,
})
}
return out, nil
}
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, since *time.Time, userID, excludeSource, source string, hasFile bool) ([]model.EventLog, int64, *repo.EventStats, error) {
items, total, err := s.events.List(ctx, repo.EventListFilter{
Limit: limit,
Offset: offset,
Kind: kind,
Status: status,
Since: since,
UserID: userID,
ExcludeSource: excludeSource,
Source: source,
HasFile: hasFile,
})
if err != nil {
return nil, 0, nil, err
}
// 用户自己的日志(userID 非空)→ 按本人统计;管理员全站视图 → 全站统计。
var stats *repo.EventStats
if userID != "" {
stats, err = s.events.StatsByUser(ctx, userID)
} else {
stats, err = s.events.Stats(ctx)
}
if err != nil {
return nil, 0, nil, err
}
return items, total, stats, nil
}
// UserNameMap builds an id -> display name lookup (name, else email, else id)
// used to annotate admin log rows with user_name (mirrors admin.py:584-596).
func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string, error) {
users, err := s.users.List(ctx)
if err != nil {
return nil, err
}
out := make(map[string]string, len(users))
for _, u := range users {
name := strings.TrimSpace(u.Name)
if name == "" {
name = strings.TrimSpace(u.Email)
}
if name == "" {
name = u.ID
}
out[u.ID] = name
}
return out, nil
}
func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) {
stats, err := s.events.Stats(ctx)
if err != nil {
return nil, err
}
recentFiles, _ := s.RecentImages(ctx, 24)
files, fileStats, _ := s.scanGeneratedFiles(ctx)
var size int64
if v, ok := fileStats["size_bytes"].(int64); ok {
size = v
}
return map[string]any{
"generated_count": len(files),
"generated_size_bytes": size,
"recent": recentFiles,
"avg_elapsed_ms": stats.AvgElapsedMS,
"avg_elapsed_ms_24h": stats.AvgElapsedMS24,
}, nil
}
// Dashboard assembles the admin overview's analytics entirely server-side
// (event windows, hourly trend, top models/failures/spenders) plus CDK / invite
// / checkin summaries. This replaces the old client-side math over the last 200
// logs, which silently undercounted week/DAU/trend once volume grew.
func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error) {
now := time.Now()
dayCut := now.Add(-24 * time.Hour)
weekCut := now.Add(-7 * 24 * time.Hour)
day, err := s.events.WindowStats(ctx, dayCut)
if err != nil {
return nil, err
}
week, err := s.events.WindowStats(ctx, weekCut)
if err != nil {
return nil, err
}
prevDay, err := s.events.CountBetween(ctx, now.Add(-48*time.Hour), dayCut)
if err != nil {
return nil, err
}
dau, err := s.events.DistinctUsersSince(ctx, dayCut)
if err != nil {
return nil, err
}
wau, err := s.events.DistinctUsersSince(ctx, weekCut)
if err != nil {
return nil, err
}
hourly, err := s.events.HourlyBuckets(ctx)
if err != nil {
return nil, err
}
// Per-window top-N analytics so the frontend can toggle 24h / 7d without a
// re-fetch (the lists are small — top 6 / top 5).
nameByID, err := s.UserNameMap(ctx)
if err != nil {
return nil, err
}
analytics := func(since time.Time) (map[string]any, error) {
models, err := s.events.ModelUsageSince(ctx, since, 6)
if err != nil {
return nil, err
}
failures, err := s.events.TopFailures(ctx, since, 5)
if err != nil {
return nil, err
}
users, err := s.events.TopUserSpend(ctx, since, 6)
if err != nil {
return nil, err
}
for i := range users {
if users[i].UserID == "" {
users[i].Name = "匿名"
} else if name, ok := nameByID[users[i].UserID]; ok {
users[i].Name = name
} else {
users[i].Name = users[i].UserID
}
}
return map[string]any{"models": models, "failures": failures, "top_users": users}, nil
}
dayAnalytics, err := analytics(dayCut)
if err != nil {
return nil, err
}
weekAnalytics, err := analytics(weekCut)
if err != nil {
return nil, err
}
cdkStats, err := s.cdks.Stats(ctx)
if err != nil {
return nil, err
}
inviteReward := parseIntSetting(s.mustSetting(ctx, "credits.invite_reward"), 3)
inviteSummary, err := s.users.InviteSummary(ctx)
if err != nil {
return nil, err
}
checkinReward := parseIntSetting(s.mustSetting(ctx, "credits.checkin_reward"), 3)
checkin, err := s.users.CheckinStats(ctx)
if err != nil {
return nil, err
}
return map[string]any{
"day": day,
"week": week,
"prev_day_total": prevDay,
"dau": dau,
"wau": wau,
"hourly": hourly,
"analytics": map[string]any{
"day": dayAnalytics,
"week": weekAnalytics,
},
"cdk": cdkStats,
"invites": map[string]any{
"total": inviteSummary.Total,
"completed": inviteSummary.Completed,
"reward": inviteReward,
"reward_paid": inviteSummary.Completed * int64(inviteReward),
},
"checkin": map[string]any{
"today": checkin.TodayCount,
"max_streak": checkin.MaxStreak,
"reward": checkinReward,
"awarded_today": checkin.TodayCount * int64(checkinReward),
},
}, nil
}
// mustSetting reads a site setting value, returning "" on error so the caller's
// parseIntSetting default kicks in (a missing reward setting shouldn't 500 the
// whole dashboard).
func (s *AdminReadService) mustSetting(ctx context.Context, key string) string {
v, err := s.settings.GetValue(ctx, key)
if err != nil {
return ""
}
return v
}
func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *repo.InviteLogStats, error) {
rewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
if err != nil {
return nil, nil, err
}
reward := parseIntSetting(rewardRaw, 3)
return s.users.AllInvites(ctx, reward)
}
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
models, err := s.models.List(ctx)
if err != nil {
return nil, err
}
tokens, err := s.tokens.List(ctx)
if err != nil {
return nil, err
}
modelCounts := map[string]int{}
for _, item := range models {
modelCounts[item.Provider]++
}
type aggregate struct {
active int
disabled int
quota int
}
tokenCounts := map[string]*aggregate{}
for _, item := range tokens {
if _, ok := tokenCounts[item.Pool]; !ok {
tokenCounts[item.Pool] = &aggregate{}
}
switch item.Status {
case "active":
tokenCounts[item.Pool].active++
case "quota":
tokenCounts[item.Pool].quota++
default:
tokenCounts[item.Pool].disabled++
}
}
providers := []struct {
Name string
Pool string
Type string
}{
{Name: "chatgpt", Pool: "chatgpt", Type: "openai"},
{Name: "adobe", Pool: "adobe", Type: "adobe"},
}
out := make([]map[string]any, 0, len(providers))
for _, item := range providers {
count := tokenCounts[item.Pool]
if count == nil {
count = &aggregate{}
}
out = append(out, map[string]any{
"name": item.Name,
"token_pool": item.Pool,
"type": item.Type,
"model_count": modelCounts[item.Name],
"tokens_total": count.active + count.disabled + count.quota,
"tokens_active": count.active,
"tokens_disabled": count.disabled,
"tokens_quota": count.quota,
})
}
return out, nil
}
func (s *AdminReadService) Images(ctx context.Context, limit, offset int, kind string) ([]map[string]any, int, map[string]any, error) {
if limit <= 0 {
limit = 30
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
allFiles, stats, err := s.scanGeneratedFiles(ctx)
if err != nil {
return nil, 0, nil, err
}
filtered := make([]generatedFile, 0, len(allFiles))
for _, item := range allFiles {
if kind == "" || item.Kind == kind {
filtered = append(filtered, item)
}
}
sort.SliceStable(filtered, func(i, j int) bool {
return filtered[i].MTime > filtered[j].MTime
})
total := len(filtered)
if offset > total {
offset = total
}
end := offset + limit
if end > total {
end = total
}
page := filtered[offset:end]
index, err := s.eventIndexByFile(ctx)
if err != nil {
return nil, 0, nil, err
}
out := make([]map[string]any, 0, len(page))
for _, item := range page {
row := map[string]any{
"name": item.Name,
"size": item.Size,
"mtime": item.MTime,
"kind": item.Kind,
"prompt": "",
"model": "",
"resolution": "",
"ratio": "",
"duration": "",
}
if event, ok := index[item.Name]; ok {
row["prompt"] = event.Prompt
row["model"] = event.Model
row["resolution"] = event.Resolution
row["ratio"] = event.Ratio
row["duration"] = event.Duration
}
out = append(out, row)
}
return out, total, stats, nil
}
func (s *AdminReadService) RecentImages(ctx context.Context, limit int) ([]map[string]any, error) {
if limit <= 0 {
limit = 24
}
allFiles, _, err := s.scanGeneratedFiles(ctx)
if err != nil {
return nil, err
}
sort.SliceStable(allFiles, func(i, j int) bool {
return allFiles[i].MTime > allFiles[j].MTime
})
if len(allFiles) > limit {
allFiles = allFiles[:limit]
}
out := make([]map[string]any, 0, len(allFiles))
for _, item := range allFiles {
out = append(out, map[string]any{
"name": item.Name,
"size": item.Size,
"mtime": item.MTime,
"kind": item.Kind,
})
}
return out, nil
}
// RecentImagesOwned lists the most-recent generated images under a single owner
// directory (used by the showcase picker so an admin sees only their OWN images).
func (s *AdminReadService) RecentImagesOwned(ctx context.Context, owner string, limit int) ([]map[string]any, error) {
if limit <= 0 {
limit = 24
}
owner = strings.TrimSpace(owner)
if owner == "" || s.store == nil || !s.store.Configured() {
return []map[string]any{}, nil
}
objs, err := s.store.List(ctx, owner+"/")
if err != nil {
return nil, err
}
files := make([]generatedFile, 0, len(objs))
for _, o := range objs {
if isReferenceFile(o.Key) {
continue
}
kind := mediaKind(o.Key)
if kind == "" {
continue
}
files = append(files, generatedFile{Name: o.Key, Size: o.Size, MTime: o.LastModified.Unix(), Kind: kind})
}
sort.SliceStable(files, func(i, j int) bool { return files[i].MTime > files[j].MTime })
if len(files) > limit {
files = files[:limit]
}
out := make([]map[string]any, 0, len(files))
for _, f := range files {
out = append(out, map[string]any{"name": f.Name, "size": f.Size, "mtime": f.MTime, "kind": f.Kind})
}
return out, nil
}
func (s *AdminReadService) eventIndexByFile(ctx context.Context) (map[string]model.EventLog, error) {
items, err := s.events.RecentByFile(ctx, 10000)
if err != nil {
return nil, err
}
out := make(map[string]model.EventLog, len(items))
for _, item := range items {
if item.File == "" {
continue
}
if _, ok := out[item.File]; ok {
continue
}
out[item.File] = item
}
return out, nil
}
type generatedFile struct {
Name string
Size int64
MTime int64
Kind string
}
// mediaKind classifies an object key by extension (image / video / "" = skip).
func mediaKind(name string) string {
i := strings.LastIndex(name, ".")
if i < 0 {
return ""
}
switch strings.ToLower(name[i+1:]) {
case "png", "jpg", "jpeg", "webp", "gif":
return "image"
case "mp4", "webm", "mov":
return "video"
default:
return ""
}
}
// isReferenceFile reports whether a key is an uploaded reference image (named
// "...-ref-..."), so the gallery / picker can skip them — only generated outputs
// are listed.
func isReferenceFile(name string) bool {
return strings.Contains(name, "-ref-")
}
// scanGeneratedFiles lists media objects from RustFS (replacing the old local
// directory walk). Keys ARE the relative paths the rest of the app expects.
func (s *AdminReadService) scanGeneratedFiles(ctx context.Context) ([]generatedFile, map[string]any, error) {
stats := map[string]any{"total": 0, "image": 0, "video": 0, "size_bytes": int64(0)}
if s.store == nil || !s.store.Configured() {
return nil, stats, nil
}
objs, err := s.store.List(ctx, "")
if err != nil {
return nil, nil, err
}
out := make([]generatedFile, 0, len(objs))
for _, o := range objs {
if isReferenceFile(o.Key) {
continue // reference uploads are not generated outputs — hide from gallery
}
kind := mediaKind(o.Key)
if kind == "" {
continue
}
stats[kind] = stats[kind].(int) + 1
stats["total"] = stats["total"].(int) + 1
stats["size_bytes"] = stats["size_bytes"].(int64) + o.Size
out = append(out, generatedFile{
Name: o.Key,
Size: o.Size,
MTime: o.LastModified.Unix(),
Kind: kind,
})
}
return out, stats, nil
}
+635
View File
@@ -0,0 +1,635 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"backend/internal/model"
"backend/internal/repo"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ErrNotFound is returned by delete/adjust service methods when the target
// row does not exist, so handlers can translate it into a 404 (GORM's Delete
// does not error on a zero-row delete).
var ErrNotFound = errors.New("not found")
type AdminWriteService struct {
users *repo.UserRepository
showcase *repo.ShowcaseRepository
models *repo.ModelRepository
events *repo.EventRepository
apiKeys *repo.APIKeyRepository
}
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository) *AdminWriteService {
return &AdminWriteService{
users: users,
showcase: showcase,
models: models,
events: events,
apiKeys: apiKeys,
}
}
func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any) (*model.User, error) {
email, err := ValidateEmail(stringValue(body["email"]))
if err != nil {
return nil, err
}
name := strings.TrimSpace(stringValue(body["name"]))
if name != "" {
name, err = ValidateUsername(name)
if err != nil {
return nil, err
}
}
password := stringValue(body["password"])
role := normalizedRole(stringValue(body["role"]))
// 管理员唯一:不能通过用户管理创建新的 admin(只能是 user / agent)。
if role == "admin" {
role = "user"
}
status := normalizedStatus(stringValue(body["status"]))
credits := maxFloat(0, floatValue(body["credits"]))
notes := strings.TrimSpace(stringValue(body["notes"]))
exists, err := s.users.ExistsEmail(ctx, email, "")
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("邮箱已存在")
}
if name != "" {
exists, err = s.users.ExistsName(ctx, name, "")
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("用户名已存在")
}
}
passwordHash := ""
if strings.TrimSpace(password) != "" {
if err := ValidatePassword(password); err != nil {
return nil, err
}
h, err := HashPassword(password)
if err != nil {
return nil, err
}
passwordHash = h
}
user := &model.User{
ID: "u-" + uuid.NewString()[:10],
Email: email,
Name: name,
PasswordHash: passwordHash,
Role: role,
Status: status,
Credits: credits,
Notes: notes,
InviteCode: randomInviteCode(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.users.Create(ctx, user); err != nil {
return nil, err
}
return s.users.GetByID(ctx, user.ID)
}
func (s *AdminWriteService) UpdateUser(ctx context.Context, userID string, body map[string]any) (*model.User, error) {
patch := map[string]any{}
if _, ok := body["email"]; ok {
email, err := ValidateEmail(stringValue(body["email"]))
if err != nil {
return nil, err
}
exists, err := s.users.ExistsEmail(ctx, email, userID)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("邮箱已存在")
}
patch["email"] = email
}
if _, ok := body["name"]; ok {
name := strings.TrimSpace(stringValue(body["name"]))
if name != "" {
var err error
name, err = ValidateUsername(name)
if err != nil {
return nil, err
}
exists, err := s.users.ExistsName(ctx, name, userID)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("用户名已存在")
}
}
patch["name"] = name
}
if _, ok := body["role"]; ok {
newRole := normalizedRole(stringValue(body["role"]))
// 管理员唯一:不能把任何人提升为 admin;也绝不改动现有 admin 的角色
// (防止把唯一管理员误降级导致后台失去管理员)。
cur, _ := s.users.GetByID(ctx, userID)
if newRole != "admin" && (cur == nil || cur.Role != "admin") {
patch["role"] = newRole
}
}
if _, ok := body["status"]; ok {
patch["status"] = normalizedStatus(stringValue(body["status"]))
}
if _, ok := body["credits"]; ok {
patch["credits"] = maxFloat(0, floatValue(body["credits"]))
}
if _, ok := body["notes"]; ok {
patch["notes"] = strings.TrimSpace(stringValue(body["notes"]))
}
if _, ok := body["password"]; ok && strings.TrimSpace(stringValue(body["password"])) != "" {
if err := ValidatePassword(stringValue(body["password"])); err != nil {
return nil, err
}
h, err := HashPassword(stringValue(body["password"]))
if err != nil {
return nil, err
}
patch["password_hash"] = h
}
return s.users.Update(ctx, userID, patch)
}
func (s *AdminWriteService) DeleteUser(ctx context.Context, userID string) error {
rows, err := s.users.Delete(ctx, userID)
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
// DeleteUsers removes many users in one call (multi-select). Returns the count
// removed.
func (s *AdminWriteService) DeleteUsers(ctx context.Context, ids []string) (int, error) {
seen := make(map[string]struct{}, len(ids))
clean := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
clean = append(clean, id)
}
if len(clean) == 0 {
return 0, nil
}
rows, err := s.users.DeleteByIDs(ctx, clean)
return int(rows), err
}
func (s *AdminWriteService) AdjustUserCredits(ctx context.Context, userID string, delta float64) (*model.User, error) {
user, err := s.users.AdjustCredits(ctx, userID, delta)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return user, nil
}
// SetUserCredits sets a user's credit balance to an absolute value (non-negative).
// Mirrors Python users_store.adjust_credits set_to mode; the update runs inside a
// transaction with a row lock so concurrent adjustments stay consistent.
func (s *AdminWriteService) SetUserCredits(ctx context.Context, userID string, value float64) (*model.User, error) {
if value < 0 {
value = 0
}
user, err := s.users.SetCredits(ctx, userID, value)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return user, nil
}
func (s *AdminWriteService) CreateUserAPIKey(ctx context.Context, userID, name string) (*model.APIKey, string, error) {
plain, err := generatePlainAPIKey()
if err != nil {
return nil, "", err
}
name = strings.TrimSpace(name)
if name == "" {
name = "admin"
}
key := &model.APIKey{
ID: "k-" + time.Now().Format("150405") + randomSuffix(2),
UserID: userID,
Name: name,
KeyPreview: previewAPIKey(plain),
KeyHash: hashAPIKey(plain),
CreatedAt: time.Now(),
}
if err := s.apiKeys.Create(ctx, key); err != nil {
return nil, "", err
}
return key, plain, nil
}
func (s *AdminWriteService) DeleteUserAPIKey(ctx context.Context, userID, keyID string) error {
if strings.TrimSpace(keyID) == "" {
return errors.New("key id required")
}
return s.apiKeys.DeleteByID(ctx, userID, keyID)
}
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
kind := normalizedShowcaseKind(stringValue(body["kind"]))
if kind == "" {
return nil, errors.New("kind must be hero, bento or work")
}
image := strings.TrimSpace(stringValue(body["image"]))
if image == "" {
return nil, errors.New("请选择底图")
}
title := strings.TrimSpace(stringValue(body["title"]))
prompt := strings.TrimSpace(stringValue(body["prompt"]))
if kind != "work" {
if title == "" {
return nil, errors.New("请填写标题")
}
if prompt == "" {
return nil, errors.New("请填写提示词")
}
}
item := &model.ShowcaseItem{
ID: "sc-" + uuid.NewString()[:10],
Kind: kind,
Title: title,
Subtitle: strings.TrimSpace(stringValue(body["subtitle"])),
Prompt: prompt,
Gradient: strings.TrimSpace(stringValue(body["gradient"])),
Span: strings.TrimSpace(stringValue(body["span"])),
Image: image,
Weight: intValue(body["weight"]),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.showcase.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string, body map[string]any) (*model.ShowcaseItem, error) {
patch := map[string]any{}
if _, ok := body["kind"]; ok {
kind := normalizedShowcaseKind(stringValue(body["kind"]))
if kind == "" {
return nil, errors.New("kind must be hero, bento or work")
}
patch["kind"] = kind
}
for _, field := range []string{"title", "subtitle", "prompt", "gradient", "span", "image"} {
if _, ok := body[field]; ok {
patch[field] = strings.TrimSpace(stringValue(body[field]))
}
}
if _, ok := body["weight"]; ok {
patch["weight"] = intValue(body["weight"])
}
return s.showcase.Update(ctx, entryID, patch)
}
func (s *AdminWriteService) DeleteShowcase(ctx context.Context, entryID string) error {
rows, err := s.showcase.Delete(ctx, entryID)
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any) (*model.ModelConfig, error) {
modelID := strings.TrimSpace(stringValue(body["id"]))
modelType := normalizedModelType(stringValue(body["type"]))
provider := strings.TrimSpace(stringValue(body["provider"]))
if modelID == "" {
return nil, errors.New("id required")
}
if modelType == "" {
return nil, errors.New("type must be image or video")
}
if provider == "" {
return nil, errors.New("provider required")
}
prices := jsonMap(body["prices"])
// image: tiers derive from the price keys (form omits resolutions);
// video: resolutions come straight from the form (720p/1080p…). Python parity.
resolutions := jsonArray(body["resolutions"])
if modelType != "video" {
resolutions = resolutionsFromPrices(prices)
}
item := &model.ModelConfig{
ID: modelID,
Type: modelType,
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
Provider: provider,
Enabled: boolValueWithDefault(body["enabled"], true),
Ratios: jsonArray(body["ratios"]),
Prices: prices,
Resolutions: resolutions,
ImageToImage: boolValueWithDefault(body["image_to_image"], false),
DurationPrices: jsonMap(body["duration_prices"]),
PricesAgent: jsonMap(body["prices_agent"]),
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
Durations: jsonArray(body["durations"]),
MaxReferenceImages: intValue(body["max_reference_images"]),
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
Weight: intValue(body["weight"]),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.models.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, body map[string]any) (*model.ModelConfig, error) {
patch := map[string]any{}
if _, ok := body["type"]; ok {
modelType := normalizedModelType(stringValue(body["type"]))
if modelType == "" {
return nil, errors.New("type must be image or video")
}
patch["type"] = modelType
}
if _, ok := body["name"]; ok {
patch["name"] = strings.TrimSpace(stringValue(body["name"]))
}
if _, ok := body["provider"]; ok {
provider := strings.TrimSpace(stringValue(body["provider"]))
if provider == "" {
return nil, errors.New("provider required")
}
patch["provider"] = provider
}
// Only touch `enabled` when the caller explicitly sends a non-null value;
// mirrors Python models_store.update ("enabled" in fields and is not None).
// Without this guard a PATCH that omits the field would default it to false
// and silently disable the model.
if raw, ok := body["enabled"]; ok && raw != nil {
patch["enabled"] = boolValueWithDefault(raw, true)
}
if _, ok := body["ratios"]; ok {
patch["ratios"] = jsonArray(body["ratios"])
}
if _, ok := body["prices"]; ok {
prices := jsonMap(body["prices"])
patch["prices"] = prices
// Python parity (models_store.update): recompute resolutions from the new
// price keys. An explicit `resolutions` field below (video) overrides this.
patch["resolutions"] = resolutionsFromPrices(prices)
}
if _, ok := body["resolutions"]; ok {
patch["resolutions"] = jsonArray(body["resolutions"])
}
if _, ok := body["image_to_image"]; ok {
patch["image_to_image"] = boolValueWithDefault(body["image_to_image"], false)
}
if _, ok := body["duration_prices"]; ok {
patch["duration_prices"] = jsonMap(body["duration_prices"])
}
if _, ok := body["prices_agent"]; ok {
patch["prices_agent"] = jsonMap(body["prices_agent"])
}
if _, ok := body["duration_prices_agent"]; ok {
patch["duration_prices_agent"] = jsonMap(body["duration_prices_agent"])
}
if _, ok := body["durations"]; ok {
patch["durations"] = jsonArray(body["durations"])
}
if _, ok := body["max_reference_images"]; ok {
patch["max_reference_images"] = intValue(body["max_reference_images"])
}
if _, ok := body["reference_mode"]; ok {
patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none")
}
if _, ok := body["weight"]; ok {
patch["weight"] = intValue(body["weight"])
}
return s.models.Update(ctx, modelID, patch)
}
func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) error {
rows, err := s.models.Delete(ctx, modelID)
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
return s.events.DeleteAll(ctx)
}
func (s *AdminWriteService) ClearPendingLogs(ctx context.Context) (int64, error) {
return s.events.DeletePending(ctx)
}
func HashPassword(password string) (string, error) {
hash, err := GeneratePasswordHash(password)
if err != nil {
return "", err
}
return "bcrypt$" + hash, nil
}
func normalizedRole(role string) string {
switch strings.TrimSpace(role) {
case "admin":
return "admin"
case "agent":
return "agent"
default:
return "user"
}
}
func normalizedStatus(status string) string {
if strings.TrimSpace(status) == "disabled" {
return "disabled"
}
return "active"
}
func normalizedShowcaseKind(kind string) string {
switch strings.TrimSpace(kind) {
case "hero", "bento", "work":
return strings.TrimSpace(kind)
default:
return ""
}
}
func normalizedModelType(v string) string {
switch strings.TrimSpace(v) {
case "image", "video":
return strings.TrimSpace(v)
default:
return ""
}
}
func stringValue(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
default:
return fmt.Sprint(v)
}
}
func floatValue(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
case string:
var f float64
_, _ = fmt.Sscanf(strings.TrimSpace(x), "%f", &f)
return f
default:
return 0
}
}
func intValue(v any) int {
return int(floatValue(v))
}
func boolValueWithDefault(v any, fallback bool) bool {
if v == nil {
return fallback
}
switch x := v.(type) {
case bool:
return x
case string:
switch strings.ToLower(strings.TrimSpace(x)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return fallback
}
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
// an image model's quality tiers ARE its price keys (the admin form never sends
// `resolutions` for images), returned in canonical 1K/2K/4K order. gpt-image-2,
// for example, only ever has a "1K" price, so it resolves to exactly ["1K"].
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
out := []string{}
for _, r := range []string{"1K", "2K", "4K"} {
if _, ok := prices[r]; ok {
out = append(out, r)
}
}
return jsonArray(out)
}
func jsonArray(v any) datatypes.JSON {
if v == nil {
return datatypes.JSON([]byte("[]"))
}
b, err := json.Marshal(v)
if err != nil {
return datatypes.JSON([]byte("[]"))
}
return datatypes.JSON(b)
}
func jsonMap(v any) datatypes.JSONMap {
if v == nil {
return datatypes.JSONMap{}
}
switch m := v.(type) {
case map[string]any:
return datatypes.JSONMap(m)
default:
b, err := json.Marshal(v)
if err != nil {
return datatypes.JSONMap{}
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
return datatypes.JSONMap{}
}
return datatypes.JSONMap(out)
}
}
func defaultString(v, fallback string) string {
if strings.TrimSpace(v) == "" {
return fallback
}
return v
}
func maxFloat(min, v float64) float64 {
if v < min {
return min
}
return v
}
func randomInviteCode() string {
return randomUpper(8)
}
var _ = gorm.ErrRecordNotFound
+20
View File
@@ -0,0 +1,20 @@
package service
import nanoid "github.com/matoous/go-nanoid/v2"
const UpperAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
func randomUpper(n int) string {
v, err := nanoid.Generate(UpperAlphabet, n)
if err != nil {
if n <= 0 {
return ""
}
out := make([]byte, n)
for i := range out {
out[i] = 'A'
}
return string(out)
}
return v
}
+130
View File
@@ -0,0 +1,130 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
"backend/internal/model"
"backend/internal/repo"
)
type APIKeyService struct {
keys *repo.APIKeyRepository
}
func NewAPIKeyService(keys *repo.APIKeyRepository) *APIKeyService {
return &APIKeyService{keys: keys}
}
func (s *APIKeyService) Current(ctx context.Context, userID string) (map[string]any, error) {
keys, err := s.keys.ListByUserID(ctx, userID)
if err != nil {
return nil, err
}
if len(keys) == 0 {
return map[string]any{"key": nil}, nil
}
key := keys[0]
return map[string]any{
"key": map[string]any{
"id": key.ID,
"name": key.Name,
"key_preview": key.KeyPreview,
"created_at": key.CreatedAt,
"last_used_at": key.LastUsedAt,
},
}, nil
}
func (s *APIKeyService) Mint(ctx context.Context, userID string) (map[string]any, error) {
plain, err := generatePlainAPIKey()
if err != nil {
return nil, err
}
key := &model.APIKey{
ID: "k-" + time.Now().Format("150405") + randomSuffix(2),
UserID: userID,
Name: "default",
KeyPreview: previewAPIKey(plain),
KeyHash: hashAPIKey(plain),
CreatedAt: time.Now(),
}
if err := s.keys.ReplaceForUser(ctx, userID, key); err != nil {
return nil, err
}
return map[string]any{
"ok": true,
"key": plain,
"preview": key.KeyPreview,
}, nil
}
func (s *APIKeyService) Revoke(ctx context.Context, userID string) error {
return s.keys.DeleteByUserID(ctx, userID)
}
func (s *APIKeyService) MintNamed(ctx context.Context, userID, name string, replace bool) (map[string]any, error) {
name = strings.TrimSpace(name)
if name == "" {
name = "default"
}
plain, err := generatePlainAPIKey()
if err != nil {
return nil, err
}
key := &model.APIKey{
ID: "k-" + time.Now().Format("150405") + randomSuffix(2),
UserID: userID,
Name: name,
KeyPreview: previewAPIKey(plain),
KeyHash: hashAPIKey(plain),
CreatedAt: time.Now(),
}
if replace {
if err := s.keys.ReplaceForUser(ctx, userID, key); err != nil {
return nil, err
}
} else {
if err := s.keys.Create(ctx, key); err != nil {
return nil, err
}
}
return map[string]any{
"ok": true,
"key": plain,
"preview": key.KeyPreview,
"id": key.ID,
"name": key.Name,
}, nil
}
func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) error {
if strings.TrimSpace(keyID) == "" {
return errors.New("key id required")
}
return s.keys.DeleteByID(ctx, userID, keyID)
}
func generatePlainAPIKey() (string, error) {
return "sk-" + randomUpper(38), nil
}
func previewAPIKey(plain string) string {
if len(plain) <= 4 {
return strings.Repeat("•", len(plain))
}
return "…" + plain[len(plain)-4:]
}
func hashAPIKey(plain string) string {
sum := sha256.Sum256([]byte(plain))
return "sha256:" + hex.EncodeToString(sum[:])
}
func randomSuffix(n int) string {
return randomUpper(n)
}
+438
View File
@@ -0,0 +1,438 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"backend/internal/repo"
"backend/internal/storage"
)
type AppSettingsService struct {
settings *repo.SiteSettingRepository
events *repo.EventRepository
smtp *SMTPService
store *storage.Client
}
type RegistrationSettings struct {
Open bool `json:"open"`
EmailCode bool `json:"email_code"`
AllowPasswordReset bool `json:"allow_password_reset"`
AllowedDomains []string `json:"allowed_email_domains"`
CodeTTLSeconds int `json:"code_ttl_seconds"`
}
type SMTPSettings struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromAddr string `json:"from_addr"`
UseTLS bool `json:"use_tls"`
}
type CreditSettings struct {
CheckinEnabled bool `json:"checkin_enabled"`
CheckinReward int `json:"checkin_reward"`
InviteEnabled bool `json:"invite_enabled"`
InviteReward int `json:"invite_reward"`
}
type ProxySettings struct {
Proxy string `json:"proxy"`
}
type RetentionSettings struct {
RetentionDays int `json:"retention_days"`
}
type MediaRetentionResult struct {
Settings *RetentionSettings
Removed int `json:"removed"`
FreedBytes int64 `json:"freed_bytes"`
}
func NewAppSettingsService(settings *repo.SiteSettingRepository, events *repo.EventRepository, smtp *SMTPService, store *storage.Client) *AppSettingsService {
return &AppSettingsService{
settings: settings,
events: events,
smtp: smtp,
store: store,
}
}
func (s *AppSettingsService) Registration(ctx context.Context) (*RegistrationSettings, error) {
openRaw, err := s.settings.GetValue(ctx, "auth.open")
if err != nil {
return nil, err
}
emailCodeRaw, err := s.settings.GetValue(ctx, "auth.email_code")
if err != nil {
return nil, err
}
resetRaw, err := s.settings.GetValue(ctx, "auth.allow_password_reset")
if err != nil {
return nil, err
}
domainsRaw, err := s.settings.GetValue(ctx, "auth.allowed_email_domains")
if err != nil {
return nil, err
}
ttlRaw, err := s.settings.GetValue(ctx, "auth.code_ttl_seconds")
if err != nil {
return nil, err
}
ttl, _ := strconv.Atoi(strings.TrimSpace(ttlRaw))
if ttl < 60 {
ttl = 600
}
return &RegistrationSettings{
Open: parseBoolSetting(openRaw, true),
EmailCode: parseBoolSetting(emailCodeRaw, false),
AllowPasswordReset: parseBoolSetting(resetRaw, false),
AllowedDomains: parseCSVSetting(domainsRaw),
CodeTTLSeconds: ttl,
}, nil
}
func (s *AppSettingsService) SaveRegistration(ctx context.Context, in RegistrationSettings) (*RegistrationSettings, error) {
// Empty list is allowed and means "no domain restriction": EmailDomainAllowed
// returns true for everyone when the whitelist is empty.
domains := ValidateAllowedEmailDomains(in.AllowedDomains)
if in.CodeTTLSeconds < 60 {
in.CodeTTLSeconds = 600
}
if err := s.settings.UpsertValues(ctx, map[string]string{
"auth.open": strconv.FormatBool(in.Open),
"auth.email_code": strconv.FormatBool(in.EmailCode),
"auth.allow_password_reset": strconv.FormatBool(in.AllowPasswordReset),
"auth.allowed_email_domains": strings.Join(domains, ","),
"auth.code_ttl_seconds": strconv.Itoa(in.CodeTTLSeconds),
}); err != nil {
return nil, err
}
return s.Registration(ctx)
}
func (s *AppSettingsService) SMTP(ctx context.Context) (*SMTPSettings, error) {
host, err := s.settings.GetValue(ctx, "smtp.host")
if err != nil {
return nil, err
}
portRaw, err := s.settings.GetValue(ctx, "smtp.port")
if err != nil {
return nil, err
}
username, err := s.settings.GetValue(ctx, "smtp.username")
if err != nil {
return nil, err
}
password, err := s.settings.GetValue(ctx, "smtp.password")
if err != nil {
return nil, err
}
fromAddr, err := s.settings.GetValue(ctx, "smtp.from_addr")
if err != nil {
return nil, err
}
useTLSRaw, err := s.settings.GetValue(ctx, "smtp.use_tls")
if err != nil {
return nil, err
}
port, _ := strconv.Atoi(strings.TrimSpace(portRaw))
if port <= 0 {
port = 587
}
return &SMTPSettings{
Host: strings.TrimSpace(host),
Port: port,
Username: strings.TrimSpace(username),
Password: maskedSecret(password),
FromAddr: strings.TrimSpace(fromAddr),
UseTLS: parseBoolSetting(useTLSRaw, true),
}, nil
}
func (s *AppSettingsService) SaveSMTP(ctx context.Context, in SMTPSettings) (*SMTPSettings, error) {
host := strings.TrimSpace(in.Host)
username := strings.TrimSpace(in.Username)
fromAddr := strings.TrimSpace(in.FromAddr)
if host == "" || username == "" || fromAddr == "" {
return nil, errors.New("请填写 主机 / 用户名 / 发件地址")
}
if _, err := ValidateEmail(fromAddr); err != nil {
return nil, err
}
if in.Port <= 0 {
return nil, errors.New("port 必须是正整数")
}
updates := map[string]string{
"smtp.host": host,
"smtp.port": strconv.Itoa(in.Port),
"smtp.username": username,
"smtp.from_addr": fromAddr,
"smtp.use_tls": strconv.FormatBool(in.UseTLS),
}
if strings.TrimSpace(in.Password) != "" && strings.TrimSpace(in.Password) != "***" {
updates["smtp.password"] = in.Password
}
if err := s.settings.UpsertValues(ctx, updates); err != nil {
return nil, err
}
return s.SMTP(ctx)
}
func (s *AppSettingsService) TestSMTP(ctx context.Context, to string) error {
to, err := ValidateEmail(to)
if err != nil {
return err
}
cfg, err := s.loadSMTPConfig(ctx)
if err != nil {
return err
}
return s.smtp.SendCode(ctx, cfg, to, "123456", "register")
}
func (s *AppSettingsService) Proxy(ctx context.Context) (*ProxySettings, error) {
proxy, err := s.settings.GetValue(ctx, "proxy.url")
if err != nil {
return nil, err
}
return &ProxySettings{Proxy: strings.TrimSpace(proxy)}, nil
}
func (s *AppSettingsService) SaveProxy(ctx context.Context, proxy string) (*ProxySettings, error) {
proxy = strings.TrimSpace(proxy)
if err := s.settings.UpsertValue(ctx, "proxy.url", proxy); err != nil {
return nil, err
}
return &ProxySettings{Proxy: proxy}, nil
}
// TestProxy routes a probe request through the given proxy to an IP-echo service
// and reports the egress IP + latency. Tests the value passed in (so the admin
// can verify before saving). Mirrors how generation calls go out — same HTTP
// CONNECT through the proxy — so a green result means upstream calls will route.
func (s *AppSettingsService) TestProxy(ctx context.Context, proxy string) (map[string]any, error) {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return nil, errors.New("代理地址为空,请先填写")
}
parsed, err := url.Parse(proxy)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("代理地址格式不正确(应形如 http://user:pass@host:port)")
}
transport := &http.Transport{Proxy: http.ProxyURL(parsed)}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 12 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.ipify.org?format=json", nil)
if err != nil {
return nil, err
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("通过代理请求失败:%v", err)
}
defer resp.Body.Close()
elapsed := int(time.Since(start).Milliseconds())
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("代理已连接,但探测返回 HTTP %d", resp.StatusCode)
}
var echo struct {
IP string `json:"ip"`
}
_ = json.Unmarshal(body, &echo)
return map[string]any{
"exit_ip": echo.IP,
"elapsed_ms": elapsed,
}, nil
}
func (s *AppSettingsService) Credits(ctx context.Context) (*CreditSettings, error) {
checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled")
if err != nil {
return nil, err
}
checkinRewardRaw, err := s.settings.GetValue(ctx, "credits.checkin_reward")
if err != nil {
return nil, err
}
inviteEnabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled")
if err != nil {
return nil, err
}
inviteRewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
if err != nil {
return nil, err
}
return &CreditSettings{
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
InviteReward: parseIntSetting(inviteRewardRaw, 3),
}, nil
}
func (s *AppSettingsService) SaveCredits(ctx context.Context, in CreditSettings) (*CreditSettings, error) {
if in.CheckinReward < 0 {
in.CheckinReward = 0
}
if in.InviteReward < 0 {
in.InviteReward = 0
}
if err := s.settings.UpsertValues(ctx, map[string]string{
"credits.checkin_enabled": strconv.FormatBool(in.CheckinEnabled),
"credits.checkin_reward": strconv.Itoa(in.CheckinReward),
"credits.invite_enabled": strconv.FormatBool(in.InviteEnabled),
"credits.invite_reward": strconv.Itoa(in.InviteReward),
}); err != nil {
return nil, err
}
return s.Credits(ctx)
}
func (s *AppSettingsService) Logs(ctx context.Context) (*RetentionSettings, error) {
return s.retention(ctx, "logs.retention_days")
}
func (s *AppSettingsService) SaveLogs(ctx context.Context, days int) (*RetentionSettings, error) {
days, err := normalizeRetentionDays(days)
if err != nil {
return nil, err
}
if err := s.settings.UpsertValue(ctx, "logs.retention_days", strconv.Itoa(days)); err != nil {
return nil, err
}
if s.events != nil {
_, _ = s.events.PurgeOlderThan(ctx, time.Duration(days)*24*time.Hour)
}
return s.Logs(ctx)
}
func (s *AppSettingsService) Media(ctx context.Context) (*RetentionSettings, error) {
return s.retention(ctx, "media.retention_days")
}
func (s *AppSettingsService) SaveMedia(ctx context.Context, days int) (*MediaRetentionResult, error) {
days, err := normalizeRetentionDays(days)
if err != nil {
return nil, err
}
if err := s.settings.UpsertValue(ctx, "media.retention_days", strconv.Itoa(days)); err != nil {
return nil, err
}
removed, freed := s.pruneGeneratedFiles(ctx, time.Duration(days)*24*time.Hour)
settings, err := s.Media(ctx)
if err != nil {
return nil, err
}
return &MediaRetentionResult{
Settings: settings,
Removed: removed,
FreedBytes: freed,
}, nil
}
func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, error) {
current, err := s.SMTP(ctx)
if err != nil {
return SMTPConfig{}, err
}
password, err := s.settings.GetValue(ctx, "smtp.password")
if err != nil {
return SMTPConfig{}, err
}
return SMTPConfig{
Host: current.Host,
Port: current.Port,
Username: current.Username,
Password: password,
FromAddr: current.FromAddr,
UseTLS: current.UseTLS,
}, nil
}
func maskedSecret(v string) string {
if strings.TrimSpace(v) == "" {
return ""
}
return "***"
}
func parseIntSetting(v string, fallback int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return fallback
}
return n
}
func (s *AppSettingsService) retention(ctx context.Context, key string) (*RetentionSettings, error) {
raw, err := s.settings.GetValue(ctx, key)
if err != nil {
return nil, err
}
days := parseIntSetting(raw, 30)
if days < 1 {
days = 30
}
return &RetentionSettings{RetentionDays: days}, nil
}
func normalizeRetentionDays(days int) (int, error) {
if days < 1 {
return 0, errors.New("留存天数至少为 1 天")
}
if days > 365 {
return 0, errors.New("留存天数最多 365 天")
}
return days, nil
}
// pruneGeneratedFiles deletes RustFS objects older than maxAge and blanks the
// matching event_log.file refs. Returns how many were removed and bytes freed.
// (The maintenance loop does the same automatically every 60s; this gives the
// admin an immediate result when they shorten the media retention window.)
func (s *AppSettingsService) pruneGeneratedFiles(ctx context.Context, maxAge time.Duration) (int, int64) {
if s.store == nil || !s.store.Configured() || maxAge <= 0 {
return 0, 0
}
objs, err := s.store.List(ctx, "")
if err != nil {
return 0, 0
}
cutoff := time.Now().Add(-maxAge)
removed := 0
var freed int64
var clearedKeys []string
for _, o := range objs {
if !o.LastModified.Before(cutoff) {
continue
}
if err := s.store.Delete(ctx, o.Key); err == nil {
removed++
freed += o.Size
clearedKeys = append(clearedKeys, o.Key)
}
}
if len(clearedKeys) > 0 {
_, _ = s.events.ClearFiles(ctx, clearedKeys)
}
return removed, freed
}
+584
View File
@@ -0,0 +1,584 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
"backend/internal/model"
"backend/internal/repo"
"gorm.io/gorm"
)
var ErrAuthFailed = errors.New("auth failed")
type AuthService struct {
users *repo.UserRepository
settings *repo.SiteSettingRepository
sessions *SessionService
codes *EmailCodeService
smtp *SMTPService
loginGuard *LoginGuard
}
type AuthSettings struct {
Open bool
EmailCode bool
AllowPasswordReset bool
AllowedDomains []string
}
func NewAuthService(
users *repo.UserRepository,
settings *repo.SiteSettingRepository,
sessions *SessionService,
codes *EmailCodeService,
smtp *SMTPService,
) *AuthService {
return &AuthService{
users: users,
settings: settings,
sessions: sessions,
codes: codes,
smtp: smtp,
loginGuard: NewLoginGuard(codes.Redis()),
}
}
func (s *AuthService) IsAuthorizedForPrivateImage(ctx context.Context, sessionCookie, owner string) (bool, error) {
// Private images are viewable ONLY via a logged-in session cookie (no Bearer
// token / API key). A regular user may view only their OWN images; an admin
// may view anyone's. `owner` is the /images/<owner>/... path segment.
if sessionCookie == "" {
return false, nil
}
payload, err := s.sessions.Validate(ctx, sessionCookie)
if err != nil {
return false, err
}
if payload == nil {
return false, nil
}
user, err := s.users.GetByID(ctx, payload.UserID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
if user.Role == "admin" {
return true, nil
}
return ownsImageDir(user, owner), nil
}
// ownsImageDir reports whether `owner` (the /images/<owner>/... directory) is one
// of the names this user's outputs are stored under. Mirrors the candidates
// V1Service.userDir picks from: sanitized name → sanitized email-local → id.
func ownsImageDir(user *model.User, owner string) bool {
owner = strings.TrimSpace(owner)
if owner == "" || user == nil {
return false
}
if owner == user.ID {
return true
}
if d := sanitizeOwnerName(user.Name); d != "" && d == owner {
return true
}
if d := sanitizeOwnerName(strings.Split(user.Email, "@")[0]); d != "" && d == owner {
return true
}
return false
}
func (s *AuthService) CurrentUserFromBearer(ctx context.Context, authHeader string) (*model.User, *SessionPayload, error) {
token := ParseBearer(authHeader)
return s.currentUserFromToken(ctx, token)
}
func (s *AuthService) CurrentUserFromRequest(ctx context.Context, authHeader, cookieToken string) (*model.User, *SessionPayload, error) {
if user, session, err := s.CurrentUserFromBearer(ctx, authHeader); err != nil || user != nil || session != nil {
return user, session, err
}
return s.currentUserFromToken(ctx, cookieToken)
}
func (s *AuthService) CurrentUserFromToken(ctx context.Context, token string) (*model.User, *SessionPayload, error) {
return s.currentUserFromToken(ctx, token)
}
func (s *AuthService) currentUserFromToken(ctx context.Context, token string) (*model.User, *SessionPayload, error) {
if token == "" {
return nil, nil, nil
}
payload, err := s.sessions.Validate(ctx, token)
if err != nil {
return nil, nil, err
}
if payload == nil {
return nil, nil, nil
}
user, err := s.users.GetByID(ctx, payload.UserID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil
}
return nil, nil, err
}
if user.Status != "active" {
return nil, nil, nil
}
return user, payload, nil
}
func (s *AuthService) Login(ctx context.Context, identifier, password, ip string) (*model.User, string, *SessionPayload, error) {
normalizedIdentifier, err := ValidateLoginIdentifier(identifier)
if err != nil {
return nil, "", nil, err
}
if strings.TrimSpace(password) == "" {
return nil, "", nil, errors.New("密码不能为空")
}
// Exponential-backoff lockout per (ip, account) + per-ip spray (Python
// api/auth.py:226-237 via core/login_guard.py).
if err := s.loginGuard.Check(ctx, ip, normalizedIdentifier); err != nil {
return nil, "", nil, err
}
user, err := s.users.GetByIdentifier(ctx, normalizedIdentifier)
if err != nil {
if err == gorm.ErrRecordNotFound {
if rerr := s.loginGuard.RecordFailure(ctx, ip, normalizedIdentifier); rerr != nil {
return nil, "", nil, rerr
}
return nil, "", nil, ErrAuthFailed
}
return nil, "", nil, err
}
if user.Status != "active" || !VerifyPassword(password, user.PasswordHash) {
if rerr := s.loginGuard.RecordFailure(ctx, ip, normalizedIdentifier); rerr != nil {
return nil, "", nil, rerr
}
return nil, "", nil, ErrAuthFailed
}
if err := s.loginGuard.RecordSuccess(ctx, ip, normalizedIdentifier); err != nil {
return nil, "", nil, err
}
if err := s.users.TouchLogin(ctx, user.ID, ip); err != nil {
return nil, "", nil, err
}
token, payload, err := s.sessions.Create(ctx, user.ID)
if err != nil {
return nil, "", nil, err
}
return user, token, payload, nil
}
func (s *AuthService) SendCode(ctx context.Context, email, purpose string) error {
cfg, err := s.loadAuthSettings(ctx)
if err != nil {
return err
}
if !cfg.EmailCode {
return errors.New("未开启邮箱验证码")
}
normalizedEmail, err := ValidateEmail(email)
if err != nil {
return err
}
purpose = strings.ToLower(strings.TrimSpace(purpose))
switch purpose {
case "register", "reset":
default:
return errors.New("验证码用途不正确")
}
if purpose == "register" && !EmailDomainAllowed(normalizedEmail, cfg.AllowedDomains) {
return errors.New("该邮箱后缀不允许注册")
}
code, err := s.codes.Issue(ctx, normalizedEmail, purpose)
if err != nil {
return err
}
return s.smtp.SendCode(ctx, s.loadSMTPSettings(ctx), normalizedEmail, code, purpose)
}
func (s *AuthService) Register(ctx context.Context, email, username, password, inviteCode, emailCode, ip string) (*model.User, string, *SessionPayload, error) {
normalizedEmail, err := ValidateEmail(email)
if err != nil {
return nil, "", nil, err
}
normalizedUsername, err := ValidateUsername(username)
if err != nil {
return nil, "", nil, err
}
if err := ValidatePassword(password); err != nil {
return nil, "", nil, err
}
settings, err := s.loadAuthSettings(ctx)
if err != nil {
return nil, "", nil, err
}
hasAdmin, err := s.users.HasAdmin(ctx)
if err != nil {
return nil, "", nil, err
}
// The very first account ever bootstraps the admin and skips the open
// toggle, the email-domain whitelist, and the email-code gate (Python
// api/auth.py:195-204). All three are only enforced once an admin exists.
if hasAdmin && !settings.Open {
return nil, "", nil, errors.New("当前未开放注册")
}
if hasAdmin && !EmailDomainAllowed(normalizedEmail, settings.AllowedDomains) {
return nil, "", nil, errors.New("该邮箱后缀不允许注册")
}
if hasAdmin && settings.EmailCode {
ok, err := s.codes.Verify(ctx, normalizedEmail, "register", emailCode)
if err != nil {
return nil, "", nil, err
}
if !ok {
return nil, "", nil, errors.New("邮箱验证码错误或已过期")
}
}
exists, err := s.users.ExistsEmail(ctx, normalizedEmail, "")
if err != nil {
return nil, "", nil, err
}
if exists {
return nil, "", nil, errors.New("邮箱已存在")
}
exists, err = s.users.ExistsName(ctx, normalizedUsername, "")
if err != nil {
return nil, "", nil, err
}
if exists {
return nil, "", nil, errors.New("用户名已存在")
}
passwordHash, err := HashPassword(password)
if err != nil {
return nil, "", nil, err
}
role := "user"
if !hasAdmin {
role = "admin"
}
var invitedBy *string
if strings.TrimSpace(inviteCode) != "" {
inviter, err := s.users.GetByInviteCode(ctx, inviteCode)
if err == nil {
invitedBy = &inviter.ID
}
}
now := time.Now()
user := &model.User{
ID: "u-" + randomUpper(10),
Email: normalizedEmail,
Name: normalizedUsername,
PasswordHash: passwordHash,
Role: role,
Status: "active",
InviteCode: randomInviteCode(),
InvitedBy: invitedBy,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.users.Create(ctx, user); err != nil {
return nil, "", nil, err
}
if err := s.users.TouchLogin(ctx, user.ID, ip); err != nil {
return nil, "", nil, err
}
token, payload, err := s.sessions.Create(ctx, user.ID)
if err != nil {
return nil, "", nil, err
}
created, err := s.users.GetByID(ctx, user.ID)
if err != nil {
return nil, "", nil, err
}
return created, token, payload, nil
}
func (s *AuthService) ResetPassword(ctx context.Context, email, password, emailCode, ip string) error {
settings, err := s.loadAuthSettings(ctx)
if err != nil {
return err
}
if !settings.EmailCode || !settings.AllowPasswordReset {
return errors.New("未开放找回密码")
}
normalizedEmail, err := ValidateEmail(email)
if err != nil {
return err
}
if err := ValidatePassword(password); err != nil {
return err
}
// Rate-limit reset attempts per IP+email so the 6-digit code can't be ground
// down even with the single-use + wrong-guess cap (Python api/auth.py:257-268).
guardID := "reset:" + normalizedEmail
if err := s.loginGuard.Check(ctx, ip, guardID); err != nil {
return err
}
ok, err := s.codes.Verify(ctx, normalizedEmail, "reset", emailCode)
if err != nil {
return err
}
if !ok {
if rerr := s.loginGuard.RecordFailure(ctx, ip, guardID); rerr != nil {
return rerr
}
return errors.New("邮箱验证码错误或已过期")
}
if err := s.loginGuard.RecordSuccess(ctx, ip, guardID); err != nil {
return err
}
passwordHash, err := HashPassword(password)
if err != nil {
return err
}
_, err = s.users.SetPasswordByEmail(ctx, normalizedEmail, passwordHash)
return err
}
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
if strings.TrimSpace(currentPassword) == "" {
return errors.New("当前密码不能为空")
}
if err := ValidatePassword(newPassword); err != nil {
return err
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return err
}
if !VerifyPassword(currentPassword, user.PasswordHash) {
return errors.New("当前密码错误")
}
passwordHash, err := HashPassword(newPassword)
if err != nil {
return err
}
_, err = s.users.Update(ctx, userID, map[string]any{
"password_hash": passwordHash,
})
return err
}
func (s *AuthService) Logout(ctx context.Context, token string) error {
return s.sessions.Destroy(ctx, token)
}
func (s *AuthService) AuthConfig(ctx context.Context) (map[string]any, error) {
hasAdmin, err := s.users.HasAdmin(ctx)
if err != nil {
return nil, err
}
settings, err := s.loadAuthSettings(ctx)
if err != nil {
return nil, err
}
credits, err := s.loadCreditSettings(ctx)
if err != nil {
return nil, err
}
return map[string]any{
"open": settings.Open,
"email_code": settings.EmailCode,
"allow_password_reset": settings.AllowPasswordReset,
"allowed_email_domains": settings.AllowedDomains,
"has_admin": hasAdmin,
"checkin_enabled": credits.CheckinEnabled,
"checkin_reward": credits.CheckinReward,
"invite_enabled": credits.InviteEnabled,
"invite_reward": credits.InviteReward,
"server_time": time.Now().Unix(),
}, nil
}
func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[string]any, error) {
if user == nil {
return nil, nil
}
credits, err := s.loadCreditSettings(ctx)
if err != nil {
return nil, err
}
stats, err := s.users.InviteStats(ctx, user.ID, credits.InviteReward)
if err != nil {
return nil, err
}
return map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"status": user.Status,
"credits": user.Credits,
"checkin_last": user.CheckinLast,
"checkin_streak": user.CheckinStreak,
"checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"),
"invite_code": user.InviteCode,
"invite_count": stats.InviteCount,
"invite_earned": stats.InviteEarned,
}, nil
}
func (s *AuthService) Checkin(ctx context.Context, userID string) (*repo.CheckinResult, error) {
credits, err := s.loadCreditSettings(ctx)
if err != nil {
return nil, err
}
if !credits.CheckinEnabled {
return nil, errors.New("签到功能未开启")
}
return s.users.DailyCheckin(ctx, userID, credits.CheckinReward)
}
func (s *AuthService) InviteList(ctx context.Context, userID string) ([]repo.InviteRecord, error) {
credits, err := s.loadCreditSettings(ctx)
if err != nil {
return nil, err
}
return s.users.InviteList(ctx, userID, credits.InviteReward)
}
func ParseBearer(header string) string {
if header == "" {
return ""
}
lower := strings.ToLower(header)
if !strings.HasPrefix(lower, "bearer ") {
return ""
}
return strings.TrimSpace(header[7:])
}
func HashAPIKey(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return "sha256:" + hex.EncodeToString(sum[:])
}
func (s *AuthService) loadAuthSettings(ctx context.Context) (*AuthSettings, error) {
openRaw, err := s.settings.GetValue(ctx, "auth.open")
if err != nil {
return nil, err
}
emailCodeRaw, err := s.settings.GetValue(ctx, "auth.email_code")
if err != nil {
return nil, err
}
resetRaw, err := s.settings.GetValue(ctx, "auth.allow_password_reset")
if err != nil {
return nil, err
}
domainsRaw, err := s.settings.GetValue(ctx, "auth.allowed_email_domains")
if err != nil {
return nil, err
}
return &AuthSettings{
Open: parseBoolSetting(openRaw, true),
EmailCode: parseBoolSetting(emailCodeRaw, false),
AllowPasswordReset: parseBoolSetting(resetRaw, false),
AllowedDomains: parseCSVSetting(domainsRaw),
}, nil
}
func (s *AuthService) loadSMTPSettings(ctx context.Context) SMTPConfig {
host, _ := s.settings.GetValue(ctx, "smtp.host")
portRaw, _ := s.settings.GetValue(ctx, "smtp.port")
username, _ := s.settings.GetValue(ctx, "smtp.username")
password, _ := s.settings.GetValue(ctx, "smtp.password")
fromAddr, _ := s.settings.GetValue(ctx, "smtp.from_addr")
useTLSRaw, _ := s.settings.GetValue(ctx, "smtp.use_tls")
port, _ := strconv.Atoi(strings.TrimSpace(portRaw))
if port <= 0 {
port = 587
}
// Fall back to username when from_addr is unset (Python core/email_codes.py:92).
from := strings.TrimSpace(fromAddr)
if from == "" {
from = strings.TrimSpace(username)
}
return SMTPConfig{
Host: strings.TrimSpace(host),
Port: port,
Username: strings.TrimSpace(username),
Password: password,
FromAddr: from,
// use_tls defaults to true to match Python (core/email_codes.py:93).
UseTLS: parseBoolSetting(useTLSRaw, true),
}
}
func parseBoolSetting(v string, fallback bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func parseCSVSetting(v string) []string {
if strings.TrimSpace(v) == "" {
return []string{}
}
return ValidateAllowedEmailDomains(strings.Split(v, ","))
}
// InviteReward returns the admin-configured 积分 awarded per completed invite
// (falls back to 3). Exposed so the invite page shows the real number.
func (s *AuthService) InviteReward(ctx context.Context) int {
cs, err := s.loadCreditSettings(ctx)
if err != nil {
return 3
}
return cs.InviteReward
}
func (s *AuthService) loadCreditSettings(ctx context.Context) (*CreditSettings, error) {
checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled")
if err != nil {
return nil, err
}
checkinRewardRaw, err := s.settings.GetValue(ctx, "credits.checkin_reward")
if err != nil {
return nil, err
}
inviteEnabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled")
if err != nil {
return nil, err
}
inviteRewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
if err != nil {
return nil, err
}
return &CreditSettings{
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
InviteReward: parseIntSetting(inviteRewardRaw, 3),
}, nil
}
+167
View File
@@ -0,0 +1,167 @@
package service
import (
"context"
"errors"
"strings"
"time"
"backend/internal/model"
"backend/internal/repo"
"gorm.io/gorm"
)
type CDKService struct {
cdks *repo.CDKRepository
users *repo.UserRepository
}
func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository) *CDKService {
return &CDKService{
cdks: cdks,
users: users,
}
}
func (s *CDKService) List(ctx context.Context) ([]model.CDKCode, map[string]any, map[string]string, error) {
items, err := s.cdks.List(ctx)
if err != nil {
return nil, nil, nil, err
}
stats, err := s.cdks.Stats(ctx)
if err != nil {
return nil, nil, nil, err
}
// Build an id -> display name map (name, else email, else id) so the handler
// can annotate redeemed codes with redeemed_by_name (mirrors admin.py).
users, err := s.users.List(ctx)
if err != nil {
return nil, nil, nil, err
}
nameByID := make(map[string]string, len(users))
for _, u := range users {
name := strings.TrimSpace(u.Name)
if name == "" {
name = strings.TrimSpace(u.Email)
}
if name == "" {
name = u.ID
}
nameByID[u.ID] = name
}
return items, stats, nameByID, nil
}
func normalizeCDKType(t string) string {
if strings.EqualFold(strings.TrimSpace(t), "marketing") {
return "marketing"
}
return "normal"
}
func (s *CDKService) Generate(ctx context.Context, amount, count int, note, cdkType string) ([]model.CDKCode, error) {
if amount <= 0 {
return nil, errors.New("金额必须大于 0")
}
if count < 1 {
count = 1
}
if count > 500 {
count = 500
}
cdkType = normalizeCDKType(cdkType)
// One batch id per generate call — marketing codes are "one per user per
// batch", so codes created together must share it.
batchID := randomUpper(20)
items := make([]model.CDKCode, 0, count)
for i := 0; i < count; i++ {
items = append(items, model.CDKCode{
Code: randomCDK(),
Amount: amount,
Status: "active",
Type: cdkType,
BatchID: batchID,
Note: strings.TrimSpace(note),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
}
if err := s.cdks.CreateBatch(ctx, items); err != nil {
return nil, err
}
return items, nil
}
func (s *CDKService) Delete(ctx context.Context, code string) error {
rows, err := s.cdks.Delete(ctx, strings.TrimSpace(strings.ToUpper(code)))
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
// DeleteBulk removes many CDK codes in one call (multi-select).
func (s *CDKService) DeleteBulk(ctx context.Context, codes []string) (int, error) {
seen := make(map[string]struct{}, len(codes))
clean := make([]string, 0, len(codes))
for _, code := range codes {
code = strings.TrimSpace(strings.ToUpper(code))
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
clean = append(clean, code)
}
if len(clean) == 0 {
return 0, nil
}
rows, err := s.cdks.DeleteByCodes(ctx, clean)
return int(rows), err
}
func (s *CDKService) Redeem(ctx context.Context, userID, code string) (map[string]any, error) {
code = strings.TrimSpace(strings.ToUpper(code))
if code == "" {
return nil, errors.New("请输入兑换码")
}
item, err := s.cdks.Redeem(ctx, code, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New("兑换码无效")
}
if errors.Is(err, repo.ErrCDKBatchLimit) {
return nil, errors.New("该营销活动每人限兑一次,你已兑换过本批次的兑换码")
}
if err == gorm.ErrDuplicatedKey {
return nil, errors.New("兑换码已被使用")
}
return nil, err
}
// Atomic, row-locked credit grant — never read-modify-write the balance, or a
// concurrent debit/redeem would clobber it (lost update).
updated, err := s.users.AdjustCredits(ctx, userID, float64(item.Amount))
if err != nil {
return nil, err
}
return map[string]any{
"amount": item.Amount,
"credits": updated.Credits,
}, nil
}
func randomCDK() string {
seg := func() string {
return randomUpper(4)
}
return seg() + "-" + seg() + "-" + seg() + "-" + seg()
}
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"context"
"crypto/rand"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// maxCodeAttempts caps wrong guesses per issued code before it's burned. With a
// single 6-digit code (1e6 space) and only this many tries per send — and sends
// throttled by the cooldown — brute force is infeasible. Mirrors the Python
// EmailCodeStore.MAX_ATTEMPTS.
const maxCodeAttempts = 5
type EmailCodeService struct {
redis *redis.Client
codeTTL time.Duration
resendCooldown time.Duration
}
func NewEmailCodeService(redis *redis.Client) *EmailCodeService {
return &EmailCodeService{
redis: redis,
codeTTL: 6 * time.Minute, // CODE_TTL_SECONDS=360
resendCooldown: 120 * time.Second, // CODE_COOLDOWN_SECONDS=120
}
}
// Redis exposes the underlying client so collaborators (e.g. LoginGuard) can be
// built without threading the client through every constructor.
func (s *EmailCodeService) Redis() *redis.Client {
return s.redis
}
func (s *EmailCodeService) Issue(ctx context.Context, email, purpose string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
purpose = strings.ToLower(strings.TrimSpace(purpose))
ok, err := s.redis.SetNX(ctx, s.cooldownKey(email, purpose), "1", s.resendCooldown).Result()
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("请稍后再试")
}
code, err := randomDigits(6)
if err != nil {
return "", err
}
if err := s.redis.Set(ctx, s.codeKey(email, purpose), code, s.codeTTL).Err(); err != nil {
return "", err
}
// Reset the wrong-guess counter for this fresh code (same TTL as the code).
if err := s.redis.Set(ctx, s.attemptsKey(email, purpose), "0", s.codeTTL).Err(); err != nil {
return "", err
}
return code, nil
}
func (s *EmailCodeService) Verify(ctx context.Context, email, purpose, code string) (bool, error) {
normalizedCode, err := ValidateEmailCode(code)
if err != nil {
return false, err
}
email = strings.ToLower(strings.TrimSpace(email))
purpose = strings.ToLower(strings.TrimSpace(purpose))
stored, err := s.redis.Get(ctx, s.codeKey(email, purpose)).Result()
if err != nil {
if err == redis.Nil {
return false, nil
}
return false, err
}
// Count the attempt first; burn the code (and its counter) once the cap is
// hit so the attacker must request a new one and wait out the send cooldown.
attempts, err := s.redis.Incr(ctx, s.attemptsKey(email, purpose)).Result()
if err != nil {
return false, err
}
if attempts > maxCodeAttempts {
if err := s.redis.Del(ctx, s.codeKey(email, purpose), s.attemptsKey(email, purpose)).Err(); err != nil {
return false, err
}
return false, nil
}
if stored != normalizedCode {
return false, nil
}
// Correct code: one-time use, clear both the code and its attempt counter.
if err := s.redis.Del(ctx, s.codeKey(email, purpose), s.attemptsKey(email, purpose)).Err(); err != nil {
return false, err
}
return true, nil
}
func (s *EmailCodeService) codeKey(email, purpose string) string {
return "email_code:" + purpose + ":" + email
}
func (s *EmailCodeService) attemptsKey(email, purpose string) string {
return "email_code_attempts:" + purpose + ":" + email
}
func (s *EmailCodeService) cooldownKey(email, purpose string) string {
return "email_code_cooldown:" + purpose + ":" + email
}
func randomDigits(n int) (string, error) {
buf := make([]byte, n)
src := make([]byte, n)
if _, err := rand.Read(src); err != nil {
return "", err
}
for i := range src {
buf[i] = byte('0' + (src[i] % 10))
}
return string(buf), nil
}
+48
View File
@@ -0,0 +1,48 @@
package service
import (
"context"
"errors"
"strings"
"backend/internal/repo"
)
type ImageAccessService struct {
generatedRoot string
showcase *repo.ShowcaseRepository
auth *AuthService
}
func NewImageAccessService(generatedRoot string, showcase *repo.ShowcaseRepository, auth *AuthService) *ImageAccessService {
return &ImageAccessService{
generatedRoot: generatedRoot,
showcase: showcase,
auth: auth,
}
}
// Resolve validates the path params and returns the object key (user/name).
// Existence isn't checked here — that's the storage GET's job (404 if missing).
func (s *ImageAccessService) Resolve(user, name string) (string, error) {
user = strings.TrimSpace(user)
name = strings.TrimSpace(name)
if user == "" || name == "" {
return "", errors.New("missing path params")
}
// :user and :name are single path segments (gin won't match "/"); guard
// against traversal tokens anyway.
if strings.Contains(user, "..") || strings.Contains(name, "..") ||
strings.ContainsAny(user, `/\`) || strings.ContainsAny(name, `/\`) {
return "", errors.New("invalid image path")
}
return user + "/" + name, nil
}
func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, error) {
return s.showcase.IsPublicFile(ctx, rel)
}
func (s *ImageAccessService) IsAuthorized(ctx context.Context, sessionCookie, owner string) (bool, error) {
return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner)
}
+169
View File
@@ -0,0 +1,169 @@
package service
import (
"context"
"errors"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// ErrLoginLocked is returned when a login/reset attempt is currently locked out
// by the LoginGuard. The wait window (in seconds) is carried by LoginLockedError.
var ErrLoginLocked = errors.New("login locked")
// LoginLockedError signals that the caller must wait RetryAfter seconds before
// retrying. Handlers map this to HTTP 429 with a Retry-After header.
type LoginLockedError struct {
RetryAfter int
}
func (e *LoginLockedError) Error() string {
return "尝试过于频繁,请 " + strconv.Itoa(e.RetryAfter) + " 秒后再试"
}
func (e *LoginLockedError) Is(target error) bool {
return target == ErrLoginLocked
}
// LoginGuard implements a Redis-backed login throttle mirroring the Python
// core.login_guard: two independent counters per attempt, exponential backoff
// lockout after a small number of free failures, and decay after a quiet period.
//
// id:<ip>|<identifier> — targeted guessing of one account from one IP (5 free).
// ip:<ip> — spraying many accounts from one IP (20 free).
//
// Either counter being locked rejects the attempt.
type LoginGuard struct {
redis *redis.Client
freeAttempts int // per (ip, account) before lockout kicks in
ipFreeAttempts int // coarser per-ip spray threshold
baseLock time.Duration // first lock duration
maxLock time.Duration // lock cap
decay time.Duration // forget a counter after this quiet period
}
func NewLoginGuard(rdb *redis.Client) *LoginGuard {
return &LoginGuard{
redis: rdb,
freeAttempts: 5,
ipFreeAttempts: 20,
baseLock: 15 * time.Second,
maxLock: 900 * time.Second,
decay: 1800 * time.Second,
}
}
func (g *LoginGuard) keys(ip, identifier string) (ipKey, idKey string) {
ident := strings.ToLower(strings.TrimSpace(identifier))
return "login_guard:ip:" + ip, "login_guard:id:" + ip + "|" + ident
}
// remaining returns the seconds the given counter is still locked for (0 = free).
// Counters are stored with TTL = decay so quiet entries expire on their own,
// matching the Python decay semantics.
func (g *LoginGuard) remaining(ctx context.Context, key string, now int64) (int, error) {
lockedRaw, err := g.redis.HGet(ctx, key, "locked_until").Result()
if err != nil {
if err == redis.Nil {
return 0, nil
}
return 0, err
}
lockedUntil, _ := strconv.ParseInt(strings.TrimSpace(lockedRaw), 10, 64)
if lockedUntil <= now {
return 0, nil
}
return int(lockedUntil - now), nil
}
// RetryAfter reports how many seconds the caller must wait (0 = allowed).
func (g *LoginGuard) RetryAfter(ctx context.Context, ip, identifier string) (int, error) {
if g == nil || g.redis == nil {
return 0, nil
}
now := time.Now().Unix()
ipKey, idKey := g.keys(ip, identifier)
ipWait, err := g.remaining(ctx, ipKey, now)
if err != nil {
return 0, err
}
idWait, err := g.remaining(ctx, idKey, now)
if err != nil {
return 0, err
}
if ipWait > idWait {
return ipWait, nil
}
return idWait, nil
}
// Check returns a *LoginLockedError when the attempt is currently locked out.
func (g *LoginGuard) Check(ctx context.Context, ip, identifier string) error {
wait, err := g.RetryAfter(ctx, ip, identifier)
if err != nil {
return err
}
if wait > 0 {
return &LoginLockedError{RetryAfter: wait}
}
return nil
}
// RecordFailure increments both counters and, once a counter passes its free
// allowance, arms an exponentially growing lockout window (capped at maxLock).
func (g *LoginGuard) RecordFailure(ctx context.Context, ip, identifier string) error {
if g == nil || g.redis == nil {
return nil
}
now := time.Now().Unix()
ipKey, idKey := g.keys(ip, identifier)
for _, kf := range []struct {
key string
free int
}{
{ipKey, g.ipFreeAttempts},
{idKey, g.freeAttempts},
} {
count, err := g.redis.HIncrBy(ctx, kf.key, "count", 1).Result()
if err != nil {
return err
}
if count >= int64(kf.free) {
over := count - int64(kf.free)
lock := g.baseLock
for i := int64(0); i < over; i++ {
lock *= 2
if lock >= g.maxLock {
lock = g.maxLock
break
}
}
if lock > g.maxLock {
lock = g.maxLock
}
lockedUntil := now + int64(lock.Seconds())
if err := g.redis.HSet(ctx, kf.key, "locked_until", lockedUntil).Err(); err != nil {
return err
}
}
// Refresh decay TTL on every failure (quiet counters expire on their own).
if err := g.redis.Expire(ctx, kf.key, g.decay).Err(); err != nil {
return err
}
}
return nil
}
// RecordSuccess clears the targeted (id) counter on a genuine login; the coarse
// per-ip counter is left to decay so one valid account can't reset spray tracking.
func (g *LoginGuard) RecordSuccess(ctx context.Context, ip, identifier string) error {
if g == nil || g.redis == nil {
return nil
}
_, idKey := g.keys(ip, identifier)
return g.redis.Del(ctx, idKey).Err()
}
+282
View File
@@ -0,0 +1,282 @@
package service
import (
"context"
"log"
"strconv"
"strings"
"sync"
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/storage"
)
// MaintenanceService runs the periodic self-healing sweep that the Python
// original did via a 60s daemon thread plus read-time lazy cleanup. Without it
// the Go token pool only ever loses capacity: tokens never re-activate after a
// quota reset, cookies never auto-renew, stale pending events permanently block
// a user's generation gate, and old media/logs accumulate unbounded.
type MaintenanceService struct {
tokens *repo.TokenRepository
tokenSvc *TokenService
events *repo.EventRepository
users *repo.UserRepository
refresh *RefreshProfileService
settings *repo.SiteSettingRepository
store *storage.Client
inflight *InflightRegistry
showcase *repo.ShowcaseRepository
interval time.Duration
stalePending time.Duration
mediaPruneEvery time.Duration
lastMediaPrune time.Time
}
func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService, events *repo.EventRepository, users *repo.UserRepository, refresh *RefreshProfileService, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository) *MaintenanceService {
return &MaintenanceService{
tokens: tokens,
tokenSvc: tokenSvc,
events: events,
users: users,
refresh: refresh,
settings: settings,
store: store,
inflight: inflight,
showcase: showcase,
interval: 60 * time.Second,
stalePending: 600 * time.Second,
mediaPruneEvery: 60 * time.Second,
}
}
// Run drives the sweep every interval until ctx is cancelled. It runs one sweep
// immediately on startup so a freshly restarted process heals stuck state right
// away rather than after the first tick.
func (m *MaintenanceService) Run(ctx context.Context) {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
m.tick(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.tick(ctx)
}
}
}
// syncRecoveredQuota re-probes each just-recovered account so its displayed
// balance reflects the post-reset value (these providers only sync quota when
// accessed). krea additionally needs /app (Activate) to actually grant the daily
// free balance before billing-data reports it. Bounded concurrency avoids a
// thundering herd at the daily reset.
func (m *MaintenanceService) syncRecoveredQuota(accs []model.TokenAccount) {
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for _, acc := range accs {
switch acc.Pool {
case "chatgpt", "leonardo", "krea", "imagine":
default:
continue
}
wg.Add(1)
sem <- struct{}{}
go func(a model.TokenAccount) {
defer wg.Done()
defer func() { <-sem }()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if a.Pool == "krea" && m.tokenSvc.krea != nil {
m.tokenSvc.krea.Activate(ctx, a.Value)
}
_, _ = m.tokenSvc.Quota(ctx, a.Pool, a.ID)
}(acc)
}
wg.Wait()
}
func (m *MaintenanceService) tick(ctx context.Context) {
// 1. Re-activate quota-exhausted tokens whose reset time has passed, then
// auto-sync their real balance — these providers only refresh quota when
// accessed, so recovery alone would leave a stale 0/—. For krea the sync
// must first load /app (Activate) to grant the daily free balance.
if recovered, err := m.tokens.RecoverQuota(ctx); err != nil {
log.Printf("maintenance: recover_quota: %v", err)
} else if len(recovered) > 0 {
log.Printf("maintenance: recovered %d quota token(s)", len(recovered))
if m.tokenSvc != nil {
go m.syncRecoveredQuota(recovered)
}
}
// 1a. Roll the 恢复时间 marker of ACTIVE daily-reset accounts forward to the next
// future reset (same time-of-day, +1 day) so the column never shows a stale
// past time. Limited accounts are intentionally skipped (RecoverQuota owns
// their marker). adobe/leonardo/krea/imagine all renew daily.
if _, err := m.tokens.RollResetMarkers(ctx, []string{"adobe", "leonardo", "krea", "imagine"}); err != nil {
log.Printf("maintenance: roll_reset: %v", err)
}
// 1b. Runway tokens have no refresh — once the JWT expiry (its reset marker)
// passes, mark them dead directly instead of letting them 401 on next use.
if n, err := m.tokens.ExpireByReset(ctx, "runway"); err != nil {
log.Printf("maintenance: expire_runway: %v", err)
} else if n > 0 {
log.Printf("maintenance: expired %d runway token(s)", n)
}
// 1c. Proactively renew krea/imagine sessions ~10min before expiry so a
// dormant account's rotating refresh_token never lapses (a dead token
// can't be recovered and, for krea, blocks the daily free-credit meter
// from being re-created). Only near-expiry accounts hit the network.
if m.tokenSvc != nil {
m.tokenSvc.RefreshExpiringTokens(ctx)
// 1d. Once-per-day krea /app activation for accounts not yet synced since the
// daily reset — krea only grants the free balance after /app loads, so an
// always-active account (never went 限额) would otherwise read 0 / 402
// after each reset. Self-guarded + background; no-op once all are done.
m.tokenSvc.ActivateKreaDue(ctx)
}
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
if m.refresh != nil {
if n, err := m.refresh.RefreshDue(ctx); err != nil {
log.Printf("maintenance: refresh_due: %v", err)
} else if n > 0 {
log.Printf("maintenance: refreshed %d cookie profile(s)", n)
}
}
// 3. Fail long-pending events so they stop blocking the per-user gate, and
// refund the credits debited up-front for each abandoned generation (the
// normal failure-refund path never ran for a process-restart orphan).
if purged, err := m.events.PurgeStale(ctx, m.stalePending); err != nil {
log.Printf("maintenance: purge_stale: %v", err)
} else if len(purged) > 0 {
refunded := 0
cancelled := 0
for _, e := range purged {
// Stop the generation goroutine if it's still running, so it doesn't
// keep grinding for minutes and surface a late "success" on this
// just-abandoned event.
if m.inflight != nil && m.inflight.Cancel(e.ID) {
cancelled++
}
// Attribute the abandoned failure back to the account it was using
// (the normal markTokenFailure path never ran for an orphaned job).
if e.AccountID != "" {
if err := m.tokens.IncrementFail(ctx, e.AccountID); err != nil {
log.Printf("maintenance: fail-count abandoned event %s (account %s): %v", e.ID, e.AccountID, err)
}
}
if e.UserID == "" || e.Cost <= 0 {
continue
}
// Exactly-once: only refund if we win the claim (the in-flight request
// may have already refunded itself on its own failure path).
claimed, err := m.events.MarkRefunded(ctx, e.ID)
if err != nil {
log.Printf("maintenance: claim refund %s: %v", e.ID, err)
continue
}
if !claimed {
continue
}
if _, err := m.users.AdjustCredits(ctx, e.UserID, e.Cost); err != nil {
log.Printf("maintenance: refund abandoned event %s (user %s, %.0f): %v", e.ID, e.UserID, e.Cost, err)
} else {
refunded++
}
}
log.Printf("maintenance: marked %d stale pending event(s) failed, refunded %d, cancelled %d in-flight", len(purged), refunded, cancelled)
}
// 4. Enforce the admin-configured log retention window.
m.pruneLogs(ctx)
// 5. Enforce the media retention window. Runs every 60s like the log prune;
// mediaPruneEvery still gates it in case the interval is ever shortened.
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
m.pruneMedia(ctx)
m.lastMediaPrune = time.Now()
}
}
func (m *MaintenanceService) pruneLogs(ctx context.Context) {
days := m.retentionDays(ctx, "logs.retention_days")
if days <= 0 {
return
}
if _, err := m.events.PurgeOlderThan(ctx, time.Duration(days)*24*time.Hour); err != nil {
log.Printf("maintenance: purge_older_than: %v", err)
}
}
func (m *MaintenanceService) pruneMedia(ctx context.Context) {
if m.store == nil || !m.store.Configured() {
return
}
days := m.retentionDays(ctx, "media.retention_days")
if days <= 0 {
return
}
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
objs, err := m.store.List(ctx, "")
if err != nil {
log.Printf("maintenance: list media: %v", err)
return
}
// Files referenced by the homepage showcase are kept forever, no matter how
// old — deleting them would break the public landing page.
var pinned map[string]struct{}
if m.showcase != nil {
if pinned, err = m.showcase.PublicFileSet(ctx); err != nil {
log.Printf("maintenance: showcase file set: %v", err)
pinned = nil
}
}
removed, skipped := 0, 0
var clearedKeys []string
for _, o := range objs {
if !o.LastModified.Before(cutoff) {
continue
}
if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok {
skipped++
continue
}
if err := m.store.Delete(ctx, o.Key); err != nil {
log.Printf("maintenance: delete %s: %v", o.Key, err)
continue
}
removed++
// event_log.file stores the same key — blank those rows so the log views
// don't dangle a 404 preview.
clearedKeys = append(clearedKeys, o.Key)
}
if removed > 0 || skipped > 0 {
log.Printf("maintenance: pruned %d expired media object(s), kept %d showcase-pinned", removed, skipped)
}
if len(clearedKeys) > 0 {
if n, err := m.events.ClearFiles(ctx, clearedKeys); err != nil {
log.Printf("maintenance: clear_files: %v", err)
} else if n > 0 {
log.Printf("maintenance: cleared file ref on %d log row(s)", n)
}
}
}
func (m *MaintenanceService) retentionDays(ctx context.Context, key string) int {
raw, err := m.settings.GetValue(ctx, key)
if err != nil {
return 0
}
days, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || days <= 0 {
return 0
}
return days
}
+46
View File
@@ -0,0 +1,46 @@
package service
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"strings"
"golang.org/x/crypto/bcrypt"
)
func GeneratePasswordHash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(bcryptPrehash(password), 12)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPassword(password, stored string) bool {
stored = strings.TrimSpace(stored)
if stored == "" {
return false
}
if strings.HasPrefix(stored, "bcrypt$") {
hash := stored[len("bcrypt$"):]
return bcrypt.CompareHashAndPassword([]byte(hash), bcryptPrehash(password)) == nil
}
parts := strings.SplitN(stored, "$", 3)
if len(parts) != 3 || parts[0] != "sha256" {
return false
}
expected := sha256.Sum256([]byte(parts[1] + password))
expectedHex := hex.EncodeToString(expected[:])
return subtle.ConstantTimeCompare([]byte(expectedHex), []byte(parts[2])) == 1
}
func bcryptPrehash(password string) []byte {
sum := sha256.Sum256([]byte(password))
dst := make([]byte, base64.StdEncoding.EncodedLen(len(sum)))
base64.StdEncoding.Encode(dst, sum[:])
return dst
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
var ErrRateLimited = errors.New("rate limited")
type RateLimitService struct {
redis *redis.Client
prefix string
}
type RateLimitResult struct {
Allowed bool
Count int64
Limit int64
RetryAfter time.Duration
}
func NewRateLimitService(redis *redis.Client) *RateLimitService {
return &RateLimitService{
redis: redis,
prefix: "rl:",
}
}
func (s *RateLimitService) Allow(ctx context.Context, bucket string, limit int64, window time.Duration) (*RateLimitResult, error) {
if limit <= 0 || window <= 0 {
return &RateLimitResult{Allowed: true, Limit: limit}, nil
}
key := s.prefix + strings.TrimSpace(bucket)
count, err := s.redis.Incr(ctx, key).Result()
if err != nil {
return nil, err
}
if count == 1 {
if err := s.redis.Expire(ctx, key, window).Err(); err != nil {
return nil, err
}
}
ttl, err := s.redis.TTL(ctx, key).Result()
if err != nil {
return nil, err
}
if ttl < 0 {
ttl = window
}
return &RateLimitResult{
Allowed: count <= limit,
Count: count,
Limit: limit,
RetryAfter: ttl,
}, nil
}
func (s *RateLimitService) Enforce(ctx context.Context, bucket string, limit int64, window time.Duration) error {
result, err := s.Allow(ctx, bucket, limit, window)
if err != nil {
return err
}
if result.Allowed {
return nil
}
retry := int(result.RetryAfter.Seconds())
if retry < 1 {
retry = 1
}
return fmt.Errorf("%w: 请稍后再试(%d 秒后)", ErrRateLimited, retry)
}
@@ -0,0 +1,185 @@
package service
import (
"context"
"errors"
"strings"
"time"
"backend/internal/model"
"backend/internal/provider/adobe"
"backend/internal/repo"
"gorm.io/datatypes"
)
type RefreshProfileService struct {
profiles *repo.RefreshProfileRepository
tokens *repo.TokenRepository
adobe *adobe.Client
}
func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *repo.TokenRepository, adobeClient *adobe.Client) *RefreshProfileService {
return &RefreshProfileService{
profiles: profiles,
tokens: tokens,
adobe: adobeClient,
}
}
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
return s.profiles.List(ctx)
}
func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error {
if s.adobe == nil || s.tokens == nil {
return errors.New("refresh client not configured")
}
profile, err := s.profiles.Get(ctx, id)
if err != nil {
return err
}
if profile.Pool != "adobe" || profile.Kind != "adobe_cookie" {
return errors.New("unsupported refresh profile")
}
now := time.Now()
_, _ = s.profiles.Update(ctx, id, map[string]any{
"last_attempt_at": now,
})
result, err := s.adobe.ExchangeCookie(ctx, profile.Cookie)
if err != nil {
failures := profile.ConsecutiveFailures + 1
// Exponential backoff: 60s per consecutive failure, capped at 1h.
secs := 60 * failures
if secs > 3600 {
secs = 3600
}
msg := err.Error()
if len(msg) > 300 {
msg = msg[:300]
}
_, _ = s.profiles.Update(ctx, id, map[string]any{
"last_error": msg,
"consecutive_failures": failures,
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
})
// After repeated failures the cookie can no longer mint a token — it's
// genuinely dead (expired/revoked). Lock the pool token (disabled+dead)
// so the UI flags it red. A single failure may be a transient blip, so
// only escalate after a few in a row (mirrors Python RefreshManager).
if failures >= 3 {
_, _ = s.tokens.Update(ctx, profile.Pool, id, map[string]any{
"status": "disabled",
"dead": true,
})
}
return err
}
tokenPatch := map[string]any{
"value": result.AccessToken,
"status": "active",
"dead": false,
"fails": 0,
"updated_at": now,
}
email, exp := parseJWTEmailExpiry(result.AccessToken)
if email != "" {
tokenPatch["account_email"] = email
}
if exp != nil {
tokenPatch["cached_quota_reset_after"] = exp.Format(time.RFC3339)
}
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
tokenPatch["account_email"] = email
}
if displayName := strings.TrimSpace(stringValue(profileData["display_name"])); displayName != "" {
tokenPatch["account_display_name"] = displayName
}
}
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
meta := datatypes.JSONMap{
"cached_quota_at": int(time.Now().Unix()),
}
if remaining, ok := quotaData["remaining"].(int); ok {
meta["cached_quota_remaining"] = remaining
}
if used, ok := quotaData["used"].(int); ok {
meta["cached_quota_used"] = used
}
if total, ok := quotaData["total"].(int); ok {
meta["cached_quota_total"] = total
}
tokenPatch["meta"] = meta
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
tokenPatch["cached_quota_reset_after"] = resetAfter
}
}
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
return err
}
interval := profile.IntervalSeconds
if interval <= 0 {
interval = 54000
}
_, err = s.profiles.Update(ctx, id, map[string]any{
"last_success_at": now,
"next_retry_at": now.Add(time.Duration(interval) * time.Second),
"last_error": "",
"consecutive_failures": 0,
})
return err
}
// RefreshDue refreshes every enabled profile whose next_retry_at has passed.
// Driven by the background maintenance loop so Adobe cookies auto-renew without
// an admin clicking "refresh". Individual failures are recorded on the profile
// (backoff + dead escalation) and don't abort the sweep.
func (s *RefreshProfileService) RefreshDue(ctx context.Context) (int, error) {
if s.adobe == nil || s.tokens == nil {
return 0, nil
}
due, err := s.profiles.ListDue(ctx, time.Now())
if err != nil {
return 0, err
}
refreshed := 0
for _, p := range due {
if p.Pool != "adobe" || p.Kind != "adobe_cookie" {
continue
}
if err := s.RefreshNow(ctx, p.ID); err != nil {
continue
}
refreshed++
}
return refreshed, nil
}
func (s *RefreshProfileService) Update(ctx context.Context, id string, body map[string]any) (*model.RefreshProfile, error) {
patch := map[string]any{}
if raw, ok := body["enabled"]; ok {
patch["enabled"] = boolValueWithDefault(raw, false)
}
if raw, ok := body["name"]; ok {
patch["name"] = stringValue(raw)
}
if raw, ok := body["interval_seconds"]; ok {
n := intValue(raw)
if n <= 0 {
return nil, errors.New("interval_seconds must be positive")
}
patch["interval_seconds"] = n
}
if len(patch) == 0 {
return s.profiles.Get(ctx, id)
}
return s.profiles.Update(ctx, id, patch)
}
func (s *RefreshProfileService) Delete(ctx context.Context, id string) error {
return s.profiles.Delete(ctx, id)
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
type SessionPayload struct {
UserID string `json:"user_id"`
ExpiresAt int64 `json:"expires_at"`
}
type SessionService struct {
client *redis.Client
prefix string
ttl time.Duration
slideAfter time.Duration
slideTo time.Duration
}
func NewSessionService(client *redis.Client, ttl, slideAfter time.Duration) *SessionService {
return &SessionService{
client: client,
prefix: "session:",
ttl: ttl,
slideAfter: slideAfter,
slideTo: ttl,
}
}
func (s *SessionService) Create(ctx context.Context, userID string) (string, *SessionPayload, error) {
token := randomUpper(48)
payload := &SessionPayload{
UserID: userID,
ExpiresAt: time.Now().Add(s.ttl).Unix(),
}
raw, err := json.Marshal(payload)
if err != nil {
return "", nil, err
}
if err := s.client.Set(ctx, s.key(token), raw, s.ttl).Err(); err != nil {
return "", nil, err
}
return token, payload, nil
}
func (s *SessionService) Validate(ctx context.Context, token string) (*SessionPayload, error) {
if token == "" {
return nil, nil
}
raw, err := s.client.Get(ctx, s.key(token)).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, err
}
var payload SessionPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
ttl, err := s.client.TTL(ctx, s.key(token)).Result()
if err == nil && ttl > 0 && ttl < s.slideAfter {
// Slide the expiry, but only update the in-memory payload after Redis
// has actually persisted it — otherwise a failed Set would leave the
// returned ExpiresAt out of sync with what's stored.
renewed := payload
renewed.ExpiresAt = time.Now().Add(s.slideTo).Unix()
if updated, marshalErr := json.Marshal(&renewed); marshalErr == nil {
if setErr := s.client.Set(ctx, s.key(token), updated, s.slideTo).Err(); setErr == nil {
payload.ExpiresAt = renewed.ExpiresAt
}
}
}
if payload.ExpiresAt <= time.Now().Unix() {
_ = s.Destroy(ctx, token)
return nil, nil
}
return &payload, nil
}
func (s *SessionService) Destroy(ctx context.Context, token string) error {
if token == "" {
return nil
}
return s.client.Del(ctx, s.key(token)).Err()
}
func (s *SessionService) key(token string) string {
return s.prefix + token
}
+20
View File
@@ -0,0 +1,20 @@
package service
import (
"context"
"backend/internal/model"
"backend/internal/repo"
)
type ShowcaseService struct {
repo *repo.ShowcaseRepository
}
func NewShowcaseService(repo *repo.ShowcaseRepository) *ShowcaseService {
return &ShowcaseService{repo: repo}
}
func (s *ShowcaseService) Grouped(ctx context.Context) (map[string][]model.ShowcaseItem, error) {
return s.repo.Grouped(ctx)
}
+81
View File
@@ -0,0 +1,81 @@
package service
import (
"context"
"strings"
"backend/internal/repo"
)
type SiteService struct {
settings *repo.SiteSettingRepository
fallback string
}
func NewSiteService(settings *repo.SiteSettingRepository, fallback string) *SiteService {
return &SiteService{
settings: settings,
fallback: fallback,
}
}
func (s *SiteService) Title(ctx context.Context) (string, error) {
v, err := s.settings.GetValue(ctx, "site.title")
if err != nil {
return "", err
}
v = strings.TrimSpace(v)
if v == "" {
return s.fallback, nil
}
return v, nil
}
func (s *SiteService) SetTitle(ctx context.Context, title string) (string, error) {
title = strings.TrimSpace(title)
if title == "" {
return "", nil
}
if err := s.settings.UpsertValue(ctx, "site.title", title); err != nil {
return "", err
}
return title, nil
}
// Contact is the admin-editable "联系我们" info shown in the public 关于 section.
type Contact struct {
QQ string `json:"qq"`
QQLink string `json:"qq_link"`
QQGroup string `json:"qq_group"`
QQGroupLink string `json:"qq_group_link"`
Email string `json:"email"`
Shop string `json:"shop"`
}
func (s *SiteService) Contact(ctx context.Context) Contact {
get := func(k string) string { v, _ := s.settings.GetValue(ctx, k); return strings.TrimSpace(v) }
return Contact{
QQ: get("contact.qq"),
QQLink: get("contact.qq_link"),
QQGroup: get("contact.qq_group"),
QQGroupLink: get("contact.qq_group_link"),
Email: get("contact.email"),
Shop: get("contact.shop"),
}
}
func (s *SiteService) SetContact(ctx context.Context, c Contact) error {
for k, v := range map[string]string{
"contact.qq": strings.TrimSpace(c.QQ),
"contact.qq_link": strings.TrimSpace(c.QQLink),
"contact.qq_group": strings.TrimSpace(c.QQGroup),
"contact.qq_group_link": strings.TrimSpace(c.QQGroupLink),
"contact.email": strings.TrimSpace(c.Email),
"contact.shop": strings.TrimSpace(c.Shop),
} {
if err := s.settings.UpsertValue(ctx, k, v); err != nil {
return err
}
}
return nil
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
FromAddr string
UseTLS bool
}
type SMTPService struct{}
func NewSMTPService() *SMTPService {
return &SMTPService{}
}
func (s *SMTPService) SendCode(ctx context.Context, cfg SMTPConfig, to, code, purpose string) error {
_ = ctx
if strings.TrimSpace(cfg.Host) == "" || cfg.Port <= 0 || strings.TrimSpace(cfg.FromAddr) == "" {
return errors.New("SMTP 未配置")
}
action := "注册"
if purpose == "reset" {
action = "找回密码"
}
subject := "Vivid AI 邮箱验证码"
body := fmt.Sprintf("你正在进行%s,验证码为:%s\n\n验证码 6 分钟内有效。", action, code)
msg := buildSMTPMessage(cfg.FromAddr, to, subject, body)
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
if cfg.UseTLS || cfg.Port == 465 {
return sendMailTLS(addr, cfg, to, msg)
}
return sendMailSTARTTLS(addr, cfg, to, msg)
}
func buildSMTPMessage(from, to, subject, body string) []byte {
lines := []string{
"From: " + from,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
body,
}
return []byte(strings.Join(lines, "\r\n"))
}
func sendMailTLS(addr string, cfg SMTPConfig, to string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return err
}
defer client.Close()
return doSMTP(client, cfg, to, msg)
}
func sendMailSTARTTLS(addr string, cfg SMTPConfig, to string, msg []byte) error {
client, err := smtp.Dial(addr)
if err != nil {
return err
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return err
}
}
return doSMTP(client, cfg, to, msg)
}
func doSMTP(client *smtp.Client, cfg SMTPConfig, to string, msg []byte) error {
if strings.TrimSpace(cfg.Username) != "" {
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
if err := client.Auth(auth); err != nil {
return err
}
}
if err := client.Mail(cfg.FromAddr); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
_ = w.Close()
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"backend/internal/model"
"backend/internal/repo"
)
type UserGenerationService struct {
v1 *V1Service
events *repo.EventRepository
users *repo.UserRepository
models *repo.ModelRepository
}
func NewUserGenerationService(v1 *V1Service, events *repo.EventRepository, users *repo.UserRepository, models *repo.ModelRepository) *UserGenerationService {
return &UserGenerationService{
v1: v1,
events: events,
users: users,
models: models,
}
}
type UserGenerateRequest struct {
Model string
Prompt string
Ratio string
Resolution string
Duration string
ReferenceImages []string
}
func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
if user == nil || strings.TrimSpace(user.ID) == "" {
return nil, errors.New("未登录或会话已过期")
}
pending, err := s.events.PendingByUser(ctx, user.ID, "user")
if err != nil {
return nil, err
}
if pending != nil {
return nil, errors.New("已有正在生成的任务,请稍候")
}
modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model))
if err != nil {
return nil, ErrUnknownModel
}
principal := &APIPrincipal{
User: user,
TokenType: "session",
}
switch modelItem.Type {
case "video":
resp, err := s.v1.prepareSessionVideo(ctx, principal, V1VideoRequest{
Model: in.Model,
Prompt: in.Prompt,
Duration: in.Duration,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
})
if err != nil {
return nil, err
}
return resp, nil
default:
resp, err := s.v1.prepareSessionImage(ctx, principal, V1ImageRequest{
Model: in.Model,
Prompt: in.Prompt,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
})
if err != nil {
return nil, err
}
return resp, nil
}
}
func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
if user == nil || strings.TrimSpace(user.ID) == "" {
return nil, errors.New("未登录或会话已过期")
}
modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model))
if err != nil {
return nil, ErrUnknownModel
}
principal := &APIPrincipal{
User: user,
TokenType: "session",
}
switch modelItem.Type {
case "video":
return s.v1.prepareAdminTestVideo(ctx, principal, V1VideoRequest{
Model: in.Model,
Prompt: in.Prompt,
Duration: in.Duration,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
})
default:
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
Model: in.Model,
Prompt: in.Prompt,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
})
}
}
func (s *UserGenerationService) MyJobs(ctx context.Context, user *model.User, source string) (map[string]any, error) {
if user == nil || strings.TrimSpace(user.ID) == "" {
return map[string]any{"pending": nil, "latest": nil}, nil
}
// source scopes the lookup: "user" = 画图台(默认),"admin" = 后台测试模型。
// Both are this caller's own events; the admin-test poll uses "admin" so a
// gateway-timed-out (524) test can still recover its result.
if source != "admin" {
source = "user"
}
pending, err := s.events.PendingByUser(ctx, user.ID, source)
if err != nil {
return nil, err
}
latest, err := s.events.LatestByUser(ctx, user.ID, source)
if err != nil {
return nil, err
}
return map[string]any{
"pending": shapeJobEvent(pending),
"latest": shapeJobEvent(latest),
}, nil
}
func shapeJobEvent(item *model.EventLog) map[string]any {
if item == nil {
return nil
}
status := item.Status
url := ""
if strings.TrimSpace(item.File) != "" {
url = "/images/" + strings.ReplaceAll(strings.TrimSpace(item.File), "\\", "/")
}
return map[string]any{
"id": item.ID,
"kind": item.Kind,
"model": item.Model,
"prompt": item.Prompt,
"ratio": item.Ratio,
"resolution": item.Resolution,
"duration": item.Duration,
"status": status,
"file": emptyOrNil(item.File),
"url": emptyOrNil(url),
"reference_urls": referenceURLs(item.RefFiles),
"elapsed_ms": item.ElapsedMS,
"error": emptyOrNil(item.Error),
"charged": item.Cost,
"cost": item.Cost,
"ts": item.TS.Unix(),
}
}
// referenceURLs turns the stored relative reference paths into /images URLs so
// the playground can re-display the uploaded reference image(s) after a reload.
func referenceURLs(raw []byte) []string {
if len(raw) == 0 {
return []string{}
}
var paths []string
if err := json.Unmarshal(raw, &paths); err != nil {
return []string{}
}
out := make([]string, 0, len(paths))
for _, p := range paths {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p != "" {
out = append(out, "/images/"+p)
}
}
return out
}
func emptyOrNil(v string) any {
if strings.TrimSpace(v) == "" {
return nil
}
return v
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"errors"
"net/mail"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
const (
MinUsernameLength = 6
MaxUsernameLength = 24
MinPasswordLength = 8
MaxPasswordLength = 24
)
var (
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{6,24}$`)
emailCodePattern = regexp.MustCompile(`^\d{6}$`)
)
func ValidateEmail(email string) (string, error) {
normalized := strings.TrimSpace(strings.ToLower(email))
if normalized == "" {
return "", errors.New("邮箱不能为空")
}
if len(normalized) > 254 {
return "", errors.New("邮箱长度不能超过 254 个字符")
}
addr, err := mail.ParseAddress(normalized)
if err != nil || strings.TrimSpace(strings.ToLower(addr.Address)) != normalized {
return "", errors.New("邮箱格式不正确")
}
local, domain, ok := strings.Cut(normalized, "@")
if !ok || local == "" || domain == "" || strings.Contains(domain, "..") || !strings.Contains(domain, ".") {
return "", errors.New("邮箱格式不正确")
}
return normalized, nil
}
func ValidateUsername(username string) (string, error) {
normalized := strings.TrimSpace(username)
if normalized == "" {
return "", errors.New("用户名不能为空")
}
length := utf8.RuneCountInString(normalized)
if length < MinUsernameLength || length > MaxUsernameLength {
return "", errors.New("用户名长度需为 6 到 24 个字符")
}
if !usernamePattern.MatchString(normalized) {
return "", errors.New("用户名只能使用字母和数字")
}
return normalized, nil
}
func ValidatePassword(password string) error {
length := utf8.RuneCountInString(password)
if length < MinPasswordLength || length > MaxPasswordLength {
return errors.New("密码长度需为 8 到 24 个字符")
}
var hasLetter bool
var hasUpper bool
var hasLower bool
var hasDigit bool
var hasSymbol bool
for _, r := range password {
if unicode.IsSpace(r) {
return errors.New("密码不能包含空白字符")
}
if !isAllowedPasswordRune(r) {
return errors.New("密码包含不允许的字符")
}
if unicode.IsLetter(r) {
hasLetter = true
if unicode.IsUpper(r) {
hasUpper = true
}
if unicode.IsLower(r) {
hasLower = true
}
}
if unicode.IsDigit(r) {
hasDigit = true
}
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
hasSymbol = true
}
}
if !hasLetter || !hasUpper || !hasLower || !hasDigit || !hasSymbol {
return errors.New("密码必须同时包含大写字母、小写字母、数字和符号")
}
return nil
}
func isAllowedPasswordRune(r rune) bool {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
switch r {
case '(', ')', '~', '!', '@', '#', '$', '%', '^', '&', '*', '-', '_', '+', '=', '|',
'{', '}', '[', ']', ':', ';', '\'', '<', '>', ',', '.', '?', '/':
return true
default:
return false
}
}
func ValidateEmailCode(code string) (string, error) {
normalized := strings.TrimSpace(code)
if !emailCodePattern.MatchString(normalized) {
return "", errors.New("邮箱验证码必须是 6 位纯数字")
}
return normalized, nil
}
func ValidateLoginIdentifier(identifier string) (string, error) {
normalized := strings.TrimSpace(identifier)
if normalized == "" {
return "", errors.New("账号不能为空")
}
if strings.Contains(normalized, "@") {
return ValidateEmail(normalized)
}
return ValidateUsername(normalized)
}
func ValidateAllowedEmailDomains(domains []string) []string {
out := make([]string, 0, len(domains))
seen := map[string]struct{}{}
for _, raw := range domains {
normalized := strings.TrimSpace(strings.ToLower(strings.TrimPrefix(raw, "@")))
if normalized == "" || strings.Contains(normalized, " ") {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out
}
func EmailDomainAllowed(email string, domains []string) bool {
if len(domains) == 0 {
return true
}
_, domain, ok := strings.Cut(strings.ToLower(strings.TrimSpace(email)), "@")
if !ok {
return false
}
for _, allowed := range ValidateAllowedEmailDomains(domains) {
if domain == allowed {
return true
}
}
return false
}