首页
/ OmniRoute 聚合 Profile 等级一致性修复解读:聚合等级必须由合计 XP 推导(11604 / 3484)

OmniRoute 聚合 Profile 等级一致性修复解读:聚合等级必须由合计 XP 推导(11604 / 3484)

2026-09-07 17:35:37作者:卓艾滢Kingsley

本篇围绕 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 derives currentLevel from the summed XP (calculateLevel(sum)), not MAX(stored current_level), and the #3484 fixture levels are aligned with the XP curve.

翻译并解构其技术含义:

  1. 目标函数getAggregateXp()(聚合 XP 查询);
  2. 行为变更currentLevel 改为由汇总后的 XP 调用 calculateLevel(sum) 推导;
  3. 被移除的旧逻辑:不再使用 MAX(stored current_level)(各 Key 已存储等级的最大值);
  4. 配套约束:#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 行,本次修复的重点加固对象);
  • 另外还提供 xpToNextLevelgetLevelTitlegetLevelTier 等展示辅助函数。

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.tsemitGamificationEvent()(第 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 CONFLICTDO 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 ?? "",
};

这个实现存在语义错位:

  1. totalXp 是"所有 Key 的 XP 之和",它本身是一个随所有 Key 增长的连续累计量;
  2. currentLevel 却是"某个 Key 曾经达到的最高存储等级",二者来自两条完全不同的推导路径,没有任何一个"累计 XP → 等级"映射关系能同时解释这两个数字。

而 Dashboard Profile 页面(src/app/(dashboard)/dashboard/profile/page.tsx/dashboard/profile/page.tsx))的进度条计算逻辑(第 154–160、213–238 行)会拿同一个响应里的 totalXpcurrentLevel 一起做区间换算:

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.tscalculateLevel(),而不是相信早已过期的存储快照。

五、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;
}

这次加固分三个层面:

  1. 非法数值兜底NaN±Infinity 以及非正数一律返回等级 1(!Number.isFinite(totalXp) || totalXp <= 0)。聚合 SQL 的 COALESCE(SUM(...), 0) 虽然保证了空表不为空,但防御性检查仍在下游函数再挡一层;
  2. 有限值上界钳制boundedXp = Math.min(totalXp, Number.MAX_SAFE_INTEGER),避免超大但有限的数值在曲线运算中出现精度失控;
  3. 精确反解:先用逆曲线公式 (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_VALUENumber.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 全量边界断言杜绝回归。

对于任何带有"排行榜 / 等级 / 进度条 / 统计卡片"类聚合展示的模块,这一模式都值得直接借鉴。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388