From 1f8af649ca3a117abe5fa857f0301e731bcc3f4b Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:05:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E5=B0=BE=20P2=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=99=BB=E5=BD=95=E6=81=92=E5=AE=9A=E8=80=97=E6=97=B6?= =?UTF-8?q?=E3=80=81AdminUsage=20N+1=E3=80=81=E7=94=A8=E9=87=8F=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E9=87=8D=E5=A1=91=E4=B8=8E=E6=97=B6=E5=8C=BA=E7=BC=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录失败路径补 argon2 恒定耗时校验(用户不存在/被禁用与密码错误的时序不可区分,抹掉用户名枚举面) - AdminUsage 每行 2 次查询改批量预取(100 行分页省 200 次查询) - usage_logs 补 key_id 索引与 (user,created)/(channel,created) 复合索引(schema v6);AutoMigrate 按名跳过已存在索引,旧库的复合索引需一次性删掉重建,用 system_configs 打标记防 PG 每次启动重演 - 用量统计默认窗口改 UTC(usage_daily 的 date 键是 UTC,非 UTC 服务器上图表窗口偏移一天) - 注册 email 大小写归一;并发撞唯一索引回查后返回 409 而非 500 - PatchKey 的 name 校验与 CreateKey 对齐(1-64 字符),PATCH 不再能绕过 - TrendChart 条宽不超过槽位(点数多时不再互相重叠),超过 32 个点隐藏标签行 --- server/internal/api/admin_stats.go | 35 +++++++++++++++++++++------- server/internal/api/auth.go | 29 ++++++++++++++++++++--- server/internal/api/keys.go | 9 ++++++- server/internal/api/usage.go | 6 +++-- server/internal/store/db.go | 32 ++++++++++++++++++++++++- server/internal/store/models.go | 9 +++---- web/src/components/ui/TrendChart.vue | 8 +++++-- 7 files changed, 107 insertions(+), 21 deletions(-) diff --git a/server/internal/api/admin_stats.go b/server/internal/api/admin_stats.go index c4804ea..98714c0 100644 --- a/server/internal/api/admin_stats.go +++ b/server/internal/api/admin_stats.go @@ -226,17 +226,36 @@ func (h *Handler) AdminUsage(c *gin.Context) { q.Count(&total) var logs []store.UsageLog q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs) + // 批量预取用户名与渠道名:每行两次查询在 100 行分页下是 200 次额外查询 + userNames := map[uint64]string{} + chNames := map[uint64]string{} + userIDs := make([]uint64, 0, len(logs)) + chIDs := make([]uint64, 0, len(logs)) + for _, l := range logs { + userIDs = append(userIDs, l.UserID) + if l.ChannelID > 0 { + chIDs = append(chIDs, l.ChannelID) + } + } + if len(userIDs) > 0 { + var us []store.User + h.a.DB.Select("id, username").Where("id IN ?", userIDs).Find(&us) + for _, u := range us { + userNames[u.ID] = u.Username + } + } + if len(chIDs) > 0 { + var cs []store.Channel + h.a.DB.Select("id, name").Where("id IN ?", chIDs).Find(&cs) + for _, ch := range cs { + chNames[ch.ID] = ch.Name + } + } out := make([]gin.H, 0, len(logs)) for _, l := range logs { - var uname string - h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname) - var chName string - if l.ChannelID > 0 { - h.a.DB.Model(&store.Channel{}).Where("id = ?", l.ChannelID).Pluck("name", &chName) - } out = append(out, gin.H{ - "id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol, - "channel_id": l.ChannelID, "channel": chName, + "id": l.ID, "user": userNames[l.UserID], "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol, + "channel_id": l.ChannelID, "channel": chNames[l.ChannelID], "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, "cache_read_tokens": l.CacheReadTokens, "cost": l.Cost, "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index f169a4e..7b2f114 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -25,10 +25,24 @@ type Handler struct { a *app.App gw *proxy.Gateway passkeys *passkey.Service + // dummyHash 与配置同参数的 argon2id 哈希:用户不存在/被禁用时也跑一次完整校验, + // 抹平「存在→校验几十 ms / 不存在→立即 401」的时序差(用户名枚举面)。 + dummyHash string } func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler { - return &Handler{a: a, gw: gw, passkeys: pk} + h := &Handler{a: a, gw: gw, passkeys: pk} + if dummy, err := a.Hasher.HashPassword("openteam-timing-equalizer"); err == nil { + h.dummyHash = dummy + } + return h +} + +// equalizeTiming 恒定耗时:登录失败路径同样消耗一次 argon2,时序上与命中用户不可区分。 +func (h *Handler) equalizeTiming(password string) { + if h.dummyHash != "" { + _, _ = h.a.Hasher.VerifyPassword(h.dummyHash, password) + } } // authLimited 认证端点滑动窗口限速:超限返回 429 并带 Retry-After。 @@ -102,8 +116,9 @@ func (h *Handler) Register(c *gin.Context) { } }() } + email := strings.ToLower(req.Email) var count int64 - h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, req.Email).Count(&count) + h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, email).Count(&count) if count > 0 { resp.Fail(c, http.StatusConflict, "username or email already exists") return @@ -115,7 +130,7 @@ func (h *Handler) Register(c *gin.Context) { } u := store.User{ Username: req.Username, - Email: strings.ToLower(req.Email), + Email: email, PasswordHash: hash, Role: store.RoleUser, Balance: 5.0, // 新用户赠送体验余额(可通过 admin 调整) @@ -126,6 +141,13 @@ func (h *Handler) Register(c *gin.Context) { u.InviteCode = &ic } if err := h.a.DB.Create(&u).Error; err != nil { + // 并发注册撞唯一索引(前置 count 检查是 TOCTOU 的):回查区分冲突与真故障 + var n int64 + h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, email).Count(&n) + if n > 0 { + resp.Fail(c, http.StatusConflict, "username or email already exists") + return + } resp.Fail(c, http.StatusInternalServerError, "failed to create user") return } @@ -156,6 +178,7 @@ func (h *Handler) Login(c *gin.Context) { var u store.User err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error if err != nil || u.Status != store.UserStatusActive { + h.equalizeTiming(req.Password) resp.Fail(c, http.StatusUnauthorized, "invalid credentials") return } diff --git a/server/internal/api/keys.go b/server/internal/api/keys.go index e2db368..4d736be 100644 --- a/server/internal/api/keys.go +++ b/server/internal/api/keys.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -140,7 +141,13 @@ func (h *Handler) PatchKey(c *gin.Context) { } updates := map[string]any{} if req.Name != nil { - updates["name"] = *req.Name + // 与 CreateKey 的 1-64 校验对齐,PATCH 不能绕过 + nm := strings.TrimSpace(*req.Name) + if len(nm) < 1 || len(nm) > 64 { + resp.Fail(c, http.StatusBadRequest, "name must be 1-64 chars") + return + } + updates["name"] = nm } // 限额:>0 生效,0 或负数视为"不限制"(写 NULL)。 // 不能存 0:token 配额判定是 `已用 >= 上限`,0 会让密钥第一次请求就被 429 打死。 diff --git a/server/internal/api/usage.go b/server/internal/api/usage.go index c29567d..cc2615d 100644 --- a/server/internal/api/usage.go +++ b/server/internal/api/usage.go @@ -34,8 +34,10 @@ func (h *Handler) UsageSummary(c *gin.Context) { // UsageStats GET /api/v1/usage/stats?from&to&group=day|model func (h *Handler) UsageStats(c *gin.Context) { u, _ := userFromContext(c) - from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02")) - to := c.DefaultQuery("to", time.Now().Format("2006-01-02")) + // usage_daily 的 date 键与 UsageLog 的 created_at 都是 UTC:默认窗口必须用 UTC, + // 否则非 UTC 服务器上(本地日期 ≠ UTC 日期)图表窗口整体偏移一天 + from := c.DefaultQuery("from", time.Now().UTC().Add(-30*24*time.Hour).Format("2006-01-02")) + to := c.DefaultQuery("to", time.Now().UTC().Format("2006-01-02")) group := c.DefaultQuery("group", "day") q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to) diff --git a/server/internal/store/db.go b/server/internal/store/db.go index 5a7f096..82e01d0 100644 --- a/server/internal/store/db.go +++ b/server/internal/store/db.go @@ -22,7 +22,10 @@ import ( // supports_vision/supports_reasoning 等列并删掉了 display_name,但当时漏改这里, // 导致已建库的实例永远跳过重迁移、新增模型时 Create 因缺列而失败。这里补到 3 触发一次迁移。 // v4:users 加 token_version(会话撤销)。 -const schemaVersion = 4 +// v5:usage_logs 补 key_id/channel_id 索引与 (user,created)/(channel,created) 复合索引。 +// v6:修正 v5 复合索引列序——AutoMigrate 按名跳过已存在的索引,旧库上的 +// idx_user_created 是 user_id 单列,光改 struct tag 不会重建,需删掉重迁一次。 +const schemaVersion = 6 // Open 打开数据库连接并自动迁移。 // 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。 @@ -56,6 +59,7 @@ func Open(driver, dsn string) (*gorm.DB, error) { if err := db.AutoMigrate(AllModels()...); err != nil { return nil, err } + reshapeUsageLogIndexes(db) if driver != "postgres" { setSQLiteVersion(db, schemaVersion) } @@ -65,6 +69,32 @@ func Open(driver, dsn string) (*gorm.DB, error) { return db, nil } +// reshapeUsageLogIndexes 一次性重建 usage_logs 的复合索引:AutoMigrate 按名跳过已存在的 +// 索引,历史库上的 idx_user_created 是 user_id 单列、idx_channel_created 形状同样不对, +// struct tag 改了也不会生效。删掉后紧接的 AutoMigrate 按新定义重建。 +// 用 system_configs 打标记:PG 每次启动都走迁移路径,没有标记会反复重建索引。 +func reshapeUsageLogIndexes(db *gorm.DB) { + var done string + db.Model(&SystemConfig{}).Where("key = ?", "index_reshape_v6").Pluck("value", &done) + if done != "" { + return + } + for _, name := range []string{"idx_user_created", "idx_channel_created"} { + if db.Migrator().HasIndex(&UsageLog{}, name) { + if err := db.Migrator().DropIndex(&UsageLog{}, name); err != nil { + log.Printf("store: drop legacy index %s: %v", name, err) + return + } + } + } + if err := db.AutoMigrate(&UsageLog{}); err != nil { + log.Printf("store: recreate usage_logs indexes: %v", err) + return + } + db.Create(&SystemConfig{Key: "index_reshape_v6", Value: `"done"`}) + log.Printf("store: usage_logs indexes reshaped (user+created / channel+created)") +} + // withSQLitePragmas 给 DSN 补上 busy_timeout 与 WAL。 // glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数—— // 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。 diff --git a/server/internal/store/models.go b/server/internal/store/models.go index cdc0487..d72f259 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -186,9 +186,9 @@ type UsageLog struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` RequestID string `gorm:"size:128" json:"request_id"` // 上游 request id TraceID string `gorm:"size:64;index" json:"trace_id"` - UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"` - KeyID uint64 `json:"key_id"` - ChannelID uint64 `json:"channel_id"` + UserID uint64 `gorm:"index:idx_user_created,priority:1;not null" json:"user_id"` + KeyID uint64 `gorm:"index" json:"key_id"` + ChannelID uint64 `gorm:"index:idx_channel_created,priority:1" json:"channel_id"` ModelID uint64 `json:"model_id"` ModelName string `gorm:"size:128" json:"model_name"` Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages @@ -205,7 +205,8 @@ type UsageLog struct { ErrorCode *string `json:"error_code,omitempty"` RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换) RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件) - CreatedAt time.Time `gorm:"index" json:"created_at"` + // CreatedAt 同时参与 user/channel 两个复合索引(时间窗过滤),自身单列索引保留给全表时间扫 + CreatedAt time.Time `gorm:"index;index:idx_user_created,priority:2;index:idx_channel_created,priority:2" json:"created_at"` } // UsageDaily 日粒度预聚合(PLANNING §6.6) diff --git a/web/src/components/ui/TrendChart.vue b/web/src/components/ui/TrendChart.vue index 121944a..f961f61 100644 --- a/web/src/components/ui/TrendChart.vue +++ b/web/src/components/ui/TrendChart.vue @@ -15,13 +15,17 @@ const chart = computed(() => { const bw = 100 / props.points.length const bars = props.points.map((p, i) => ({ x: i * bw, - w: Math.max(bw * 0.5, 2), + // 条宽最小 2 保证可点,但不得超过槽位宽 bw——点数多时(按模型分组)否则互相重叠 + w: Math.min(Math.max(bw * 0.5, 2), bw), h: (p.value / max) * 100, label: p.label, value: props.format(p.value), })) return { bars, maxLabel: props.format(max) } }) + +// 标签行是 flex 均分:点数一多每个标签挤成竖排还与条形错位,超过阈值整行隐藏 +const showLabels = computed(() => props.points.length > 0 && props.points.length <= 32)