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:
1 parent
4df2506a74
commit
1f8af649ca
7 files changed
+107
-21
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 打死。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user