- 【已实现】模型健康度(5 分钟切片 → 小时聚合 → 成功率展示)
-
原始需求(保留):实现维护模型健康度(健康度是一个时间的比值,每5分钟一个单位,如果该单位内只有失败的请求,那么记为失败时间片,如果有一个或多个成功请求并且(返回的byte长度大于1k或完成token大于2或实际响应模型回复大于2char),记为成功时间片,可查看不同小时时间段的模型成功率),实现后端和对应前端,,设计数据结构和新表实现良好性能。实现对非管理员隐藏可自定义模型和时间的查询(在控制台),并实现在导航栏添加新的页面(新页面所有用户即使非登录也可查看),显示所有模型最近24小时每小时的健康度。
-
口径与数据结构(后端聚合“最小单位=5分钟切片”)
- 5 分钟切片对齐:
model.AlignSliceStartTs()使用createdAt - (createdAt % 300)(300s常量见modelHealthSliceSeconds)。 - “成功且满足阈值”判定:
model.IsQualifiedSuccess()实现responseBytes > 1024 || completionTokens > 2 || assistantChars > 2;事件归一化在(*model.ModelHealthEvent).Normalize()内计算SuccessIsQualified。 - 新表结构:
model.ModelHealthSlice5m映射表名model_health_slice_5m(ModelHealthSlice5m.TableName());主键为(model_name, slice_start_ts),并带按时间/模型索引字段(gorm tag 见model.ModelHealthSlice5m)。
- 5 分钟切片对齐:
-
写入路径与性能设计(异步队列 + UPSERT)
- 写入入口封装:
model.RecordModelHealthEventAsync()将事件推入内存队列(modelHealthEventQueueSize)并由固定 worker 消费(modelHealthWorkerCount),避免请求路径同步写库。 - UPSERT 聚合:
model.UpsertModelHealthSlice5m()使用ON CONFLICT(gorm clause)按(model_name, slice_start_ts)做增量更新:total_requests / error_requests / success_qualified_requests累加(见updates)has_success_qualified采用 OR 聚合(见"has_success_qualified": gorm.Expr(...))max_response_bytes / max_completion_tokens / max_assistant_chars用GREATEST取最大(见updates)- 数据库兼容:冲突更新引用新插入值时按方言生成 SQL,PostgreSQL 使用
EXCLUDED.col,MySQL 保持VALUES(col),避免 PostgreSQL 下VALUES(...)报错,同时不改变 MySQL 现有行为。
- 说明:代码提供了“事件写入器 + 聚合表”的通用能力;调用方在“成功响应/失败响应”处构造
model.ModelHealthEvent并调用model.RecordModelHealthEventAsync()即可把请求结果滚入 5 分钟切片统计。
- 写入入口封装:
-
小时聚合查询(按小时 bucket 计算 success_slices/total_slices/成功率)
- 管理员查询单模型小时聚合:
controller.GetModelHealthHourlyStatsAPI()- 入参:
model_name必填;时间可用start_hour/end_hour或hours=ts,ts...(解析见controller.parseHourListParam(),对齐校验见controller.isAlignedHour()) - 后端查询:
model.GetModelHealthHourlyStats()从model_health_slice_5m聚合到小时:- 小时桶表达式:
model.hourStartExprSQL()兼容 mysql/sqlite/postgres(避免整数/浮点除法差异) - 成功率表达式:
model.successRateExprSQL()强制 float 除法避免截断 - 布尔聚合兼容:
success_slices / success_rate不再直接对has_success_qualified做SUM(bool),统一改为CASE WHEN has_success_qualified THEN 1 ELSE 0 END后再聚合,兼容 PostgreSQL,同时保持 MySQL 结果不变。
- 小时桶表达式:
- 返回补齐:当某小时无数据时,API 会补 0 行,保证前端渲染稳定(补齐逻辑见
controller.GetModelHealthHourlyStatsAPI())。
- 入参:
- 管理员查询单模型小时聚合:
-
公共页面数据源(无需登录,展示所有模型最近 24h 每小时健康度)
- 公共 API:
controller.GetPublicModelsHealthHourlyLast24hAPI()计算start_hour/end_hour(对齐到整点)后调用model.GetAllModelsHealthHourlyStats(),并按“每模型 × 24 小时”补齐缺失小时(补齐见controller.GetPublicModelsHealthHourlyLast24hAPI())。 成功请求 token展示(不修改成功率口径):- 数据来源:复用小时聚合表
quota_data的token_used(写入点见model.LogQuotaData()),按model_name + created_at(整点)聚合得到每小时成功 token。 - 合并位置:在公共 API 内额外查询
quota_data并把结果合并进返回行字段success_tokens(实现见controller.GetPublicModelsHealthHourlyLast24hAPI())。 - 说明:成功率仍完全来自健康度切片表
model_health_slice_5m(success_slices/total_slices/success_rate逻辑不变),token 统计仅用于展示与排序。
- 数据来源:复用小时聚合表
- 前端展示与“低流量视作无数据”策略(公共页面
/model-health):- 文案提示:页面“最近 24 小时各模型运行状态一览”后追加“监测所有请求(包括格式不正确导致的错误)”。
- 报表过滤:按每模型最近 24 小时的每小时
success_tokens计算 P10(仅对>0token 的小时取分位数),若某小时success_tokens < P10则该小时视作“无数据”(UI 会使用该模型的平均成功率进行填充渲染)。 - 平均值/总体成功率:模型平均成功率与整体成功率的分子分母(
success_slices/total_slices)均忽略这些被判定为“无数据”的低流量小时。
- 路由与鉴权:
- 管理员接口
/api/model_health/hourly:在router.SetApiRouter()中挂载并强制middleware.AdminAuth()(满足“非管理员隐藏可自定义模型和时间的查询(在控制台)”)。 - 公共接口
/api/public/model_health/hourly_last24h:在router.SetApiRouter()中挂载且无鉴权(满足“新页面所有用户即使非登录也可查看”所需的数据源)。
- 管理员接口
- 缓存:公共接口仍使用 Redis + 内存双层缓存,缓存内容包含新增字段
success_tokens;key/TTL 定义见publicModelHealthCacheKey与publicModelHealthCacheTTL;读取见getPublicModelHealthCache(),写入见setPublicModelHealthCache()。
- 公共 API:
- 【已实现】管理员豁免“用户请求限速 RPM”(在无法按标签精确解除某类限速时的兜底)
-
原始需求(保留):若Newapi无法实现在对所有用户限速的情况下,使用标签解除对应的限速(而不是其他限速),那么添加管理员豁免用户限速RPM
-
具体实现位置(限速点与豁免点)
- 实际生效的“用户请求限速”中间件:
middleware.ModelRequestRateLimit() - 豁免判断:在中间件内获取当前请求用户
id后,调用setting.IsModelRequestRateLimitExemptUser();若命中则直接放行并打标头X-RateLimit-Bypass: ModelRequestRateLimit(见middleware.ModelRequestRateLimit()里的豁免分支)。 - 限速开关:
setting.ModelRequestRateLimitEnabled(在middleware.ModelRequestRateLimit()的请求入口实时检查,关闭则c.Next())。
- 实际生效的“用户请求限速”中间件:
-
限速策略(两套计数:总请求 + 成功请求)
- 时间窗口:
duration := ModelRequestRateLimitDurationMinutes * 60(见setting.ModelRequestRateLimitDurationMinutes与middleware.ModelRequestRateLimit())。 - 总请求数(包含失败):
ModelRequestRateLimitCount(见setting.ModelRequestRateLimitCount);Redis 模式下通过令牌桶限制(见limiter.New(...).Allow())。 - 成功请求数:
ModelRequestRateLimitSuccessCount(见setting.ModelRequestRateLimitSuccessCount);Redis 模式下用 list 记录成功请求时间戳并比较窗口(见checkRedisRateLimit()与成功计数 key 构造successKey)。 - 仅成功请求才计入成功限制:请求结束后
c.Writer.Status() < 400才写入成功计数(见redisRateLimitHandler()与memoryRateLimitHandler()的“请求成功才记录”逻辑)。 - 说明:当
totalMaxCount == 0时,总请求限制跳过(见checkRedisRateLimit()与memoryRateLimitHandler()对 total 的判断),仅剩“成功请求数限制”。
- 时间窗口:
-
分组覆盖(不同 group 可配置不同限速)
- 分组读取:优先 token group,其次 user group(见
common.GetContextKeyString()读取constant.ContextKeyTokenGroup/constant.ContextKeyUserGroup)。 - 分组限速配置:
setting.GetGroupRateLimit()允许覆盖默认totalMaxCount/successMaxCount(见middleware.ModelRequestRateLimit()内的覆盖逻辑)。
- 分组读取:优先 token group,其次 user group(见
-
豁免配置的数据结构与更新方式(Root/管理员通过 option 写入)
- 豁免列表存储:
setting.ModelRequestRateLimitExemptUserIDs(map[int]struct{})。 - 更新/解析:
setting.UpdateModelRequestRateLimitExemptUserIDs()+setting.ParseModelRequestRateLimitExemptUserIDs()支持逗号/空白/换行等分隔,非法 id 会报错(invalid userId)。 - 配置下发:option 系统会暴露与加载
ModelRequestRateLimit*相关键(见model/option.go写入与model/option.go读取开关/参数)。
- 豁免列表存储:
- 【已实现】最近 100 次 API 调用请求/响应缓存(含错误与上游流式原始 chunk)+ 管理员 UI 查阅
-
原始需求(保留):实现缓存最近100次API调用的请求和返回信息到内存里(包括报错,记录客户端原始请求和上游原始响应(包括上游原始流式响应)),提供UI查阅,实现后端和对应前端,设计数据结构和新表实现良好性能
-
后端:内存环形缓存(只存 meta)+ 临时文件存全文(容量=100,按请求 id 覆盖)
- 单例与容量:
service.RecentCallsCache()返回全局单例,默认容量DefaultRecentCallsCapacity=100。 - 环形缓冲实现:
type recentCallsCache维护buffer []*recentCallEntry+nextID atomic.Uint64;写入位置由idx := int(id % capacity)决定(见(*recentCallsCache).put()),天然只保留最近 N 条。 - “只存 meta”:内存里只保留
RecentCallRecord的 headers/status/flags 等元信息;request.body / response.body / stream.chunks / stream.aggregated_text写入临时文件后按需读回(返回 API 时仍是完整字段)。 - 临时目录:使用
os.TempDir()下的进程 session 临时目录(前缀new-api-recent-calls-*),启动时会清理旧 session 目录,确保严格最多保留 100 条对应文件。
- 单例与容量:
-
后端:记录入口(请求开始 / 非流式响应 / 流式 chunk / 错误)
- 请求开始(记录客户端原始请求):
(*recentCallsCache).BeginFromContext()- 用户/渠道从 context 取:
constant.ContextKeyUserId、constant.ContextKeyChannelId - headers 脱敏:
sanitizeHeaders()会 maskauthorization/x-api-key/x-goog-api-key/proxy-authorization - 请求 body 省略/截断:
encodeBodyForRecord()对multipart/form-data直接 omit(原因multipart_form_data);文本按DefaultMaxRequestBodyBytes截断后写入临时文件(内存只保留 meta)。 - 将 record id 写入 gin context:key 为
RecentCallsContextKeyID
- 用户/渠道从 context 取:
- Relay 主链路接入(确保每次请求都会 Begin):在
controller.Relay()中读取 requestBody 后调用service.RecentCallsCache().BeginFromContext()(若之前未写入 recent_calls_id)。 - 非流式上游响应:
(*recentCallsCache).UpsertUpstreamResponseByContext(),例如 OpenAI 非流式路径调用见service.RecentCallsCache().UpsertUpstreamResponseByContext(),记录status_code/headers并将body(按DefaultMaxResponseBodyBytes截断)写入临时文件。 - 流式上游响应(保存 raw chunk + 聚合文本)
- 初始化 stream:
(*recentCallsCache).EnsureStreamByContext()(OpenAI 流式见EnsureStreamByContext(),Gemini 流式见EnsureStreamByContext()) - 追加 raw chunk:
(*recentCallsCache).AppendStreamChunkByContext()将 chunk 先编码为 JSONL 行并写入 entry 级内存缓冲;默认累计到 16KiB 后再批量 append 到临时文件。单 chunk 仍按DefaultMaxStreamChunkBytes截断,总量仍按DefaultMaxStreamTotalBytes限制(超限标记chunks_truncated)。 - 写入聚合 assistant 文本:
(*recentCallsCache).FinalizeStreamAggregatedTextByContext()将聚合文本写入临时文件;返回 API 时按需读回(OpenAI/Gemini 调用点同原实现)。
- 初始化 stream:
- 错误记录:
(*recentCallsCache).UpsertErrorByContext(),在processChannelError()里写入(见UpsertErrorByContext()),包含message/type/code/status。 - 上游错误响应体读取上限:当上游返回非 200 并进入
service.RelayErrorHandler()时,仅读取最多 1MiB 的 error body(超出追加...[truncated]),避免上游回显大 payload 导致日志/IO 压力。
- 请求开始(记录客户端原始请求):
-
后端:管理端查询 API(debug 路由)
- 列表:
controller.GetRecentCalls()支持limit与before_id,数据来自(*recentCallsCache).List()(按 id 倒序)。 - 单条:
controller.GetRecentCallByID()调用(*recentCallsCache).Get()返回 request/response/stream/error 详情。 - 路由挂载:
router.SetApiRouter()的 debug group 注册/api/debug/recent_calls与/api/debug/recent_calls/:id(见debugRoute.GET("/recent_calls"...)。
- 列表:
-
前端:管理员 UI 页面与入口
- 路由:
/console/recent-calls懒加载RecentCalls并受AdminRoute保护(见该路由定义/console/recent-calls)。 - 侧边栏入口:“最近调用”菜单项
recent_calls -> /console/recent-calls(见routerMap与adminItems)。 - API 封装:
getRecentCalls()与getRecentCallById()请求/api/debug/recent_calls*。 - 页面实现:
RecentCallsPage列表(limit/before_id 翻页)+ 右侧 SideSheet 详情(请求/响应 CodeViewer + 流式回放);403 时跳转/forbidden(见isAxiosError403()与query())。 - 列表增强:新增“最后的用户消息”列,从 recent call 的
request.body中解析并显示最后一条 user 文本,兼容 Anthropic/v1/messages、OpenAI Chatmessages、OpenAI Responsesinput三类格式;列表表格加横向scroll,单元格内容限制为前 100 字并支持纵向滚动。 - 流式详情增强:
RecentCallStreamViewer改为先展示aggregated_text,再通过外层折叠面板展示SSE数据流与原始 chunk 文本;展开SSE数据流后,内部每个 SSE event 仍逐条单独折叠。 - SSE 折叠修复:
SSEViewer的受控折叠面板改用itemKey与标准化onChange,修复“点开一个事件会联动展开全部事件”的问题,保留“全部展开 / 全部收起”能力。
- 路由:
- 【已实现】生成随机兑换码(支持前缀、数量、随机额度区间、并下载 txt 文件)
-
原始需求(保留):实现生成随机兑换码(输入最小值和最大值,以及其他普通兑换码具有的字段,并且支持设置生成的兑换码前缀,生成随机的兑换码并提供文件下载),实现后端和对应前端
-
后端:随机 Key 生成(前缀 + 随机字符串,最大长度 32)
- 最大长度常量:
redemptionKeyMaxLength=32。 - 前缀输入:请求体字段
key_prefix(dto.CreateRedemptionRequest.KeyPrefix),后端在AddRedemption()里strings.TrimSpace()(见keyPrefix := strings.TrimSpace(req.KeyPrefix))。 - 前缀长度保护:至少留 8 位随机段(
minRandomLength=8),若前缀过长直接返回错误(见prefixLen > redemptionKeyMaxLength-minRandomLength)。 - 随机段长度:
randomLength := redemptionKeyMaxLength - prefixLen(randomLength),然后调用common.GenerateRandomCharsKey()生成随机字符串并拼接成最终 key(key := keyPrefix + randomPart)。
- 最大长度常量:
-
后端:随机额度区间(min/max)与兼容逻辑
- DTO 字段:
dto.CreateRedemptionRequest.RandomQuotaEnabled、dto.CreateRedemptionRequest.QuotaMin、dto.CreateRedemptionRequest.QuotaMax。 - 模式判断:
dto.CreateRedemptionRequest.RandomQuotaMode()兼容两种开启方式:random_quota_enabled=true- 或者同时提供
quota_min+quota_max
- 校验逻辑:在
AddRedemption()里,随机额度模式要求quota_min/quota_max必填、>0、且min <= max(见req.RandomQuotaMode()分支)。 - 随机取值:每个兑换码独立生成额度,使用加密随机数函数
cryptoRandIntInclusive()在[min,max]之间取整(见randomQuota, err := cryptoRandIntInclusive(...))。
- DTO 字段:
-
后端:批量生成与返回(用于前端下载)
- 生成数量:
dto.CreateRedemptionRequest.EffectiveCount()对count<=0做兼容(默认 1);在AddRedemption()里使用count := req.EffectiveCount()(count)。 - 单次生成上限:后端校验
count <= 100000(见redemptionBulkCreateMaxCount与count > redemptionBulkCreateMaxCount)。 - 批量生成:循环
count次构造model.Redemption并Insert()(cleanRedemption.Insert());成功 key 追加到keys(keys = append(keys, key))。 - 返回格式:创建成功时
data/keys都返回[]string(见"data": keys, "keys": keys),前端可直接拿到列表用于下载 txt。
- 生成数量:
-
前端:表单字段与下载(创建成功后弹窗确认并下载)
- 创建表单:
EditRedemptionModal- 新建时提供
key_prefix输入(见field='key_prefix') - 随机额度开关:
random_quota_enabled(见Form.Switch field='random_quota_enabled'),开启后展示quota_min/quota_max两个输入(见field='quota_min'与field='quota_max')
- 新建时提供
- 提交请求:新建走
API.post('/api/redemption/', localInputs),随机额度模式会写入:random_quota_enabled=true(localInputs.random_quota_enabled = true)quota_min/quota_max(localInputs.quota_min,localInputs.quota_max)
- 文件下载:后端返回
keys(或兼容读data)后弹出确认框,并用downloadTextAsFile()下载${name}.txt(见downloadTextAsFile(text, \${localInputs.name}.txt`)`)。
- 创建表单:
- 【已实现】每渠道“模型角色映射”(将特定 role 转换为另一种 role,不是全局模型映射)
-
原始需求(保留):实现模型自定义配置没有将特定role转换为另一种role的功能的话实现它。不是全局模型映射,而是每个渠道一个配置,加入渠道额外设置
-
配置入口(前端:渠道额外设置 JSON)
- 渠道编辑弹窗提供字段
model_role_mappings(纯字符串 JSON):EditChannelModal的默认值见originInputs.model_role_mappings。 - UI 组件:在“渠道额外设置”卡片中使用
JSONEditor field='model_role_mappings';placeholder 里明确说明“仅作用于当前渠道”(满足“每个渠道一个配置”)。 - 提交时写入渠道的
setting字段:- 解析/校验:提交前用
verifyJSON()校验,成功后JSON.parse()存入channelExtraSettings.model_role_mappings(见channelExtraSettings.model_role_mappings = JSON.parse(...))。 - 序列化:最终
localInputs.setting = JSON.stringify(channelExtraSettings)(见localInputs.setting = JSON.stringify(channelExtraSettings)),后端持久化该 JSON。
- 解析/校验:提交前用
- 编辑回填兼容:读取旧渠道时,
data.setting解析后会兼容model_role_mappings被存成“对象”或“JSON 字符串”两种形态(见if (typeof rawMappings === 'string')分支)。
- 渠道编辑弹窗提供字段
-
后端:ChannelSettings DTO 兼容与字段承载(不是全局,随渠道走)
- 字段定义:
dto.ChannelSettings.ModelRoleMappings使用dto.ModelRoleMappingsField承载model_role_mappings。 - 兼容三种入库形态(历史/后端/前端写法)
- object(推荐):
{ "gpt-4o": { "system": "developer" } }(见注释ModelRoleMappingsField supports...) - string(双层 JSON):
"{\"gpt-4o\":{\"system\":\"developer\"}}"(UnmarshalJSON()在检测到首字符"时递归解析内部 JSON) - legacy flat:
{ "system": "developer" }会被自动提升为 wildcard*(见"*": flat),用于“对所有模型生效”的兜底。
- object(推荐):
- 字段定义:
-
后端:映射解析/校验(只允许 OpenAI roles;错误配置自动忽略并告警)
- role 白名单:
allowedOpenAIRoles允许system/user/assistant/developer/tool。 - 解析与强校验:
service.ParseAndValidateModelRoleMappingsJSON()- JSON 必须是 object:
map[modelPrefix]map[fromRole]toRole(见expected object) - fromRole/toRole 必须都在白名单内(见
IsAllowedOpenAIRole()的校验点)。
- JSON 必须是 object:
- 防御式读取渠道设置:
service.GetModelRoleMappingsFromChannelSettings()- 从 gin context 读取当前选中渠道的
constant.ContextKeyChannelSetting(该 context 在选中渠道后由中间件填充) - 将
setting.ModelRoleMappings重新 marshal 成 JSON 再调用解析函数做二次校验(见ParseAndValidateModelRoleMappingsJSON(string(b)));失败只logger.LogWarn并返回false,避免错误配置影响请求。
- 从 gin context 读取当前选中渠道的
- role 白名单:
-
匹配规则(按模型前缀最长匹配;支持 wildcard "*")
- 选择策略:
service.ResolveRoleMappingForModel()- 普通前缀:
strings.HasPrefix(model, prefix)(见strings.HasPrefix) - wildcard:prefix 为
"*"时匹配任意模型但优先级最低(见candidateLen = 0) - 最终取“匹配且前缀最长”的 roleMap(见
if matched && candidateLen > bestLen),保证更具体的模型规则覆盖 default/*。
- 普通前缀:
- 选择策略:
-
应用点(关键:每次重试前先恢复原 role,再按渠道映射重写)
- snapshot 原始 role:在 relay 解析请求后立即做快照(见
roleSnapshot := service.SnapshotRequestRoles(request)),覆盖两种请求结构:- Chat Completions:保存每条 message 的 role(见
SnapshotRequestRoles()的MessagesRoles) - Responses API:保存
input[]的 role(见SnapshotRequestRoles()的InputRoles)
- Chat Completions:保存每条 message 的 role(见
- 每次选中/切换渠道(含重试)后:先恢复原 role,再应用当前渠道映射(见
RestoreRequestRoles(...); ApplyModelRoleMappingsToRequest(...))- 恢复:
service.RestoreRequestRoles()把 role 还原到最初客户端请求,避免“多次重试叠加映射导致 role 漂移” - 映射:
service.ApplyModelRoleMappingsToRequest()针对不同请求类型分别处理:- Chat Completions:
applyToGeneralOpenAIRequest()遍历messages[i].role并按roleMap[orig]替换(见r.Messages[i].Role = target) - Responses API:
applyToOpenAIResponsesRequest()解析input[]后按roleMap替换并回写 JSON(见r.Input = b)
- Chat Completions:
- 恢复:
- 异常 role 处理:如果请求里出现非白名单 role,且未在映射表中,会通过
warnUnknownRoleOnce()仅告警一次(按model|role去重),降低日志噪声。
- snapshot 原始 role:在 relay 解析请求后立即做快照(见
- 【已实现】强制在日志记录 IP(即使用户关闭 IP 记录开关)
-
原始需求(保留):实现强制在日志记录IP,即使用户关闭IP记录
-
用户侧开关仍存在,但“日志写入 IP”不再受其影响
- 用户设置字段:
dto.UserSetting.RecordIpLog(json keyrecord_ip_log)用于个人设置开关。 - 保存接口:
controller.UpdateUserSetting()接收UpdateUserSettingRequest.RecordIpLog并写回用户 setting(见settings.RecordIpLog: req.RecordIpLog)。
- 用户设置字段:
-
实际日志写入点:消费日志/错误日志均无条件写入
c.ClientIP()- 错误日志:
model.RecordErrorLog()中Ip字段直接取c.ClientIP()(c == nil才返回空串)。 - 消费日志:
model.RecordConsumeLog()同样对Ip字段直接取c.ClientIP()。 - 结论:无论用户
record_ip_log设为 true/false,只要请求具备 gin context,日志表logs.ip都会被写入(满足“强制记录 IP”)。
- 错误日志:
- 【已实现】
web/public/oauth-redirect-linuxdo.html多站点 OAuth 重定向回调页(回调需先跳到该页)
-
原始需求(保留):web\public\oauth-redirect-linuxdo.html 多站点重定向登录 回调需跳到该页否则会出错
-
设计目标:在“第三方 OAuth 回调域名固定/受限”的情况下,把回调落到当前站点的静态页,再安全跳回发起登录的原站点
- 解析参数:从 querystring 读取
code/state/error(见const code = params.get('code')与const finalState = params.get('state'))。 - 错误兜底:若
error存在,直接展示失败状态并停止跳转(见if (error) { ... ui.showError(...) })。 - 必要参数校验:缺少
code/state直接报错(见if (!code || !finalState))。
- 解析参数:从 querystring 读取
-
“多站点”实现:把 origin 域名编码进
state,回调时解码后拼接跳转 URL- state 编码结构:
baseState|b64(originDomain)(解码逻辑见const parts = finalState.split('|')与const originDomain = atob(encodedDomain))。 - 构造跳转目标:使用当前协议 + 解码出的域名,拼回业务回调路径(见
redirectUrl = \${protocol}//${originDomain}/oauth/linuxdo?code=${code}&state=${baseState}``)。 - 无域名信息兜底:
state不带|时,退回“本域名”直接跳/oauth/linuxdo(见redirectUrl = \/oauth/linuxdo?code=${code}&state=${finalState}``)。 - 体验:延迟 800ms 让用户看到“授权成功/目标域名”(见
setTimeout(..., 800))。
- state 编码结构:
-
【新增】前端自定义 JS 注入(环境变量,逗号分隔,按顺序 defer 注入)
- 环境变量:
CUSTOM_JS_URLS=https://example.com/a.js,https://example.com/b.js - 注入点:后端启动时替换
web/dist/index.html的占位符<!--custom-js-->(见InjectCustomJavascripts()与<!--custom-js-->)
- 环境变量:
-
【修复】
X-New-Api-Version只取VERSION的第一行,避免多行内容导致响应头异常- 响应头写入:
middleware.PoweredBy()写入common.Version - 版本解析:
common.InitEnv()从环境变量VERSION取第一行并TrimSpace
- 响应头写入:
- 【已实现】接入 FingerprintJS:记录用户最近 5 次去重 visitor id + ip(按 ip+visitor_id 去重)+ 管理员查询同 visitor id 用户(工作台面板“关联追踪”)
-
原始需求(保留):实现接入 fingerprintjs/fingerprintjs 库,记录用户的历史5次visitor id + ip(按 ip+visitor_id 去重后的5次),并实现管理员查询相同visitor id的用户,也就是说创建一个管理员可见的在工作台的面板,面板名称4个字
-
前端:采集 visitor id + 1 小时节流上报
- 依赖:前端包已引入
@fingerprintjs/fingerprintjs(动态加载见loadFingerprintJS())。 - 上报策略:默认 1 小时一次(
REPORT_INTERVAL),localStorage记录上次上报时间(LAST_REPORT_KEY)。 - 上报接口:
reportFingerprint()POST/api/fingerprint/record(见API.post('/api/fingerprint/record'...)。 - 缓存 visitor id:写入
localStorage(VISITOR_ID_KEY;写入见localStorage.setItem(VISITOR_ID_KEY, visitorId))。 - Hook 用法:登录后触发一次非强制采集(见
useFingerprint(isLoggedIn))。
- 依赖:前端包已引入
-
后端:记录到表
user_fingerprints,同用户最多保留 5 个不同ip + visitor_id组合(按 ip+visitor_id 去重)- 写入入口:
controller.RecordFingerprint()读取visitor_id(RecordFingerprintRequest),并取User-Agent与c.ClientIP()一起入库。 - 数据表:
model.UserFingerprint映射表名user_fingerprints(TableName())。 - 去重逻辑:后端使用 upsert,按
(user_id, visitor_id, ip)组合去重;命中则更新user_agent/updated_at(实现见model.RecordFingerprint())。 - 保留 5 条:超过 5 个
ip + visitor_id组合记录时,先取第 5 条的(updated_at,id)作为阈值,再删除更旧的记录(避免 MySQL 下出现仅OFFSET无LIMIT的非法 SQL;实现见model.RecordFingerprint())。 - MySQL 迁移:项目启动时会执行
DB.AutoMigrate(),并会尝试创建复合唯一索引ux_user_fingerprints_user_visitor_ip(见model.UserFingerprint的uniqueIndextag),以保证并发下按(user_id, visitor_id, ip)组合去重正确。 - 搜索兼容:管理员搜索 visitor id / username / email 时,后端统一使用
LOWER(column) LIKE LOWER(?)做大小写不敏感匹配,避免 PostgreSQL 默认LIKE大小写敏感而与 MySQL 行为不一致。 - 若你是“存量库迁移”且线上账号无建索引权限/或 AutoMigrate 未生效,请手动补该索引(否则可能产生重复行,导致“保留 5 条”失真):
ALTER TABLE user_fingerprints ADD UNIQUE KEY ux_user_fingerprints_user_visitor_ip (user_id, visitor_id, ip);
- 写入入口:
-
管理员查询:重复指纹列表 + 点进查看关联用户
- 路由挂载(管理员):在
router.SetApiRouter()的 admin fingerprint group 下提供: - “重复”口径:visitor_id + ip 组合下
COUNT(DISTINCT user_id) > 1才算重复(见model.GetDuplicateVisitorIds()的GROUP BY visitor_id, ip HAVING ...)。 - UI 面板(4 个字):页面标题为
title={t('关联追踪')},并提供“重复指纹/全部记录”两 tab(见<Tabs ...>)。
- 路由挂载(管理员):在
- 【已实现】活跃任务槽追踪系统(全局 1000 / 单用户 50,SimHash 相似匹配,LRU 淘汰)+ 600s 高活跃扫描入库 + 24h token 消耗查询
-
原始需求(保留):实现维护每个用户100个槽,储存在内存中,每个槽是一次哈希和时间的记录,在8,64,512...长度的多个哈希结果(每个仅保存6位)。每当遇到请求时,都先计算哈希并和槽进行比较,如果能继承,那么继承并覆盖,否则LRU占用新槽。接下来实现一个查询页面,展示用户在30秒内的活跃任务数(即槽数)feat: 活跃任务槽追踪系统 - 全局1000槽/单用户50槽上限,多级哈希匹配,LRU淘汰策略,管理员查询页面。添加功能:实现每600秒扫描一次,如果发现活跃任务数在5(600s)以上的记录到新表 可查询。实现记录的用户可点击查看其24小时内消耗的不同模型的多少token
-
核心数据结构:内存槽 + SimHash(64-bit) + LRU
- 全局/单用户上限:
MaxGlobalSlots=1000、MaxUserSlots=50(与需求文案一致)。 - 活跃窗口:默认 30s(
ActiveWindowSeconds),排名 API 可调window(见controller.GetActiveTaskRankAPI())。 - 槽指纹:每槽保存一个
SimHash uint64(见TaskSlot.SimHash)。 - 相似匹配:当
hamming(slot.SimHash, newHash) <= 5时继承同一槽,并用新指纹覆盖旧指纹(阈值常量见SimHashThreshold,匹配逻辑见RecordTask())。 - 指纹计算:对原始 data(通常为原始请求体字符串)直接
strings.Fields分词,按 token 计算 SimHash(见simhash64())。 - 每次启动随机盐:token 哈希使用
sha1(salt || token),salt 在进程启动时随机生成(见simhashTokenSalt、init()、tokenHash64())。 - LRU 淘汰:匹配命中/复用都会移动到 LRU 末尾(见
moveToLRUEnd())。
- 全局/单用户上限:
-
记录入口:从请求上下文抽取“可识别对话连续性”的数据
- 只统计 chat 类请求:路径命中
/chat/completions、/v1/completions、/v1/responses、/v1/messages、GeminigenerateContent(见isChatRequest := ...)。 - 优先使用缓存过的请求 body:读取 gin context 的
common.KeyRequestBody(见gc.Get("key_request_body")),若为空则退回modelName(见if data == "" { data = modelName })。 - 写入动作:
RecordActiveTaskSlot()→manager.RecordTask(...)(见manager.RecordTask(userID, username, data))。 - 实际接入点:每次记录消费/错误日志时都会顺带记录活跃槽(见
RecordActiveTaskSlot(...)与RecordActiveTaskSlot(...))。
- 只统计 chat 类请求:路径命中
-
管理员 API + 前端查询页
- 路由(管理员):
activeTaskRoute挂载:- 实时排名:
GET /api/active_task/rank - 统计信息:
GET /api/active_task/stats - 高活跃历史:
GET /api/active_task/history - 24h token 消耗:
GET /api/active_task/user_token_usage
- 实时排名:
- 前端页面:
ActiveTaskRankPage- 实时 tab:轮询刷新 5s(见
setInterval(..., 5000)),调用/api/active_task/rank(见API.get('/api/active_task/rank')。 - 历史 tab:查询
/api/active_task/history(见API.get('/api/active_task/history')。 - token 弹窗:点击“Token消耗”调用
/api/active_task/user_token_usage(见API.get('/api/active_task/user_token_usage')。
- 实时 tab:轮询刷新 5s(见
- 路由(管理员):
-
600s 高活跃扫描 → 新表落库
- 扫描周期:
HighActiveTaskScanInterval=600s;阈值:HighActiveTaskThreshold=5;窗口:HighActiveTaskWindowSeconds=600s。 - 启动扫描器:
StartHighActiveTaskScanner()使用 ticker 定时调用scanAndSaveHighActiveUsers()。 - 新表:
model.HighActiveTaskRecord映射high_active_task_records(TableName())。 - 过滤管理员:扫描保存时会跳过管理员(见
if IsAdmin(u.UserID) { continue })。
- 扫描周期:
-
“查看该用户 24h 不同模型 token 消耗”
- 后端:
controller.GetUserTokenUsage24hAPI()计算startTimestamp = now - 24*60*60(见startTimestamp := now - 24*60*60),调用model.GetUserTokenUsageByModel()。 - 查询来源:优先走
quota_data小时聚合表(见注释优先使用quota_data表(数据看板统计表)与实际查询DB.Table("quota_data")),返回{model_name,total_tokens,request_count}(见ModelTokenUsage)。
- 后端:
- 【已实现】合并上游签到更新 + 增加“是否开启随机额度”开关
-
原始需求(保留):合并上游的签到更新。添加是否开启随机额度功能
-
配置结构:checkin_setting 同时支持固定额度与随机额度模式
- 后端配置结构:
operation_setting.CheckinSetting包含:enabled(Enabled)min_quota/max_quota(随机模式区间,见MinQuota/MaxQuota)fixed_quota(固定模式额度,见FixedQuota)random_mode(是否随机额度,见RandomMode),默认 true(见RandomMode: true)。
- 配置注册:通过
config.GlobalConfig.Register("checkin_setting"...纳入 option 系统。
- 后端配置结构:
-
用户侧接口:查询状态 / 执行签到
- 路由:用户登录后
/api/user/checkinGET/POST(见selfRoute.GET("/checkin"...)。 - 状态接口返回随机/固定模式信息:
controller.GetCheckinStatus()返回fixed_quota/random_mode/min_quota/max_quota(见"random_mode": setting.RandomMode)。 - 执行签到:
controller.DoCheckin()调用model.UserCheckin(userId)并写系统日志(见model.RecordLog(... "用户签到,获得额度" ...))。
- 路由:用户登录后
-
管理端 UI:系统设置里的“随机额度模式”开关
- 页面:
SettingsCheckin。 - 开关字段:
Form.Switch field={'checkin_setting.random_mode'},并在“启用签到功能”关闭时禁用(见disabled={!inputs['checkin_setting.enabled']})。 - 字段联动:随机模式下启用
min_quota/max_quota,固定模式下启用fixed_quota(见disabled ... isRandomMode与disabled ... !isRandomMode)。
- 页面:
- 【已实现】OpenAI 文本 token 统计 CPU 优化:抽样真实计数校准 + 字符倍率估算(CPU 优先)
-
目标:降低
CountTextToken触发tiktoken-go/regexp2的频率,在允许小幅 usage 波动前提下优先降低 CPU。 -
修改位置:
service/token_counter.go(仅改CountTextToken的 OpenAI 文本模型分支;非 OpenAI 分支保持EstimateTokenByModel不变)。 -
实现细节:
- 新增按
model维度的内存校准器(sync.Map+ 每模型mutex),样本池固定容量 10。 - 样本项为
(chars,tokens);chars使用utf8.RuneCountInString。 - 比率采用稳健口径:
ratio = sum(tokens_i) / sum(chars_i)。 - 短文本噪声过滤:
chars < 64不入池。 - 池未满时:每次真实计数并入池,返回真实 token。
- 池已满时:默认估算
int(chars * ratio)返回;并进行 1% 抽样真实计数更新池(环形 FIFO 替换)。 - 低流量兜底:距离上次真实校准超过 300s 强制一次真实计数更新池。
- 估算结果夹逼:下限 0,上限
chars*4,避免异常倍率放大。
- 新增按
-
并发与性能:
- 校准器结构线程安全:
sync.Map保存*tokenCalibrator,每实例内部sync.Mutex。 - 抽样计数使用轻量计数器(
atomic)实现,估算路径仅RuneCount + 算术 + 少量状态读取。
- 校准器结构线程安全:
-
行为说明(代码注释已加):
- 本地快速路径为近似计数,会轻微影响内部预扣与部分回填给客户端的
usage.prompt_tokens。 - 该波动是有意设计,目标为 CPU 优先,并通过持续真实抽样自适应校准。
- 本地快速路径为近似计数,会轻微影响内部预扣与部分回填给客户端的
-
可选环境变量:
ENABLE_FAST_TIKTOKEN(默认true)FAST_TIKTOKEN_SAMPLE_RATE(默认0.01)FAST_TIKTOKEN_FORCE_REAL_SECONDS(默认300)
- 【已实现】应用层 gin gzip 增加环境开关(默认关闭,启用时 BestSpeed)
-
目标:避免在上游 nginx 已压缩时,应用层
gin-contrib/gzip继续占用 CPU(compress/flate热点)。 -
修改文件:
router/web-router.gorouter/api-router.gorouter/dashboard.go
-
行为变更:
- 默认不启用应用层 gzip(
ENABLE_GIN_GZIP默认false)。 - 当
ENABLE_GIN_GZIP=true时才注册 gzip middleware。 - 启用时压缩级别固定为
gzip.BestSpeed(避免误用高压缩等级导致 CPU 升高)。
- 默认不启用应用层 gzip(
-
实现说明:
- 复用现有
common.GetEnvOrDefaultBool(定义在common/env.go),未新增重复 env helper。 - 三处原先的
gzip.DefaultCompression已改为条件注册 +gzip.BestSpeed。
- 复用现有
-
验证建议:
- 默认关闭:不设置
ENABLE_GIN_GZIP(或设为false),直连应用端口curl -I,应看不到应用层添加的Content-Encoding: gzip。 - 开启:设置
ENABLE_GIN_GZIP=true重启后,直连应用端口curl -I应出现Content-Encoding: gzip。 - pprof:默认关闭后,
compress/flate.(*compressor).deflate/findMatch热点应显著下降。
- 默认关闭:不设置
- 【已实现】CPU 驱动的自适应重试延时(env 开关,0~1s 动态调节)
- 目标:在上游/自身高负载时自动“变慢”重试,降低瞬时并发与 CPU/IO 压力,避免重试风暴。
- 行为:当启用后,系统监控每次采样 CPU 使用率时:
- 若
cpu > threshold:重试间隔+10ms - 若
cpu <= threshold:重试间隔-10ms - 间隔范围限制为
[0, 1s];仅在 发生重试 时,才会在两次尝试之间 sleep。
- 若
- 配置(环境变量):
RETRY_DELAY_ADAPTIVE_ENABLED(默认false)RETRY_DELAY_CPU_THRESHOLD(默认50,取值 0~100)RETRY_DELAY_STEP_MS(默认10,每次调整的步进毫秒数)RETRY_DELAY_MAX_MS(默认1000,最大重试间隔毫秒数)
- 【已实现】基于 pprof 证据的 Claude/OpenAI 兼容转换热路径最小性能修复
-
背景与目标(保留外部行为)
- 已根据线上 pprof 热点,对 Claude/OpenAI 兼容转换中的高频 JSON 往返和重复扫描做最小改动优化,目标是降低
dto.(*ClaudeRequest).SearchToolNameByToolCallId、dto.(*ClaudeMessage).ParseContent、dto.(*ClaudeMediaMessage).ParseMediaContent、dto.(*ClaudeRequest).ParseSystem所在热路径 CPU 与不必要的Marshal/Unmarshal开销。 - 约束保持不变:未修改对外 API、JSON tag、导出字段语义;新增缓存字段使用
json:"-",不影响序列化。
- 已根据线上 pprof 热点,对 Claude/OpenAI 兼容转换中的高频 JSON 往返和重复扫描做最小改动优化,目标是降低
-
请求级 tool name 索引缓存
- 在
dto.ClaudeRequest内新增非序列化字段toolNameByCallID map[string]string,按tool_use.id -> tool_use.name懒加载建立索引。 (*dto.ClaudeRequest).SearchToolNameByToolCallId()现已改为:- 空
toolCallId直接返回空字符串; - 首次查询时调用内部
ensureToolNameIndex()扫描一次消息; - 后续查询直接走 map,避免每次重新全量扫描并重复解析
messages[*].content。
- 空
- 索引构建仅收集
type == "tool_use"且id/name非空的条目;解析失败时跳过该消息,保持原有兼容回退风格。
- 在
-
Claude 内容解析快路径
- 在
dto/claude.go新增内部 helperparseClaudeMediaMessagesFast(data any)与parseClaudeMediaMessageItemFast(item any)。 - 快路径优先覆盖常见输入形态:
[]ClaudeMediaMessage[]*ClaudeMediaMessage[]any[]map[string]anynil
- 对
[]any/[]map[string]any:- 若元素已是
ClaudeMediaMessage或*ClaudeMediaMessage,直接复用; - 其他单项仍允许回退到
common.Any2Type,保留兼容性; - 如果快路径过程中遇到无法按单项处理的内容,则整体回退到原有
Any2Type[[]ClaudeMediaMessage]逻辑。
- 若元素已是
- 以下函数已切换到快路径实现,降低整块
content/system的Marshal+Unmarshal次数:
- 在
-
convert 路径的实际收益点
service.ClaudeToOpenAIRequest()中tool_result分支仍保持原有行为:当mediaMsg.Name为空时,调用SearchToolNameByToolCallId回查名称。- 由于该查询现在已变为“首次建索引 + 后续 O(1) map 查找”,因此无需在
convert层做更大范围缓存改造,即可消除原来的“每个 tool_result 都重新扫描全部 messages 并重复解析”的热点开销。
-
验证
- 新增
dto/claude_test.go:- 覆盖多条 messages / 多个
tool_use/tool_result下的SearchToolNameByToolCallId正确性; - 覆盖重复调用结果稳定;
- 覆盖
ParseContent/ParseSystem/ParseMediaContent对[]ClaudeMediaMessage、[]any{map[string]any{...}}、nil、字符串内容路径的兼容性; - 增加基准,对比旧式全量扫描与新索引查找。
- 覆盖多条 messages / 多个
- 新增
- 【已实现】流式 Flush 节流 + recent calls stream chunk 批量落盘(最小补丁)
-
目标与约束
- 仅处理两处已确认热点:
- 流式 SSE 输出不再每个 chunk 无条件
Flush recentCallsCache的 stream chunk 不再每片同步OpenFile/Write/Close
- 流式 SSE 输出不再每个 chunk 无条件
- 保持 SSE 语义不变,不改协议,不做额外重构。
- 仅处理两处已确认热点:
-
流式 Flush 节流
- 修改文件:
- 实现:
- 在 gin context 上增加内部
streamFlushState,维护:pendingByteslastFlushTime
- 新增内部 helper:
maybeFlushWriter(c, force, wroteBytes)。 - 普通流式 event 先写 response writer,再按阈值决定是否真正 flush:
- 字节阈值:8KiB
- 时间阈值:25ms
- 强制 flush 场景保持及时性:
PingData始终强制 flushDone始终强制 flushStreamScannerHandlerdefer 收尾时补一次 flush,避免 handler return 前残留未刷出
StringData、ClaudeData、ClaudeChunkData、ResponseChunkData已切到节流路径,不再每片直接FlushWriter- OpenAI->Gemini 流式转换中的两处直接
Render+Flush也已改为走helper.StringData,避免绕过节流层。
- 在 gin context 上增加内部
-
recent calls stream chunk 批量落盘
- 修改文件:
- 实现:
- 在
recentCallEntry增加 entry 级streamChunkBuf bytes.Buffer AppendStreamChunkByContext仍保留:- 单 chunk 截断逻辑
- 总量上限判断
- 但不再每片直接写文件,而是:
- 将 chunk 编码为一行 JSONL
- 追加到内存缓冲
- 缓冲达到 16KiB 时一次性 append 到
stream_chunks.jsonl
- 新增底层批量写接口:
marshalJSONLStringLineappendRawflushStreamChunkBuffer
- 收尾刷盘位置:
FinalizeStreamAggregatedTextByContext中写聚合文本前先 flush pending chunk bufferUpsertErrorByContext中也会尽量 flush,减少错误结束时丢尾巴概率materializeEntry读取 recent calls 前会先 flush,确保管理端查看时能看到最新 chunk
- 在
-
验证
- 新增
relay/helper/common_test.go:- 验证普通小 chunk 不会立刻 flush
- 验证
PingData必定即时 flush - 验证时间阈值到达后会触发 flush
- 新增
service/recent_calls_cache_test.go:- 验证 stream chunk 会先留在 entry 内存缓冲
- 验证
FinalizeStreamAggregatedTextByContext会把 pending buffer 刷到stream_chunks.jsonl - 验证刷盘后
Get()仍能正确读回 chunk 与 aggregated text
- 新增
- 【已实现】修复流式 SSE 渲染 panic 与 pprof 监控自杀式异常退出
-
修改文件:
-
背景:
- 线上容器重启时抓到堆栈落在
common.CustomEvent.Render->writeData - 旧实现对
Data做了data.(string)强制断言,只要流式路径传入的不是string,就会直接 panic,触发进程重启 - 同时
common.Monitor在 CPU 采样失败时会panic(err),这会把原本只用于诊断的 pprof 监控变成新的退出源 - 进一步抓到
fatal error: concurrent map iteration and map write,堆栈落在gin responseWriter.Flush(),说明流式 flush 节流收尾时和其他写协程并发操作了同一个 HTTP response header
- 线上容器重启时抓到堆栈落在
-
实现:
writeData改为使用fmt.Sprint(data)序列化任意类型,不再依赖字符串类型断言- 保留原有 SSE 数据写入与
data:前缀补\n\n的行为 writeData现在会把底层 writer 错误向上返回,避免静默吞错Monitor在 CPU 采样失败时改为记录系统日志并继续下一轮,不再 panic 杀进程StreamScannerHandler的 defer 收尾顺序改为:- 先发停止信号并停止 ticker
- 等待 ping / scanner / data handler 协程退出
- 最后在
writeMutex保护下串行执行FlushPendingWriter
- 这样最终 flush 不会再和并发中的
Render/WriteHeader交错执行,避免触发net/http.Header的并发 map fatal
-
验证:
- 新增
common/custom-event_test.go - 覆盖:
- 字符串 SSE payload 仍保留
\n\n结束符 map[string]any等非字符串 payload 不再 panicnilpayload 不再 panic
- 字符串 SSE payload 仍保留
- 新增