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>
This commit is contained in:
@@ -27,15 +27,17 @@ type AdminWriteService struct {
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
apiKeys *repo.APIKeyRepository
|
||||
tokens *repo.TokenRepository
|
||||
}
|
||||
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository) *AdminWriteService {
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository) *AdminWriteService {
|
||||
return &AdminWriteService{
|
||||
users: users,
|
||||
showcase: showcase,
|
||||
models: models,
|
||||
events: events,
|
||||
apiKeys: apiKeys,
|
||||
tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any)
|
||||
status := normalizedStatus(stringValue(body["status"]))
|
||||
credits := maxFloat(0, floatValue(body["credits"]))
|
||||
notes := strings.TrimSpace(stringValue(body["notes"]))
|
||||
cgroupID := strings.TrimSpace(stringValue(body["concurrency_group_id"]))
|
||||
|
||||
exists, err := s.users.ExistsEmail(ctx, email, "")
|
||||
if err != nil {
|
||||
@@ -99,6 +102,7 @@ func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any)
|
||||
Status: status,
|
||||
Credits: credits,
|
||||
Notes: notes,
|
||||
ConcurrencyGroupID: cgroupID,
|
||||
InviteCode: randomInviteCode(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -161,6 +165,9 @@ func (s *AdminWriteService) UpdateUser(ctx context.Context, userID string, body
|
||||
if _, ok := body["notes"]; ok {
|
||||
patch["notes"] = strings.TrimSpace(stringValue(body["notes"]))
|
||||
}
|
||||
if _, ok := body["concurrency_group_id"]; ok {
|
||||
patch["concurrency_group_id"] = strings.TrimSpace(stringValue(body["concurrency_group_id"]))
|
||||
}
|
||||
if _, ok := body["password"]; ok && strings.TrimSpace(stringValue(body["password"])) != "" {
|
||||
if err := ValidatePassword(stringValue(body["password"])); err != nil {
|
||||
return nil, err
|
||||
@@ -458,9 +465,52 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Clean up: strip this model id from every custom upstream account's
|
||||
// supported-models list so no orphan reference is left behind.
|
||||
s.removeModelFromUpstreams(ctx, modelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeModelFromUpstreams drops modelID from the CSV in each custom account's
|
||||
// Meta["models"]. Best-effort — a failure here doesn't undo the model delete.
|
||||
func (s *AdminWriteService) removeModelFromUpstreams(ctx context.Context, modelID string) {
|
||||
if s.tokens == nil {
|
||||
return
|
||||
}
|
||||
items, err := s.tokens.ListByPool(ctx, "custom")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
raw, _ := it.Meta["models"].(string)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
kept := make([]string, 0, len(strings.Split(raw, ",")))
|
||||
changed := false
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == modelID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
meta := datatypes.JSONMap{}
|
||||
for k, v := range it.Meta {
|
||||
meta[k] = v
|
||||
}
|
||||
meta["models"] = strings.Join(kept, ",")
|
||||
_, _ = s.tokens.Update(ctx, "custom", it.ID, map[string]any{"meta": meta})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
||||
return s.events.DeleteAll(ctx)
|
||||
}
|
||||
|
||||
@@ -41,10 +41,11 @@ type SMTPSettings struct {
|
||||
}
|
||||
|
||||
type CreditSettings struct {
|
||||
CheckinEnabled bool `json:"checkin_enabled"`
|
||||
CheckinReward int `json:"checkin_reward"`
|
||||
InviteEnabled bool `json:"invite_enabled"`
|
||||
InviteReward int `json:"invite_reward"`
|
||||
CheckinEnabled bool `json:"checkin_enabled"`
|
||||
CheckinReward int `json:"checkin_reward"`
|
||||
InviteEnabled bool `json:"invite_enabled"`
|
||||
InviteReward int `json:"invite_reward"`
|
||||
CDKRedeemEnabled bool `json:"cdk_redeem_enabled"`
|
||||
}
|
||||
|
||||
type ProxySettings struct {
|
||||
@@ -70,6 +71,75 @@ func NewAppSettingsService(settings *repo.SiteSettingRepository, events *repo.Ev
|
||||
}
|
||||
}
|
||||
|
||||
// UploadLogo stores a new site logo in object storage under branding/, deletes
|
||||
// the previously-uploaded one (if any), persists site.logo, and returns its URL.
|
||||
func (s *AppSettingsService) UploadLogo(ctx context.Context, data []byte, contentType string) (string, error) {
|
||||
if s.store == nil || !s.store.Configured() {
|
||||
return "", errors.New("对象存储未配置")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", errors.New("空文件")
|
||||
}
|
||||
if len(data) > 4*1024*1024 {
|
||||
return "", errors.New("logo 不能超过 4MB")
|
||||
}
|
||||
key := "branding/logo-" + randomUpper(10) + "." + logoExt(contentType)
|
||||
if err := s.store.Put(ctx, key, data, contentType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
url := "/images/" + key
|
||||
// Delete the previous uploaded logo (best-effort), then point site.logo at the new one.
|
||||
if old, _ := s.settings.GetValue(ctx, "site.logo"); strings.HasPrefix(old, "/images/branding/") {
|
||||
_ = s.store.Delete(ctx, strings.TrimPrefix(old, "/images/"))
|
||||
}
|
||||
if err := s.settings.UpsertValue(ctx, "site.logo", url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// UploadAsset stores a public image (e.g. a 首页内容 底图) under branding/ and
|
||||
// returns its storage path (for form.image). Does NOT touch site settings.
|
||||
func (s *AppSettingsService) UploadAsset(ctx context.Context, data []byte, contentType string) (string, error) {
|
||||
if s.store == nil || !s.store.Configured() {
|
||||
return "", errors.New("对象存储未配置")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", errors.New("空文件")
|
||||
}
|
||||
if len(data) > 8*1024*1024 {
|
||||
return "", errors.New("图片不能超过 8MB")
|
||||
}
|
||||
key := "branding/sc-" + randomUpper(10) + "." + logoExt(contentType)
|
||||
if err := s.store.Put(ctx, key, data, contentType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// RemoveLogo deletes the uploaded logo and resets site.logo to the built-in default (empty).
|
||||
func (s *AppSettingsService) RemoveLogo(ctx context.Context) error {
|
||||
if old, _ := s.settings.GetValue(ctx, "site.logo"); strings.HasPrefix(old, "/images/branding/") && s.store != nil {
|
||||
_ = s.store.Delete(ctx, strings.TrimPrefix(old, "/images/"))
|
||||
}
|
||||
return s.settings.UpsertValue(ctx, "site.logo", "")
|
||||
}
|
||||
|
||||
func logoExt(contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/svg+xml":
|
||||
return "svg"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
default:
|
||||
return "png"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppSettingsService) Registration(ctx context.Context) (*RegistrationSettings, error) {
|
||||
openRaw, err := s.settings.GetValue(ctx, "auth.open")
|
||||
if err != nil {
|
||||
@@ -281,11 +351,13 @@ func (s *AppSettingsService) Credits(ctx context.Context) (*CreditSettings, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdkRaw, _ := s.settings.GetValue(ctx, "credits.cdk_redeem_enabled")
|
||||
return &CreditSettings{
|
||||
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
|
||||
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
|
||||
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
|
||||
InviteReward: parseIntSetting(inviteRewardRaw, 3),
|
||||
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
|
||||
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
|
||||
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
|
||||
InviteReward: parseIntSetting(inviteRewardRaw, 3),
|
||||
CDKRedeemEnabled: parseBoolSetting(cdkRaw, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -297,10 +369,11 @@ func (s *AppSettingsService) SaveCredits(ctx context.Context, in CreditSettings)
|
||||
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),
|
||||
"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),
|
||||
"credits.cdk_redeem_enabled": strconv.FormatBool(in.CDKRedeemEnabled),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type AuthService struct {
|
||||
codes *EmailCodeService
|
||||
smtp *SMTPService
|
||||
loginGuard *LoginGuard
|
||||
cgroups *repo.ConcurrencyGroupRepository
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
@@ -39,6 +40,7 @@ func NewAuthService(
|
||||
sessions *SessionService,
|
||||
codes *EmailCodeService,
|
||||
smtp *SMTPService,
|
||||
cgroups *repo.ConcurrencyGroupRepository,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
users: users,
|
||||
@@ -47,6 +49,7 @@ func NewAuthService(
|
||||
codes: codes,
|
||||
smtp: smtp,
|
||||
loginGuard: NewLoginGuard(codes.Redis()),
|
||||
cgroups: cgroups,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +303,12 @@ func (s *AuthService) Register(ctx context.Context, email, username, password, i
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
// Bind new users to the default concurrency group.
|
||||
if s.cgroups != nil {
|
||||
if def, derr := s.cgroups.GetDefault(ctx); derr == nil && def != nil {
|
||||
user.ConcurrencyGroupID = def.ID
|
||||
}
|
||||
}
|
||||
if err := s.users.Create(ctx, user); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
@@ -426,6 +435,20 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Concurrency group + its cap (0 = unlimited) for the profile page.
|
||||
concName, concMax := "", 0
|
||||
if s.cgroups != nil {
|
||||
var g *model.ConcurrencyGroup
|
||||
if user.ConcurrencyGroupID != "" {
|
||||
g, _ = s.cgroups.Get(ctx, user.ConcurrencyGroupID)
|
||||
}
|
||||
if g == nil {
|
||||
g, _ = s.cgroups.GetDefault(ctx)
|
||||
}
|
||||
if g != nil {
|
||||
concName, concMax = g.Name, g.MaxConcurrency
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
@@ -433,6 +456,8 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"concurrency_group": concName,
|
||||
"concurrency_limit": concMax,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"),
|
||||
|
||||
@@ -12,14 +12,16 @@ import (
|
||||
)
|
||||
|
||||
type CDKService struct {
|
||||
cdks *repo.CDKRepository
|
||||
users *repo.UserRepository
|
||||
cdks *repo.CDKRepository
|
||||
users *repo.UserRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
}
|
||||
|
||||
func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository) *CDKService {
|
||||
func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository) *CDKService {
|
||||
return &CDKService{
|
||||
cdks: cdks,
|
||||
users: users,
|
||||
cdks: cdks,
|
||||
users: users,
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +129,12 @@ func (s *CDKService) DeleteBulk(ctx context.Context, codes []string) (int, error
|
||||
}
|
||||
|
||||
func (s *CDKService) Redeem(ctx context.Context, userID, code string) (map[string]any, error) {
|
||||
// Honor the admin "兑换码" switch — when off, no code can be redeemed.
|
||||
if s.settings != nil {
|
||||
if v, _ := s.settings.GetValue(ctx, "credits.cdk_redeem_enabled"); v == "false" {
|
||||
return nil, errors.New("兑换功能已关闭")
|
||||
}
|
||||
}
|
||||
code = strings.TrimSpace(strings.ToUpper(code))
|
||||
if code == "" {
|
||||
return nil, errors.New("请输入兑换码")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ConcurrencyService is a Redis-backed, self-healing concurrency limiter shared
|
||||
// by the per-user gate (画图台 + API key) and the per-account upstream gate.
|
||||
//
|
||||
// Each slot is a member of a sorted set keyed by the subject (user/account),
|
||||
// scored with its expiry time. Acquire prunes expired members first, so a slot
|
||||
// whose Release was lost (crash / missed defer) auto-frees after the TTL — the
|
||||
// count can never leak forever. It's intentionally lossy-tolerant: if Redis is
|
||||
// unavailable it FAILS OPEN (allows the work) rather than blocking generation.
|
||||
type ConcurrencyService struct {
|
||||
redis *redis.Client
|
||||
// ttl is the max lifetime of a slot — the longest a generation can run
|
||||
// (video ~3min) plus head-room, after which a stuck slot self-heals.
|
||||
ttl int
|
||||
}
|
||||
|
||||
func NewConcurrencyService(rdb *redis.Client) *ConcurrencyService {
|
||||
return &ConcurrencyService{redis: rdb, ttl: 900} // 15 min
|
||||
}
|
||||
|
||||
// acquireScript: KEYS[1]=set, ARGV[1]=max (0=unlimited), ARGV[2]=ttl secs,
|
||||
// ARGV[3]=token. Prunes expired members, then admits the token iff under max.
|
||||
// Returns 1 on success, 0 when full.
|
||||
var acquireScript = redis.NewScript(`
|
||||
local t = redis.call('TIME')
|
||||
local now = tonumber(t[1])
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
|
||||
local n = redis.call('ZCARD', KEYS[1])
|
||||
local max = tonumber(ARGV[1])
|
||||
if max > 0 and n >= max then return 0 end
|
||||
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[2]), ARGV[3])
|
||||
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
||||
return 1
|
||||
`)
|
||||
|
||||
// Acquire takes one slot under `key` (capped at max; 0 = unlimited), tagged with
|
||||
// `token`. Returns true if admitted. Fail-open when Redis is down/unset.
|
||||
func (c *ConcurrencyService) Acquire(ctx context.Context, key string, max int, token string) bool {
|
||||
if c == nil || c.redis == nil {
|
||||
return true
|
||||
}
|
||||
res, err := acquireScript.Run(ctx, c.redis, []string{key}, max, c.ttl, token).Int()
|
||||
if err != nil {
|
||||
return true // fail open — never block a generation on Redis trouble
|
||||
}
|
||||
return res == 1
|
||||
}
|
||||
|
||||
// Release frees the slot held by `token` under `key`. Safe to call even if the
|
||||
// slot already expired.
|
||||
func (c *ConcurrencyService) Release(ctx context.Context, key, token string) {
|
||||
if c == nil || c.redis == nil {
|
||||
return
|
||||
}
|
||||
_ = c.redis.ZRem(ctx, key, token).Err()
|
||||
}
|
||||
|
||||
// Count returns the live (non-expired) slot count under `key` — for display.
|
||||
func (c *ConcurrencyService) Count(ctx context.Context, key string) int {
|
||||
if c == nil || c.redis == nil {
|
||||
return 0
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_ = c.redis.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(now, 10)).Err()
|
||||
n, err := c.redis.ZCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// CountUsers returns live concurrency for many users in one round-trip
|
||||
// (group_id display etc. don't need this, but the user list does). Keyed by the
|
||||
// raw subject id passed in.
|
||||
func (c *ConcurrencyService) CountMany(ctx context.Context, prefix string, ids []string) map[string]int {
|
||||
out := make(map[string]int, len(ids))
|
||||
if c == nil || c.redis == nil || len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
pipe := c.redis.Pipeline()
|
||||
cmds := make(map[string]*redis.IntCmd, len(ids))
|
||||
for _, id := range ids {
|
||||
cmds[id] = pipe.ZCard(ctx, prefix+id)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return out
|
||||
}
|
||||
for id, cmd := range cmds {
|
||||
if n, err := cmd.Result(); err == nil && n > 0 {
|
||||
out[id] = int(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
)
|
||||
|
||||
// ConcurrencyGroupService manages the admin-facing concurrency groups: the
|
||||
// definitions live in the DB (repo), the live in-flight counts come from Redis.
|
||||
type ConcurrencyGroupService struct {
|
||||
repo *repo.ConcurrencyGroupRepository
|
||||
conc *ConcurrencyService
|
||||
}
|
||||
|
||||
func NewConcurrencyGroupService(r *repo.ConcurrencyGroupRepository, conc *ConcurrencyService) *ConcurrencyGroupService {
|
||||
return &ConcurrencyGroupService{repo: r, conc: conc}
|
||||
}
|
||||
|
||||
// List returns every group with its bound-user count.
|
||||
func (s *ConcurrencyGroupService) List(ctx context.Context) ([]map[string]any, error) {
|
||||
groups, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, _ := s.repo.UserCounts(ctx)
|
||||
out := make([]map[string]any, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, map[string]any{
|
||||
"id": g.ID,
|
||||
"name": g.Name,
|
||||
"max_concurrency": g.MaxConcurrency,
|
||||
"is_default": g.IsDefault,
|
||||
"user_count": counts[g.ID],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ConcurrencyGroupService) Create(ctx context.Context, name string, max int) (*model.ConcurrencyGroup, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("名称不能为空")
|
||||
}
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
g := &model.ConcurrencyGroup{
|
||||
ID: "cg-" + randomUpper(10), Name: name, MaxConcurrency: max, IsDefault: false,
|
||||
}
|
||||
if err := s.repo.Create(ctx, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *ConcurrencyGroupService) Update(ctx context.Context, id string, name *string, max *int) (*model.ConcurrencyGroup, error) {
|
||||
patch := map[string]any{}
|
||||
if name != nil {
|
||||
n := strings.TrimSpace(*name)
|
||||
if n == "" {
|
||||
return nil, errors.New("名称不能为空")
|
||||
}
|
||||
patch["name"] = n
|
||||
}
|
||||
if max != nil {
|
||||
m := *max
|
||||
if m < 0 {
|
||||
m = 0
|
||||
}
|
||||
patch["max_concurrency"] = m
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
return s.repo.Update(ctx, id, patch)
|
||||
}
|
||||
|
||||
func (s *ConcurrencyGroupService) SetDefault(ctx context.Context, id string) error {
|
||||
if _, err := s.repo.Get(ctx, id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.repo.SetDefault(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a group (members fall back to the default group). The default
|
||||
// group itself can never be deleted.
|
||||
func (s *ConcurrencyGroupService) Delete(ctx context.Context, id string) error {
|
||||
def, err := s.repo.GetDefault(ctx)
|
||||
if err != nil || def == nil {
|
||||
return errors.New("缺少默认并发分组")
|
||||
}
|
||||
if id == def.ID {
|
||||
return errors.New("默认并发分组不可删除")
|
||||
}
|
||||
rows, err := s.repo.Delete(ctx, id, def.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -40,6 +40,11 @@ func (s *ImageAccessService) Resolve(user, name string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, error) {
|
||||
// Branding assets (the site logo) are public — they render on the homepage /
|
||||
// header for logged-out visitors.
|
||||
if strings.HasPrefix(rel, "branding/") {
|
||||
return true, nil
|
||||
}
|
||||
return s.showcase.IsPublicFile(ctx, rel)
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,14 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
||||
pinned = nil
|
||||
}
|
||||
}
|
||||
// The site logo is permanent too — pin it like a showcase image so the
|
||||
// retention sweep never deletes it. site.logo is "/images/<key>".
|
||||
if logo, _ := m.settings.GetValue(ctx, "site.logo"); strings.TrimSpace(logo) != "" {
|
||||
if pinned == nil {
|
||||
pinned = map[string]struct{}{}
|
||||
}
|
||||
pinned[strings.TrimPrefix(strings.TrimLeft(logo, "/"), "images/")] = struct{}{}
|
||||
}
|
||||
removed, skipped := 0, 0
|
||||
var clearedKeys []string
|
||||
for _, o := range objs {
|
||||
|
||||
@@ -51,17 +51,16 @@ func (s *SiteService) get(ctx context.Context, key string) string {
|
||||
func (s *SiteService) Logo(ctx context.Context) string { return s.get(ctx, "site.logo") }
|
||||
func (s *SiteService) Subtitle(ctx context.Context) string { return s.get(ctx, "site.subtitle") }
|
||||
|
||||
// SetBranding persists logo / subtitle (either may be empty).
|
||||
func (s *SiteService) SetBranding(ctx context.Context, logo, subtitle string) error {
|
||||
for k, v := range map[string]string{
|
||||
"site.logo": strings.TrimSpace(logo),
|
||||
"site.subtitle": strings.TrimSpace(subtitle),
|
||||
} {
|
||||
if err := s.settings.UpsertValue(ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// CDKRedeemEnabled reflects the admin "兑换码" switch (default on). The front-end
|
||||
// hides the redeem UI when off.
|
||||
func (s *SiteService) CDKRedeemEnabled(ctx context.Context) bool {
|
||||
return s.get(ctx, "credits.cdk_redeem_enabled") != "false"
|
||||
}
|
||||
|
||||
// SetSubtitle persists the homepage 子标题. (The logo is managed separately via
|
||||
// the upload/delete endpoints so a site-form save never clobbers it.)
|
||||
func (s *SiteService) SetSubtitle(ctx context.Context, subtitle string) error {
|
||||
return s.settings.UpsertValue(ctx, "site.subtitle", strings.TrimSpace(subtitle))
|
||||
}
|
||||
|
||||
// Contact is the admin-editable "联系我们" info shown in the public 关于 section.
|
||||
|
||||
@@ -39,13 +39,8 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
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("已有正在生成的任务,请稍候")
|
||||
}
|
||||
// No single-job lock anymore — concurrent generations are allowed, capped by
|
||||
// the user's concurrency group (enforced in prepareImageExecution/Video).
|
||||
|
||||
modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model))
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,9 @@ var (
|
||||
// ErrConcurrencyFull — every eligible account is busy (each account runs at
|
||||
// most ONE generation at a time). English message: surfaced to API / UI.
|
||||
ErrConcurrencyFull = errors.New("all accounts are busy (1 concurrent job each), please try again shortly")
|
||||
// ErrUserConcurrencyFull — the caller already has their concurrency-group's max
|
||||
// generations in flight (画图台 + API key combined). 0 = unlimited.
|
||||
ErrUserConcurrencyFull = errors.New("too many generations in progress, please wait for one to finish")
|
||||
// ErrVideoJobNotFound / ErrVideoNotReady — /v1/videos async job lookups.
|
||||
ErrVideoJobNotFound = errors.New("video job not found")
|
||||
ErrVideoNotReady = errors.New("video is not ready yet")
|
||||
@@ -66,6 +69,7 @@ type V1Service struct {
|
||||
events *repo.EventRepository
|
||||
tokens *repo.TokenRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
cgroups *repo.ConcurrencyGroupRepository
|
||||
adobe *adobe.Client
|
||||
chatgpt *chatgpt.Client
|
||||
runway *runway.Client
|
||||
@@ -93,48 +97,57 @@ type V1Service struct {
|
||||
// for minutes and surface a late "success" on an already-abandoned event).
|
||||
inflight *InflightRegistry
|
||||
|
||||
// gate enforces 1 concurrent generation PER account: a scheduler skips any
|
||||
// account that's currently busy, and fails with ErrConcurrencyFull when every
|
||||
// eligible account is occupied. In-memory (single process).
|
||||
gate accountGate
|
||||
// conc is the Redis-backed concurrency limiter for BOTH the per-account
|
||||
// upstream gate (1+ jobs per account) and the per-user gate (画图台 + API key,
|
||||
// capped by the user's concurrency group). Self-healing + fail-open.
|
||||
conc *ConcurrencyService
|
||||
}
|
||||
|
||||
// accountGate is a 1-slot-per-account in-flight gate. tryAcquire wins only if the
|
||||
// account isn't already running a generation; release frees it when done.
|
||||
type accountGate struct{ m sync.Map } // accountID -> struct{} held while busy
|
||||
|
||||
// tryAcquireN wins if the account has fewer than max in-flight jobs, atomically
|
||||
// bumping its counter. max=1 is the default 1-job-per-account policy; some
|
||||
// providers (grok) allow more.
|
||||
func (g *accountGate) tryAcquireN(id string, max int) bool {
|
||||
if id == "" {
|
||||
return true
|
||||
}
|
||||
// acctAcquire takes one per-account upstream slot (capped at max; 0/1 = single),
|
||||
// tagged with the generation's eventID (unique per job; a generation only ever
|
||||
// holds one slot on a given account at a time, so failover reuses it cleanly).
|
||||
func (s *V1Service) acctAcquire(ctx context.Context, accountID, eventID string, max int) bool {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
v, _ := g.m.LoadOrStore(id, new(int64))
|
||||
cnt := v.(*int64)
|
||||
for {
|
||||
cur := atomic.LoadInt64(cnt)
|
||||
if cur >= int64(max) {
|
||||
return false
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(cnt, cur, cur+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return s.conc.Acquire(ctx, "conc:a:"+accountID, max, eventID)
|
||||
}
|
||||
|
||||
func (g *accountGate) tryAcquire(id string) bool { return g.tryAcquireN(id, 1) }
|
||||
func (s *V1Service) acctRelease(ctx context.Context, accountID, eventID string) {
|
||||
s.conc.Release(ctx, "conc:a:"+accountID, eventID)
|
||||
}
|
||||
|
||||
func (g *accountGate) release(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
// userAcquire takes one per-user generation slot, capped by the user's
|
||||
// concurrency group (0 = unlimited). Returns false when the user is already at
|
||||
// their limit. `token` is a unique per-generation tag passed back to userRelease.
|
||||
func (s *V1Service) userAcquire(ctx context.Context, user *model.User, token string) bool {
|
||||
if user == nil {
|
||||
return true
|
||||
}
|
||||
if v, ok := g.m.Load(id); ok {
|
||||
atomic.AddInt64(v.(*int64), -1)
|
||||
return s.conc.Acquire(ctx, "conc:u:"+user.ID, s.userConcurrencyLimit(ctx, user), token)
|
||||
}
|
||||
|
||||
func (s *V1Service) userRelease(ctx context.Context, userID, token string) {
|
||||
s.conc.Release(ctx, "conc:u:"+userID, token)
|
||||
}
|
||||
|
||||
// userConcurrencyLimit resolves the user's concurrency-group cap (0 = unlimited),
|
||||
// falling back to the default group when unset/missing.
|
||||
func (s *V1Service) userConcurrencyLimit(ctx context.Context, user *model.User) int {
|
||||
if s.cgroups == nil || user == nil {
|
||||
return 0
|
||||
}
|
||||
var g *model.ConcurrencyGroup
|
||||
if user.ConcurrencyGroupID != "" {
|
||||
g, _ = s.cgroups.Get(ctx, user.ConcurrencyGroupID)
|
||||
}
|
||||
if g == nil {
|
||||
g, _ = s.cgroups.GetDefault(ctx)
|
||||
}
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
return g.MaxConcurrency
|
||||
}
|
||||
|
||||
// InflightRegistry tracks the cancel func of every in-progress generation by
|
||||
@@ -199,7 +212,7 @@ type V1VideoRequest struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service {
|
||||
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, cgroups *repo.ConcurrencyGroupRepository, conc *ConcurrencyService, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service {
|
||||
return &V1Service{
|
||||
cfg: cfg,
|
||||
models: models,
|
||||
@@ -207,6 +220,8 @@ func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.
|
||||
events: events,
|
||||
tokens: tokens,
|
||||
settings: settings,
|
||||
cgroups: cgroups,
|
||||
conc: conc,
|
||||
adobe: adobeClient,
|
||||
chatgpt: chatGPTClient,
|
||||
runway: runwayClient,
|
||||
@@ -327,6 +342,16 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
genCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Per-user concurrency gate (画图台 + API key combined). Admin model-tests are
|
||||
// exempt. Held for the whole generation; released on return.
|
||||
if source != "admin" && principal != nil && principal.User != nil {
|
||||
slot := randomUpper(12)
|
||||
if !s.userAcquire(ctx, principal.User, slot) {
|
||||
return nil, ErrUserConcurrencyFull
|
||||
}
|
||||
defer s.userRelease(ctx, principal.User.ID, slot)
|
||||
}
|
||||
|
||||
modelItem, resolution, aspectRatio, price, err := s.prepareImage(ctx, principal, in, charge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -550,6 +575,15 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Per-user concurrency gate (画图台 + API key combined); admin tests exempt.
|
||||
if source != "admin" && principal != nil && principal.User != nil {
|
||||
slot := randomUpper(12)
|
||||
if !s.userAcquire(ctx, principal.User, slot) {
|
||||
return nil, ErrUserConcurrencyFull
|
||||
}
|
||||
defer s.userRelease(ctx, principal.User.ID, slot)
|
||||
}
|
||||
|
||||
modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, charge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -922,12 +956,11 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
|
||||
if err := ensureReferenceSizes(in.ReferenceImages); err != nil {
|
||||
return nil, "", "", 0, err
|
||||
}
|
||||
// `size` (WxH) drives BOTH the aspect ratio AND the resolution tier — its long
|
||||
// edge maps to a tier (<1800→1K, 1800–3499→2K, ≥3500→4K). The web path passes
|
||||
// an explicit resolution; the OpenAI /v1 path derives it from size. There is no
|
||||
// `quality` param — size is the single source of truth for resolution.
|
||||
aspectRatio, resolution := parseImageSize(in.Size, in.AspectRatio, in.Resolution)
|
||||
// Strict OpenAI path (/v1) sends no resolution — pick the tier from `quality`
|
||||
// (low/medium/high/auto → 1K/2K/4K/default), clamped to the model's tiers.
|
||||
if strings.TrimSpace(in.Resolution) == "" {
|
||||
resolution = resolutionForQuality(modelItem, in.Quality)
|
||||
}
|
||||
// parseImageSize defaults a blank resolution to "2K" (OpenAI-size parity).
|
||||
// For a model that doesn't price that tier — e.g. gpt-image-2 is 1K-only —
|
||||
// fall back to its first supported tier so a missing/stale resolution from
|
||||
@@ -1240,13 +1273,13 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
|
||||
tempDeadCount := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.gate.tryAcquire(token.ID) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
// release via defer so a panic in tryAccount can't leak the 1-job slot.
|
||||
data, err, failover, tempDead := func() ([]byte, error, bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
return s.tryAccount(ctx, eventID, pool, token, kind, attempt, classify, refreshOnAuth, tempAsDead)
|
||||
}()
|
||||
if err == nil {
|
||||
@@ -1524,13 +1557,13 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.gate.tryAcquire(token.ID) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
teamID := ""
|
||||
@@ -1663,13 +1696,13 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod
|
||||
var lastErr error
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
if !s.gate.tryAcquireN(token.ID, accountConcurrency(token)) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
baseURL := stringValue(token.Meta["base_url"])
|
||||
@@ -1730,13 +1763,13 @@ func (s *V1Service) generateCustomVideo(ctx context.Context, eventID string, mod
|
||||
var videoURL string
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
if !s.gate.tryAcquireN(token.ID, accountConcurrency(token)) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
baseURL := stringValue(token.Meta["base_url"])
|
||||
@@ -1869,13 +1902,13 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
||||
for _, token := range active {
|
||||
// grok allows 10 concurrent jobs per account (unlike the 1-per-account
|
||||
// default of the other pools).
|
||||
if !s.gate.tryAcquireN(token.ID, grokConcurrencyPerAccount) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, grokConcurrencyPerAccount) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
d, meta, genErr := s.grok.GenerateVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
|
||||
@@ -1970,13 +2003,13 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.gate.tryAcquire(token.ID) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
teamID := ""
|
||||
@@ -2571,7 +2604,14 @@ func guessRatio(w, h int) string {
|
||||
W int
|
||||
H int
|
||||
}
|
||||
candidates := []candidate{{1, 1}, {16, 9}, {9, 16}, {4, 3}, {3, 4}, {4, 1}, {1, 4}, {8, 1}, {1, 8}}
|
||||
// The 14 ratios actually used across our models. Must stay in sync with the
|
||||
// custom-model picker (CustomModelModal RATIO_OPTS) and the docs 对照表, so a
|
||||
// /v1 `size` maps to exactly one of them.
|
||||
candidates := []candidate{
|
||||
{1, 1},
|
||||
{5, 4}, {4, 3}, {3, 2}, {16, 9}, {2, 1}, {21, 9}, {3, 1}, // 横
|
||||
{4, 5}, {3, 4}, {2, 3}, {9, 16}, {9, 21}, {1, 3}, // 竖
|
||||
}
|
||||
best := candidates[0]
|
||||
bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H))
|
||||
for _, item := range candidates[1:] {
|
||||
|
||||
Reference in New Issue
Block a user