Files
image2api/backend/internal/bootstrap/app.go
T
chiyiandClaude Opus 4.8 5cf6206ee9 feat: 并发分组系统 + 品牌/Logo 上传 + 兑换码开关 + 文档分辨率表
并发分组(新功能):
- 新表 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>
2026-06-30 13:52:31 +08:00

188 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}