【交叉评测】阶梯计划 / Ascend:预算闸竞态、生产自检落地与安全模块测试覆盖 #4

Open
opened 2026-08-06 17:23:19 +08:00 by LIGHTNINGWHALE · 0 comments

【交叉评测】阶梯计划 / Ascend:预算闸竞态、生产自检落地与安全模块测试覆盖

评测账号:LIGHTNINGWHALE
评测对象:panic/Ascend(阶梯计划 · 以疑问为原子单位的学习工作台)
评测日期:2026-08-06(Asia/Shanghai)
评测方式:克隆完整仓库并逐文件阅读源代码(backend/app/core/backend/app/api/auth.py/deps.pybackend/app/llm/router.py/base.pybackend/scripts/preflight.py.env.example/.env.prod.examplebackend/tests/install.sh/deploy.sh 等),未安装依赖、未启动服务、未调用任何真实模型 API。

1. 项目理解

“阶梯计划”是一个把 AI 生成的课程/文档内容与用户主动提出的“问题卡片”结合起来的学习工作台:正文旁划词生成卡片,卡片之间可以无限套娃形成追问链,再叠加概念图/问题图/进度图和基于 FSRS 的复习调度,试图解决“AI 学习内容生成得快、但学完什么都没留下”的问题。

从代码结构看,这是一个自托管、多用户、真金白银调用云端 LLM API 的产品,而不是纯本地 Demo,所以“别人用你的 key 花你的钱”这条风险线(README 原话)贯穿了整个后端设计:注册准入控制、速率限制、每用户每日 token 预算、上线自检脚本,都是围绕这条线展开的。

2. 做得好的地方

  1. 鉴权与会话管理的细节考虑得很到位。 security.py 用 argon2id 而不是有 72 字节截断问题的 bcrypt;access token 走短期 JWT、refresh token 走随机串 + 数据库哈希存储(可撤销),且两者都以 httpOnly cookie 下发、refresh cookie 的 path 被缩小到 /api/auth,这些都是超出一般课程作业水准的、经过实际权衡的安全设计,注释里也写清楚了每个决定背后的原因(如“localStorage 里的 token 一旦被 XSS 拿到就是全量沦陷”)。

  2. 数据隔离层把“拿不到”和“存在但没权限”统一处理为 404。 core/scope.pyUserScope 是全项目唯一的数据访问入口,业务层被要求一律通过它取数据;对没有 user_id 列的从属表(Chapter/Section/CardMessage/DocBlock)都沿外键 join 回 owner 校验,neighbor_card_ids() 在做图遍历时甚至对 CardLink 的两端分别做 owner 校验,避免一条被篡改的关联记录就能把别人的卡片拉进当前用户的“第二大脑”。这是很多同类项目容易漏掉、但这里做得比较严谨的一层。

  3. 上线自检脚本把“容易忘的坑”变成了可执行的检查项。 scripts/preflight.py 把 JWT 密钥强度、COOKIE_SECURE 与实际 HTTPS 状态是否匹配、注册准入、每日预算、速率限制、Provider 降级链是否跨供应商等逐项检查并打印出来,而且明确指出了一个容易被忽略的反直觉坑:“HTTP 下把 COOKIE_SECURE 设为 true 会导致登录态直接失效,比不加密更致命”。这种把血泪教训写成检查脚本的做法,比只在 README 里提醒更可靠。

  4. LLM 输出的工程化容错做得比较扎实。 llm/router.py 里的 repair_truncated_json() 针对“大纲被 max_tokens 截断就整份报废”这个真实踩过的坑,用括号栈实现了有损修复,并且注释里特别强调“调用方必须让用户知道内容不完整——悄悄接受残缺数据比直接报错更糟”,这个取舍是对的;extract_json() 对模型输出的多种畸形包装(代码块、寒暄前后缀)做了分层兜底,而不是一次正则了事。

  5. 速率限制按端点性质分桶,而不是一刀切。 core/ratelimit.py 把认证端点(防撞库)和 AI 端点(防刷额度)分开计数,并且用注释明确了架构边界:“内存滑动窗口够单机用,多实例部署时需要换 Redis——但那个规模下你也该上 PostgreSQL 了,一起换”,说明限流方案的取舍是有意识的,不是偷懒。

3. 主要问题

3.1 每日预算闸存在“先查后写”的竞态窗口,恰恰是项目自己定义的最高风险点

llm/router.pycheck_budget() 在发起 LLM 调用前查询 AICall 表里该用户过去 24 小时的用量总和,若小于配额则放行;真正的用量记录要等调用完成后由 _log_call() 写入。这两步之间没有任何加锁、乐观锁或"预扣减"机制:

