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:
2026-06-30 13:52:31 +08:00
co-authored by Claude Opus 4.8
parent 1137d0bd1d
commit 5cf6206ee9
37 changed files with 1257 additions and 161 deletions
@@ -0,0 +1,128 @@
package repo
import (
"context"
"time"
"backend/internal/model"
"gorm.io/gorm"
)
type ConcurrencyGroupRepository struct {
db *gorm.DB
}
func NewConcurrencyGroupRepository(db *gorm.DB) *ConcurrencyGroupRepository {
return &ConcurrencyGroupRepository{db: db}
}
func (r *ConcurrencyGroupRepository) List(ctx context.Context) ([]model.ConcurrencyGroup, error) {
var items []model.ConcurrencyGroup
// Default first, then by name — stable ordering for the admin table.
err := r.db.WithContext(ctx).Order("is_default desc, created_at asc").Find(&items).Error
return items, err
}
func (r *ConcurrencyGroupRepository) Get(ctx context.Context, id string) (*model.ConcurrencyGroup, error) {
var g model.ConcurrencyGroup
if err := r.db.WithContext(ctx).First(&g, "id = ?", id).Error; err != nil {
return nil, err
}
return &g, nil
}
func (r *ConcurrencyGroupRepository) GetDefault(ctx context.Context) (*model.ConcurrencyGroup, error) {
var g model.ConcurrencyGroup
if err := r.db.WithContext(ctx).First(&g, "is_default = ?", true).Error; err != nil {
return nil, err
}
return &g, nil
}
func (r *ConcurrencyGroupRepository) Create(ctx context.Context, g *model.ConcurrencyGroup) error {
now := time.Now()
g.CreatedAt = now
g.UpdatedAt = now
return r.db.WithContext(ctx).Create(g).Error
}
func (r *ConcurrencyGroupRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.ConcurrencyGroup, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).Model(&model.ConcurrencyGroup{}).Where("id = ?", id).Updates(patch).Error; err != nil {
return nil, err
}
return r.Get(ctx, id)
}
// SetDefault makes id the sole default (used for new registrations).
func (r *ConcurrencyGroupRepository) SetDefault(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.ConcurrencyGroup{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
return err
}
return tx.Model(&model.ConcurrencyGroup{}).Where("id = ?", id).Update("is_default", true).Error
})
}
// Delete removes a group and reassigns its members to the default group, so no
// user is left without a concurrency limit. The default group itself is never
// deletable (guarded in the service).
func (r *ConcurrencyGroupRepository) Delete(ctx context.Context, id, defaultID string) (int64, error) {
var rows int64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.User{}).Where("concurrency_group_id = ?", id).
Update("concurrency_group_id", defaultID).Error; err != nil {
return err
}
res := tx.Delete(&model.ConcurrencyGroup{}, "id = ?", id)
rows = res.RowsAffected
return res.Error
})
return rows, err
}
// UserCounts returns group_id → number of bound users.
func (r *ConcurrencyGroupRepository) UserCounts(ctx context.Context) (map[string]int64, error) {
type row struct {
GroupID string
N int64
}
var rows []row
if err := r.db.WithContext(ctx).Model(&model.User{}).
Select("concurrency_group_id as group_id, count(*) as n").
Where("concurrency_group_id <> ''").
Group("concurrency_group_id").Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, x := range rows {
out[x.GroupID] = x.N
}
return out, nil
}
// EnsureDefault creates the seed "默认并发" group (MaxConcurrency 10) when none
// exists, and binds any ungrouped users to the default. Idempotent — safe at boot.
func (r *ConcurrencyGroupRepository) EnsureDefault(ctx context.Context) error {
var count int64
if err := r.db.WithContext(ctx).Model(&model.ConcurrencyGroup{}).Count(&count).Error; err != nil {
return err
}
if count == 0 {
now := time.Now()
if err := r.db.WithContext(ctx).Create(&model.ConcurrencyGroup{
ID: "cg-default", Name: "默认并发", MaxConcurrency: 10, IsDefault: true,
CreatedAt: now, UpdatedAt: now,
}).Error; err != nil {
return err
}
}
def, err := r.GetDefault(ctx)
if err != nil {
return err
}
// Bind ungrouped users to the default group.
return r.db.WithContext(ctx).Model(&model.User{}).
Where("concurrency_group_id = '' OR concurrency_group_id IS NULL").
Update("concurrency_group_id", def.ID).Error
}
+9
View File
@@ -238,6 +238,15 @@ func (r *EventRepository) CountBetween(ctx context.Context, start, end time.Time
return n, err
}
// CountPendingByUser returns how many generations the user currently has
// in-flight (status=pending) — used to enforce the per-user concurrency limit.
func (r *EventRepository) CountPendingByUser(ctx context.Context, userID string) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
Where("user_id = ? AND status = ?", userID, "pending").Count(&n).Error
return n, err
}
// DistinctUsersSince counts distinct (non-empty) user_ids active since `since`.
func (r *EventRepository) DistinctUsersSince(ctx context.Context, since time.Time) (int64, error) {
var n int64