OmniRoute 聚合 Profile 等级一致性修复解读:聚合等级必须由合计 XP 推导(11604 / 3484)
本篇围绕 OmniRoute 中一条 changelog.d/fixes/11604-aggregate-profile-level-pin.md 修复记录展开,深入剖析其在游戏化(Gamification)模块落地的一致性语义:仪表盘"操作者维度"(operator-wide)聚合 Profile 的等级,不再取各 API Key 已存储等级的最大值 MAX(stored current_level),而是先汇总所有 Key 的 XP 总数,再用 XP 曲线反解函数 calculateLevel(sum) 统一推导。文章会结合源码、数据库访问层、API 路由、Dashboard 页面与测试用例,解释这次修复背后的不一致根源、边界处理与可复现的验证路径。读完你将理解"派生字段必须与展示字段同源推导"这一数据一致性原则在该项目中的具体实现方式。
一、修复入口:一行 changelog 描述的语义变更
本次研究对象位于仓库根目录的变更记录 11604-aggregate-profile-level-pin.md,原文核心信息如下:
test(gamification): pin the aggregate profile level to the XP-derived semantics of #11604 —
getAggregateXp()now derivescurrentLevelfrom the summed XP (calculateLevel(sum)), notMAX(stored current_level), and the #3484 fixture levels are aligned with the XP curve.
翻译并解构其技术含义:
- 目标函数:
getAggregateXp()(聚合 XP 查询); - 行为变更:
currentLevel改为由汇总后的 XP 调用calculateLevel(sum)推导; - 被移除的旧逻辑:不再使用
MAX(stored current_level)(各 Key 已存储等级的最大值); - 配套约束:#3484 相关的 fixture 等级数据需要与 XP 曲线对齐,保证测试、展示与推导三者口径一致。
与之配套的仪表盘修复记录 11604-profile-xp-consistency.md 则从另一面印证了同一目标:
fix(dashboard): Keep the Profile level and progress aligned with aggregate XP, including bounded handling for invalid totals.
也就是说,这不仅是一个内部查询改写,还要求"Profile 页面等级与进度条"和"展示出来的聚合 XP 数字"保持完全自洽,并且要能防御无效总量(如非有限数值)的边界输入。
二、背景:XP/等级系统如何分层运转
要理解这次修复,先要看清楚该模块的分层结构。OmniRoute 的游戏化系统按"纯函数引擎 → 事件发射 → 数据库持久化 → API/展示"四个层次组织。
2.1 XP 曲线引擎(纯函数层)
核心曲线定义在 src/lib/gamification/xp.ts,全部是无副作用纯函数:
xpForLevel(level):升级到某级所需的单级增量 XP,采用多项式曲线xp_for_level(n) = floor(100 * n^1.5)(第 22–25 行);cumulativeXpForLevel(level):从 1 级累加到目标级的累计 XP(第 37–44 行);calculateLevel(totalXp):给定累计 XP,反解出当前精确等级(第 59–73 行,本次修复的重点加固对象);- 另外还提供
xpToNextLevel、getLevelTitle、getLevelTier等展示辅助函数。
XP 动作奖励常量 XP_REWARDS(第 138–159 行)定义了每次请求 request=1、切换 Provider provider_switch=5、切换模型 model_switch=3、创建组合 combo_create=10、使用组合 combo_use=2、每日登录 daily_login=5 等基础奖励值。
2.2 事件发射与持久化(数据库层)
实际把 XP 写入数据库的入口是 src/lib/gamification/events.ts 的 emitGamificationEvent()(第 18–33 行),它由聊天管线在成功请求后触发,且是 fire-and-forget 设计——所有错误都被捕获记录,绝不阻塞主请求链路。
真正的 SQL 落在 src/lib/db/gamification.ts:
// addXp:先写审计日志,再累计 total_xp(第 157–173 行)
export function addXp(apiKeyId: string, action: string, amount: number, metadata?: string): void {
db().prepare(
`INSERT INTO xp_audit_log (api_key_id, action, xp_earned, metadata)
VALUES (?, ?, ?, ?)`
).run(apiKeyId, action, amount, metadata ?? null);
db().prepare(
`INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(api_key_id)
DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')`
).run(apiKeyId, amount, calculateLevel(amount));
}
注意一个关键细节:ON CONFLICT 的 DO UPDATE 只累加 total_xp 并刷新 updated_at,不会同步更新 current_level。单 Key 的 current_level 是冗余存储的派生列,只有在 events.ts 检测到 calculateLevel(xp.totalXp) !== xp.currentLevel 时(第 50–59 行)才会通过 updateLevel() 回写。也就是说,存储等级本质上是"按事件更新"的快照,不是任意时刻都精确等于当前 XP 曲线反解值。
user_levels 行结构对应接口 UserLevelRow(第 20–25 行):apiKeyId / totalXp / currentLevel / updatedAt。
2.3 操作者聚合视图(本次修复的落点)
getAggregateXp()(src/lib/db/gamification.ts 第 290–310 行)是专门为仪表盘 Profile 页面提供的"跨全部 API Key"汇总视图,其函数注释明确写道:
Aggregate XP across every API key (the operator-wide profile view used by the dashboard profile page, which is not scoped to a single key). The aggregate level must be derived from the same summed XP displayed by the profile. (#3484)
它不属于任何单 Key,而是整个节点(operator)维度的汇总档案。聚合徽章视图 getAllEarnedBadges()(第 316–344 行)也遵循同样的"跨 Key 聚合 + 按最早解锁时间去重"设计,二者共同构成 Profile 页面数据面。
API 出口在 src/app/api/gamification/level/route.ts:
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const apiKeyId = new URL(request.url).searchParams.get("apiKeyId");
const level = apiKeyId ? getXp(apiKeyId) : getAggregateXp();
return NextResponse.json({ level }, { headers: CORS_HEADERS });
}
即 GET /api/gamification/level 不带 apiKeyId 时返回操作者聚合;带 apiKeyId 时返回单 Key 数据。路由受 requireManagementAuth 管理鉴权保护。
三、缺陷根源:MAX(current_level) 与展示的合计 XP 不同源
通过查看本修复实际提交(仓库内 commit c4d149c2e,即 #11604 的实现)可以还原修复前的实现,当时 getAggregateXp() 的关键 SQL 与返回逻辑是:
const row = db().prepare(
`SELECT COALESCE(SUM(total_xp), 0) AS total_xp,
COALESCE(MAX(current_level), 1) AS current_level,
MAX(updated_at) AS updated_at
FROM user_levels`
).get();
return {
apiKeyId: "*",
totalXp: row?.total_xp ?? 0, // 合计 XP
currentLevel: row?.current_level ?? 1, // 各 Key 存储等级的最大值
updatedAt: row?.updated_at ?? "",
};
这个实现存在语义错位:
totalXp是"所有 Key 的 XP 之和",它本身是一个随所有 Key 增长的连续累计量;currentLevel却是"某个 Key 曾经达到的最高存储等级",二者来自两条完全不同的推导路径,没有任何一个"累计 XP → 等级"映射关系能同时解释这两个数字。
而 Dashboard Profile 页面(src/app/(dashboard)/dashboard/profile/page.tsx/dashboard/profile/page.tsx))的进度条计算逻辑(第 154–160、213–238 行)会拿同一个响应里的 totalXp 和 currentLevel 一起做区间换算:
const level = userLevel?.currentLevel ?? 1;
const totalXp = userLevel?.totalXp ?? 0;
const currentLevelCumulative = cumulativeXpForLevel(level);
const nextLevelCumulative = cumulativeXpForLevel(level + 1);
const xpInCurrentLevel = totalXp - currentLevelCumulative;
const xpForNext = nextLevelCumulative - currentLevelCumulative;
const xpProgress = xpForNext > 0 ? (xpInCurrentLevel / xpForNext) * 100 : 0;
这里隐含一个核心不变量:totalXp 必须落在 [cumulativeXpForLevel(level), cumulativeXpForLevel(level+1)) 区间内,进度百分比才有意义。只要 level 不是 calculateLevel(totalXp) 的结果,这个不变量就可能被破坏:
- 当合计 XP 小于
MAX(current_level)对应等级的门槛时,xpInCurrentLevel会算成负数; - 当合计 XP 越过该等级下一档门槛时,进度会溢出 100%;
- 等级与"已显示的总 XP"在曲线上对不上号,用户看到的数字彼此矛盾。
changelog 中提到的 "#3484 fixture levels are aligned with the XP curve",正是要求测试/演示数据中预设的等级也要以同一套曲线门槛为准,避免 fixture 本身把不一致固化下来。
四、修复方案:聚合等级由合计 XP 反推,单一事实来源
修复后的 getAggregateXp()(当前实现,见 src/lib/db/gamification.ts 第 290–310 行):
export function getAggregateXp(): UserLevelRow {
const row = db()
.prepare(
`SELECT COALESCE(SUM(total_xp), 0) AS total_xp,
MAX(updated_at) AS updated_at
FROM user_levels`
)
.get() as { total_xp: number; updated_at: string | null };
const totalXp = row?.total_xp ?? 0;
return {
apiKeyId: "*",
totalXp,
currentLevel: calculateLevel(totalXp),
updatedAt: row?.updated_at ?? "",
};
}
变更要点:
- SQL 中删除了
COALESCE(MAX(current_level), 1) AS current_level,不再读取冗余存储等级; currentLevel改为对totalXp(含空表兜底 0)调用纯函数calculateLevel(totalXp);- 这样 API 返回的
{ totalXp, currentLevel }就天然满足页面进度条依赖的"等级 = 累计 XP 的精确反解"不变量,Profile 展示的两个数字永远来自同一曲线。
这一设计本质是单一事实来源(Single Source of Truth):total_xp 的 SQL 聚合是事实,currentLevel 只是由它派生出来的展示字段,派生逻辑必须统一收敛到 src/lib/gamification/xp.ts 的 calculateLevel(),而不是相信早已过期的存储快照。
五、calculateLevel() 同时被加固:精确反解与非法总量兜底
仅仅改聚合查询还不够——calculateLevel() 在本次提交中同样被重写加固(见 src/lib/gamification/xp.ts 第 59–73 行),这正是配套 fragment 里 "bounded handling for invalid totals" 的落点:
export function calculateLevel(totalXp: number): number {
if (!Number.isFinite(totalXp) || totalXp <= 0) return 1;
const boundedXp = Math.min(totalXp, Number.MAX_SAFE_INTEGER);
// Use the inverse curve only as a fast starting point, then reconcile it
// against the exact floored cumulative thresholds used by the XP engine.
let level = Math.max(1, Math.floor(Math.pow((boundedXp * 2.5) / 100, 0.4)));
while (level > 1 && cumulativeXpForLevel(level) > boundedXp) {
level -= 1;
}
while (cumulativeXpForLevel(level + 1) <= boundedXp) {
level += 1;
}
return level;
}
这次加固分三个层面:
- 非法数值兜底:
NaN、±Infinity以及非正数一律返回等级 1(!Number.isFinite(totalXp) || totalXp <= 0)。聚合 SQL 的COALESCE(SUM(...), 0)虽然保证了空表不为空,但防御性检查仍在下游函数再挡一层; - 有限值上界钳制:
boundedXp = Math.min(totalXp, Number.MAX_SAFE_INTEGER),避免超大但有限的数值在曲线运算中出现精度失控; - 精确反解:先用逆曲线公式
(totalXp * 2.5 / 100) ^ 0.4估算出一个快速起点(此前旧实现只做到这一步、返回近似等级),随后用两个while循环对"精确 floor 的累计门槛"做双向归约:向上微调直到下一档门槛超过当前 XP,向下微调直到当前等级门槛不高于 XP,从而保证calculateLevel()永远落在真实的整数等级边界上。
因此修复后形成一条完整自洽的闭环:聚合查询只负责累加 total_xp → 页面展示的 totalXp 与传给 calculateLevel() 的是同一个数 → 等级推导与进度区间换算使用同一组 cumulativeXpForLevel() 门槛,展示数字与等级在任意 XP 总量下都不再互相矛盾。
六、测试如何固化这一语义
这次语义不是一次性手工修改,而是被单元测试锁定为长期契约,主要证据在 tests/unit/gamification/db-gamification.test.ts:
it("derives the operator level from aggregate XP instead of the highest key level", () => {
const firstKey = `test-aggregate-xp-a-${Date.now()}`;
const secondKey = `test-aggregate-xp-b-${Date.now()}`;
const db = getDbInstance();
const existing = getAggregateXp();
const firstXp = 9000;
const secondXp = 8153;
try {
addXp(firstKey, "request", firstXp);
addXp(secondKey, "request", secondXp);
const aggregate = getAggregateXp();
const expectedTotal = existing.totalXp + firstXp + secondXp;
assert.equal(aggregate.totalXp, expectedTotal);
assert.equal(aggregate.currentLevel, calculateLevel(expectedTotal)); // 关键断言
} finally {
// ...清理两把测试 Key 的 user_levels 与 xp_audit_log
}
});
用例刻意构造"两把 Key 分别入账 9000 与 8153 XP"的场景,其断言语义非常精确:聚合等级必须等于 calculateLevel(合计XP),而不是两把 Key 中任何一把的存储等级。只要未来有人把实现退回 MAX(current_level),这条测试就会立刻失败。
同文件还覆盖了单 Key 语义(第 8–34 行):大额初始 XP 入账后 currentLevel === calculateLevel(amount)、小额 XP 保持在 1 级,保证单 Key 侧 addXp 的写路径同样走曲线反解。
而 tests/unit/gamification/xp.test.ts 则锁死曲线函数本身的精度契约,本次提交将原先"近似断言"(如 level >= 4 && level <= 7)全部升级为精确断言:
calculateLevel(3162)精确等于 5;- 对 1 到 100 的每个整数等级,
calculateLevel(cumulativeXpForLevel(level)) === level(边界精确命中); calculateLevel(cumulativeXpForLevel(level+1) - 1) === level(未达到下一档门槛绝不提前升级);NaN / ±Infinity均回落到 1,Number.MAX_VALUE与Number.MAX_SAFE_INTEGER的反解结果一致(钳制生效);- 结果随 XP 单调递增。
七、相关源码路径索引
便于继续深入阅读的关键文件:
| 职责 | 仓库相对路径 |
|---|---|
| 本次变更记录 | changelog.d/fixes/11604-aggregate-profile-level-pin.md |
| 配套仪表盘一致性记录 | changelog.d/fixes/11604-profile-xp-consistency.md |
| XP 曲线与等级引擎(纯函数) | src/lib/gamification/xp.ts |
| XP 事件发射入口 | src/lib/gamification/events.ts |
| 聚合 XP/等级查询与数据库层 | src/lib/db/gamification.ts |
| 等级 API 路由 | src/app/api/gamification/level/route.ts |
| 仪表盘 Profile 页面消费方 | src/app/(dashboard)/dashboard/profile/page.tsx/dashboard/profile/page.tsx) |
| 聚合等级语义单元测试 | tests/unit/gamification/db-gamification.test.ts |
| 曲线/等级反解单元测试 | tests/unit/gamification/xp.test.ts |
八、小结:这类 Dashboard 数据的一个通用教训
从这一行 changelog 背后可以提炼出一条可复用的工程约束:当一个展示字段与另一个展示字段必须满足数学关系(如 level = f(totalXp))时,不要让它们各自独立存储或独立聚合——派生字段应在读取时由事实字段重新推导,并用精确断言测试锁死"同源推导"这个不变量。
OmniRoute 的这次修复正是这样做的:
- 事实层只存/只聚合
total_xp; - 展示层读取时统一经过
calculateLevel(sum(total_xp))反推等级; - 引擎层对无效输入(非有限值、超大值)做钳制兜底;
- 测试层用"多 Key 聚合"场景和 1–100 全量边界断言杜绝回归。
对于任何带有"排行榜 / 等级 / 进度条 / 统计卡片"类聚合展示的模块,这一模式都值得直接借鉴。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00