async def check_budget(user_id: str | None, quota: int | None = None) -> None:
    ...
    used = await s.scalar(select(func.coalesce(func.sum(...), 0)).where(...))
    if (used or 0) >= limit:
        raise BudgetExceeded(...)

结合 README 和 .env.prod.example 里给出的真实数值——LLM_TIMEOUT_SECONDS=180、大纲生成约 90 秒、正文生成约 70 秒——单次调用的窗口本身就长达一到三分钟。如果同一用户(或共享同一账号的游客,见 3.2)在这个窗口内并发发起多个课程/正文/卡片请求,每个请求各自查到的 used 都还是"调用开始前"的旧值,都会判定为"未超额"而放行,实际消耗的 token 会远超 daily_token_quota/guest_daily_token_quota。这正是项目自己在注释里反复强调的"最大风险点"("真正烧钱的地方"),建议:

  • AICall 写入前先做一次"预占"(例如插入一条 pending 状态的记录并计入 used,调用结束后再回填真实 token 数,失败则删除/回滚预占);
  • 或者给同一 user_id 的预算检查加数据库级别的行锁/单飞(single-flight)限制,至少保证同一用户的多个 AI 请求不能同时穿过 check_budget

3.2 游客模式的共享额度在 3.1 的竞态下更容易被单个访问者提前烧光

api/auth.pyguest_login() 让所有游客共用同一个 user_idguest_daily_token_quota 是全体游客共烧的一份(这一点 README 和代码注释都已如实说明,属于"演示场景"下的有意设计,不算问题)。但结合 3.1 的竞态,任何一个访问者只要在浏览器里开几个并发标签页疯狂调用 AI 端点,就可能在配额检查完全生效前把当天所有游客的共享额度提前透支完,导致其他正在体验的评委/访客直接遇到 BudgetExceeded。由于 /auth/guest 本身只受登录频率限制(rate_auth_per_minute)约束,登录之后对 AI 端点的并发数并没有额外的"每用户同时在途请求数"限制,建议至少给同一 user_id 增加一个"同时只允许 N 个 AI 请求在途"的并发闸,配合 3.1 的预占机制一起解决。

3.3 production_warnings() 只在启动时打日志,不会阻止应用以不安全配置对外提供服务

app/main.pylifespan() 里,settings.production_warnings() 检测到 JWT_SECRET 仍是默认值、COOKIE_SECURE 未开启、注册完全开放无邀请码/人数上限、RATE_LIMIT_ENABLED=false 等问题时,只是 log.warning(...) 打印出来,应用依然会正常启动并对外提供服务:

if warnings := settings.production_warnings():
    log.warning("─" * 62)
    log.warning("生产环境自检发现 %s 个问题:", len(warnings))
    for w in warnings:
        log.warning("  ⚠️  %s", w)

preflight.py 作为独立脚本可以在部署前手动跑一遍,但如果操作者是通过 docker compose up -d 或 systemd 以后台方式启动(install.sh/deploy.sh 走的正是这两条路径),这些警告只会进日志文件,很容易被忽略。对于"JWT_SECRET 是默认值"这种一旦被外部知晓就等于任何人都能伪造登录态的严重问题,建议在 is_prod 为真且检测到该项时直接 raise SystemExit/拒绝启动,而不是仅仅打印警告;其余偏"业务选择"性质的项(如是否开放注册)继续保留为警告级别即可。

3.4 后端安全相关模块缺少自动化测试,测试覆盖与前端严重不对称

backend/tests/ 目录下目前只有 test_json_repair.py 一个测试文件,覆盖的是 repair_truncated_json() 这类纯函数逻辑。而 core/scope.py(数据隔离)、core/security.py(密码哈希/JWT)、core/ratelimit.py(限流)、api/auth.py(注册准入、游客账号冲突处理)、llm/router.pycheck_budget()(预算闸,也是 3.1 提到的竞态高发点)都没有对应的单元或集成测试。相比之下 README 提到前端有 67 项测试专门覆盖渲染、XSS、流式半截内容等场景,后端在"数据不能越权""预算不能被刷爆""密钥不能是默认值"这些同样关键甚至更关键的路径上却是测试盲区。建议至少为以下场景补充测试:

  • UserScope.require_* 系列在跨用户访问时必须返回 404(而不是 200 或 403);
  • check_budget() 在并发调用下的行为(即使只是先写一个会失败的测试来证明 3.1 的问题存在,也比没有测试更好);
  • guest_login() 对"邮箱已被正式账号占用"的 409 冲突处理。

