Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/config"
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/storage"
|
||||
)
|
||||
|
||||
type AdminReadService struct {
|
||||
cfg *config.Config
|
||||
users *repo.UserRepository
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
tokens *repo.TokenRepository
|
||||
cdks *repo.CDKRepository
|
||||
store *storage.Client
|
||||
}
|
||||
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client) *AdminReadService {
|
||||
return &AdminReadService{
|
||||
cfg: cfg,
|
||||
users: users,
|
||||
models: models,
|
||||
events: events,
|
||||
settings: settings,
|
||||
tokens: tokens,
|
||||
cdks: cdks,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]any, error) {
|
||||
users, err := s.users.List(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
counts, err := s.events.UserSuccessCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for i := range users {
|
||||
meta := users[i].Notes
|
||||
_ = meta
|
||||
}
|
||||
stats, err := s.users.Stats(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stats["generation_counts"] = counts
|
||||
return users, stats, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Models(ctx context.Context) ([]model.ModelConfig, error) {
|
||||
return s.models.List(ctx)
|
||||
}
|
||||
|
||||
func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, error) {
|
||||
items, err := s.models.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := s.events.ModelSuccessCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, map[string]any{
|
||||
"id": item.ID,
|
||||
"type": item.Type,
|
||||
"name": item.Name,
|
||||
"provider": item.Provider,
|
||||
"enabled": item.Enabled,
|
||||
"ratios": repo.JSONStrings(item.Ratios),
|
||||
"prices": map[string]any(item.Prices),
|
||||
"resolutions": repo.JSONStrings(item.Resolutions),
|
||||
"image_to_image": item.ImageToImage,
|
||||
"duration_prices": map[string]any(item.DurationPrices),
|
||||
"prices_agent": map[string]any(item.PricesAgent),
|
||||
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
||||
"durations": repo.JSONStrings(item.Durations),
|
||||
"max_reference_images": item.MaxReferenceImages,
|
||||
"reference_mode": item.ReferenceMode,
|
||||
"weight": item.Weight,
|
||||
"generation_count": counts[item.ID],
|
||||
"created_at": item.CreatedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, since *time.Time, userID, excludeSource, source string, hasFile bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
||||
items, total, err := s.events.List(ctx, repo.EventListFilter{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Kind: kind,
|
||||
Status: status,
|
||||
Since: since,
|
||||
UserID: userID,
|
||||
ExcludeSource: excludeSource,
|
||||
Source: source,
|
||||
HasFile: hasFile,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
// 用户自己的日志(userID 非空)→ 按本人统计;管理员全站视图 → 全站统计。
|
||||
var stats *repo.EventStats
|
||||
if userID != "" {
|
||||
stats, err = s.events.StatsByUser(ctx, userID)
|
||||
} else {
|
||||
stats, err = s.events.Stats(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
return items, total, stats, nil
|
||||
}
|
||||
|
||||
// UserNameMap builds an id -> display name lookup (name, else email, else id)
|
||||
// used to annotate admin log rows with user_name (mirrors admin.py:584-596).
|
||||
func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string, error) {
|
||||
users, err := s.users.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(users))
|
||||
for _, u := range users {
|
||||
name := strings.TrimSpace(u.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(u.Email)
|
||||
}
|
||||
if name == "" {
|
||||
name = u.ID
|
||||
}
|
||||
out[u.ID] = name
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) {
|
||||
stats, err := s.events.Stats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recentFiles, _ := s.RecentImages(ctx, 24)
|
||||
files, fileStats, _ := s.scanGeneratedFiles(ctx)
|
||||
var size int64
|
||||
if v, ok := fileStats["size_bytes"].(int64); ok {
|
||||
size = v
|
||||
}
|
||||
return map[string]any{
|
||||
"generated_count": len(files),
|
||||
"generated_size_bytes": size,
|
||||
"recent": recentFiles,
|
||||
"avg_elapsed_ms": stats.AvgElapsedMS,
|
||||
"avg_elapsed_ms_24h": stats.AvgElapsedMS24,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Dashboard assembles the admin overview's analytics entirely server-side
|
||||
// (event windows, hourly trend, top models/failures/spenders) plus CDK / invite
|
||||
// / checkin summaries. This replaces the old client-side math over the last 200
|
||||
// logs, which silently undercounted week/DAU/trend once volume grew.
|
||||
func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error) {
|
||||
now := time.Now()
|
||||
dayCut := now.Add(-24 * time.Hour)
|
||||
weekCut := now.Add(-7 * 24 * time.Hour)
|
||||
|
||||
day, err := s.events.WindowStats(ctx, dayCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
week, err := s.events.WindowStats(ctx, weekCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prevDay, err := s.events.CountBetween(ctx, now.Add(-48*time.Hour), dayCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dau, err := s.events.DistinctUsersSince(ctx, dayCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wau, err := s.events.DistinctUsersSince(ctx, weekCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hourly, err := s.events.HourlyBuckets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-window top-N analytics so the frontend can toggle 24h / 7d without a
|
||||
// re-fetch (the lists are small — top 6 / top 5).
|
||||
nameByID, err := s.UserNameMap(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
analytics := func(since time.Time) (map[string]any, error) {
|
||||
models, err := s.events.ModelUsageSince(ctx, since, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
failures, err := s.events.TopFailures(ctx, since, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := s.events.TopUserSpend(ctx, since, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].UserID == "" {
|
||||
users[i].Name = "匿名"
|
||||
} else if name, ok := nameByID[users[i].UserID]; ok {
|
||||
users[i].Name = name
|
||||
} else {
|
||||
users[i].Name = users[i].UserID
|
||||
}
|
||||
}
|
||||
return map[string]any{"models": models, "failures": failures, "top_users": users}, nil
|
||||
}
|
||||
dayAnalytics, err := analytics(dayCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
weekAnalytics, err := analytics(weekCut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cdkStats, err := s.cdks.Stats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inviteReward := parseIntSetting(s.mustSetting(ctx, "credits.invite_reward"), 3)
|
||||
inviteSummary, err := s.users.InviteSummary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checkinReward := parseIntSetting(s.mustSetting(ctx, "credits.checkin_reward"), 3)
|
||||
checkin, err := s.users.CheckinStats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"day": day,
|
||||
"week": week,
|
||||
"prev_day_total": prevDay,
|
||||
"dau": dau,
|
||||
"wau": wau,
|
||||
"hourly": hourly,
|
||||
"analytics": map[string]any{
|
||||
"day": dayAnalytics,
|
||||
"week": weekAnalytics,
|
||||
},
|
||||
"cdk": cdkStats,
|
||||
"invites": map[string]any{
|
||||
"total": inviteSummary.Total,
|
||||
"completed": inviteSummary.Completed,
|
||||
"reward": inviteReward,
|
||||
"reward_paid": inviteSummary.Completed * int64(inviteReward),
|
||||
},
|
||||
"checkin": map[string]any{
|
||||
"today": checkin.TodayCount,
|
||||
"max_streak": checkin.MaxStreak,
|
||||
"reward": checkinReward,
|
||||
"awarded_today": checkin.TodayCount * int64(checkinReward),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mustSetting reads a site setting value, returning "" on error so the caller's
|
||||
// parseIntSetting default kicks in (a missing reward setting shouldn't 500 the
|
||||
// whole dashboard).
|
||||
func (s *AdminReadService) mustSetting(ctx context.Context, key string) string {
|
||||
v, err := s.settings.GetValue(ctx, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *repo.InviteLogStats, error) {
|
||||
rewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
reward := parseIntSetting(rewardRaw, 3)
|
||||
return s.users.AllInvites(ctx, reward)
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
|
||||
models, err := s.models.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := s.tokens.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelCounts := map[string]int{}
|
||||
for _, item := range models {
|
||||
modelCounts[item.Provider]++
|
||||
}
|
||||
type aggregate struct {
|
||||
active int
|
||||
disabled int
|
||||
quota int
|
||||
}
|
||||
tokenCounts := map[string]*aggregate{}
|
||||
for _, item := range tokens {
|
||||
if _, ok := tokenCounts[item.Pool]; !ok {
|
||||
tokenCounts[item.Pool] = &aggregate{}
|
||||
}
|
||||
switch item.Status {
|
||||
case "active":
|
||||
tokenCounts[item.Pool].active++
|
||||
case "quota":
|
||||
tokenCounts[item.Pool].quota++
|
||||
default:
|
||||
tokenCounts[item.Pool].disabled++
|
||||
}
|
||||
}
|
||||
providers := []struct {
|
||||
Name string
|
||||
Pool string
|
||||
Type string
|
||||
}{
|
||||
{Name: "chatgpt", Pool: "chatgpt", Type: "openai"},
|
||||
{Name: "adobe", Pool: "adobe", Type: "adobe"},
|
||||
}
|
||||
out := make([]map[string]any, 0, len(providers))
|
||||
for _, item := range providers {
|
||||
count := tokenCounts[item.Pool]
|
||||
if count == nil {
|
||||
count = &aggregate{}
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"name": item.Name,
|
||||
"token_pool": item.Pool,
|
||||
"type": item.Type,
|
||||
"model_count": modelCounts[item.Name],
|
||||
"tokens_total": count.active + count.disabled + count.quota,
|
||||
"tokens_active": count.active,
|
||||
"tokens_disabled": count.disabled,
|
||||
"tokens_quota": count.quota,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Images(ctx context.Context, limit, offset int, kind string) ([]map[string]any, int, map[string]any, error) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
allFiles, stats, err := s.scanGeneratedFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
filtered := make([]generatedFile, 0, len(allFiles))
|
||||
for _, item := range allFiles {
|
||||
if kind == "" || item.Kind == kind {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].MTime > filtered[j].MTime
|
||||
})
|
||||
total := len(filtered)
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
page := filtered[offset:end]
|
||||
index, err := s.eventIndexByFile(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(page))
|
||||
for _, item := range page {
|
||||
row := map[string]any{
|
||||
"name": item.Name,
|
||||
"size": item.Size,
|
||||
"mtime": item.MTime,
|
||||
"kind": item.Kind,
|
||||
"prompt": "",
|
||||
"model": "",
|
||||
"resolution": "",
|
||||
"ratio": "",
|
||||
"duration": "",
|
||||
}
|
||||
if event, ok := index[item.Name]; ok {
|
||||
row["prompt"] = event.Prompt
|
||||
row["model"] = event.Model
|
||||
row["resolution"] = event.Resolution
|
||||
row["ratio"] = event.Ratio
|
||||
row["duration"] = event.Duration
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, stats, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) RecentImages(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
if limit <= 0 {
|
||||
limit = 24
|
||||
}
|
||||
allFiles, _, err := s.scanGeneratedFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(allFiles, func(i, j int) bool {
|
||||
return allFiles[i].MTime > allFiles[j].MTime
|
||||
})
|
||||
if len(allFiles) > limit {
|
||||
allFiles = allFiles[:limit]
|
||||
}
|
||||
out := make([]map[string]any, 0, len(allFiles))
|
||||
for _, item := range allFiles {
|
||||
out = append(out, map[string]any{
|
||||
"name": item.Name,
|
||||
"size": item.Size,
|
||||
"mtime": item.MTime,
|
||||
"kind": item.Kind,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RecentImagesOwned lists the most-recent generated images under a single owner
|
||||
// directory (used by the showcase picker so an admin sees only their OWN images).
|
||||
func (s *AdminReadService) RecentImagesOwned(ctx context.Context, owner string, limit int) ([]map[string]any, error) {
|
||||
if limit <= 0 {
|
||||
limit = 24
|
||||
}
|
||||
owner = strings.TrimSpace(owner)
|
||||
if owner == "" || s.store == nil || !s.store.Configured() {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
objs, err := s.store.List(ctx, owner+"/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]generatedFile, 0, len(objs))
|
||||
for _, o := range objs {
|
||||
if isReferenceFile(o.Key) {
|
||||
continue
|
||||
}
|
||||
kind := mediaKind(o.Key)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
files = append(files, generatedFile{Name: o.Key, Size: o.Size, MTime: o.LastModified.Unix(), Kind: kind})
|
||||
}
|
||||
sort.SliceStable(files, func(i, j int) bool { return files[i].MTime > files[j].MTime })
|
||||
if len(files) > limit {
|
||||
files = files[:limit]
|
||||
}
|
||||
out := make([]map[string]any, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, map[string]any{"name": f.Name, "size": f.Size, "mtime": f.MTime, "kind": f.Kind})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) eventIndexByFile(ctx context.Context) (map[string]model.EventLog, error) {
|
||||
items, err := s.events.RecentByFile(ctx, 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]model.EventLog, len(items))
|
||||
for _, item := range items {
|
||||
if item.File == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := out[item.File]; ok {
|
||||
continue
|
||||
}
|
||||
out[item.File] = item
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type generatedFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
MTime int64
|
||||
Kind string
|
||||
}
|
||||
|
||||
// mediaKind classifies an object key by extension (image / video / "" = skip).
|
||||
func mediaKind(name string) string {
|
||||
i := strings.LastIndex(name, ".")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(name[i+1:]) {
|
||||
case "png", "jpg", "jpeg", "webp", "gif":
|
||||
return "image"
|
||||
case "mp4", "webm", "mov":
|
||||
return "video"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// isReferenceFile reports whether a key is an uploaded reference image (named
|
||||
// "...-ref-..."), so the gallery / picker can skip them — only generated outputs
|
||||
// are listed.
|
||||
func isReferenceFile(name string) bool {
|
||||
return strings.Contains(name, "-ref-")
|
||||
}
|
||||
|
||||
// scanGeneratedFiles lists media objects from RustFS (replacing the old local
|
||||
// directory walk). Keys ARE the relative paths the rest of the app expects.
|
||||
func (s *AdminReadService) scanGeneratedFiles(ctx context.Context) ([]generatedFile, map[string]any, error) {
|
||||
stats := map[string]any{"total": 0, "image": 0, "video": 0, "size_bytes": int64(0)}
|
||||
if s.store == nil || !s.store.Configured() {
|
||||
return nil, stats, nil
|
||||
}
|
||||
objs, err := s.store.List(ctx, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out := make([]generatedFile, 0, len(objs))
|
||||
for _, o := range objs {
|
||||
if isReferenceFile(o.Key) {
|
||||
continue // reference uploads are not generated outputs — hide from gallery
|
||||
}
|
||||
kind := mediaKind(o.Key)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
stats[kind] = stats[kind].(int) + 1
|
||||
stats["total"] = stats["total"].(int) + 1
|
||||
stats["size_bytes"] = stats["size_bytes"].(int64) + o.Size
|
||||
out = append(out, generatedFile{
|
||||
Name: o.Key,
|
||||
Size: o.Size,
|
||||
MTime: o.LastModified.Unix(),
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
return out, stats, nil
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user