Files
image2api/backend/internal/http/handler/admin_read.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

231 lines
6.1 KiB
Go

package handler
import (
"net/http"
"strconv"
"time"
"backend/internal/model"
"backend/internal/service"
"github.com/gin-gonic/gin"
)
type AdminReadHandler struct {
admin *service.AdminReadService
}
func NewAdminReadHandler(admin *service.AdminReadService) *AdminReadHandler {
return &AdminReadHandler{admin: admin}
}
func (h *AdminReadHandler) Users(c *gin.Context) {
users, stats, err := h.admin.Users(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load users"})
return
}
out := make([]gin.H, 0, len(users))
for _, user := range users {
row := userPublic(user)
row["generation_count"] = user.GenerationCount
out = append(out, row)
}
c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats})
}
func (h *AdminReadHandler) Models(c *gin.Context) {
items, err := h.admin.ModelsView(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *AdminReadHandler) Logs(c *gin.Context) {
limit := parseInt(c.Query("limit"), 50)
offset := parseInt(c.Query("offset"), 0)
kind := c.Query("kind")
status := c.Query("status")
var since *time.Time
if raw := c.Query("since"); raw != "" {
if f, err := strconv.ParseFloat(raw, 64); err == nil {
t := time.Unix(int64(f), 0)
since = &t
}
}
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", "", c.Query("source"), false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
// Resolve user_id -> display name once for the page (mirrors admin.py).
nameByID, err := h.admin.UserNameMap(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
out := make([]gin.H, 0, len(items))
for _, item := range items {
var userName any
if item.UserID == "" {
userName = "匿名"
} else if name, ok := nameByID[item.UserID]; ok {
userName = name
} else {
userName = item.UserID
}
out = append(out, gin.H{
"id": item.ID,
"ts": item.TS.Unix(),
"kind": item.Kind,
"status": item.Status,
"model": item.Model,
"provider": item.Provider,
"prompt": item.Prompt,
"ratio": item.Ratio,
"resolution": item.Resolution,
"duration": item.Duration,
"refs": item.Refs,
"source": item.Source,
"user_id": emptyStringNil(item.UserID),
"user_name": userName,
"cost": item.Cost,
"elapsed_ms": item.ElapsedMS,
"file": emptyStringNil(item.File),
"error": emptyStringNil(item.Error),
"created_at": unixSec(item.CreatedAt),
"updated_at": unixSec(item.UpdatedAt),
})
}
c.JSON(http.StatusOK, gin.H{
"data": out,
"total": total,
"limit": limit,
"offset": offset,
"stats": stats,
})
}
func (h *AdminReadHandler) Stats(c *gin.Context) {
stats, err := h.admin.Stats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load stats"})
return
}
c.JSON(http.StatusOK, stats)
}
func (h *AdminReadHandler) Dashboard(c *gin.Context) {
data, err := h.admin.Dashboard(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load dashboard"})
return
}
c.JSON(http.StatusOK, data)
}
func (h *AdminReadHandler) Invites(c *gin.Context) {
items, stats, err := h.admin.Invites(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items, "stats": stats})
}
func (h *AdminReadHandler) Providers(c *gin.Context) {
items, err := h.admin.Providers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load providers"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *AdminReadHandler) Images(c *gin.Context) {
limit := parseInt(c.Query("limit"), 30)
offset := parseInt(c.Query("offset"), 0)
kind := c.Query("kind")
items, total, stats, err := h.admin.Images(c.Request.Context(), limit, offset, kind)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load images"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"total": total,
"limit": limit,
"offset": offset,
"stats": stats,
})
}
func userPublic(user model.User) gin.H {
keys := make([]gin.H, 0, len(user.APIKeys))
for _, key := range user.APIKeys {
keys = append(keys, gin.H{
"id": key.ID,
"name": key.Name,
"key_preview": key.KeyPreview,
"created_at": unixSec(key.CreatedAt),
"last_used_at": unixSecPtr(key.LastUsedAt),
})
}
return gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"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,
"invite_code": user.InviteCode,
"invited_by": user.InvitedBy,
"checkin_last": user.CheckinLast,
"checkin_streak": user.CheckinStreak,
"api_keys": keys,
"has_password": user.PasswordHash != "",
}
}
// unixSec / unixSecPtr render timestamps as unix SECONDS — the frontend's
// fmtTs/fmtRelative expect seconds (matching the Python reference's time.time()),
// not the RFC3339 string Go marshals a time.Time into (which parses to NaN → "—").
func unixSec(t time.Time) any {
if t.IsZero() {
return nil
}
return t.Unix()
}
func unixSecPtr(t *time.Time) any {
if t == nil || t.IsZero() {
return nil
}
return t.Unix()
}
func parseInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
if n, err := strconv.Atoi(raw); err == nil {
return n
}
return fallback
}
func emptyStringNil(v string) any {
if v == "" {
return nil
}
return v
}