4. 建议的优先级与验收方式

优先级 建议 可验证的验收结果
P0 check_budget() 增加预占/加锁机制,消除"先查后写"竞态 用同一账号并发发起多个 AI 请求,实际消耗的 token 总量不超过 daily_token_quota
P0 production_warnings() 中真正致命的项(默认 JWT_SECRET 等)在 is_prod 下应阻止启动,而不是仅打日志 用默认 JWT_SECRET + APP_ENV=prod 启动时进程直接退出并给出明确错误
P1 给同一 user_id 增加"AI 请求并发在途数"限制,缓解游客共享额度被单人快速透支 单个游客快速并发发起大量 AI 请求时,超出并发上限的请求被直接拒绝而不是排队消耗额度
P1 core/scope.pyllm/router.py 的预算闸、api/auth.py 的准入逻辑补齐自动化测试 CI 中新增测试覆盖跨用户越权、预算超限、游客邮箱冲突等场景并全部通过
P2 preflight.py/README 中补充"并发/竞态"相关的已知限制说明 文档中出现关于预算闸并发边界的明确描述,便于自建者评估风险

5. 综合评价

“阶梯计划”在鉴权、数据隔离和上线自检这几个后端项目最容易偷工减料的地方投入了明显超出一般水平的工程量——argon2id、httpOnly cookie、UserScope 逐跳校验、按端点分桶限流、preflight.py 自检脚本,这些设计和注释里的取舍说明读下来都站得住脚。

当前最值得优先投入的,不是继续扩展卡片/图谱这些产品功能,而是把"预算闸"这条项目自己反复强调的最高风险线从"先查后写"的竞态状态收紧成真正原子的预占机制,并让致命的生产配置错误在启动阶段就直接拦下,而不是寄希望于操作者会去读日志。把这两点,连同后端安全模块的测试覆盖补上之后,"阶梯计划"会从一个"安全意识很强的 Demo"更进一步,成为一个在真实多用户、真金白银调用云端 API 的场景下也经得起考验的产品。


评测方:LIGHTNINGWHALE

