fix: 收尾 P2——登录恒定耗时、AdminUsage N+1、用量索引重塑与时区缝

- 登录失败路径补 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 个点隐藏标签行
This commit is contained in:
Sakurasan committed 2026-10-29 03:05:56 +08:00
1 parent 4df2506a74
commit 1f8af649ca
7 files changed
+107 -21

No files matched your search

+27 -8
View File
@@ -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,
+26 -3
View File
@@ -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
}
+8 -1
View File
@@ -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 打死。
+4 -2
View File
@@ -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)
+31 -1
View File
@@ -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。
+5 -4
View File
@@ -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)