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
@@ -182,6 +182,7 @@ func userPublic(user model.User) gin.H {
"status": user.Status,
"credits": user.Credits,
"notes": user.Notes,
"concurrency_group_id": user.ConcurrencyGroupID,
"created_at": unixSec(user.CreatedAt),
"last_login_at": unixSecPtr(user.LastLoginAt),
"last_login_ip": user.LastLoginIP,
@@ -1,7 +1,9 @@
package handler
import (
"encoding/base64"
"net/http"
"strings"
"backend/internal/service"
"github.com/gin-gonic/gin"
@@ -15,6 +17,87 @@ func NewAppSettingsHandler(settings *service.AppSettingsService) *AppSettingsHan
return &AppSettingsHandler{settings: settings}
}
// LogoUpload stores a base64 image as the site logo in RustFS (deleting the old
// one) and persists site.logo. Called on 保存 — not on file pick.
func (h *AppSettingsHandler) LogoUpload(c *gin.Context) {
var body struct {
Data string `json:"data"` // base64, optionally a "data:...;base64," URL
ContentType string `json:"content_type"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
raw := strings.TrimSpace(body.Data)
if strings.HasPrefix(raw, "data:") {
if i := strings.Index(raw, ","); i >= 0 {
if body.ContentType == "" {
meta := raw[5:i] // e.g. image/png;base64
if j := strings.Index(meta, ";"); j >= 0 {
body.ContentType = meta[:j]
}
}
raw = raw[i+1:]
}
}
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片解码失败"})
return
}
url, err := h.settings.UploadLogo(c.Request.Context(), data, body.ContentType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "logo": url})
}
// AssetUpload stores a public image (homepage 底图 etc.) in RustFS and returns
// its storage path for the caller to save (e.g. as a showcase card's image).
func (h *AppSettingsHandler) AssetUpload(c *gin.Context) {
var body struct {
Data string `json:"data"`
ContentType string `json:"content_type"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
raw := strings.TrimSpace(body.Data)
if strings.HasPrefix(raw, "data:") {
if i := strings.Index(raw, ","); i >= 0 {
if body.ContentType == "" {
meta := raw[5:i]
if j := strings.Index(meta, ";"); j >= 0 {
body.ContentType = meta[:j]
}
}
raw = raw[i+1:]
}
}
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片解码失败"})
return
}
path, err := h.settings.UploadAsset(c.Request.Context(), data, body.ContentType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "path": path})
}
// LogoDelete removes the uploaded logo and falls back to the built-in default.
func (h *AppSettingsHandler) LogoDelete(c *gin.Context) {
if err := h.settings.RemoveLogo(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to remove logo"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "logo": ""})
}
func (h *AppSettingsHandler) RegistrationGet(c *gin.Context) {
data, err := h.settings.Registration(c.Request.Context())
if err != nil {
@@ -0,0 +1,80 @@
package handler
import (
"errors"
"net/http"
"backend/internal/service"
"github.com/gin-gonic/gin"
)
type ConcurrencyGroupHandler struct {
svc *service.ConcurrencyGroupService
}
func NewConcurrencyGroupHandler(svc *service.ConcurrencyGroupService) *ConcurrencyGroupHandler {
return &ConcurrencyGroupHandler{svc: svc}
}
func (h *ConcurrencyGroupHandler) List(c *gin.Context) {
items, err := h.svc.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load concurrency groups"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *ConcurrencyGroupHandler) Create(c *gin.Context) {
var body struct {
Name string `json:"name"`
MaxConcurrency int `json:"max_concurrency"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
g, err := h.svc.Create(c.Request.Context(), body.Name, body.MaxConcurrency)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": g.ID})
}
func (h *ConcurrencyGroupHandler) Update(c *gin.Context) {
var body struct {
Name *string `json:"name"`
MaxConcurrency *int `json:"max_concurrency"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
g, err := h.svc.Update(c.Request.Context(), c.Param("id"), body.Name, body.MaxConcurrency)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "id": g.ID})
}
func (h *ConcurrencyGroupHandler) SetDefault(c *gin.Context) {
if err := h.svc.SetDefault(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *ConcurrencyGroupHandler) Delete(c *gin.Context) {
if err := h.svc.Delete(c.Request.Context(), c.Param("id")); err != nil {
if errors.Is(err, service.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"detail": "分组不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+5 -4
View File
@@ -23,9 +23,10 @@ func (h *SiteHandler) Public(c *gin.Context) {
}
ctx := c.Request.Context()
c.JSON(http.StatusOK, gin.H{
"title": title,
"logo": h.site.Logo(ctx),
"subtitle": h.site.Subtitle(ctx),
"contact": h.site.Contact(ctx),
"title": title,
"logo": h.site.Logo(ctx),
"subtitle": h.site.Subtitle(ctx),
"cdk_redeem_enabled": h.site.CDKRedeemEnabled(ctx),
"contact": h.site.Contact(ctx),
})
}
@@ -35,7 +35,6 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) {
ctx := c.Request.Context()
var body struct {
Title string `json:"title"`
Logo string `json:"logo"`
Subtitle string `json:"subtitle"`
Contact service.Contact `json:"contact"`
}
@@ -53,8 +52,8 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"})
return
}
if err := h.site.SetBranding(ctx, body.Logo, body.Subtitle); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save branding"})
if err := h.site.SetSubtitle(ctx, body.Subtitle); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save subtitle"})
return
}
if err := h.site.SetContact(ctx, body.Contact); err != nil {
@@ -80,7 +80,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderQuota):
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrConcurrencyFull):
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderExecution):
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
@@ -136,7 +136,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderQuota):
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrConcurrencyFull):
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrNoProviderAccount):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
+1 -3
View File
@@ -72,7 +72,6 @@ func (h *V1Handler) ImageGenerations(c *gin.Context) {
Prompt: body.Prompt,
N: body.N,
Size: body.Size,
Quality: body.Quality,
BaseURL: requestBaseURL(c),
})
if err != nil {
@@ -110,7 +109,6 @@ func (h *V1Handler) ImageEdits(c *gin.Context) {
Prompt: c.PostForm("prompt"),
N: n,
Size: c.PostForm("size"),
Quality: c.PostForm("quality"),
ReferenceImages: refs,
BaseURL: requestBaseURL(c),
})
@@ -336,7 +334,7 @@ func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]a
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrProviderTemporary):
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrConcurrencyFull):
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrVideoJobNotFound):
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})