# 【交叉评测】阶梯计划 / Ascend:预算闸竞态、生产自检落地与安全模块测试覆盖 > 评测账号:LIGHTNINGWHALE > 评测对象:`panic/Ascend`(阶梯计划 · 以疑问为原子单位的学习工作台) > 评测日期:2026-08-06(Asia/Shanghai) > 评测方式:克隆完整仓库并逐文件阅读源代码(`backend/app/core/`、`backend/app/api/auth.py`/`deps.py`、`backend/app/llm/router.py`/`base.py`、`backend/scripts/preflight.py`、`.env.example`/`.env.prod.example`、`backend/tests/`、`install.sh`/`deploy.sh` 等),未安装依赖、未启动服务、未调用任何真实模型 API。 ## 1. 项目理解 “阶梯计划”是一个把 AI 生成的课程/文档内容与用户主动提出的“问题卡片”结合起来的学习工作台:正文旁划词生成卡片,卡片之间可以无限套娃形成追问链,再叠加概念图/问题图/进度图和基于 FSRS 的复习调度,试图解决“AI 学习内容生成得快、但学完什么都没留下”的问题。 从代码结构看,这是一个自托管、多用户、真金白银调用云端 LLM API 的产品,而不是纯本地 Demo,所以“别人用你的 key 花你的钱”这条风险线(README 原话)贯穿了整个后端设计:注册准入控制、速率限制、每用户每日 token 预算、上线自检脚本,都是围绕这条线展开的。 ## 2. 做得好的地方 1. **鉴权与会话管理的细节考虑得很到位。** `security.py` 用 argon2id 而不是有 72 字节截断问题的 bcrypt;access token 走短期 JWT、refresh token 走随机串 + 数据库哈希存储(可撤销),且两者都以 httpOnly cookie 下发、`refresh` cookie 的 `path` 被缩小到 `/api/auth`,这些都是超出一般课程作业水准的、经过实际权衡的安全设计,注释里也写清楚了每个决定背后的原因(如“localStorage 里的 token 一旦被 XSS 拿到就是全量沦陷”)。 2. **数据隔离层把“拿不到”和“存在但没权限”统一处理为 404。** `core/scope.py` 的 `UserScope` 是全项目唯一的数据访问入口,业务层被要求一律通过它取数据;对没有 `user_id` 列的从属表(`Chapter`/`Section`/`CardMessage`/`DocBlock`)都沿外键 join 回 owner 校验,`neighbor_card_ids()` 在做图遍历时甚至对 `CardLink` 的两端分别做 owner 校验,避免一条被篡改的关联记录就能把别人的卡片拉进当前用户的“第二大脑”。这是很多同类项目容易漏掉、但这里做得比较严谨的一层。 3. **上线自检脚本把“容易忘的坑”变成了可执行的检查项。** `scripts/preflight.py` 把 JWT 密钥强度、`COOKIE_SECURE` 与实际 HTTPS 状态是否匹配、注册准入、每日预算、速率限制、Provider 降级链是否跨供应商等逐项检查并打印出来,而且明确指出了一个容易被忽略的反直觉坑:“HTTP 下把 COOKIE_SECURE 设为 true 会导致登录态直接失效,比不加密更致命”。这种把血泪教训写成检查脚本的做法,比只在 README 里提醒更可靠。 4. **LLM 输出的工程化容错做得比较扎实。** `llm/router.py` 里的 `repair_truncated_json()` 针对“大纲被 `max_tokens` 截断就整份报废”这个真实踩过的坑,用括号栈实现了有损修复,并且注释里特别强调“调用方必须让用户知道内容不完整——悄悄接受残缺数据比直接报错更糟”,这个取舍是对的;`extract_json()` 对模型输出的多种畸形包装(代码块、寒暄前后缀)做了分层兜底,而不是一次正则了事。 5. **速率限制按端点性质分桶,而不是一刀切。** `core/ratelimit.py` 把认证端点(防撞库)和 AI 端点(防刷额度)分开计数,并且用注释明确了架构边界:“内存滑动窗口够单机用,多实例部署时需要换 Redis——但那个规模下你也该上 PostgreSQL 了,一起换”,说明限流方案的取舍是有意识的,不是偷懒。 ## 3. 主要问题 ### 3.1 每日预算闸存在“先查后写”的竞态窗口,恰恰是项目自己定义的最高风险点 `llm/router.py` 的 `check_budget()` 在发起 LLM 调用前查询 `AICall` 表里该用户过去 24 小时的用量总和,若小于配额则放行;真正的用量记录要等调用完成后由 `_log_call()` 写入。这两步之间没有任何加锁、乐观锁或"预扣减"机制: ```python async def check_budget(user_id: str | None, quota: int | None = None) -> None: ... used = await s.scalar(select(func.coalesce(func.sum(...), 0)).where(...)) if (used or 0) >= limit: raise BudgetExceeded(...) ``` 结合 README 和 `.env.prod.example` 里给出的真实数值——`LLM_TIMEOUT_SECONDS=180`、大纲生成约 90 秒、正文生成约 70 秒——单次调用的窗口本身就长达一到三分钟。如果同一用户(或共享同一账号的游客,见 3.2)在这个窗口内并发发起多个课程/正文/卡片请求,每个请求各自查到的 `used` 都还是"调用开始前"的旧值,都会判定为"未超额"而放行,实际消耗的 token 会远超 `daily_token_quota`/`guest_daily_token_quota`。这正是项目自己在注释里反复强调的"最大风险点"("真正烧钱的地方"),建议: - 在 `AICall` 写入前先做一次"预占"(例如插入一条 `pending` 状态的记录并计入 `used`,调用结束后再回填真实 token 数,失败则删除/回滚预占); - 或者给同一 `user_id` 的预算检查加数据库级别的行锁/单飞(single-flight)限制,至少保证同一用户的多个 AI 请求不能同时穿过 `check_budget`。 ### 3.2 游客模式的共享额度在 3.1 的竞态下更容易被单个访问者提前烧光 `api/auth.py` 的 `guest_login()` 让所有游客共用同一个 `user_id`,`guest_daily_token_quota` 是全体游客共烧的一份(这一点 README 和代码注释都已如实说明,属于"演示场景"下的有意设计,不算问题)。但结合 3.1 的竞态,任何一个访问者只要在浏览器里开几个并发标签页疯狂调用 AI 端点,就可能在配额检查完全生效前把当天所有游客的共享额度提前透支完,导致其他正在体验的评委/访客直接遇到 `BudgetExceeded`。由于 `/auth/guest` 本身只受登录频率限制(`rate_auth_per_minute`)约束,登录之后对 AI 端点的并发数并没有额外的"每用户同时在途请求数"限制,建议至少给同一 `user_id` 增加一个"同时只允许 N 个 AI 请求在途"的并发闸,配合 3.1 的预占机制一起解决。 ### 3.3 `production_warnings()` 只在启动时打日志,不会阻止应用以不安全配置对外提供服务 `app/main.py` 的 `lifespan()` 里,`settings.production_warnings()` 检测到 `JWT_SECRET` 仍是默认值、`COOKIE_SECURE` 未开启、注册完全开放无邀请码/人数上限、`RATE_LIMIT_ENABLED=false` 等问题时,只是 `log.warning(...)` 打印出来,应用依然会正常启动并对外提供服务: ```python if warnings := settings.production_warnings(): log.warning("─" * 62) log.warning("生产环境自检发现 %s 个问题:", len(warnings)) for w in warnings: log.warning(" ⚠️ %s", w) ``` `preflight.py` 作为独立脚本可以在部署前手动跑一遍,但如果操作者是通过 `docker compose up -d` 或 systemd 以后台方式启动(`install.sh`/`deploy.sh` 走的正是这两条路径),这些警告只会进日志文件,很容易被忽略。对于"JWT_SECRET 是默认值"这种一旦被外部知晓就等于任何人都能伪造登录态的严重问题,建议在 `is_prod` 为真且检测到该项时直接 `raise SystemExit`/拒绝启动,而不是仅仅打印警告;其余偏"业务选择"性质的项(如是否开放注册)继续保留为警告级别即可。 ### 3.4 后端安全相关模块缺少自动化测试,测试覆盖与前端严重不对称 `backend/tests/` 目录下目前只有 `test_json_repair.py` 一个测试文件,覆盖的是 `repair_truncated_json()` 这类纯函数逻辑。而 `core/scope.py`(数据隔离)、`core/security.py`(密码哈希/JWT)、`core/ratelimit.py`(限流)、`api/auth.py`(注册准入、游客账号冲突处理)、`llm/router.py` 的 `check_budget()`(预算闸,也是 3.1 提到的竞态高发点)都没有对应的单元或集成测试。相比之下 README 提到前端有 67 项测试专门覆盖渲染、XSS、流式半截内容等场景,后端在"数据不能越权""预算不能被刷爆""密钥不能是默认值"这些同样关键甚至更关键的路径上却是测试盲区。建议至少为以下场景补充测试: - `UserScope.require_*` 系列在跨用户访问时必须返回 404(而不是 200 或 403); - `check_budget()` 在并发调用下的行为(即使只是先写一个会失败的测试来证明 3.1 的问题存在,也比没有测试更好); - `guest_login()` 对"邮箱已被正式账号占用"的 409 冲突处理。 ## 4. 建议的优先级与验收方式 | 优先级 | 建议 | 可验证的验收结果 | | --- | --- | --- | | P0 | 给 `check_budget()` 增加预占/加锁机制,消除"先查后写"竞态 | 用同一账号并发发起多个 AI 请求,实际消耗的 token 总量不超过 `daily_token_quota` | | P0 | `production_warnings()` 中真正致命的项(默认 `JWT_SECRET` 等)在 `is_prod` 下应阻止启动,而不是仅打日志 | 用默认 JWT_SECRET + `APP_ENV=prod` 启动时进程直接退出并给出明确错误 | | P1 | 给同一 `user_id` 增加"AI 请求并发在途数"限制,缓解游客共享额度被单人快速透支 | 单个游客快速并发发起大量 AI 请求时,超出并发上限的请求被直接拒绝而不是排队消耗额度 | | P1 | 为 `core/scope.py`、`llm/router.py` 的预算闸、`api/auth.py` 的准入逻辑补齐自动化测试 | CI 中新增测试覆盖跨用户越权、预算超限、游客邮箱冲突等场景并全部通过 | | P2 | 在 `preflight.py`/README 中补充"并发/竞态"相关的已知限制说明 | 文档中出现关于预算闸并发边界的明确描述,便于自建者评估风险 | ## 5. 综合评价 “阶梯计划”在鉴权、数据隔离和上线自检这几个后端项目最容易偷工减料的地方投入了明显超出一般水平的工程量——argon2id、httpOnly cookie、UserScope 逐跳校验、按端点分桶限流、`preflight.py` 自检脚本,这些设计和注释里的取舍说明读下来都站得住脚。 当前最值得优先投入的,不是继续扩展卡片/图谱这些产品功能,而是把"预算闸"这条项目自己反复强调的最高风险线从"先查后写"的竞态状态收紧成真正原子的预占机制,并让致命的生产配置错误在启动阶段就直接拦下,而不是寄希望于操作者会去读日志。把这两点,连同后端安全模块的测试覆盖补上之后,"阶梯计划"会从一个"安全意识很强的 Demo"更进一步,成为一个在真实多用户、真金白银调用云端 API 的场景下也经得起考验的产品。 --- 评测方:LIGHTNINGWHALE
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
panic/Ascend#4
No description provided.