用户身份、成就规则与进度更新
成就系统把应用事件转换为账号成就解锁:前端统一判断登录状态、远端快照同步状态与规则条件,再通过单槽更新队列触发上报;后端代理接口负责读取用户信息和转发成就更新。
目的与范围
本页覆盖 useAchievementEngine 的生命周期与防重逻辑、ACHIEVEMENT_RULES 的全部触发条件,以及用户信息和成就更新两个后端处理器。重点回答:事件何时可解锁成就、初始化竞态如何处理、多个成就如何顺序派发,以及上游请求失败时返回什么。
本页不展开测速、IP 查询、安全检查等业务功能内部实现,它们在这里仅作为事件来源;报告收集与分享也不属于本页范围。登录提供方、登录凭据格式、User.vue 的具体请求实现、成就展示元数据和上游数据库结构未纳入本次源码读取,不能据此推断其协议或持久化保证。运行时没有提供相邻 Wiki 页的准确路径,因此不构造未经确认的跨页链接。
概述
系统有三个不同层次的状态,排查问题时必须区分:
| 状态 | 源码标识 | 作用 |
|---|---|---|
| 用户已登录 | store.isSignedIn | 决定事件是否参与成就判断 |
| 远端成就已同步 | store.userAchievementsSynced | 避免以初始全未完成状态重复解锁既有成就 |
| 单项成就已获得 | store.userAchievements[slug].achieved | 过滤已经完成的项目 |
引擎并不维护百分比或累计计数;它消费事件中的速度、检查数量、国家数量等数据,并判定是否达到解锁条件。账号累计功能使用次数来自 user:info-loaded 的 totalFunctionUses,不是由引擎自行递增。
来源:use-achievement-engine.js、achievement-rules.js。
架构与职责边界
Sources: use-achievement-engine.js、get-user-info.js、update-user-achievement.js。
图中刻意不补画 User.vue 到具体 HTTP 接口的调用细节:引擎注释确认它监听单槽并向后端报告,但其请求格式、乐观更新和通知逻辑尚未直接核对。后端的两个处理器均依赖 fetchUpstream 和 logger;已读处理器没有本地数据库读写。
这样的分层让业务组件只需发出领域事件,不必重复编写登录检查、已有成就检查和派发间隔逻辑。规则表描述“什么条件达成”,引擎负责“何时允许派发”,代理处理器负责“如何转交上游”。
成就引擎的真实执行流程
1. 初始化与订阅
useAchievementEngine() 无参数,取得 useMainStore() 后创建闭包内的 queue、draining 和 pendingUntilSynced。随后对每一条规则调用 onAppEvent(rule.event, callback)。
订阅以规则为单位,而不是先对事件去重。例如一次 speedtest:finished 会交给六条测速规则分别判断,因此一次结果可同时满足多个成就。注释要求从 App.vue 的 setup 调用一次;重复实例化会创建独立队列和重复订阅。
2. 事件入口的判断顺序
事件回调严格依次执行:
- 未登录则直接返回,不缓存事件。
- 存在
when且条件不满足则返回。 - 已登录但远端成就未同步时,将
slug放入pendingUntilSynced,暂不派发。 - 已同步时,查询对应成就;项目不存在或已完成则返回。
- 符合条件的项目进入派发队列。
1 onAppEvent(rule.event, (payload) => {
2 if (!store.isSignedIn) return;
3 if (rule.when && !rule.when(payload)) return;
4 if (!store.userAchievementsSynced) {
5 pendingUntilSynced.add(rule.slug);
6 return;
7 }
8 const entry = store.userAchievements[rule.slug];
9 if (!entry || entry.achieved) return;
10 enqueue(rule.slug);
11 }),Source: use-achievement-engine.js。
这是引擎回调原文节选,不是独立可运行的应用示例。它也揭示了身份边界:isSignedIn 仅是前端资格门控,不等价于后端已验证身份,更不能代替上游授权。
3. 启动阶段的远端快照屏障
启动时 ipinfo:finished、iphistory:updated 等事件可能比远端用户成就快照更早到达。如果直接使用初始未完成状态,账号已有徽章也会再次触发通知和上报。
引擎使用 Set 保存同步前已满足条件的 slug,在 userAchievementsSynced 变为真时重新检查成就是否存在且尚未完成,再加入队列,最后清空集合。监听使用 { flush: 'sync' }。
1 const pendingUntilSynced = new Set();
2 watch(() => store.userAchievementsSynced, (synced) => {
3 if (!synced) return;
4 for (const slug of pendingUntilSynced) {
5 const entry = store.userAchievements[slug];
6 if (entry && !entry.achieved) enqueue(slug);
7 }
8 pendingUntilSynced.clear();
9 }, { flush: 'sync' });Source: use-achievement-engine.js。
该集合保存的是“条件曾经成立”的事实,而不是原始 payload。同步完成后不重新运行 when,也不重新验证事件数据;这适合一次性达成事件,但扩展为依赖当前状态的规则时需要注意语义差别。
4. 单槽队列与二次去重
Store 的更新入口只有一个成就槽位。为避免一次事件同时满足多条规则而相互覆盖,引擎以 2 秒间隔派发:
enqueue(slug)用queue.includes(slug)去除等待队列中的重复项。- 队列空闲时立即调用
drain(),首项不等待 2 秒。 drain()从队首取出项目,派发前再次检查achieved。- 调用
store.setTriggerUpdateAchievements(slug)后,以setTimeout安排下一次处理。 - 如果队列已空,将
draining置为假并结束。
1 const drain = () => {
2 const slug = queue.shift();
3 if (!slug) {
4 draining = false;
5 return;
6 }
7 draining = true;
8 // Re-check at dispatch time — the achievement may have been unlocked
9 // while this entry waited in the queue.
10 if (!store.userAchievements[slug]?.achieved) {
11 store.setTriggerUpdateAchievements(slug);
12 }
13 setTimeout(drain, TRIGGER_SPACING_MS);
14 };Source: use-achievement-engine.js。
这是定时派发,不是等待请求确认的串行事务。 引擎不 await 后端响应,也没有由请求成功回调驱动出队。2 秒只解决单槽触发间距,不保证上游请求不重叠或恰好写入一次。
5. 生命周期清理
onScopeDispose 会逐一调用订阅返回的取消函数,注释说明其主要目的是避免 HMR 重跑 setup 时堆积监听器。源码没有保留定时器句柄,也没有在清理时执行 clearTimeout;已经排队的定时派发不能视为已被取消。
来源:use-achievement-engine.js、use-achievement-engine.js。
核心控制流
Source: use-achievement-engine.js。
派发前的第二次检查可能跳过已经完成的项目;图中的 setTriggerUpdateAchievements 仅在该检查允许时发生。同步状态保持为假期间,事件只累计到集合,不进入更新槽位。
成就规则参考
规则结构为 { event, slug, when? }。event 是领域事件名,slug 对应成就键;省略 when 表示事件本身足够,但仍受引擎登录与同步门控约束。以下条件按源码表达式列出,不能仅凭徽章名称推断含义。
| 事件 | 成就 slug | 条件与边界 |
|---|---|---|
speedtest:finished | BarelyEnough | downloadSpeed >= 100 Mbps |
| 同上 | RapidPace | downloadSpeed >= 500 Mbps |
| 同上 | TorrentFlow | downloadSpeed >= 1000 Mbps |
| 同上 | SteadyGoing | uploadSpeed >= 50 Mbps |
| 同上 | TooFastTooSimple | uploadSpeed >= 200 Mbps |
| 同上 | SwiftAscent | uploadSpeed >= 1000 Mbps |
user:info-loaded | IAmHuman | 无附加条件 |
| 同上 | MakingBigNews | totalFunctionUses > 1000,恰好 1000 不满足 |
censorship:tested | ItIsOpen | blocked 为真值 |
securitychecklist:progress | SurfaceCheck | checked > 0 |
| 同上 | HalfwayThere | total > 0 且 checked / total > 0.5,正好一半不满足 |
| 同上 | FullySecured | total > 0 且 checked === total |
whois:lookup | CuriousCat | `(query |
ruletest:finished | CrossingTheWall | uniqueIPCount === 8,不是大于等于 8 |
invisibility:started | JustInCase | 无附加条件 |
invisibility:result | HiddenWell | proxyScore === 0 && vpnScore === 0 |
| 同上 | SlipUp | `proxyScore > 50 |
preferences:multiple-tests-toggled | ResourceHog | 无附加条件 |
preferences:autorun-changed | EnergySaver | allAutoRunOff 为真值 |
shortcut:help-opened | CleverTrickery | 无附加条件 |
ipinfo:finished | PrettyDirty | 任一卡片 Number(qualityScore) === 1 |
| 同上 | SqueakyClean | 任一卡片 Number(qualityScore) === 100 |
| 同上 | WalkWithPenguins | 任一卡片 `(country_code |
iphistory:updated | GlobeTrotter | countryCount >= 10 |
pulse:status-sent | HelloWorld | 无附加条件;注释约定在后端确认写入后发出 |
connectivity:custom-added | AddSweetAdd | 无附加条件;注释约定仅手动添加触发,导入列表不发此事件 |
阈值与数据类型的实际含义
- 规则可以叠加。 下载速度达到 1000 Mbps 时,三条下载速度规则同时成立;检查列表全部完成时,也可能同时满足三个进度成就。
- 不是统一强类型校验。
>=、>和除法可能发生 JavaScript 类型转换,而===不转换类型。调用方应按注释传入数值,避免字符串造成不一致结果。 - 容错是局部的。 IP 卡片规则使用
p.cards || [],但没有保护p本身,也没有验证cards是否为数组。Whois 规则保护缺失的query,但真值非字符串仍可能在toLowerCase()处失败。 - 质量分显式转换。
sign_in_required、quota_exceeded和缺失值经Number()转换不会满足 1 或 100 的精确条件。不要把这些非数值状态解释成有效质量分。 - 语义取决于生产者。 引擎不会检查
pulse:status-sent是否真的已获服务端确认,也不会识别手动添加与导入;这些是事件生产方的责任。
使用与扩展示例:纯谓词规则
以下为安全检查规则的原文,展示同一个进度事件如何产生多个独立成就:
1 // Security checklist progress — payload { checked, total }.
2 { event: 'securitychecklist:progress', slug: 'SurfaceCheck', when: (p) => p.checked > 0 },
3 { event: 'securitychecklist:progress', slug: 'HalfwayThere', when: (p) => p.total > 0 && p.checked / p.total > 0.5 },
4 { event: 'securitychecklist:progress', slug: 'FullySecured', when: (p) => p.total > 0 && p.checked === p.total },Source: achievement-rules.js。
新增规则时,应保持 when 为纯谓词,在事件 payload 中提供判断所需数据,并确保 slug 对应实际成就项。缺少成就项会被正常事件路径和同步补发路径过滤。不要把登录判断、远端快照等待或节流再写入各业务组件,否则同一职责会分散到多个位置。
规则头部注释指出,规则单元测试会检查谓词和 slug 与成就定义的一致性;本次未读取该测试实现,因此这里不声称测试已经运行或覆盖全部边界。来源:achievement-rules.js。
用户身份与后端 API
身份信息的可验证边界
前端登录状态控制是否参与成就评估。后端两个处理器则都读取服务端 IPCHECKING_API_KEY,并将 req.headers 展开到上游请求中。服务端 API key 与用户登录状态不是同一个概念:前者控制代理是否具备调用配置,后者影响前端规则入口。
已读处理器没有显式解析用户标识、验证令牌或检查 store.isSignedIn;用户凭据如何从浏览器传入、上游如何识别账号,以及全局中间件是否另有验证,未从这些实现中确认。不能将客户端门控描述为完整的安全授权机制。
来源:get-user-info.js、update-user-achievement.js。
接口参考
路由检索确认后端注册了 app.get('/api/getuserinfo', getUserinfo) 和 app.put('/api/updateuserachievement', updateUserAchievement)。见 backend-server.js。处理器均为默认导出的 async (req, res) => { ... },通过 res 写入 JSON 响应,没有声明 TypeScript 参数或返回类型。
| 项目 | 用户信息 | 成就更新 |
|---|---|---|
| 本地接口 | GET /api/getuserinfo | PUT /api/updateuserachievement |
| 上游路径 | /userinfo?key=… | /updateuserachievements?key=… |
| 方法处理 | 未显式传入上游 method;处理器内部不检查请求方法 | 处理器先检查 req.method === 'PUT',上游显式使用 PUT |
| 请求头 | 展开 req.headers | 展开 req.headers |
| 请求体 | 无显式上游 body | JSON.stringify(req.body) |
| 成功返回 | 上游 JSON 原样交给 res.json(data) | 同左 |
| 本地数据转换 | 未见用户字段转换 | 未见成就字段转换 |
来源:get-user-info.js、update-user-achievement.js。
请求体约束不能从错误文字推导。 更新处理器虽然返回 Achievement name is required,实际只检查 req.body 是否为假值,没有检查 name 字段,也没有验证 slug、规则条件、进度或成就是否已存在。因此不能据此给出确定的 { name: ... } 公共请求协议;该结构需进一步核对消费者与上游契约。
成就更新请求的源码示例
1 const apiResponse = await fetchUpstream(url, {
2 method: 'PUT',
3 headers: {
4 ...req.headers,
5 },
6 body: JSON.stringify(req.body),
7 });Source: update-user-achievement.js。
这是代理转发代码,不是浏览器调用样例。它不显式设置 Content-Type,也不在处理器内筛选转发头;若要修改请求格式或增加身份字段,应先核对上游契约与共享请求函数,而不是只修改规则表。
后端请求时序
Source: update-user-achievement.js。
配置选项与状态数据
| 配置/常量 | 类型 | 默认值 | 生效位置与说明 |
|---|---|---|---|
IPCHECKING_API_KEY | 环境变量字符串 | 处理器未提供默认值 | 两个接口都要求存在,否则返回 500;作为上游查询参数 key |
IPCHECKING_API_ENDPOINT | 环境变量字符串 | 处理器未提供默认值 | 拼接上游 /userinfo 与 /updateuserachievements URL |
TRIGGER_SPACING_MS | JavaScript number 常量 | 2000 | 成就引擎闭包外常量;不是已实现的环境变量配置 |
来源:get-user-info.js、update-user-achievement.js、use-achievement-engine.js。
引擎本地数据只有等待数组、同步前集合和布尔派发标记,均存在于本次 composable 调用的内存中;未见持久化或跨标签页共享。远端成就快照通过 Store 中的映射被读取,但映射的全部字段、更新时间、保存格式和账号切换重置逻辑未由已读源码确定。后端只代理 JSON,不定义本地成就实体或数据库事务。
失败模式、边界与并发
接口错误行为
| 场景 | 已验证行为 | 调试含义 |
|---|---|---|
| 更新处理器收到非 PUT 方法 | 405,Method not allowed | 这是处理器防御检查;路由层本身也按 PUT 注册 |
| 缺少 API key | 500,API key is missing | 在发起上游请求前结束 |
| 更新请求体为假值 | 400,Achievement name is required | 空对象和空数组并不属于假值,不能依赖此检查完成 schema 校验 |
上游返回非 ok | 抛出包含上游状态码的 Error,随后返回 500 | 不透传上游原始 HTTP 状态或错误体 |
| 请求失败或 JSON 解析失败 | 记录结构化错误日志并返回 500 | JSON 响应中的 error 是异常的 message |
| 上游 URL 构造失败 | 发生在处理器的 try 之外 | 不经过这里的日志与 500 转换分支;最终行为取决于外层错误处理 |
两个处理器的日志消息分别为 get-user-info upstream request failed 和 update-user-achievement upstream request failed,便于区分读快照失败和更新失败。源码没有在这两个处理器中实现重试。fetchUpstream 的具体超时、重试和头部处理细节未读取,不能从函数名推断其默认值。
来源:get-user-info.js、update-user-achievement.js。
队列不是严格幂等机制
防重有三层:同步前集合去重、等待数组去重、派发前检查 achieved。但已经 shift() 出队的项目不再受 queue.includes 保护。如果远端更新或本地状态变更尚未完成,同一个事件再次出现,仍可能把该成就重新加入等待队列。最终是否重复请求、重复通知或重复写入,还取决于未读取的消费者和上游幂等实现。
此外需要注意:
- 退出登录后不自动清队列。 入口检查
isSignedIn,但同步监听和drain()没有再次检查。不能保证退出登录后不会继续派发已排队项目。 - 同步迟迟不完成会阻塞补发。
pendingUntilSynced没有超时、重试或持久化机制;它只等待同步标志为真。 - 排队后项目被移除仍可能派发。 正常入队路径要求条目存在,但
drain()使用!store.userAchievements[slug]?.achieved;条目变为不存在时该表达式仍为真。 - 规则异常没有局部兜底。 引擎直接调用
rule.when(payload),没有独立try/catch。异常是否被事件总线隔离需查看其实现,不能保证一条坏 payload 不影响其他订阅。 - 没有跨实例锁。 每次 composable 调用都有自己的队列;多个实例或多个浏览器标签页之间不共享防重状态。
性能与运维排查
性能特征
规则注册成本与规则数成正比,每条规则注册一个监听。数组排队通过 includes 做线性去重;IP 卡片规则通过 .some() 遍历卡片;同步前则仅保存去重后的 slug,不保留完整 payload。
连续队列按每 2000 ms 处理一项的节奏推进,首项立即派发;即使某项在派发前已完成而被跳过,仍会安排下一次 2 秒后的处理。该策略优先保证单槽触发之间有间隔,而不是追求瞬时吞吐或等待网络完成。来源:use-achievement-engine.js、achievement-rules.js。
推荐排查顺序
- 没有解锁: 先检查事件名和 payload 是否匹配规则,特别是严格等于、严格大于和数值类型。
- 登录前操作未计入: 检查事件发生时
isSignedIn;未登录事件不会进入待同步集合。 - 登录后仍无响应: 检查
userAchievementsSynced是否变真,以及映射中是否存在该slug。 - 部分成就稍后才出现: 检查是否一次触发多个规则;2 秒派发间隔是预期行为。
- 反复触发: 检查本地
achieved更新时机、重复 composable 初始化及请求延迟;不要仅以队列去重作为成功写入证明。 - 接口失败: 检查 API key、endpoint URL 和对应日志;再区分网络失败、非成功状态与 JSON 解析失败。
上述顺序分别对应规则入口、同步屏障、队列和代理接口四个已核对的边界,不依赖未经确认的数据库或 UI 行为。
测试与安全扩展建议
源码检索定位到成就引擎测试 composable-achievement-engine.test.js,以及更新处理器测试 api-handlers.test.js。检索片段显示后者包含非 PUT 返回 405 的用例;本次未读取完整测试文件,也未执行测试,因此不将其他行为描述为已由测试保证。
扩展时建议针对本页已识别的边界增加或核对测试:
- 阈值恰好相等、略高于阈值、缺失 payload 字段及字符串数值。
- 事件早于快照、快照已含该成就、同步后集合清空。
- 一次事件触发多个规则、排队期间外部解锁、同一 slug 在请求未完成前重复出现。
- 排队期间退出登录、作用域销毁、账号切换和多个引擎实例。
- 缺少 key、缺少 body、空对象 body、上游非成功响应、无效 JSON 和 URL 构造异常。
若要把“固定间隔派发”升级为可靠更新任务,应在核对 User.vue 与上游协议后再设计确认、重试、幂等键及取消机制;这些是扩展方向,不是当前引擎已经提供的能力。
相关链接
- 规则字段约定与扩展入口:新增规则时先核对事件与
slug契约。 - 同步屏障与事件门控实现:排查启动竞态和重复解锁。
- 用户信息代理错误处理:排查远端快照读取失败。
- 成就更新代理请求与错误处理:排查上游更新失败。