并发分组(新功能): - 新表 concurrency_groups(名称/上限/默认),用户加 concurrency_group_id - 启动自动建「默认并发」组(上限10、默认),老用户回填、新注册自动绑定 - 并发计数改用 Redis(自愈 sorted-set + TTL + fail-open): · 用户并发(画图台 + API key 合计)受其分组上限限制,0=不限制 → 超返回 429 · 账号级并发也从内存 gate 换成同一套 Redis(6 处调用点) · 移除旧的「已有正在生成的任务」单任务锁 - 后台「并发分组」新菜单:增删改、设默认、用户数;默认组不可删(删别的组成员转默认) - 用户管理:并发列 + 新建/编辑可选分组 - 个人设置页:账户信息卡(用户名/邮箱/角色/余额/并发);/me 暴露 concurrency_group/limit 品牌 / Logo(上传到 RustFS): - Logo 改成拖拽/点击上传,点保存才上传;替换自动删旧;branding/ 设为公开且被清理任务 pin 住(永不删) - 有自定义就用:前台左侧 nav + 后台侧栏 + favicon(浏览器标签);没有则默认 V 图标 - 前台页头还原成文字;首页 Hero 子标题用 site.subtitle(默认那句宣传语,设置页预填) - 邮件验证码标题用站点名;新增 POST/DELETE /settings/logo + POST /settings/asset(首页底图上传) 兑换码开关: - 系统设置→积分 新增「开启兑换码」(默认开);关闭后后端拒绝兑换、前台隐藏兑换入口(/site 暴露 cdk_redeem_enabled) 文档 / 分辨率: - 去掉 quality 参数:size(宽x高)同时决定比例 + 分辨率档(长边映射 1K/2K/4K) - 文档加「分辨率对照表」(14 个比例 × 1K/2K/4K → size 该传的值);guessRatio 与自定义模型 RATIO_OPTS 对齐到 14 个 其它: - 删模型时同步清掉各上游账号「支持模型」里的该 id - 首页设置/兑换码弹窗去固定高度滚动条;展示位弹窗浅色主题适配 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
7.5 KiB
Go
188 lines
7.5 KiB
Go
package bootstrap
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"time"
|
||
|
||
"backend/internal/config"
|
||
"backend/internal/http/handler"
|
||
"backend/internal/http/router"
|
||
"backend/internal/model"
|
||
"backend/internal/provider/adobe"
|
||
"backend/internal/provider/chatgpt"
|
||
"backend/internal/provider/custom"
|
||
"backend/internal/provider/grok"
|
||
"backend/internal/provider/imagine"
|
||
"backend/internal/provider/krea"
|
||
"backend/internal/provider/leonardo"
|
||
"backend/internal/provider/runway"
|
||
"backend/internal/repo"
|
||
"backend/internal/service"
|
||
"backend/internal/storage"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/redis/go-redis/v9"
|
||
"gorm.io/driver/postgres"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type App struct {
|
||
Config *config.Config
|
||
DB *gorm.DB
|
||
Redis *redis.Client
|
||
Engine *gin.Engine
|
||
maintenanceCancel context.CancelFunc
|
||
}
|
||
|
||
func NewApp(ctx context.Context) (*App, error) {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Ensure the media root (generated outputs + uploaded reference images)
|
||
// exists from the first request — don't rely on lazy per-file MkdirAll.
|
||
if err := os.MkdirAll(cfg.GeneratedRoot, 0o755); err != nil {
|
||
return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err)
|
||
}
|
||
|
||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey,
|
||
// 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey)
|
||
// 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open postgres: %w", err)
|
||
}
|
||
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("sql db: %w", err)
|
||
}
|
||
sqlDB.SetMaxIdleConns(5)
|
||
sqlDB.SetMaxOpenConns(20)
|
||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||
|
||
if err := db.WithContext(ctx).AutoMigrate(model.AutoMigrateModels()...); err != nil {
|
||
return nil, fmt.Errorf("auto migrate: %w", err)
|
||
}
|
||
// Hard backstop for "one marketing code per user per batch": a partial unique
|
||
// index. Concurrent double-redeems that slip past the in-tx count check still
|
||
// fail here. AutoMigrate can't express partial indexes, so do it raw.
|
||
if err := db.WithContext(ctx).Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_cdk_marketing_batch_user ` +
|
||
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
||
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
||
}
|
||
if err := seedDefaults(ctx, db); err != nil {
|
||
return nil, fmt.Errorf("seed defaults: %w", err)
|
||
}
|
||
|
||
rdb := redis.NewClient(&redis.Options{
|
||
Addr: cfg.RedisAddr,
|
||
Password: cfg.RedisPassword,
|
||
DB: cfg.RedisDB,
|
||
})
|
||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||
return nil, fmt.Errorf("ping redis: %w", err)
|
||
}
|
||
|
||
userRepo := repo.NewUserRepository(db)
|
||
showcaseRepo := repo.NewShowcaseRepository(db)
|
||
siteRepo := repo.NewSiteSettingRepository(db, rdb)
|
||
modelRepo := repo.NewModelRepository(db)
|
||
eventRepo := repo.NewEventRepository(db)
|
||
cdkRepo := repo.NewCDKRepository(db)
|
||
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
||
tokenRepo := repo.NewTokenRepository(db)
|
||
refreshRepo := repo.NewRefreshProfileRepository(db)
|
||
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
||
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
||
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
||
log.Printf("ensure default concurrency group: %v", err)
|
||
}
|
||
concSvc := service.NewConcurrencyService(rdb)
|
||
cgroupSvc := service.NewConcurrencyGroupService(cgroupRepo, concSvc)
|
||
sessionSvc := service.NewSessionService(rdb, cfg.SessionTTL, cfg.SessionSlideAfter)
|
||
emailCodeSvc := service.NewEmailCodeService(rdb)
|
||
smtpSvc := service.NewSMTPService()
|
||
rateLimitSvc := service.NewRateLimitService(rdb)
|
||
rustfsClient := storage.New(cfg.RustFSEndpoint, cfg.RustFSBucket, cfg.RustFSAccessKey, cfg.RustFSSecretKey)
|
||
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
||
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
||
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
||
adobeClient := adobe.NewClient("clio-playground-web", "")
|
||
chatGPTClient := chatgpt.NewClient("")
|
||
runwayClient := runway.NewClient("")
|
||
leonardoClient := leonardo.NewClient("")
|
||
kreaClient := krea.NewClient("")
|
||
imagineClient := imagine.NewClient("")
|
||
grokClient := grok.NewClient("")
|
||
customClient := custom.NewClient()
|
||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
|
||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient)
|
||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo)
|
||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo)
|
||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
||
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient)
|
||
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
|
||
// Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token
|
||
// from the cookie). Wired post-construction to avoid a ctor init cycle.
|
||
v1Svc.SetRefresh(refreshSvc)
|
||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||
|
||
engine := router.New(cfg, authSvc, router.Handlers{
|
||
Health: handler.NewHealthHandler(),
|
||
Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient),
|
||
V1: handler.NewV1Handler(v1Svc),
|
||
Site: handler.NewSiteHandler(siteSvc),
|
||
Showcase: handler.NewShowcaseHandler(showcaseSvc),
|
||
Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc),
|
||
SiteSettings: handler.NewSiteSettingsHandler(siteSvc),
|
||
AppSettings: handler.NewAppSettingsHandler(appSettingsSvc),
|
||
AdminRead: handler.NewAdminReadHandler(adminReadSvc),
|
||
AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc),
|
||
CDK: handler.NewCDKHandler(cdkSvc),
|
||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
|
||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||
})
|
||
|
||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||
// cleanup, log retention) — the Go equivalent of the Python daemon thread.
|
||
maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo)
|
||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||
go maintenanceSvc.Run(loopCtx)
|
||
|
||
return &App{
|
||
Config: cfg,
|
||
DB: db,
|
||
Redis: rdb,
|
||
Engine: engine,
|
||
maintenanceCancel: loopCancel,
|
||
}, nil
|
||
}
|
||
|
||
func (a *App) Close() error {
|
||
if a.maintenanceCancel != nil {
|
||
a.maintenanceCancel()
|
||
}
|
||
if a.Redis != nil {
|
||
if err := a.Redis.Close(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if a.DB != nil {
|
||
sqlDB, err := a.DB.DB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return sqlDB.Close()
|
||
}
|
||
return nil
|
||
}
|