XiaoQing 全项目代码审查报告
审查完成日期:2026-07-28 审查方式:逐行阅读全部生产代码 + 测试树审查 + 实跑测试套件
关于本文档
本文档是整理版:审查过程分 99 个批次进行,原始记录是追加式的,包含批次编号、 续接说明、以及后续批次对前面结论的修订。本版本已把这些全部消化掉,只保留最终结论。
具体做了五件事:
- 去掉了过程——不再有"第 N 批"、"下次续接点"、"本批新增观察"这类批次结构。
- 去掉了推翻——凡是后续批次修订或撤销过的结论,本文档直接写修订后的版本, 不再保留"此前认为 X,现更正为 Y"的叙述。被完全撤销的条目已删除 (共 22 条,见「附录三」)。
- 去掉了重复——跨模块的共性问题(时区、投递语义、手写配置读取等) 在「第二部分 主线」里各出现一次并附完整受影响清单; 模块清单里只留指向主线的一行,不再复述。
- 重新编号了冲突的条目——正文与末批附录都用过
Q-/R-/S-前缀, 现改为PM-(plugin_manager)、AP-(app.py)、TS-(tests)。 - 保留了全部推导——第四部分每个模块先给索引表,再给详述; 详述是原始记录里的完整分析(代码引用、失效路径、影响面核对、修法), 一字未删,只去掉了其中的批次与修订叙述。 1 240 条里有 366 条带完整推导;其余 874 条在审查时即记为单条结论, 索引表里的那一行就是全部内容。
阅读建议:只想知道该修什么,看第三部分(必修清单)和第五部分(修复顺序); 要理解为什么这些问题会反复出现,看第二部分(主线); 要查某个模块的全部问题,看第四部分的索引表; 要看某一条的证据和推导,看该模块「详述」里的同名条目(索引表中带 † 标记)。
一、总览与统计
1.1 覆盖范围
| 目标 | 文件 | 行数 | 覆盖 |
|---|---|---|---|
core/ + main.py | 37 | 26 565 | 100% 逐行 |
| 插件(29 个) | 299 | 100 797 | 100% 逐行 |
tests/ | 314 | 132 089 | 结构 + 门禁 + 关键模块逐行 + 实跑 |
| 合计 | 650 | 259 451 | — |
按打包边界排除的部分:plugins/*/train_model/、pendo/scripts/ (MANIFEST.in prune,不进发行物,仅做打包边界核对)。 xiaoqing_chat/experiments/ 虽同属工具目录,但已逐行读过。
1.2 问题统计
| 级别 | 数量 | 含义 |
|---|---|---|
| P0 | 3 | 影响正确性或安全的语义缺陷,跨多个模块 |
| P1 | 70 | 单点的高严重度缺陷:数据损坏、隐私泄露、可复现的用户可见错误 |
| 中 | 479 | 有明确用户可见后果或运维后果,但不损坏数据 |
| 低 | 536 | 一致性、可维护性、可观测性、文案与冗余 |
| 未分级 | 151 | 模块小节的引导条目与跨模块观察 |
| 合计 | 1 240 | 另有 70 条正面实现、37 条方法论,见附录一 / 附录二 |
按模块分布(问题数):
| 模块 | 问题数 | 行数 | 密度(每千行) |
|---|---|---|---|
| pendo | 717 | 24 065 | 29.8 |
| xiaoqing_chat | 232 | 24 491 | 9.5 |
| codex | 40 | 5 021 | 8.0 |
| qingpet | 34 | 10 004 | 3.4 |
| qingssh | 26 | 4 428 | 5.9 |
| arxiv_filter | 19 | 2 346 | 8.1 |
| ads_paper | 16 | 1 698 | 9.4 |
| jupyter | 12 | 2 101 | 5.7 |
| minecraft | 10 | 1 683 | 5.9 |
| 21 个小插件(含 shell) | 106 | 9 000 | 11.8 |
core/ 全部 | 20 | 26 565 | 0.8 |
tests/ 与工程门禁 | 8 | 132 089 | — |
密度差异本身就是一条结论:core/ 的每千行问题数比插件层低一个数量级。 pendo 的密度最高,但它也是唯一带完整 Web UI + SQLite 数据层 + 8 个定时任务的插件, 且经历了一次进行到一半的时区迁移(见主线 M-2),其中约 40% 的条目属于那条主线。
1.3 一句话结论
core/的工程强度很高,问题几乎全部出在「边界没有被强制执行」。 core 提供了public_errors、投递收据协议、total_timeout_seconds、keyed_path_lock、get_settings_snapshot、manifest 的services/contexts/concurrency字段, 插件层要么不知道它们存在,要么各自重写了一份更差的。而且这不只是插件层的问题:core 自己也不用自己造的异步快照(PM-2)、 自己的有界缓存(PM-5)、自己的服务契约(AP-1 / AP-5)。
这个项目缺的不是能力,是把能力变成默认路径的机制。
二、贯穿全局的主线
以下 14 条是跨模块的共性问题。每条只在这里出现一次,附完整受影响清单; 第四部分的模块清单里不再复述。
M-1 投递结果的三态语义(P0-1)
问题:send_action 返回 bool,但底层有三种状态——已送达、被拒绝、结果未知 (WebSocket 已提交但没等到回执)。False 同时表示后两者。调用方据此回滚状态时, 会把已经送达的消息标记成未送达。
六个受害者,按后果严重度排序:
| 后果等级 | 插件 | 具体表现 |
|---|---|---|
| 凭据状态误报 | pendo /pendo web token | 提示失败但实际已送达 → 用户反复生成有效登录码(N-258 / N260-1) |
| 重复投递 | pendo 提醒 | 同一提醒重复推送或反向提示(N-3) |
| 游标不推进 | arxiv_filter | 已推送的论文下次再推一遍 |
| 游标不推进 | minecraft | 同上 |
| 提示不准 | chime | 用户看到"发送失败"而消息已到 |
| 提示不准 | earthquake | 同上 |
已确认的正面事实(O-49):面向用户的投递路径 100% 采纳了 core 的收据协议 (core/delivery.py 的 DeliveryReceipt / attach_receipt), 面向运营方的推送路径 0% 采纳。也就是说 core 侧的能力是完整的 (_send_action 里 receipt.record() 用 asyncio.shield 包住,多段消息先 add_expected_actions(len-1) 再发),要改的只是这六个调用点。
修法:把 send_action 的返回值改成三值枚举 DELIVERED / REJECTED / UNKNOWN, 或让这六处改用 core/delivery.py 的收据协议。UNKNOWN 在凭据场景有明确且安全的 渲染方式("已尝试发送,未收到请重试"),布尔值没有。
M-2 时区:一次进行到一半的迁移
这不是"41 处疏忽",而是一次被搁置的子系统迁移。 这一点决定了修复方式—— 必须按子系统边界整体迁移 + 存量数据转换,不能逐点把 datetime.now() 改成 UTC, 否则每改一处就多一个交界 bug。
pendo 的现状(该主线 90% 的体量):
- 已迁至 UTC:租约、外发箱、日志保留(并加了 10 处
raise级断言) - 未迁移:条目 CRUD
- 交界处出错:
prune_operation_logs—— 隐私保留策略按部署时区平移甚至反转(N-12)
items 表的同一列存在四种时间形式:
| 形式 | 写入路径 | 可区分性 |
|---|---|---|
| 带偏移 ISO | 导入路径 bundle_import.py | 正则可区分 |
| UTC 朴素 | 部分迁移后的路径 | 与下面两种形状相同 |
| 服务器本地朴素 | insert_item / handlers/event.py 三处裸 datetime.now() | 不可区分 |
| 用户本地朴素 | handlers/diary.py / Web 任务编辑器 | 不可区分 |
后两者在数据层面完全无法分辨,只能靠"哪条代码路径写的"来推断(N147-1)。
二次放大:SQLite 的 date() / strftime() 会把带偏移的时间串按 UTC 解释。 全仓 87 处引擎侧日期函数(stats.py 独占 27 处),与导入产生的带偏移形式叠加后, 统计口径整体位移(N-224 / N226-1)。
第三种假设:Web 分析层(widget.py / event_schedule.py)假定"库里存的是 DEFAULT_TIMEZONE 墙钟",而这个假定从未成立;同一响应内还与四个 *_overview 的用户时区基准混用(N-195)。widget.py:43 有一条注释把这个错误假定固化了下来。
其余受影响处:core 的 _business_now 分叉(A7-1)、codex 归档目录名用 time.localtime()(J3-6)、qingpet 的字符串时间比较(M2-6)、 ads_paper 笔记用朴素本地时间而 cmd_daily 特意用 UTC(P-6)。
修法(三步,顺序不能换):
- 重建真实时刻——按写入路径反推每一行的真实 UTC 时刻。
web/services/bundle_import.py的_resolve_source_wall_time是全仓唯一正确处理 夏令时折叠的实现(N-190 / N-191),它正是这一步需要的工具。 动手前必须先跑探测脚本统计存量数据中四种形式各占多少(此前推断过一次,方向反了)。 - 统一查询写法——把所有对
start_time/end_time/created_at做字符串范围 比较的查询,改写成get_events_for_range的形状(日期前缀粗筛 + Python 精确判定)。 这一步不动数据,可立即消除用户可见的漏查(N-97 / N101-1)。 - 统一存储格式——最后做存量数据的格式统一。
结构性原因:49 处时区感知检查全部位于消费侧,产生侧零防御(N269-1)。 不在写入口设闸,任何一次迁移都会重新长出混合形式。
M-3 正确实现已经在仓库内,调用点却不用
这是本次审查发现的最省力的一类修复:不用设计新方案,只改调用点。 累计 46 处以上,分布如下:
| 类型 | 已有的正确实现 | 不用它的地方 |
|---|---|---|
| 异步文件快照 | plugin_manager._capture_plugin_snapshot_async | 同文件 4 条重路径(PM-2)← core 自身 |
| 有界缓存淘汰 | core/bounded_file_cache.py | _module_origin_cache 到上限直接 clear()(PM-5)← core 自身 |
| 声明式服务契约 | core/models.py _SERVICE_CONTRACTS | app.py 按名字 getattr 反射调用(AP-5)← core 自身 |
| SQLite 连接模型 | pendo 的 threading.local 每线程连接 | qingpet 单连接 + 全局 RLock,WAL 失效(M2-2) |
| 设置写入 | json_patch upsert | pendo save_user_setting 丢更新(N-15) |
| 乐观锁 | update_item(expected_version=) | 聊天路径不传(N-33) |
| 定时任务索引 | scheduled_delivery_outbox.next_attempt_at + 索引 | 提醒主队列每分钟全表扫描(N-112) |
| 混合时间查询 | get_events_for_range | get_briefing_items 字符串比较(N-97) |
| 增量设置补丁 | web/api/settings.py:117 | utils/settings_utils.py:163 全量快照覆盖(N215-1) |
to_thread 版本 | 已存在 | 部分调用点仍同步(K4-2、O-150) |
| 配置快照 API | get_settings_snapshot() | 全仓只有 1 个插件(codex)在用 |
| 测试夹具 | tests/helpers/pendo_leak_guard.py(会自检形状) | 用 SimpleNamespace 手搓替身(TS-6) |
共同结构:正确的抽象被写出来了,却没有任何机制强制使用。 这三类只能靠 CI 规则收口(AST 检查 / 调用点白名单),建议合并为一个专项。
M-4 core/args.py 的解析缺陷
问题:core.args.parse 把任何以 - 开头的 token 当作选项,且 rest() 用单空格 重新拼接。没有 -- 终止符。
两个可复现的 bug:
wolframalpha:/alpha -1+2被拒绝——计算器插件的核心输入形态被挡掉(A3-1)astro_tools:负赤纬-30.5被当成命令选项吃掉(D-0)ads_paper:/paper search "fast radio burst"(plugin.json 自己的示例)引号被吃;-3σ 偏差明显被当短选项并吞掉后一个词 → "内容不能为空";空白/反斜杠不可逆(P-4)
七个插件干脆绕开它自己写解析:dict、choice、github、guess_number、 adnmb、color、qingssh。
修法:core/args.py 补 -- 终止符并收紧选项判定(只认 -x / --xxx 形态, 数字与非 ASCII 开头不算选项),然后收敛那 7 个自写解析器; 自由文本入口(ads_paper)改用 split(maxsplit=n),不过通用解析器。
M-5 目录名被当成授权令牌
core 里共有七处按插件目录名分支的逻辑,其中五处决定安全或能力边界。
| 位置 | 内容 | 有无第二道闸 |
|---|---|---|
plugin_manager.py:185 | _TRUSTED_ADMIN_TIMEOUT_EXEMPT_PLUGINS = {codex, jupyter, qingssh, shell} → 执行超时强制为 0(无限) | 无 |
app.py:2099 | plugin_name == "xiaoqing_chat" → OneBotMediaService | 无 |
app.py:2103 | plugin_name == "pendo" → ConfigSubscriptionService | 无 |
app.py:1626 | plugin_manager.get("xiaoqing_chat") + getattr(module, "observe_outgoing_action") | 无 |
app.py:2087 | plugin_name == "bot_core" → SecretAdminService(可写 secrets) | 有(admin + 私聊) |
app.py:2116 | plugin_name == "arxiv_filter" → CodexArxivSummaryService | 有(admin/system) |
dispatcher.py:62 | _ADMIN_SESSION_PLUGINS = {codex, shell, jupyter, qingssh, minecraft} | — |
dispatcher.py:1026,1091 | {smalltalk, xiaoqing_chat} 闲聊回落 | — |
dispatcher.py:908 | plugin_registry.get("url_parser") | — |
需要说清楚哪一半是做对的:这些能力的接口面收得很紧。 OneBotMediaService 只暴露 get_msg / get_image 两个动作,参数逐个校验、互斥、拒空; SecretAdminService 在调用时用 _authorized 闭包再查一次权限。 错的是授予条件是一个字符串比较,而不是一份声明。
三个后果:改目录名 = 静默丢能力(if 不匹配不报错); 授权不可审计(只能读 core/app.py 源码才知道谁有什么特权); manifest 已有 services[].callers / required_capability 字段却不用。
注意:tests/test_app_plugin_capabilities.py:32 目前把这些名字断言成了规格。 改设计时那条测试必须同步改写,否则会被误读成回归。
M-6 manifest 已能表达的策略,散落在 Python 里各写一遍
| manifest 字段 | 声明率 | 后果 |
|---|---|---|
contexts | 0/30 | 六个高权限插件在群聊里可用,无声明。bot_core 用 Python 硬编码实现了同一语义 |
concurrency | 9/30 | 漏了最需要它的几个 |
watch_files | 1/30 | — |
services[].callers | 用了,但 core 的能力授予不走它 | 见 M-5 |
contexts 缺失的六处(G-0):五个高权限插件全未声明 contexts: ["private"]; 第六处是 ads_paper——存储层按 user_id 认真隔离,展示层完全不隔离, 于是群里 /paper writing 会泄露未发表论文的写作思路(P-5)。 pendo 的 manifest 无 contexts/concurrency/admin_only,是隐私后果最重的一例(N5-3)。
M-7 手写 _get_config —— 13+ 个插件各写一份
至少 13 个插件手写了同一个 context.config["plugins"][name] 遍历。 配置来源在插件间实际有三套互不相同的做法:
- 手写
_get_config(13+ 个插件) get_settings_snapshot()+ revision 栅栏(只有 codex,且它的reconfigure可以直接作为文档里的参考实现)- 类属性 + 环境变量(pendo 的
PendoConfig,且只有 1 个键能从config.json读) —— 这一套让安全开关只能走环境变量(N5-6)
后果:改 config.json 对 23 个插件不生效(它们读的是构造上下文时冻结的 context.config)。
M-8 外部内容校验:能力天花板与地板差四个数量级
| 实现 | 强度 |
|---|---|
codex/artifacts.py | 天花板:拒硬链接 / O_NOFOLLOW / TOCTOU 三重身份 / 校验副本而非源 / 逐帧解码 |
earthquake | 次之:多容器尾部校验 |
twitter、url_parser | 逐行重复且弱 |
apod | 最弱 |
core 提取应以 codex 版为蓝本。
同类问题还有"收窄第三方文本":minecraft._bounded_text(字符+字节双上限,二分查找, 剥 ANSI/控制字符)是最完整的,另有 signin._safe_text、chime._extract_scalar、 github._clean_element_text 各一份——六份独立实现。
M-9 isdigit() + int() 可复现的 ValueError
str.isdigit() 对全角数字、上标、部分 Unicode 数字返回 True,而 int() 会抛。 qingpet 9 处可复现(M2-9),pendo 2 处(ai_parser.py、commands/operations.py)。 正解是 int() + except ValueError(ads_paper 用的就是这个)。
M-10 "5 分钟内可撤销"——文案与事实不符
pendo 的撤销时长承诺在四处出现且互不相同:task.py:1248、task.py:1273、 ledger.py:1184、event.py:1241 都写"5 分钟",帮助文本写"30 分钟", 而真实窗口是"距上次 00:15 清理的时长"(5 分钟 ~ 24 小时)。 更根本的问题是每晚 00:15 的清理把全部快照擦掉,包括刚写入几秒的—— 撤销功能在每晚 00:15 之后彻底失效。
M-11 撤销子系统在物理删除审计日志
undo_* 路径会删除原始 operation_logs 行,因此审计记录可被用户操作抹除 (N-39 / N-44:整个撤销子系统都是这个形状)。同时 operation_logs 零索引, 撤销与清理路径反复全表扫描它(N-104)。
M-12 主数据与密钥放在插件源码目录内
这是 core 的选址决定,不是某个插件的特例。plugin_manager._ensure_plugin_data_dir 返回的就是 plugins/<name>/data/, 所以每一个正确使用 context.data_dir 的插件(qingpet 等)都把数据放在包安装目录内。
对所有插件成立的后果:升级/重建镜像可能让数据变成孤儿;只读安装目录下无法启动 (_ensure_plugin_data_dir 的 mkdir 先失败)。 不成立的后果:docs/remote-sync.md:42 明确把 plugins/*/data/ 列进 rsync protect 规则, 数据在保护范围内。
pendo 额外犯的错是自己拼路径而不调 API(main.py:139、auth.py:26), 于是同时绕过了 mkdir(mode=0o700) 和 _verify_data_directory_record 的 inode 身份校验; web_token_secret.txt 与主库同源,一次升级会让所有会话与 180 天有效期的 Widget 令牌 静默全部失效。
修法:core 接受 data_root 配置项(默认 <project>/data/<plugin_name>/), context.data_dir 从那里发;迁移期两处都读、只往新处写。 完成后 _iter_watch_files 里对 data 的特判可以删掉——那个特判本身就是选址错误的证据。
M-13 同步阻塞散落在事件循环上
| 位置 | 内容 |
|---|---|
| pendo | 约 10 处事件循环上的同步数据库读取(N-170)。盲区来源:now_in_timezone 这种名字完全看不出会读库的辅助函数 |
| xiaoqing_chat | 写路径全部 to_thread,读路径全在事件循环上——而全库重算恰好发生在读路径(O-75 / O-121 / O-74) |
| core/plugin_manager | 四条重路径的全量读+哈希(PM-2)、别名扫描 2 000~4 000 次 stat()(PM-5) |
| core/app | 每条消息 2 次 lstat()(PM-7) |
正确的检查方法:从数据访问层反向追溯——列出所有直接调用 db.* 的函数, 再递归找出全部调用者。按"函数名是否暗示 I/O"来筛选必然漏检。
M-14 外部服务的客户端指纹硬编码且会静默失效
signin(有赞 uuid / version)、earthquake(微博 rid / ver)、 twitter / apod / github(各自一份 User-Agent,版本号从 Chrome 77 到 120 不等)。 这类值会静默失效,失败被吞掉后只表现为"取不到数据"。 应集中到各插件的 constants.py 并统一注明"抓包来源 + 失效征兆", 并在连续失败 N 次时升级日志级别。
三、必修清单
3 条 P0 + 70 条 P1。 按后果分六类。属于第二部分某条主线的,只写该主线未覆盖的增量, 并标注归属。
A · 数据损坏与丢失
| 编号 | 位置 | 问题 | 后果 |
|---|---|---|---|
| P-1 | ads_paper/bibtex.py | BibTeX 解析器把 % 当作注释起点,而 % 在 BibTeX 条目内部是普通字符 | 一个百分号("90% confidence")让整份 .bib 抛 unterminated → /paper ref_add 与 /paper refs 全部失效。bibtex.py 是新文件,旧数据已存在。README 特意宣传"修好了 @ 的误判"——修掉一个误判的同时引入了一个漏判 |
| O-142 | xiaoqing_chat/expression/store.py | 每来一条消息,ExpressionStore.bind() 就把合并基线清空一次 | 存储层写对了三方合并,被同一插件里每条消息都执行的一行 bind() 完全废掉。修法只需一行 if self._data_dir != data_dir 守卫 |
| N-116 | pendo schema 迁移 | amount_cents 有 ADD COLUMN 迁移但没有回填,回填代码在 scripts/ 里而该目录被 MANIFEST.in prune 掉 | 金额列在发行环境永远是空的,且修复能力不在包里。同类问题第二次出现(第一次是 rebuild_fts_index,N-66) |
| N-259 | handlers/note.py:825 + db.py:811-814 | 查看一条笔记会改写它的 updated_at、version 和时间形式 | 四个看起来毫不相关的 bug:旧笔记置顶 / 网页保存莫名 409 / 时间形式漂移 / 条目缓存失效 |
| N-85 | pendo 级联删除 | 撤销时用"当前所有已删除的子节点"近似"这次删除的子节点" | 有历史删除时多恢复用户并未删除的节点 |
| N-112 | pendo save_user_setting | 全量快照覆盖式写入,两个并发写互相丢更新 | 同仓已有 json_patch upsert 的正确实现(属 M-3) |
| O-94 | xiaoqing_chat | 会话清除覆盖 8 个存储、漏掉 3 个 | 漏掉的其中一个在设计上就无法按会话清除 |
| O-76 | xiaoqing_chat | .bak 备份被认真维护、被显式删除,却没有任何读路径会用它 | 备份是纯开销,真出事时没有恢复路径 |
| M2-10 | qingpet | 增量合并可构造负余额,只被 SQL 触发器拦住 | 错误语义丢失——用户看到的是数据库约束错误而不是业务提示 |
| M2-1 | qingpet | 每日金币上限烧进 SQL 触发器且无 DROP TRIGGER | 改 constants.py 不生效,且没有任何提示 |
B · 时区(全部归属主线 M-2)
以下 P1 条目都是 M-2 的具体落点,修复必须按 M-2 的三步顺序进行,不能逐点改。
| 编号 | 位置 | 具体落点 |
|---|---|---|
| N-8 / N10-1 | pendo 全插件 41 处 | 有一套完整正确的时区体系,然后在 41 个地方绕开了它(41 : 7) |
| N-12 | prune_operation_logs | 审计日志保留策略被时区偏移整体平移,且方向随部署地反转。这是"只把比较的一侧改对"比双侧都错更危险的例子 |
| N-70 / N82-2 | db.py:1416、1472 vs 1291 | 同一张表的同一列被两个构造函数写成两种时间形式,且 items.created_at(主表主排序列)受影响 |
| N-97 / N100-1 | db.py:2876-2887 | get_briefing_items 对混合格式做字符串比较,而同文件 get_events_for_range 已有正确解法 → 每日简报漏掉带偏移的日程 |
| N-141 | Web 任务编辑器 | 把带时区的 deadline_at 静默改写成朴素串 |
| N-148 | 日记 entry_time | "用户本地朴素"的第 2 例,失效方式与待办不同 |
| N-155 | 日程编辑器 | 第 3 例,也是后果最重的一例 |
| N146-1 | diary.py:246-247 | items.created_at 的第三种形式,且与第一种形状相同(不可区分) |
| N163-1 | event.py:406、498、616 | 三处裸 datetime.now(),使 event 成为唯一写服务器本地时间的条目类型 |
| N-195 / N197-1 | widget.py:44、event_schedule.py:24 | Web 分析层以 DEFAULT_TZ 为基准,与实际存储形式全都不符;同一响应内还与四个 *_overview 的用户时区基准混用。widget.py:43 的注释把这个错误假定固化了 |
| N-224 / N226-1 | stats.py 27 处 + 全仓 87 处 | SQLite date()/strftime() 把带偏移时间串按 UTC 解释 → 统计口径整体位移 |
| N-217 | pendo 子条目 | 子条目从未拿到过 UTC 时间(setdefault("created_at", now) 的三个调用方全都显式传值) |
| N269-1 | 全仓产生侧 | 49 处时区感知检查全部在消费侧,产生侧零防御——这是该主线能长期存在的结构性原因 |
可直接复用的正确实现:web/services/bundle_import.py 的 _resolve_source_wall_time 是全仓唯一正确处理夏令时折叠的代码(N-190 / N-191),它就是 M-2 第 1 步需要的工具。
C · 安全与隐私
| 编号 | 位置 | 问题 |
|---|---|---|
| P0-2 | shell、qingssh | 把裸异常文本发进聊天。(此前一度认为有 5 个受害者,经复核 jupyter / minecraft / codex 有自有安全出口,实为 2 个) |
| AP-1 | core/app.py + core/dispatcher.py + core/plugin_manager.py | core 把插件目录名当授权令牌,七处,五处决定安全或能力边界 —— 详见主线 M-5 |
| N-2 / N5-1 | pendo/main.py:139、pendo/web/auth.py:26 | 主数据库与 JWT 签名密钥自拼路径,绕过 mode=0o700 与目录 inode 身份校验 —— 详见主线 M-12 |
| N-174 | scriptable/pendo_widget.js | Widget 令牌以明文常量存放在会被 iCloud 同步的脚本文件里,180 天有效,且没有吊销手段(payload 无 jti,服务端无黑名单,revoke_web_session* 只作用于 Cookie 会话) |
| N-258 / N260-1 | handlers/web.py:166,181,205,222 | /pendo web token 是 P0-1 后果最重的受害者:提示失败但实际已送达 → 诱导用户反复生成有效登录码 —— 详见主线 M-1 |
| N-39 / N-44 | pendo 撤销子系统 | 撤销会删除原始审计日志 → 审计记录可被用户操作抹除 —— 详见主线 M-11 |
| P-5 | ads_paper | 存储层按 user_id 认真隔离,展示层完全不隔离 → 群里 /paper writing 泄露未发表论文的写作思路 —— 属主线 M-6 |
| J-1 | codex/config.py:10 | 把开发者个人路径硬编码为出厂默认(DEFAULT_CWD) |
| P-2 | ads_paper | 用户输入未转义拼进 ADS 查询语法(author / bibcode / citations / related 四处),而同插件 _daily_topic_query 做了 \ 与 " 转义 |
| N159-1 | pages/events.js | 三个筛选器把下拉值原样写进 _state.filters 发给后端,而文件顶部就定义了白名单集合且归一化函数已实现。对照 tasks.js 每个回调都先过白名单——同一份代码库、同一种控件,一页校验一页不校验 |
D · 事件循环与吞吐
| 编号 | 位置 | 问题 |
|---|---|---|
| P1-1 | core/config.py | 配置监视器每 2 秒把两个 JSON 完整解析 6 遍,永不停止。缺 stat 预检 |
| P1-2 | core/dispatcher.py AdjustableSemaphore | 每次释放唤醒全部等待者(惊群)。应改 FIFO |
| P1-3 | parse_bounded_json | 纯 Python 逐字符扫描,且对同一份数据走三遍 |
| P1-4 | 入站事件 | 被 pydantic 校验两次,且被完整 dump 一次 |
| P1-5 | 插件上下文 | 每次事件都重建插件作用域配置视图 |
| P1-6 | BoundedFileCache | 每次访问都全目录扫描 |
| P1-7 | core/safe_http.py | 每一跳新建 TCPConnector + ClientSession |
| PM-1 | plugin_manager._capture_plugin_snapshot | 插件 watcher 无廉价预检:每轮 2 次全树遍历 + 347 文件 / 3.66 MB 全量哈希。轮询下限 0.01 秒,而文档给运维的示例值是 2.0 秒 |
| PM-2 | plugin_manager 四条重路径 | 为"别阻塞事件循环"写的异步快照只用在最轻的检测路径,四条重路径仍在循环上同步读+哈希,且检测算出的指纹被丢弃 —— 属主线 M-3 |
| PM-5 | _plugin_module_aliases | 每次插件加载对 sys.modules 全量 stat() 两遍(约 2 000~4 000 次系统调用);缓存键含 stat 身份故永远省不掉那次 syscall;到上限直接 clear() |
| N-170 | pendo | 约 10 处事件循环上的同步数据库读取。盲区来源:now_in_timezone 这种名字看不出会读库的辅助函数 |
| N-112 / N114-1 | db.py:3798-3865 | 每分钟两次跨用户全表扫描且无索引支撑;同插件的 scheduled_delivery_outbox 已有正确形态(next_attempt_at + 索引) |
| N-104 | operation_logs | 零索引,撤销与清理路径反复全表扫描 |
| N-157 / N160-1 | pendo get_items | 缓存写入发生在最底层的通用读方法里,既服务"翻一页"也服务批量遍历,无法区分 → 缓存污染。缓存决策必须由调用方声明 |
| N-231 | pendo 小组件端点 | 三份独立的全量物化循环,无日期/数量收窄,被高频端点串联调用 |
| O-75 | xiaoqing_chat | 写路径全部 to_thread,读路径全在事件循环上——而全库重算恰好发生在读路径 |
| O-121 | xiaoqing_chat | 每发/收一个表情,下一次回复就把整个表情库重新读盘并 sha256,全在事件循环上 |
| O-74 | xiaoqing_chat | 人物事实抽取的"每 20 条"节流门,在会话满 200 条后永久失效为"每条都跑" |
| AP-4 | core/app.py:815 | _start_runtime 的所有权重试是无界 sleep(0) 自旋,另两处 continue 连 sleep(0) 都没有 |
| M2-2 | qingpet | 单连接 + 全局 RLock 使 WAL 失效,构成吞吐天花板 |
E · 兼容性、打包与降级
| 编号 | 位置 | 问题 |
|---|---|---|
| P0-3 | core | 两道硬版本墙让项目无法跟随运行时升级。应改为能力探测 |
| PM-6 | _mark_restore_specs_initializing | 依赖 CPython 私有的 ModuleSpec._initializing,失效是哑的(写入永远成功,读取方若消失不会有任何异常)。必须补启动期自检 |
| N-116 | pendo | prune 掉的 scripts/ 里存放了生产环境可能需要执行的逻辑(见 A 类) |
| P-3 | ads_paper | 远端故障显示成"没有结果":_search_docs 吞掉全部异常返回 [],public_error_message 的返回值被丢弃;token 过期与"确实没论文"文案相同 |
| F1-5 | arxiv_filter | 模型路径写错一个字母会静默回退到另一个模型继续出结果,只打一条 WARNING。回退应只在配置未显式指定时启用 |
| PM-4 | _load_definition | 把八种原因压成同一个 None → 依赖装到一半(pip install -U 窗口期)时插件被卸载,而日志说"manifest 无效" |
| PM-3 | _shutdown_plugin_instance | 关停超时在日志里没有原因(str(TimeoutError()) 为空 → Plugin xxx shutdown error: );deadline 过期时 timeout=0,shutdown 一行不执行,日志同样只有那句空原因 |
| AP-2 | _apply_config_impl | 裸 float()/int() 让配置热更新半途失败,留下混合代际(watcher 已用新配置、并发度和会话超时还是旧值)。同一批数值键在同一文件里有三套读法 |
| AP-3 | _reschedule / _notify_change | 排程 handler 缺失的"全有或全无"是刻意设计且有测试;但它经 _notify_change 被吞成一句不含插件名的 warning,且旧代任务不撤下(强引用退休代模块、照常触发、打在关闭闸上只记 debug)→ 定时任务静默死亡。这两段没有任何测试 |
F · 工程门禁(完整清单见第四部分「tests/ 与工程门禁」)
| 编号 | 问题 |
|---|---|
| TS-1 | mypy 在 CI 里跑,但 core 69% 的行被 exclude 排除(10 个文件:app / plugin_manager / dispatcher / server / onebot / config / safe_http / bounded_http / durable_fanout / logging_config)——正是本轮缺陷最集中的十个。守门的测试只保证豁免条目数 ≤ 59 |
| TS-2 | 覆盖率门槛 fail_under = 50 被一条测试断言,而 CI 从不执行覆盖率(workflow 无 --cov) |
| TS-3 | test_project_tmp_root_isolated_across_xdist_workers 在任何自动化配置下都不会真正运行:CI 无 -n → 8 个全 skip;无 xdist → 8 个 error |
| TS-4 | 9 052 行前端契约测试的存亡取决于运行机器恰好装了 node,而 workflow 从未声明这个依赖 |
| TS-5 | CI 只跑 ubuntu-latest,Windows 进程树回收 / taskkill / PowerShell 监控脚本测试全部跳过;而主要开发平台是 Windows |
| TS-6 | 当前工作树不是绿的:2 failed / 5598 passed / 8 errors。其中一条隐私回归测试因替身与生产调用形状漂移而红——它要保护的性质此刻没有被验证 |
四、按模块的完整问题清单
1 240 条,按模块 → 严重度 → 发现顺序排列。
每个模块先给索引表(该模块全部条目),再给详述—— 详述保留原始审查记录里的完整推导:代码引用、失效路径、影响面核对与修法。
说明:
- 编号后带 † 的条目有完整推导,见该模块的「详述」小节; 其余条目在审查时即记为单条结论,索引表里的那一行就是全部内容。 1 240 条中 366 条有完整推导(约 11 000 行),集中在 core 与两个大插件。
- 「位置」列只在原始记录给出了明确文件行号时填写;留
—表示位置写在「问题」列里, 或该条是跨文件的观察。 - 属于第二部分某条主线的条目仍列在这里(便于按模块查全), 但不重复主线的分析——要理解它为什么发生,回看对应主线。
- 少数条目的推导写于结论被修订之前,这类条目在详述开头用引用块给出最终结论, 正文推导保留原样以便追溯证据。
- 编号沿用原始记录,便于与 git 历史、既有 issue 对照。
PM-=core/plugin_manager.py,AP-=core/app.py,TS-=tests/,BODY-= 原报告正文条目(因编号冲突而重编)。
core/app.py(9 条,其中 9 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| AP-1 † | P1 | — | core 把「插件目录名」当成授权令牌——正文 S-1 数的四份名单实际是七处,app.py 独占三处 |
| AP-2 † | 中 | — | 同一批数值配置在同一个文件里有三套读法;_apply_config_impl 里的裸 float()/int() 让热更新**半途失败 |
| AP-3 † | 中 | — | 同一个错误——排程 handler 不存在——在启动路径上让整个应用起不来,在 reload 路径上只留一句不含插件名的 warning,且旧任务不会被撤下 |
| AP-4 † | 中 | — | 启动期的所有权重试是无界 sleep(0) 自旋 |
| AP-5 † | 中 | — | core 直接按名字反射调用某个插件的函数,而 manifest 的 services 机制正是为此存在 |
| AP-6 † | 低 | — | 日志字段 message_length 记的是被截断到 220 的预览长度,而且预览算完就扔了 |
| AP-7 † | 低 | — | default_group_ids 的 int() 在两条路径上都没有防护,抛到两个不同的地方 |
| AP-8 † | 低 | — | 启动期每个插件的排程被注册两遍,且 scheduler 是在插件加载中途被隐式启动的 |
| AP-9 † | 低 | — | 会话清理循环先睡 60 秒再干活 |
详述(9 条,† 标记的条目)
AP-1 [P1] core 把「插件目录名」当成授权令牌——正文 S-1 数的四份名单实际是七处,app.py 独占三处
最终结论:名单为 7 处(正文原记 4 处)。见主线 M-5。以下推导保留原始分析,与该结论有出入处以本行为准。
dispatcher.py 与 plugin_manager.py 占四处(见主线 M-5 的完整表), app.py 另占三处,其中两处是能力授予:
# _build_plugin_capabilities:2087
if plugin_name == "bot_core" and is_bot_admin and principal.is_private:
secret_admin = SecretAdminService(..., _writer=self.config_manager.update_secret)
# :2099
if plugin_name == "xiaoqing_chat":
onebot_media = OneBotMediaService(self._request_onebot_action)
# :2103
if plugin_name == "pendo":
config_subscription = ConfigSubscriptionService(subscribe)
# :2116
if plugin_name == "arxiv_filter" and (is_system or is_bot_admin):
codex_arxiv_summary = CodexArxivSummaryService(...)
# :2128
if plugin_name == "smalltalk":
voice_synthesis = VoiceSynthesisService(...)
chat_reply = ChatReplyService(...)
# _notify_outgoing_action_observers:1626
loaded = self.plugin_manager.get("xiaoqing_chat")
observer = getattr(getattr(loaded, "module", None), "observe_outgoing_action", None)
# _enqueue_codex_arxiv_summary:2066
caller_plugin="arxiv_filter", service_name="codex.enqueue_arxiv_summary"先说清楚哪一半是做对的:这些能力的接口面收得很紧。 OneBotMediaService(capabilities.py:49-87)只暴露 get_msg 与 get_image 两个动作, 参数逐个校验、互斥、拒空;SecretAdminService 还额外要求 is_bot_admin and principal.is_private 并在调用时用 _authorized 闭包再查一次。 所以这不是"某个插件能对 OneBot 为所欲为"。
错的是授予条件本身:plugin_name == "字面量"。后果有三条:
- 改目录名 = 静默丢能力。
if不匹配就是不匹配,没有任何日志、没有异常。 把plugins/smalltalk/改名后,语音合成和 chat 回落静默消失, 表现为"这个功能有时候不工作",排查要一路读到 core。 - 授权不可审计。 运维想知道"哪些插件有哪些特权",唯一办法是读
core/app.py源码。plugin.json里看不到任何痕迹——而 manifest 已经有services[].callers/services[].required_capability字段(models.py:_SERVICE_CONTRACTS) 专门表达这件事。 - 与
_TRUSTED_ADMIN_TIMEOUT_EXEMPT_PLUGINS合起来,目录名就是权限。plugin_manager.py:185那条没有第二道闸:目录名叫shell就拿到无限执行预算。 本组三处里,onebot_media与config_subscription也没有第二道闸 (只看名字)。
因此完整的结论是:
core 里一共有七处按插件名分支的逻辑,其中五处决定安全或能力边界。 这是本仓「manifest 已经能表达的策略却写死在 Python 里」这一模式最集中的一处, 而且它就在 core 自己身上。
这条与报告结尾那句一句话总结是同一件事的两面: core 造了声明式的服务/能力契约(_SERVICE_CONTRACTS), 然后在给自己接线时绕过了它。
AP-2 [中] 同一批数值配置在同一个文件里有三套读法;_apply_config_impl 里的裸 float()/int() 让热更新**半途失败
同一个文件里三种纪律并存:
| 纪律 | 位置 |
|---|---|
| 完整防御(类型/bool/NaN/负数/异常全兜底 + 回默认) | _plugin_watch_poll_interval:450、_parse_ws_queue_size:2718、_connect_notification_min_interval:1457、_plugin_shutdown_budget_seconds:1222 |
裸 float() / int(),无 try | __init__:332(plugin_poll_interval)、:348(session_timeout)、:356(max_concurrency)、_start_runtime:789/792、_apply_config_impl:2565/2568 |
第三套在 plugin_manager._parse_poll_interval:1569(返回 None → 调用方回默认,下限 0.01) | — |
plugin_poll_interval 一个键就有三个不同的解析器:__init__:332 裸 float()、 _plugin_watch_poll_interval:450 全防御回 3600、_parse_poll_interval:1569 回 None 且下限 0.01。
真正有后果的是 _apply_config_impl:
# :2562-2576
self.dispatcher.refresh_prefix_cache() # ← 已生效
self._configure_plugin_execution(config) # ← 已生效
self._configure_plugin_watch(config) # ← 已生效
session_timeout = float(config.get("session_timeout", DEFAULT_SESSION_TIMEOUT_SEC)) # ← 这里抛
self.session_manager.set_default_timeout(session_timeout)
concurrency = int(config.get("max_concurrency", DEFAULT_MAX_CONCURRENCY)) # ← 或这里抛config.json 里把 session_timeout 写成 "5m",热更新会在前三项已经生效之后抛 ValueError,于是运行时进入一个混合代际:watcher 用了新配置、并发度和会话超时用的是旧值。 异常沿 _apply_config → ApplicationLifecycleFatalError 之外的普通路径抛回 ConfigManager 的 reload 回调。配置应用不是原子的,而这个文件在别的地方 (_ConfigApplyOwner 那一整套代际所有权)为原子性下了极大功夫—— 在最简单的标量赋值这一段反而没有先解析后提交。
同样的值在 __init__:348/356 会直接让进程起不来,且 ValueError: could not convert string to float: '5m' 不带键名,运维要靠 traceback 行号猜是哪个配置项。
修法:把这三种纪律统一成一个 _coerce_number(config, key, default, *, minimum=..., maximum=...), 先把本轮要用的全部标量解析完(失败即整轮拒绝 + 指名键名),再统一提交。
AP-3 [中] 同一个错误——排程 handler 不存在——在启动路径上让整个应用起不来,在 reload 路径上只留一句不含插件名的 warning,且旧任务不会被撤下
# _reschedule:3256-3261
handler = getattr(loaded.module, entry.handler, None)
if not callable(handler):
raise ValueError(
f"plugin {loaded.definition.name!r} schedule handler {entry.handler!r} "
"is missing or not callable"
)两条调用路径:
路径 A — 启动:_start_runtime:805 直接 self._reschedule("startup"),没有 try。 prefix = "plugin.",target_plugins 是全部插件。任意一个插件的 plugin.json 里排程 handler 名字打错一个字母 → ValueError → _start_runtime 抛出 → start() 走完整回滚 → 整个 bot 起不来。 而且因为 jobs 列表是先全部构建再一次性 replace_prefix, 其余 28 个插件的排程一条也不会被注册。
路径 B — reload / 单插件加载:plugin_manager._notify_change:1730-1735
for handler in self._change_handlers:
try:
handler(name)
except Exception as exc:
logger.warning("Plugin change handler failed: %s", exc)同一个 ValueError 被吞成一句 warning。这句 warning: 不含插件名(name 在 _reschedule 内部,异常文本里有插件名但日志前缀没有)、 不说明"排程未注册"、级别是 warning 不是 error。
更要命的是第二层后果:replace_prefix 没有被调用,所以这个插件上一代的排程任务仍留在 scheduler 里,且 functools.partial 捕获着旧代的 handler 和 loaded。 于是:① 退休代的模块对象被 scheduler 强引用,无法回收; ② 任务照常触发,invoke_loaded_plugin(loaded, ...) 打在已关闭的执行闸上, 抛 PluginExecutionClosed,被 _run_job:3344 记成 debug 一行。 最终现象是:定时任务悄无声息地停止工作,三层日志一层比一层安静。
顺带:replace_prefix:482-483 会拒绝重复 job_id,而 job_id 是 f"plugin.{name}.{entry.id or entry.handler}"——同一插件里两条排程共用一个 handler 又都不写 id,就会撞上同一个 ValueError,落进上面同样的两条路径。
修法:_reschedule 内部按插件隔离(一个插件构建失败只跳过它自己并 replace_prefix(f"plugin.{name}.", []) 显式撤下它的旧任务), 错误以 logger.error 带插件名和 handler 名上报; _notify_change 的 warning 至少要带上 name。
AP-4 [中] 启动期的所有权重试是无界 sleep(0) 自旋
# _start_runtime:815-820
while not self._stopping:
startup_snapshot = self._latest_startup_snapshot()
owner = self._claim_or_reuse_startup_owner(startup_snapshot)
if owner is None:
await asyncio.sleep(0)
continuesleep(0) 只让出一次调度,不休眠。若配置侧持续发布新 revision(配置文件被反复写、 或某个测试替身每次 snapshot() 都返回新对象且不等值),这个循环会跑满一个核, 且没有迭代上限、没有超时、没有任何日志——外部只能观察到"启动卡住且 CPU 100%"。
同一函数下方还有两个 continue(:828、:852),它们连 sleep(0) 都没有, 直接回到循环顶部,是更紧的忙等。
注释把设计意图写得很清楚("retry until one owner survives every await and publication boundary"),意图正确,缺的是收敛保护:至少要有一个尝试次数上限或截止时间, 超过后记 error 并 fail-closed,而不是无限期地占着 CPU 装作"正在启动"。
AP-5 [中] core 直接按名字反射调用某个插件的函数,而 manifest 的 services 机制正是为此存在
# _notify_outgoing_action_observers:1626-1648
loaded = self.plugin_manager.get("xiaoqing_chat")
observer = getattr(getattr(loaded, "module", None), "observe_outgoing_action", None)
if observer is None:
return
...
await invoke_loaded_plugin(loaded, run_observer)这是 R-1 里最"裸"的一处:不只是按名字授予能力,而是按名字 + 按属性名反射调用。 它在每一条成功外发的群聊/私聊消息之后执行(_send_action:1588)。
问题:
- 插件改名或把函数改名 →
observer is None→ 直接return,没有任何日志。 一个每条消息都跑的观察者静默停止工作。 - 契约不可见:
xiaoqing_chat/plugin.json里没有任何字段说明"我提供 observe_outgoing_action"。 - 与同文件里
_invoke_declared_service(:2007,走plugin_manager.resolve_service+_SERVICE_CONTRACTS白名单 +granted_capabilities)形成鲜明对比: 同一个文件里,跨插件调用有一条严谨的声明式路径,和一条按字符串反射的后门路径。
修法:定义一个 outgoing_action_observer 服务契约,插件在 manifest 里声明, core 通过 resolve_service 拿;查不到就在 debug 记一行"无观察者", 名字对不上时能立刻看出来。
AP-6 [低] 日志字段 message_length 记的是被截断到 220 的预览长度,而且预览算完就扔了
# _send_single_action:1700-1712
preview = _extract_message_preview(msg[:12]).replace("\n", "\\n").strip()
if len(preview) > MAX_MESSAGE_PREVIEW_LENGTH: # = 220
preview = preview[: MAX_MESSAGE_PREVIEW_LENGTH - 1] + "…"
logger.info("Sending: action=%s group=%s user=%s message_length=%s",
act, ..., len(preview))三件事同时不对:
- 字段名叫
message_length,值是预览的长度——而且是截断后的, 所以任何超过 220 字符的消息都记成220。想从日志统计消息长度分布的人会得到一条 全是 220 的直方图。 - 只取前 12 个段(
msg[:12]),后面的段完全不计入。 preview这个变量除了取长度以外没有任何用途——_extract_message_preview的调用、replace、strip、截断,全部是为了得到一个 被截断的长度。既然日志里不打印内容(这是对的,避免消息内容进日志,符合 O-114 的 脱敏纪律),就不该走整条预览提取。
修法:要么直接算真实长度(sum(len(str(seg)) ...) 或段数), 要么把字段改名为 preview_length 并说明它是有上限的。前者更有用。
AP-7 [低] default_group_ids 的 int() 在两条路径上都没有防护,抛到两个不同的地方
# _on_ws_connected:1451
"group_id": int(group_id), # 直接在 WS 连接成功回调里
# _run_job:3296
delivery_targets = tuple(DeliveryTarget("group", int(value)) for value in raw_target_groups)default_group_ids 里混进一个字符串 "12345 "(带空格)或 null: 前者在每次 WS 重连时抛进 _on_ws_connected,后者在每次定时任务触发时 抛进 APScheduler 的作业执行器(_run_job:3296 在 try 之前,:3306 才开始 try)。 两处都不会指出是哪个配置项、哪个元素。
同一文件里 _parse_admin_user_ids:161 是这类解析的正确样板: 显式 type() is int / 十进制字符串、拒绝 bool、拒绝非正数、 要么整份接受要么整份拒绝("reject it without partial grants")。 default_group_ids 应该复用同一个函数。
AP-8 [低] 启动期每个插件的排程被注册两遍,且 scheduler 是在插件加载中途被隐式启动的
_start_runtime:
self.plugin_manager.load_all() # :802 → 每个插件 _notify_change → _reschedule(name)
# → replace_prefix → ensure_started()
await self.plugin_manager.wait_inits()
self.scheduler.ensure_started() # :804 ← 到这里其实早就启动了
self._reschedule("startup") # :805 ← 把全部插件的排程再注册一遍结果幂等(replace_prefix 是原子替换),但有两个副作用: scheduler 线程实际是在第一个插件发布时被隐式拉起的,早于第 804 行那句看起来 "负责启动它"的代码;以及 N 个插件在启动期触发 N+1 次全量排程重建。 把 on_change 回调在 load_all() 期间挂起、启动末尾统一 _reschedule("startup") 一次即可。
AP-9 [低] 会话清理循环先睡 60 秒再干活
# _cleanup_sessions_loop:1409-1415
while True:
await asyncio.sleep(60)
await self.session_manager.cleanup_expired()进程重启后的第一分钟内不会清理任何过期会话。若上一次是崩溃退出、会话状态由持久化 恢复,这一分钟里过期会话仍可被命中。改成先清一次再进循环即可。
core/plugin_manager.py(11 条,其中 10 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| PM-1 † | 中 | — | 插件 watcher 没有廉价预检:每一轮都把全部插件源码读完并哈希,目录树还走两遍 |
| PM-2 † | 中 | core/plugin_manager.py:5133 vs 2739 / 3168 / 4693 / 3947 | 为「别阻塞事件循环」而写的 _capture_plugin_snapshot_async 只用在最轻的检测路径(2 处);首次加载 / reload 授权 / 导入前复核 / 发布前复核 4 处仍在事件循环上同步读+哈希,且检测路径算出的指纹被丢弃。一次 xiaoqing_chat(24 491 行)reload 会在循环上串行发生至少 3 次全量读+哈希,外加一次对全部源码的 compile() |
| PM-3 † | 中 | — | 封装被穿透:manager 直接改 finder 的私有属性 |
| PM-4 † | 中 | — | _load_definition 把八种原因压成同一个 None,调用方于是把依赖缺失当成"manifest 无效"并卸载插件 |
| PM-5 † | 中 | — | 别名扫描对 sys.modules 里每个带 __file__ 的模块 stat() 一次,每次加载调两次,全在事件循环上 |
| PM-6 † | 中 | — | 恢复事务依赖 CPython 私有的 ModuleSpec._initializing,而且它失效时是哑的 |
| BODY-Q1 | 中 | core/plugin_manager.py(6059) / core/app.py(3347) | 两个巨型模块是本仓最大的可维护性负债:单文件超过 3 000 行、职责跨越导入事务 / 生命周期 / 配置发布 / 投递 / 排程。建议按职责拆分(导入事务、代际发布、watcher / 凭据发布、ingress 重协商、投递、排程) |
| PM-7 † | 低 | — | 每条消息的分发路径上有 2 次 lstat() |
| PM-8 † | 低 | — | 两处各自实现"init 是否接受 context"的探测,都会误判 **kwargs,且错误捕获语义不同 |
| PM-9 † | 低 | — | shutdown 上下文可能拿到一个"用完即弃"的空 state |
| PM-10 † | 低 | — | 插件根目录下的任意 .json 都进授权指纹,文档没写;附带一处未被注释的正面设计 |
详述(10 条,† 标记的条目)
PM-1 [中] 插件 watcher 没有廉价预检:每一轮都把全部插件源码读完并哈希,目录树还走两遍
# _capture_plugin_snapshot:5032 —— 第一次全树遍历
paths = sorted(self._iter_watch_files(plugin_dir, definition), ...)
...
# :5100 —— 读完之后再走一次,确认文件集没变
final_paths = sorted(self._iter_watch_files(plugin_dir, definition), ...)_iter_watch_files(:5142)对每个插件做一次完整 os.scandir 下降 + 每个条目 一次 lstat() + 每个入选文件一次 resolve(strict=True);_capture_plugin_snapshot 随后把每个文件整读一遍喂给 blake2b。没有任何 mtime/size 预检。
实测规模:plugins/ 下 347 个 .py/.json、3.66 MB 源码、29 个插件。 于是每一轮 reconcile 的固定成本是:2 次全树遍历 + 347 次 open/read/fstat + 3.66 MB 哈希, 无论有没有文件变过。
轮询下限是 _MIN_PLUGIN_POLL_INTERVAL_SECONDS = 0.01(:201)——形同没有下限; 而 docs/06-configuration.md:289 给运维的示例值就是 {"plugin_poll_interval": 2.0}。 按文档示例部署,这台机器会持续每 2 秒重读并重哈希 3.66 MB。
这与正文 P1-1(配置监视器缺 stat 预检)是同一类问题,但体量大三个数量级: 配置监视器 stat 的是几个文件,这里是整棵源码树。
修法:先用 (st_dev, st_ino, st_size, st_mtime_ns) 组成的廉价指纹逐文件比对 ——这个元组 _watch_file_identity(:5124)已经写好了——只有元组变化时才读内容做 内容指纹。第二次遍历可以直接复用第一次的 dirent 结果,只重新 lstat 不重新 scandir。
PM-2 [中] 为「别阻塞事件循环」而写的 _capture_plugin_snapshot_async 只用在最轻的检测路径(2 处);首次加载 / reload 授权 / 导入前复核 / 发布前复核 4 处仍在事件循环上同步读+哈希,且检测路径算出的指纹被丢弃。一次 xiaoqing_chat(24 491 行)reload 会在循环上串行发生至少 3 次全量读+哈希,外加一次对全部源码的 compile()
位置:core/plugin_manager.py:5133 vs 2739 / 3168 / 4693 / 3947
# :5133
async def _capture_plugin_snapshot_async(self, plugin_dir, definition):
"""在线程中捕获完整内容快照,避免监控循环被文件 I/O 阻塞。"""
return await asyncio.to_thread(self._capture_plugin_snapshot, plugin_dir, definition)全仓调用点分布:
| 路径 | 调用 | 是否离开事件循环 |
|---|---|---|
| 检测(reconcile 比对) | _reconcile_plugin_path:4255 | ✅ to_thread |
| 首次加载 | load_plugin:2739 → _authorize_plugin_snapshot | ❌ |
| reload 授权 | _prepare_reload_authorization:3168 | ❌ |
| 导入前复核 | _prepare_module_load:4693 | ❌ |
| 发布前复核 | _definition_is_current:3947(由 _register_loaded_plugin:5280 走到) | ❌ |
而 4255 那次唯一在线程里算出来的指纹,算完只用于与旧代比较,随后被丢弃; 真正要用指纹的四个地方全部重算。
后果具体化:一次 xiaoqing_chat(24 491 行)的 reload,在事件循环上串行发生 至少 3 次全量读+哈希,外加 _prepare_module_load:4720-4727 对全部源码的一次 compile()。整个过程中 bot 不处理任何消息。
同一个函数内部的纪律还自相矛盾:_reconcile_plugin_path 把 _load_definition (:4175)和 resolve_plugin_entry(:4210)都老老实实 to_thread 了, 紧接着第 4281 行直接同步调用 self.load_plugin(plugin_dir)—— 而 load_plugin 内部把上面全部重来一遍,还加上导入。
这是主线 N101-2("正确实现已经在仓库内,调用点却不用",插件层已确认 46 处) 第一次落在 core 自身上。该主线此前的证据全部来自插件层;这一条说明 core 内部同样存在, 因此它是全仓性的,而不是插件层特有的。
PM-3 [中] 封装被穿透:manager 直接改 finder 的私有属性
# :3867-3876
shutdown_timeout = PLUGIN_INIT_TIMEOUT_SECONDS
if shutdown_deadline is not None:
shutdown_timeout = min(shutdown_timeout, max(0.0, shutdown_deadline - time.monotonic()))
await asyncio.wait_for(invoke_loaded_plugin(plugin, run_shutdown, allow_closed=True),
timeout=shutdown_timeout)
...
# :3881-3883
except Exception as exc:
logger.warning("Plugin %s shutdown error: %s", name, exc)asyncio.wait_for 超时抛的是 TimeoutError(),它的 str() 是空字符串。 于是这条日志实际输出是:
WARNING Plugin xiaoqing_chat shutdown error:冒号后面什么都没有。运维拿到这行日志无法区分"超时"、"插件抛了个空消息的异常"、 "底层 I/O 错误"。
叠加第二层:当 shutdown_deadline 已经过去,shutdown_timeout 被压成 0.0, asyncio.wait_for(..., timeout=0) 直接取消协程——插件的 shutdown() 一行都不会执行, shutdown_completed 置 False,日志仍然只有上面那句空原因。这是应用停机路径上 最常见的一种情况,也是最需要解释的一种。
修法:except TimeoutError 单列一支,把 shutdown_timeout 与 shutdown_deadline 的剩余量一起打进日志;timeout <= 0 时应显式记录 "deadline already elapsed, shutdown skipped" 而不是走通用异常分支。
PM-4 [中] _load_definition 把八种原因压成同一个 None,调用方于是把依赖缺失当成"manifest 无效"并卸载插件
_load_definition(:4353-4520)在下列情况一律返回 None:
plugin.json缺失(:4376)- 路径不安全 / 越出插件根(:4385)
- 读到的不是稳定快照(:4422,ABA 保护触发)
- 超过
_MAX_PLUGIN_MANIFEST_BYTES(:4432) - JSON 语法错(:4443)
- schema 校验不过(:4458)
- 必需的 Python 依赖不可导入(:4471,
_dependencies_available返回 False) - manifest name 与目录名不符(:4485)
调用方只能看到 None:
# _reconcile_plugin_path:4191-4200
if definition is None:
if self._has_plugin_runtime(directory_name):
logger.warning("Unloading plugin %s because its manifest is unavailable or invalid",
directory_name)
await self._unload_plugin_once(directory_name)后果:pip install -U somepkg 期间某个包短暂不可导入(卸载旧版与装新版之间有真实窗口), 恰好被 watcher 撞上,插件被卸载,而日志说的是"manifest 不可用或无效"。 运维会去检查 plugin.json,那里什么问题都没有。第 7 种原因与第 5、6 种的正确处置 方向完全相反:依赖缺失应该是"保留旧代、下一轮重试",manifest 语法错才该卸载。
讽刺的是这个函数内部已经把原因分好桶了——_log_manifest_issue 的 bucket 参数 取值为 missing / invalid / read / dependency / optional-dependency (:4377、:4386、:4396、:4448、:4461、:4475、:4533、:4543、:4487)—— 桶信息只用于日志限流,没有一个字节传给调用方。
修法:返回 PluginDefinition | _ManifestRejection,让 _ManifestRejection 带上 bucket;_reconcile_plugin_path 对 dependency / read 走"保留旧代 + 记录 + 下轮重试", 只对 invalid / missing / 名字不符走卸载。
PM-5 [中] 别名扫描对 sys.modules 里每个带 __file__ 的模块 stat() 一次,每次加载调两次,全在事件循环上
| 位置 | 问题 |
|---|---|
core/plugin_base.py:236-241,264 | split_message_segments 内部用局部变量 text 遮蔽了模块导出的 text() 消息段构造器 |
core/message.py:238 | is_only_bot_name 用精确大小写比较,而 contains_bot_name 与 compile_bot_name_pattern 都忽略大小写。"XIAOQING" 单独发被当普通消息,"XIAOQING hi" 却算被点名 |
core/message.py:236 | has_command_prefix 对未剥离的原文判断,"@bot /help" 会得到 False |
core/async_keyed_lock.py:71,82 | finally 块里 raise RuntimeError 会掩盖原始异常,并跳过其后的 entry.users -= 1,永久泄漏该 entry |
core/dispatcher.py:673 | PluginExecutionClosed/Timeout/Unavailable 分支不记录 metrics,熔断风暴在统计里不可见 |
core/dispatcher.py:458,489,544 | 同一个 session peek 分散在三处、三套不同的守卫条件,"最多 peek 一次"这个不变量无法从任何单点读出 |
core/dispatcher.py:1058 | _call_provider 用 None/[] 区分"provider 不可用"与"provider 选择静默",但 return result if result else [] 把 provider 返回 None 也折叠成静默。插件用 return None 表达"我不处理这条"时不会触发 smalltalk 回落 / default_response。语义可辩护(静默更安全),但应在 docs/03-plugin-development.md 里写明"provider 返回 None 与 [] 等价",否则插件作者会误以为能靠 None 触发回落 |
core/config.py:200 | model_dump(exclude_none=True) 会静默丢弃显式写成 null 的配置项 |
core/logging_config.py:66 | def __init__(self, fmt: str = None, ...) 类型标注应为 str | None |
core/metrics.py:295 | get_metrics_collector() 的惰性全局初始化非线程安全 |
core/onebot.py:271 | _finalize_onebot_action 往调用方的 action 字典里注入 _result_message_id,而 strip_receipt 只清 _delivery_receipt |
core/onebot.py:509 / core/server.py:274 | 会话队列 / dispatcher lane 按用户键无上限增长,只靠 TTL 回收;AsyncKeyedLockPool 已经示范了 max_keys 的正确做法 |
core/server.py:1558 | WS worker 被取消后不会被补上,直到下一条帧到达才触发 _ensure_ws_workers |
plugins/smalltalk/main.py:117 | @lru_cache(maxsize=2048) 缓存持有 asyncio.Lock 的快照对象;LRU 淘汰可能在有协程持锁时丢弃条目,下次调用会创建新锁指向同一文件 → 丢失更新 |
PM-6 [中] 恢复事务依赖 CPython 私有的 ModuleSpec._initializing,而且它失效时是哑的
# _mark_restore_specs_initializing:5749-5765
if type(original_spec) is importlib.machinery.ModuleSpec:
spec_namespace = vars(original_spec)
...
spec_namespace["_initializing"] = True
...
temporary_spec = importlib.machinery.ModuleSpec(module_name, loader=None)
temporary_spec._initializing = True用途是正当的:让并发 import 在恢复事务提交期间进入 CPython 的 "模块正在初始化"等待路径,从而看不到半发布状态。docstring 也写清楚了。
问题在失效模式。_initializing 是 CPython 导入实现的私有属性。 写入永远会成功(普通 dict 赋值 / 普通属性赋值),所以如果未来的 CPython 不再读这个属性,这里不会抛任何异常、不会有任何日志——只是并发导入的互斥 保护无声消失,退化成半发布状态可被观察。
对照正文 P0-3(两道版本墙):那两处至少会显式失败并拒绝启动;这一处不会。 这是本仓少见的"哑失效"型兼容性依赖。
修法:启动期做一次自检——构造一个设了 _initializing = True 的 spec, 从另一个线程发起对它的导入,确认确实被阻塞;自检不过就让 _restore_generation_modules 直接拒绝恢复(fail closed), 而不是在没有互斥保护的情况下假装事务成立。
PM-7 [低] 每条消息的分发路径上有 2 次 lstat()
dispatcher._build_event_context:611(每事件、每插件) → build_context:6042 → _ensure_plugin_data_dir:5988 → _verify_data_directory_record:5971:
current_root = record.plugin_root.lstat()
current_data = record.path.lstat()安全语义是对的(防止 data/ 在运行期被换成软链或换成别的 inode), 但这是同步系统调用落在事件循环的热路径上,且群聊里一条消息可能要为 多个插件各建一次上下文。
修法:按代际节流——_data_directories 的记录里加一个校验代际号, 只有 watcher 轮次推进或收到显式失效信号时才重新 lstat,同一轮内复用结论。
PM-8 [低] 两处各自实现"init 是否接受 context"的探测,都会误判 **kwargs,且错误捕获语义不同
# _initialize_plugin_instance:3813(恢复路径)
if len(inspect.signature(init_func).parameters) > 0:
await call_plugin_callback(init_func, context)
# _start_plugin_init:4895(正常路径)
accepts_context = len(inspect.signature(init_func).parameters) > 0async def init(**kwargs) 或 def init(*, verbose=False) 的 parameters 长度是 1,会被判成"接受 context",然后按位置传参 → TypeError → PluginLoadError("Failed to load plugin")。插件作者拿到的报错与"我的 init 里 有个真 bug"完全无法区分。正确的判定是看是否存在 POSITIONAL_ONLY / POSITIONAL_OR_KEYWORD 参数。
两处还有一个不对称:正常路径把 init 包进 self._capture_lifecycle(...)(:4917), 恢复路径没有(:3819 直接 await asyncio.wait_for(gate.run(run_init), ...))。 同一个操作在两条路径上有两种生命周期错误捕获语义,而恢复路径恰恰是更需要 把错误收进 _deferred_lifecycle_errors 的那条。
PM-9 [低] shutdown 上下文可能拿到一个"用完即弃"的空 state
# :3855
self._plugin_states.get(name, {}) if state is None else statebuild_context 用的是 setdefault(:6045),拿到的是登记在管理器里的那个 dict; 这里用的是 get 加临时默认值。若 state 已在退休流程中被 pop,插件 shutdown() 里对 context.state 的任何写入都落进一个临时 dict 并被丢弃,插件无从察觉。
与已确认的 B-0(context.state 是跨事件共享的 per-plugin dict,不是每消息新建) 放在一起看:同一个对象在生命周期两端有两种语义——正常期是共享持久 dict, 关停期可能是一次性空 dict。插件若在 shutdown 里做"把内存状态写回 state 供下一代读" 这种事,会静默失败。
PM-10 [低] 插件根目录下的任意 .json 都进授权指纹,文档没写;附带一处未被注释的正面设计
# _iter_watch_files:5200-5202
watched_json = path.suffix == ".json" and (
root_path == plugin_root or relative in explicit_watch_files
)docs/06-configuration.md 关于 watcher 的说明只提到 plugins/*/data/ 与 __pycache__/ 在遍历阶段被剪枝,没有说明"插件根目录下的 .json 改动会触发 reload"。
现状无害:plugins/arxiv_filter/config.json 与 plugins/minecraft/config.json 都是只读消费。但设计上留了一个自激循环:任何插件把运行时状态写进自己根目录的 .json,就会得到 reload → shutdown/init → 插件再写 → 下一轮再 reload 的循环, 而且 reload 会清掉它刚建立的状态。文档应把"根目录 .json 属于代码授权面、 运行时数据必须写 data/"写成硬规则。
附带的正面:_capture_plugin_snapshot:5082-5096 只把 .py 与 plugin.json 的内容留在 sources / manifest_payload 里,其余 watch 文件只进摘要不进内存。 于是 minecraft/config.json 里的 RCON 密码被指纹覆盖(改了会触发 reload) 却不会常驻在 LoadedPlugin.mtime.sources 中。这个区分是对的,可惜没有一行注释 说明它是刻意的——它看起来只是"因为要给 restore 留源码"的副产品。
小插件(signin/apod/wolframalpha/dict/github/guess_number)(36 条,其中 6 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A2-1 | 中 | main.py:348-360 | scheduled() 构造 event = {"message_type":"group","group_id":None,"user_id":None} 并附注释"group_id 将由定时任务框架根据 plugin.json 中的配置自动填充"。但 handle() 第 199-320 行完全没有引用过 event。这个字典是死数据,注释描述的机制不存在。误导性注释比没有注释更糟 |
| A2-2 | 中 | main.py:248 | soup.find("img") 取页面第一个 img。标题提取写了三级降级策略,图片提取一级都没有——NASA 页面加个 logo 或 banner,插件就会去下载错误的图。至少应限定在正文链接内,或校验 URL 落在 apod.nasa.gov/apod/image/ 路径下 |
| A3-1 | 中 | main.py:187 | 见 A-0:以 - 开头的表达式(/alpha -1+2)被 core.args.parse 误判为选项并拒绝。计算器插件的核心输入形态被挡掉了 |
| A3-2 | 中 | main.py:288-289 | validate_bounded_xml() 用 expat 严格校验(禁 DTD/实体、限深度节点)后返回原始 bytes,紧接着 ElementTree.fromstring(payload) 用默认设置重新解析一遍。当前安全(已确认无 DTD/实体),但 (a) 同一份 XML 解析两次纯属浪费;(b) 安全性完全押在第一遍校验的完备性上,一旦漏掉某种构造,第二遍就是无防护解析。建议让 validate_bounded_xml 直接返回已解析的树,或改用 defusedxml |
| A5-1 | 中 | main.py:375, 430 | datetime.now().astimezone() 用的是系统本地时区,而调度器用 config.timezone(core/scheduler.py:70 默认 Asia/Shanghai)。两者不一致时,08:30 的定时任务会用另一个时区的日期命名历史文件和渲染标题。部署在 UTC 容器里跑 Asia/Shanghai 调度就会出现"文件名日期比任务日期早一天" |
| A6-1 | 中 | main.py:348-374 | _parse_session_input 必须自己识别 /猜数字 status 这类完整命令,因为活跃会话优先于命令路由(core/dispatcher.py 的 Step D 在 Step E 之前)。docstring 说明了原因,但这意味着每个有会话的插件都要重复实现一次"在会话里也能认出自己的命令"——qingssh、jupyter、pendo 应该都有同样的代码。建议 core 在会话续接前先尝试解析"本插件自己的命令",把结构化结果一并传给 handle_session |
| A7-1 | 中 | — | 时区处理没有统一约定**。github 用 datetime.now().astimezone()(系统时区)、调度器用 config.timezone、smalltalk 审计用 datetime.now(timezone.utc)。三种做法并存,跨时区部署必然出错。应由 core 提供 context.now() 返回配置时区的时间,并在文档里定为唯一路径 |
| A1-1 | 低 | main.py:54 | 未知平台: {target} 把用户输入原样回显;target 长度不受本插件限制,只靠下游 split_message_segments 兜底。应截断到 32 字符 |
| A1-2 | 低 | yingshi.py:40 | 又一份手写 _get_config(见正文 R-1) |
| A1-3 | 低 | yingshi.py:90-100 | _build_extra_data 的 uuid 由硬编码前缀 xncgEoy8XBh9siy 加时间戳拼成,version 硬编码 2.210.8.101。这些是抓包得到的客户端指纹,有赞一改协议就整体失效,而失败信息只有"获取签到信息失败"。应提为模块常量并注明来源与失效征兆 |
| A1-4 | 低 | main.py:67 | scheduled_yingshi 返回消息段但不携带任何投递目标,目标完全依赖 plugin.json 的 schedule[].group_ids。这是框架约定,但插件里没有一行注释说明,读代码的人无法判断这条消息会发到哪里 |
| A2-3 | 低 | main.py:223 | images_dir.mkdir(parents=True, exist_ok=True) 是 async handler 里的同步文件系统调用,未走 run_sync。单次开销小,但与同文件其它路径(run_sync(cache.put)、to_thread(validate))的纪律不一致 |
| A2-4 | 低 | main.py:140-167 | _extract_title 用 except Exception 兜住解析失败并调 public_error_message——把一次常规降级记成完整错误诊断(含异常链 + 栈帧)。标题拿不到本来就有 DEFAULT_FALLBACK_TITLE,这里应该是 logger.debug |
| A2-5 | 低 | main.py:34-37 | User-Agent 伪装成 Chrome 77(2019 年)。要伪装就该跟上版本,否则反而更像爬虫 |
| A2-6 | 低 | main.py:113-120 | _safe_download_image 里 _allowed_hosts(context) 被调两次(直接一次、经 _require_allowed_url 一次),每次重走配置解析 |
| A2-7 | 低 | main.py:59 | 手写 _get_config(见 R-1) |
| A3-3 | 低 | main.py:187 | parsed.rest() 用单空格重新拼接 token,原始空白被规范化。/alpha integrate x^2(双空格)变单空格——对多数表达式无害,但会静默改写空白敏感的输入 |
| A3-4 | 低 | main.py:328-340 | _query_complete 的双层循环里 if len(results) == MAX_RESULT_ITEMS: break 写了两遍。用生成器 + itertools.islice 更清楚 |
| A3-5 | 低 | main.py:122 | 手写 _get_appid(见 R-1) |
| A4-1 | 低 | main.py:332-342 | 查询是对最多 50 000 条词条的线性扫描,多关键词时是 all(kw in source)。已卸载到线程池,量级也只有毫秒级;但词库变大或并发变高时需要倒排索引。属于"记一笔"而非"现在改" |
| A4-2 | 低 | main.py:395-399 | handle 的形参写成 _command / _event(下划线前缀),全仓其它 28 个插件都用 command / event。按位置传参所以不影响功能,但破坏签名一致性;用 del command, event 更贴合本仓写法 |
| A4-3 | 低 | main.py:239 | @lru_cache(maxsize=4) 会长期驻留最多 4 份完整词典(4 × 50k 条 NamedTuple)。有上限、随插件卸载释放,可接受,但应在 docstring 写明常驻内存量级 |
| A5-2 | 低 | main.py:252 | 走代理分支时 aiohttp_request_bounded 永不返回 None(失败抛异常),因此 if fetched is None 在该分支是死代码 |
| A5-3 | 低 | main.py:143-177 | _get_proxy 对格式错误抛裸 ValueError,被 handle 的通用兜底转成 public_error_response。运维只看到"操作失败,请稍后重试 + request_id",看不出是代理配错了。应抛 GitHubCommandError 并给可操作文案 |
| A5-4 | 低 | main.py:305-312 | _extract_gained_stars 对每篇文章扫最多 100 个 span 并各跑一次正则,50 篇 = 最多 5 000 次正则。已在线程池,但可先用 CSS 选择器定位到 star 区域再匹配 |
| A5-5 | 低 | main.py:438-441 | 每次运行写两份完整 JSON(latest + 当日),write_json 走 AtomicJsonStore,每份 = 读旧文件 + 写 .bak + 写正文 + 2 次 fsync(见正文 P1-8)。两份就是 6 次文件操作 + 4 次 fsync |
| A6-2 | 低 | main.py:141 | 手写 _parse_request(见 A-0) |
| A6-3 | 低 | main.py:273 | 猜数字用 secrets.randbelow 属于过度设计(random 足够),无害,且与 choice 用 random.SystemRandom() 一致。仅作风格一致性记录 |
| A7-2 | 低 | — | 回显用户输入未截断**是重复模式:signin/main.py:54、apod/main.py:213 未限长,github 则已正确限长(MAX_ARGUMENT_CHARS=64)。应统一提供 core.plugin_base.echo_safe(text, max_chars) |
| A7-3 | 低 | — | help 文本全部硬编码在插件里**,而 plugin.json 的 commands[].help / usage / examples 已被 core/router.py 的 format_command_catalog 支持。两套帮助并存且会漂移——shell 的帮助里写着"命令链接符: 已禁用",manifest 里没有对应描述。建议帮助由 catalog 生成,插件只补充示例 |
| A-1 † | 低 | — | signin(352 行) |
| A-2 † | 低 | — | apod(360 行) |
| A-3 † | 低 | — | wolframalpha(366 行) |
| A-4 † | 低 | — | dict(432 行)— 本组质量最高,建议作为范本 |
| A-5 † | 低 | — | github(442 行) |
| A-6 † | 低 | — | guess_number(474 行)— 会话 API 的参考实现 |
详述(6 条,† 标记的条目)
A-1 [低] signin(352 行)
整体:质量好。远端签到走 bounded_http + JsonLimits,第三方返回的每个 文本字段都经 _safe_text 收窄(限长 256、拒绝 bool、超长省略), manifest 声明了 concurrency: sequential——对远端签到是正确选择。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A1-1 | 低 | main.py:54 | 未知平台: {target} 把用户输入原样回显;target 长度不受本插件限制,只靠下游 split_message_segments 兜底。应截断到 32 字符 |
| A1-2 | 低 | yingshi.py:40 | 又一份手写 _get_config(见正文 R-1) |
| A1-3 | 低 | yingshi.py:90-100 | _build_extra_data 的 uuid 由硬编码前缀 xncgEoy8XBh9siy 加时间戳拼成,version 硬编码 2.210.8.101。这些是抓包得到的客户端指纹,有赞一改协议就整体失效,而失败信息只有"获取签到信息失败"。应提为模块常量并注明来源与失效征兆 |
| A1-4 | 低 | main.py:67 | scheduled_yingshi 返回消息段但不携带任何投递目标,目标完全依赖 plugin.json 的 schedule[].group_ids。这是框架约定,但插件里没有一行注释说明,读代码的人无法判断这条消息会发到哪里 |
A-2 [低] apod(360 行)
整体:安全侧做得好——强制 HTTPS、主机白名单、fetch_public_bytes 限字节 限 MIME、PIL 像素预算校验、内容寻址缓存。解析侧偏脆弱。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A2-1 | 中 | main.py:348-360 | scheduled() 构造 event = {"message_type":"group","group_id":None,"user_id":None} 并附注释"group_id 将由定时任务框架根据 plugin.json 中的配置自动填充"。但 handle() 第 199-320 行完全没有引用过 event。这个字典是死数据,注释描述的机制不存在。误导性注释比没有注释更糟 |
| A2-2 | 中 | main.py:248 | soup.find("img") 取页面第一个 img。标题提取写了三级降级策略,图片提取一级都没有——NASA 页面加个 logo 或 banner,插件就会去下载错误的图。至少应限定在正文链接内,或校验 URL 落在 apod.nasa.gov/apod/image/ 路径下 |
| A2-3 | 低 | main.py:223 | images_dir.mkdir(parents=True, exist_ok=True) 是 async handler 里的同步文件系统调用,未走 run_sync。单次开销小,但与同文件其它路径(run_sync(cache.put)、to_thread(validate))的纪律不一致 |
| A2-4 | 低 | main.py:140-167 | _extract_title 用 except Exception 兜住解析失败并调 public_error_message——把一次常规降级记成完整错误诊断(含异常链 + 栈帧)。标题拿不到本来就有 DEFAULT_FALLBACK_TITLE,这里应该是 logger.debug |
| A2-5 | 低 | main.py:34-37 | User-Agent 伪装成 Chrome 77(2019 年)。要伪装就该跟上版本,否则反而更像爬虫 |
| A2-6 | 低 | main.py:113-120 | _safe_download_image 里 _allowed_hosts(context) 被调两次(直接一次、经 _require_allowed_url 一次),每次重走配置解析 |
| A2-7 | 低 | main.py:59 | 手写 _get_config(见 R-1) |
A-3 [低] wolframalpha(366 行)
整体:三种查询模式各配独立的 MIME/JSON/XML 限制,错误分类细致 (HttpStatusError / TimeoutError / ClientError / ResponseFormatError 各有稳定文案),是错误处理的正面样板。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A3-1 | 中 | main.py:187 | 见 A-0:以 - 开头的表达式(/alpha -1+2)被 core.args.parse 误判为选项并拒绝。计算器插件的核心输入形态被挡掉了 |
| A3-2 | 中 | main.py:288-289 | validate_bounded_xml() 用 expat 严格校验(禁 DTD/实体、限深度节点)后返回原始 bytes,紧接着 ElementTree.fromstring(payload) 用默认设置重新解析一遍。当前安全(已确认无 DTD/实体),但 (a) 同一份 XML 解析两次纯属浪费;(b) 安全性完全押在第一遍校验的完备性上,一旦漏掉某种构造,第二遍就是无防护解析。建议让 validate_bounded_xml 直接返回已解析的树,或改用 defusedxml |
| A3-3 | 低 | main.py:187 | parsed.rest() 用单空格重新拼接 token,原始空白被规范化。/alpha integrate x^2(双空格)变单空格——对多数表达式无害,但会静默改写空白敏感的输入 |
| A3-4 | 低 | main.py:328-340 | _query_complete 的双层循环里 if len(results) == MAX_RESULT_ITEMS: break 写了两遍。用生成器 + itertools.islice 更清楚 |
| A3-5 | 低 | main.py:122 | 手写 _get_appid(见 R-1) |
A-4 [低] dict(432 行)— 本组质量最高,建议作为范本
整体:这是全仓最值得当范本的插件,它做对了几件别处没做的事:
- 资产完整性校验:
assets/manifest.json声明 sha256 + 字节数 + 条目数,_load_dictionary(main.py:239-286)逐项比对,任何不符都DictionaryDataError失败闭合; - 缓存代际隔离:
@lru_cache的 key 里带上文件(mtime_ns, ctime_ns, size)指纹(main.py:305-306),文件一变缓存自动失效 ——这正是smalltalk的@lru_cache没做对的地方(见正文 Q-5); - 逐行数据校验:字段数、strip 一致性、长度、控制字符、重复对,全部检查;
- 扫描在线程池里跑(
run_sync),不阻塞事件循环。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A4-1 | 低 | main.py:332-342 | 查询是对最多 50 000 条词条的线性扫描,多关键词时是 all(kw in source)。已卸载到线程池,量级也只有毫秒级;但词库变大或并发变高时需要倒排索引。属于"记一笔"而非"现在改" |
| A4-2 | 低 | main.py:395-399 | handle 的形参写成 _command / _event(下划线前缀),全仓其它 28 个插件都用 command / event。按位置传参所以不影响功能,但破坏签名一致性;用 del command, event 更贴合本仓写法 |
| A4-3 | 低 | main.py:239 | @lru_cache(maxsize=4) 会长期驻留最多 4 份完整词典(4 × 50k 条 NamedTuple)。有上限、随插件卸载释放,可接受,但应在 docstring 写明常驻内存量级 |
A-5 [低] github(442 行)
整体:HTML 解析比 apod 严谨得多——先 decompose() 掉 script/style/template/noscript,仓库路径用正则 fullmatch 白名单, 计数字段单独规范化,历史文件有保留上限和名称白名单。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A5-1 | 中 | main.py:375, 430 | datetime.now().astimezone() 用的是系统本地时区,而调度器用 config.timezone(core/scheduler.py:70 默认 Asia/Shanghai)。两者不一致时,08:30 的定时任务会用另一个时区的日期命名历史文件和渲染标题。部署在 UTC 容器里跑 Asia/Shanghai 调度就会出现"文件名日期比任务日期早一天" |
| A5-2 | 低 | main.py:252 | 走代理分支时 aiohttp_request_bounded 永不返回 None(失败抛异常),因此 if fetched is None 在该分支是死代码 |
| A5-3 | 低 | main.py:143-177 | _get_proxy 对格式错误抛裸 ValueError,被 handle 的通用兜底转成 public_error_response。运维只看到"操作失败,请稍后重试 + request_id",看不出是代理配错了。应抛 GitHubCommandError 并给可操作文案 |
| A5-4 | 低 | main.py:305-312 | _extract_gained_stars 对每篇文章扫最多 100 个 span 并各跑一次正则,50 篇 = 最多 5 000 次正则。已在线程池,但可先用 CSS 选择器定位到 star 区域再匹配 |
| A5-5 | 低 | main.py:438-441 | 每次运行写两份完整 JSON(latest + 当日),write_json 走 AtomicJsonStore,每份 = 读旧文件 + 写 .bak + 写正文 + 2 次 fsync(见正文 P1-8)。两份就是 6 次文件操作 + 4 次 fsync |
A-6 [低] guess_number(474 行)— 会话 API 的参考实现
整体:这是全仓唯一把 SessionManager 事务语义完整用对的插件, 应该被 docs/03-plugin-development.md 引用为范例。
三点值得单独表扬:
_load_game_state(main.py:180-222)不信任会话里的任何值,交叉校验 全部冗余字段:attempts == len(history)、max_attempts == difficulty.max_attempts、minimum <= target <= maximum,以及target not in history且历史猜测 都已落在当前区间之外。任何不一致直接InvalidGameState→ 结束会话。 这是对"会话数据可能被别的代码写坏"这一现实的正确防御。- 难度参数是算过的:HARD 1-200 给 8 次(log₂200≈7.64), HELL 1-1000 给 10 次(log₂1000≈9.97)——二分恰好够用,不多不少。
handle_session通过session.set(...)就地修改、由框架事务统一提交, 胜负时才context.end_session()。没有绕过事务自行持久化。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| A6-1 | 中 | main.py:348-374 | _parse_session_input 必须自己识别 /猜数字 status 这类完整命令,因为活跃会话优先于命令路由(core/dispatcher.py 的 Step D 在 Step E 之前)。docstring 说明了原因,但这意味着每个有会话的插件都要重复实现一次"在会话里也能认出自己的命令"——qingssh、jupyter、pendo 应该都有同样的代码。建议 core 在会话续接前先尝试解析"本插件自己的命令",把结构化结果一并传给 handle_session |
| A6-2 | 低 | main.py:141 | 手写 _parse_request(见 A-0) |
| A6-3 | 低 | main.py:273 | 猜数字用 secrets.randbelow 属于过度设计(random 足够),无害,且与 choice 用 random.SystemRandom() 一致。仅作风格一致性记录 |
小插件(chime/chat/bot_core/adnmb)(32 条,其中 5 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| B1-1 | 中 | main.py:474 | delivered = bool(await context.send_action(action))——这正是正文 P0-1 的具体落地点。HTTP 通道读超时(消息其实已发出)会返回 False,目标保持 pending,下次定时检查重复推送同一条 FRB 通知。CHIME 每天跑两次,一次网络抖动就是一条重复群公告 |
| B1-2 | 中 | main.py:429, 436 | 单源查询 /chime FRB20180916B 和 /chime list 都会先完整拉取整个目录(_CHIME_BODY_LIMITS 允许 8 MiB、max_nodes=100_000),再在内存里筛。没有任何缓存。用户连发几次单源查询 = 连续多次 8 MiB 下载 + 解析。应该给目录加一个短 TTL(几分钟)的内存缓存 |
| B1-3 | 中 | main.py:59-64 | _CHIME_JSON_LIMITS 是全仓最大的 JSON 预算(max_bytes 8 MiB、max_string_chars 6 MiB)。叠加正文 P1-3(_preflight_json 的纯 Python 逐字符扫描),一次 CHIME 拉取最坏要跑约 800 万次解释器迭代,每天至少两次、外加每条手动命令 |
| B2-1 | 中 | main.py:186-205 | 每日额度只存在内存里**(context.state["chat_usage"])。context.state 随插件卸载/重载/进程重启清空,因此 daily_user_limit=20 / daily_global_limit=100 在每次 /reload 或重启后归零。这是付费 API 的成本控制功能,不落盘等于没有上限。应持久化到 data_dir(可复用 AtomicJsonStore) |
| B2-2 | 中 | main.py:190 | _QUOTA_LOCKS.hold(id(state)) 用字典的 id() 做锁键。当前 _plugin_states[name] 与插件同生命周期所以 id 稳定;但 id() 会被 GC 回收后复用,一旦将来 state 的生命周期变化,两个无关状态就会共用一把锁。改用插件名做键即可 |
| B3-1 | 中 | main.py:411-440, 569-634 | handle 是 async def,但 _handle_reload 与 _handle_set_secret 都是同步函数,在事件循环上直接做阻塞文件 I/O:• _handle_reload → context.reload_config() → ConfigManager.reload() → _read_stable_sources(),最多 6 次成对完整读取 + JSON 解析 + pydantic 校验 + 递归冻结(正文 P1-1);• _handle_set_secret → capability.set() → _commit_secrets_mutation() → 打开 secrets 文件、读、写、fsync。一次 /reload 或 /set_secret 会把整个事件循环卡住到磁盘返回为止。应通过 run_sync / asyncio.to_thread 卸载 |
| B3-2 | 中 | main.py:59-63 | mask_secret 对字符串返回 value[:2] + "****" + value[-2:]。长度 5 的密钥会露出 4/5 个字符,长度 6 露出 4/6。应要求 len >= 12 才显示首尾,否则一律全遮 |
| B4-1 | 中 | main.py:305-382 | 约 80 行纯粹是在弥补 core.args.parse 的缺陷**(见 A-0)。最直白的证据是 _read_value_options 里的注释:"core.args 为无值选项写入字符串 "true";该值不能进入业务 API。"因为解析器不区分"标志"和"必须带值的选项",插件必须自己过滤哨兵值 "true",还要自己检测"同一选项同时用了短名和长名"、"并列了多个操作"、"有未知选项"、"有多余位置参数"。一个声明式的选项规格(类似 argparse 的 store_true vs nargs=1)能删掉这 80 行 |
| B4-2 | 中 | main.py:194-207 | format_posts 在循环里串行 await client.download_image(post.img),最多 10 篇 = 10 次串行 HTTP 下载。应改为有界并发(asyncio.gather + Semaphore(3)),否则一条 /adnmb -t 的响应时间是所有图片下载耗时之和 |
| B4-3 | 中 | main.py:497-504 | f"订阅结果: {result}" / f"取消订阅结果: {result}" 把 A 岛接口返回的文本未经长度或字符校验直接发到群里。同文件其它路径(_safe_text 风格)都有收窄,这两处漏了。需要确认 adapi.py 是否已在上游收窄;若没有,是一个第三方内容注入面 |
| B5-1 | 中 | — | 管理类命令在事件循环上做阻塞磁盘 I/O**。bot_core 的 /reload(最多 6 次完整配置读取 + 校验 + 冻结)和 /set_secret(写 + fsync)都是同步执行的。核心已经提供了 run_sync(走每插件 bulkhead),但这条最该用的路径没用。建议在 docs/03-plugin-development.md 里立一条硬规则:"handle 里任何触碰磁盘的调用都必须经 run_sync",并在 core 侧给 reload_config / secret_admin.set 提供 async 变体 |
| B5-2 | 中 | — | 成本/配额类状态没有持久化约定**。chat 的每日额度在内存里,重启即清零。signin、chime 用 data_dir 里的 JSON 持久化。同一类"必须跨重启存活"的状态,两种做法。core 应提供 context.store(name) 返回一个 AtomicJsonStore,把 data_dir 拼路径这件事收口 |
| B1-4 | 低 | main.py:483 | mark_delivered 每确认一个目标就重写整个 fanout 状态文件(见正文 C-09/P1-8)。默认群不多时无所谓,但语义上是 O(目标数) 次完整校验 + 原子写 |
| B1-5 | 低 | main.py:513 vs 417 | scheduled_check 持 _DELIVERY_LOCK,但手动 handle() 不持锁就 load_history()。写入是原子的所以读不到撕裂数据,但手动查询可能显示尚未提交的旧基线。属于可接受的良性竞争,建议加一行注释说明是有意为之 |
| B1-6 | 低 | main.py:34-35 | 注释说这个 JSON 端点在官方目录重建期间"可能暂时不可用",且"目前没有等价的实时归档接口"。这是很好的注释;但插件没有任何降级路径——端点一停,用户只会看到"❌ 无法获取 CHIME FRB 数据" |
| B2-3 | 低 | main.py:213-225 | get_config 在每次调用时对未配置情况打 warning("Chat 插件配置不存在"/"缺少 token")。未配置的部署会按消息数刷日志。应只在 init 时校验一次并缓存结论 |
| B2-4 | 低 | main.py:216 | 又一处手写命名空间解析(_chat_namespace(context.secrets),见正文 R-1) |
| B2-5 | 低 | main.py:696-700 | 服务入口 reply() 直接转调 handle("chat", text, event, context)——于是走了完整的 help 子命令解析、长度校验和配置校验。smalltalk 转发过来的用户闲聊如果恰好以 help 开头,会得到 /chat 的帮助文本而不是 AI 回答 |
| B2-6 | 低 | 全文 | chat 用 ZoneInfo 读取配置时区计算 _business_date,而 github 用系统时区、smalltalk 用 UTC。三种做法并存,再次印证 A7-1 |
| B3-3 | 低 | main.py:728, 737 | success_rate 用 _metric_number(..., maximum=1.0) 读取,越界时回落为 0.0 并渲染成 "成功率: 0.0%"。指标损坏会被显示成"全部失败",比显示 "n/a" 更有误导性。同理 avg_time 等字段 |
| B3-4 | 低 | main.py:620, 624 | f"❌ {exc}" 把 KeyError / ValueError 的文本回显给用户。这就是正文里统计到的 bot_core 的 3 处 {exc}。风险低(仅限 Bot 管理员私聊,且异常文本是 ConfigManager 自己生成的配置路径),但仍应改成显式文案,避免将来异常来源变化后无声泄露 |
| B3-5 | 低 | main.py:231-261 | _parse_help_request 的分支相当绕:第 243 行 if action == "page" or (tokens and len(tokens) == 1 and tokens[0].isdigit())——list/all/全部 后跟一个数字也会被当页码。行为可能是有意的,但从代码读不出意图,需要注释或用显式子命令表重写 |
| B4-4 | 低 | main.py:105 | context.secrets.get("plugins", {}).get("adnmb", {}) 是全仓唯一不带 isinstance(Mapping) 守卫的链式取值。其它 11 个插件都写了守卫。虽然异常会被顶层兜底,但破坏了一致性 |
| B4-5 | 低 | main.py:112-116 | 未配置 uuid 时用 uuid5(NAMESPACE_URL, f"{cache_dir}:{owner_key}") 派生身份——cache_dir 是可推断的固定路径,owner_key 是 QQ 号,因此这个"匿名"身份可被第三方离线推算。配置了 uuid 的路径没有这个问题。应改用一次性生成并持久化的随机盐 |
| B4-6 | 低 | main.py:385 | handle(...) -> list 用了裸 list 而非 Segments,与其余 28 个插件不一致 |
| B5-3 | 低 | — | 第三方文本回显的收窄不统一**。signin._safe_text、chime._extract_scalar、github._clean_element_text 各写了一份"限长 + 拒绝控制字符 + 拒绝容器"的收窄函数,而 adnmb 的订阅结果完全没收窄。应提升为 core.plugin_base.bounded_external_text(value, max_chars) |
| B5-4 | 低 | — | asyncio.Semaphore 在模块顶层构造**是全仓通用写法(chat._API_SEMAPHORE、wolframalpha._WA_SEMAPHORE、voice._VOICE_SEMAPHORE、url_parser._PREVIEW_SEMAPHORE)。在 Python 3.10+ 这是安全的(Semaphore 惰性绑定事件循环),且随模块卸载释放。仅记录为已确认安全的模式,避免后续审查重复怀疑 |
| B-0 † | 低 | — | 先纠正一条:context.state 是跨事件共享的,不是每条消息新建 |
| B-1 † | 低 | — | chime(604 行)— durable_fanout 的唯一消费者 |
| B-2 † | 低 | — | chat(700 行,Coze API) |
| B-3 † | 低 | — | bot_core(796 行)— 管理命令面板 |
| B-4 † | 低 | — | adnmb(883 行)— core/args.py 问题的最强证据 |
详述(5 条,† 标记的条目)
B-0 [低] 先纠正一条:context.state 是跨事件共享的,不是每条消息新建
初审时我对 PluginContext.state(core/context.py:134 用 field(default_factory=dict))的判断留了疑问。核对后确认它是对的: core/plugin_manager.py:6045 的 build_context 用 self._plugin_states.setdefault(plugin_name, {}) 取同一个 per-plugin 字典 传进去,卸载/重载时才 pop。
所以 context.state 是框架唯一的"跨事件、随插件生命周期存活"的内存状态位, adnmb 的客户端缓存、chat 的额度计数都依赖它,行为正确。
但是:docs/03-plugin-development.md 里没有说明这一点, interfaces.PluginContextProtocol 只写了 state: dict[str, Any] 而没有注释 它的生存期。插件作者只能靠读 plugin_manager 才能知道能不能往里放缓存。 这个语义必须写进文档。
顺带一个性能观察:build_context 每次都会调 _ensure_plugin_data_dir(),走缓存路径时仍会执行 _verify_data_directory_record()——对 plugin_root 和 data_dir 各做一次 lstat() 并比对 samestat(core/plugin_manager.py:5987+5975)。 即每条消息每个插件固定 2 次 lstat 系统调用。这是有意的安全检查 (检测 data 目录被换掉),但应与正文 P1-5 合并计入"每消息固定开销": 2 × lstat + 作用域配置视图重建 + 2 次 pydantic 校验。
B-1 [低] chime(604 行)— durable_fanout 的唯一消费者
整体:全仓唯一使用 core/durable_fanout 的插件,实现了 "全部目标群确认收到后才推进本地基线"的语义,历史文件有严格 schema 校验 (_validate_history,任何损坏都抛错而不是当成空历史触发全量重发)—— 这个考虑很到位。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| B1-1 | 中 | main.py:474 | delivered = bool(await context.send_action(action))——这正是正文 P0-1 的具体落地点。HTTP 通道读超时(消息其实已发出)会返回 False,目标保持 pending,下次定时检查重复推送同一条 FRB 通知。CHIME 每天跑两次,一次网络抖动就是一条重复群公告 |
| B1-2 | 中 | main.py:429, 436 | 单源查询 /chime FRB20180916B 和 /chime list 都会先完整拉取整个目录(_CHIME_BODY_LIMITS 允许 8 MiB、max_nodes=100_000),再在内存里筛。没有任何缓存。用户连发几次单源查询 = 连续多次 8 MiB 下载 + 解析。应该给目录加一个短 TTL(几分钟)的内存缓存 |
| B1-3 | 中 | main.py:59-64 | _CHIME_JSON_LIMITS 是全仓最大的 JSON 预算(max_bytes 8 MiB、max_string_chars 6 MiB)。叠加正文 P1-3(_preflight_json 的纯 Python 逐字符扫描),一次 CHIME 拉取最坏要跑约 800 万次解释器迭代,每天至少两次、外加每条手动命令 |
| B1-4 | 低 | main.py:483 | mark_delivered 每确认一个目标就重写整个 fanout 状态文件(见正文 C-09/P1-8)。默认群不多时无所谓,但语义上是 O(目标数) 次完整校验 + 原子写 |
| B1-5 | 低 | main.py:513 vs 417 | scheduled_check 持 _DELIVERY_LOCK,但手动 handle() 不持锁就 load_history()。写入是原子的所以读不到撕裂数据,但手动查询可能显示尚未提交的旧基线。属于可接受的良性竞争,建议加一行注释说明是有意为之 |
| B1-6 | 低 | main.py:34-35 | 注释说这个 JSON 端点在官方目录重建期间"可能暂时不可用",且"目前没有等价的实时归档接口"。这是很好的注释;但插件没有任何降级路径——端点一停,用户只会看到"❌ 无法获取 CHIME FRB 数据" |
B-2 [低] chat(700 行,Coze API)
整体:额度预留/提交/回滚是正确的事务模式(_answer_query 的 finally: if reservation.active: await asyncio.shield(reservation.rollback())), 远端超时后还会尽力 _cancel_chat 取消对端任务,请求预算用 _request_coze_json_before_deadline 统一收口——都是高于平均水平的做法。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| B2-1 | 中 | main.py:186-205 | 每日额度只存在内存里(context.state["chat_usage"])。context.state 随插件卸载/重载/进程重启清空,因此 daily_user_limit=20 / daily_global_limit=100 在每次 /reload 或重启后归零。这是付费 API 的成本控制功能,不落盘等于没有上限。应持久化到 data_dir(可复用 AtomicJsonStore) |
| B2-2 | 中 | main.py:190 | _QUOTA_LOCKS.hold(id(state)) 用字典的 id() 做锁键。当前 _plugin_states[name] 与插件同生命周期所以 id 稳定;但 id() 会被 GC 回收后复用,一旦将来 state 的生命周期变化,两个无关状态就会共用一把锁。改用插件名做键即可 |
| B2-3 | 低 | main.py:213-225 | get_config 在每次调用时对未配置情况打 warning("Chat 插件配置不存在"/"缺少 token")。未配置的部署会按消息数刷日志。应只在 init 时校验一次并缓存结论 |
| B2-4 | 低 | main.py:216 | 又一处手写命名空间解析(_chat_namespace(context.secrets),见正文 R-1) |
| B2-5 | 低 | main.py:696-700 | 服务入口 reply() 直接转调 handle("chat", text, event, context)——于是走了完整的 help 子命令解析、长度校验和配置校验。smalltalk 转发过来的用户闲聊如果恰好以 help 开头,会得到 /chat 的帮助文本而不是 AI 回答 |
| B2-6 | 低 | 全文 | chat 用 ZoneInfo 读取配置时区计算 _business_date,而 github 用系统时区、smalltalk 用 UTC。三种做法并存,再次印证 A7-1 |
B-3 [低] bot_core(796 行)— 管理命令面板
整体:/help 的目录查询做得相当完整(精确匹配优先 → 命中父节点返回整棵子树 → 全文模糊匹配,带分页和 JSON 导出),并且是唯一真正消费 core/router.py 的 CommandCatalogNode 结构化目录的地方。 密钥管理走 capabilities.secret_admin 并二次核验 is_bot_admin + is_private(main.py:76-89),是正确的纵深防御。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| B3-1 | 中 | main.py:411-440, 569-634 | handle 是 async def,但 _handle_reload 与 _handle_set_secret 都是同步函数,在事件循环上直接做阻塞文件 I/O:• _handle_reload → context.reload_config() → ConfigManager.reload() → _read_stable_sources(),最多 6 次成对完整读取 + JSON 解析 + pydantic 校验 + 递归冻结(正文 P1-1);• _handle_set_secret → capability.set() → _commit_secrets_mutation() → 打开 secrets 文件、读、写、fsync。一次 /reload 或 /set_secret 会把整个事件循环卡住到磁盘返回为止。应通过 run_sync / asyncio.to_thread 卸载 |
| B3-2 | 中 | main.py:59-63 | mask_secret 对字符串返回 value[:2] + "****" + value[-2:]。长度 5 的密钥会露出 4/5 个字符,长度 6 露出 4/6。应要求 len >= 12 才显示首尾,否则一律全遮 |
| B3-3 | 低 | main.py:728, 737 | success_rate 用 _metric_number(..., maximum=1.0) 读取,越界时回落为 0.0 并渲染成 "成功率: 0.0%"。指标损坏会被显示成"全部失败",比显示 "n/a" 更有误导性。同理 avg_time 等字段 |
| B3-4 | 低 | main.py:620, 624 | f"❌ {exc}" 把 KeyError / ValueError 的文本回显给用户。这就是正文里统计到的 bot_core 的 3 处 {exc}。风险低(仅限 Bot 管理员私聊,且异常文本是 ConfigManager 自己生成的配置路径),但仍应改成显式文案,避免将来异常来源变化后无声泄露 |
| B3-5 | 低 | main.py:231-261 | _parse_help_request 的分支相当绕:第 243 行 if action == "page" or (tokens and len(tokens) == 1 and tokens[0].isdigit())——list/all/全部 后跟一个数字也会被当页码。行为可能是有意的,但从代码读不出意图,需要注释或用显式子命令表重写 |
B-4 [低] adnmb(883 行)— core/args.py 问题的最强证据
整体:客户端缓存设计得好——按用户键的 OrderedDict LRU + TTL (MAX_CACHED_CLIENTS=128、CLIENT_IDLE_TTL_SECONDS=3600), shutdown() 里逐个释放;并且用 uuid5(配置UUID + QQ号) 为每个用户派生 独立的匿名身份,而不是所有人共用一个饼干——这是很体贴的隐私设计。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| B4-1 | 中 | main.py:305-382 | 约 80 行纯粹是在弥补 core.args.parse 的缺陷(见 A-0)。最直白的证据是 _read_value_options 里的注释:"core.args 为无值选项写入字符串 "true";该值不能进入业务 API。"因为解析器不区分"标志"和"必须带值的选项",插件必须自己过滤哨兵值 "true",还要自己检测"同一选项同时用了短名和长名"、"并列了多个操作"、"有未知选项"、"有多余位置参数"。一个声明式的选项规格(类似 argparse 的 store_true vs nargs=1)能删掉这 80 行 |
| B4-2 | 中 | main.py:194-207 | format_posts 在循环里串行 await client.download_image(post.img),最多 10 篇 = 10 次串行 HTTP 下载。应改为有界并发(asyncio.gather + Semaphore(3)),否则一条 /adnmb -t 的响应时间是所有图片下载耗时之和 |
| B4-3 | 中 | main.py:497-504 | f"订阅结果: {result}" / f"取消订阅结果: {result}" 把 A 岛接口返回的文本未经长度或字符校验直接发到群里。同文件其它路径(_safe_text 风格)都有收窄,这两处漏了。需要确认 adapi.py 是否已在上游收窄;若没有,是一个第三方内容注入面 |
| B4-4 | 低 | main.py:105 | context.secrets.get("plugins", {}).get("adnmb", {}) 是全仓唯一不带 isinstance(Mapping) 守卫的链式取值。其它 11 个插件都写了守卫。虽然异常会被顶层兜底,但破坏了一致性 |
| B4-5 | 低 | main.py:112-116 | 未配置 uuid 时用 uuid5(NAMESPACE_URL, f"{cache_dir}:{owner_key}") 派生身份——cache_dir 是可推断的固定路径,owner_key 是 QQ 号,因此这个"匿名"身份可被第三方离线推算。配置了 uuid 的路径没有这个问题。应改用一次性生成并持久化的随机盐 |
| B4-6 | 低 | main.py:385 | handle(...) -> list 用了裸 list 而非 Segments,与其余 28 个插件不一致 |
小插件(twitter/earthquake)(16 条,其中 2 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| C1-1 | 中 | main.py:917 | delivered = bool(await context.send_action(action))——正文 P0-1 的第二个落地点,且比 chime 更严重:定时任务每 5 分钟跑一次,一次 HTTP 读超时(消息其实已发出)就会让目标保持 pending,下一轮重复推送同一条地震警报。地震通知重复发到群里是用户可见的严重体验问题 |
| C1-2 | 中 | main.py:656-658 | _fetch_batch 每次扫描都新建 requests.Session 并跑一次 _bootstrap_session(2 次额外 HTTP 请求换访客 Cookie),Cookie 用完即弃。定时任务每 5 分钟一轮 = 每天 288 轮 × 3 次请求 = 864 次请求,其中 576 次纯粹是重复的握手。应把 session 缓存到 context.state 并按 TTL 续期 |
| C1-3 | 中 | main.py:442-451 | _bootstrap_session 里硬编码了抓包得到的 "rid": "01Cn_5z8ew6..." 和 "ver": "20250916"。与 signin(A1-3)同一类脆弱性:微博改协议即整体失效,且失败被 _report_bootstrap_error 吞掉后只表现为"取不到数据"。应提为带注释的常量并在 bootstrap 连续失败 N 次时升级日志级别 |
| C2-1 | 中 | main.py:530-549 | _detect_image_extension 与 url_parser/main.py:159-178 近乎逐行重复,且比 earthquake 弱得多(见 C-0)。应统一到 core |
| C3-1 | 中 | — | durable_fanout 的两个消费者都栽在同一处**。chime(B1-1)与 earthquake(C1-1)都用 bool(await context.send_action(...)) 判断投递结果,因此正文 P0-1 的影响面不是理论的:修好 HTTP 通道的三态语义会同时修好这两个插件的重复推送。这提升了 P0-1 的优先级 |
| C3-2 | 中 | — | 外部服务的客户端指纹(UA/rid/ver/uuid)散落在三个插件里硬编码**:signin(有赞 uuid/version)、earthquake(微博 rid/ver)、twitter/apod/github(各自一份 User-Agent 字符串,版本号从 Chrome 77 到 Chrome 120 不等)。这类值会静默失效。建议集中到各插件的 constants.py 并统一注明"抓包来源 + 失效征兆" |
| C1-4 | 低 | main.py:781-793 | _render_cards 在循环里串行 await _download_figure(...)。同一仓库的 twitter._fetch_twitter_images(main.py:628)已经用 asyncio.gather + 信号量做对了,应对齐 |
| C1-5 | 低 | main.py:729-738 | _validate_image_bytes 对同一份最大 8 MiB 的数据 打开两次 Image、调用三次 _validate_image_metadata。这是有意的(verify() 会使对象失效,load() 后才能确认渐进式解码的真实尺寸),但代价是两次完整解码。提取到 core 时应在 docstring 写明这个取舍 |
| C1-6 | 低 | 全文 | 本插件同时使用两套 HTTP 栈:微博走 requests + requests_request_bounded(需要 Cookie 会话),图片走 fetch_public_bytes(aiohttp/safe_http)。选择是合理的,但它是全仓唯一这么做的插件,正好说明正文 P1-7 提到的"两套栈的适用边界"必须写进插件开发文档 |
| C2-2 | 低 | main.py:387-398 | _original_media_url 把 pbs.twimg.com 的图片地址改写成 name=4096x4096 请求最高清版本,随后才用 MAX_IMAGE_BYTES 拒绝超限。也就是说超大图会先被完整下载再丢弃,浪费带宽。应改请求一个受控尺寸档位(如 name=large) |
| C2-3 | 低 | main.py:222-236 | _FETCH_TASK / _MANUAL_NOTIFICATION_TASK 是模块级全局变量(配 global 赋值),而 adnmb、chat、earthquake 把同类运行时状态放在 context.state。两种做法都随插件卸载失效,但应统一——context.state 是框架承认的位置,模块全局不是 |
| C2-4 | 低 | main.py:239 | 手写 _get_config(见正文 R-1) |
| C2-5 | 低 | main.py:454 | for key in ("data","user","result","timeline","timeline") 连续两次 "timeline" 是对 GraphQL 嵌套结构的正确处理,但读起来像笔误。应加一行注释说明是 timeline.timeline 两层 |
| C3-3 | 低 | — | 并发下载的正确写法只有 twitter 一家**(gather + _MEDIA_DOWNLOAD_SEMAPHORE)。adnmb(B4-2)和 earthquake(C1-4)都是串行 await。应把 twitter 的写法提炼成 core.plugin_base.gather_bounded(coros, limit) |
| C-1 † | 低 | — | earthquake(1 004 行)— 全仓最严谨的外部内容处理 |
| C-2 † | 低 | — | twitter(974 行) |
详述(2 条,† 标记的条目)
C-1 [低] earthquake(1 004 行)— 全仓最严谨的外部内容处理
整体:这个插件在"不可信输入处理"上是全仓天花板。除上面的图片校验外:
- 游标状态机有检查点和隔离:
_load_since读活动游标失败时, 先_quarantine_corrupt_state()用原子replace把损坏文件挪走 (不读取内容、不生成摘要),再从.checkpoint.json恢复;_save_since先写活动文件再写检查点(main.py:305-358)。 - 微博卡片展开有共享预算:
_iter_mblogs用显式栈展开嵌套card_group,且嵌套节点与顶层节点共用_MAX_WEIBO_CARDS预算 (main.py:420-421),杜绝了"顶层 200 条合规、每条再挂 200 条子卡片"的放大。 - 手动查询与定时游标完全隔离:
handle走force=True+since_id="0", 注释明说"任何手动请求都不会改动定时游标"。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| C1-1 | 中 | main.py:917 | delivered = bool(await context.send_action(action))——正文 P0-1 的第二个落地点,且比 chime 更严重:定时任务每 5 分钟跑一次,一次 HTTP 读超时(消息其实已发出)就会让目标保持 pending,下一轮重复推送同一条地震警报。地震通知重复发到群里是用户可见的严重体验问题 |
| C1-2 | 中 | main.py:656-658 | _fetch_batch 每次扫描都新建 requests.Session 并跑一次 _bootstrap_session(2 次额外 HTTP 请求换访客 Cookie),Cookie 用完即弃。定时任务每 5 分钟一轮 = 每天 288 轮 × 3 次请求 = 864 次请求,其中 576 次纯粹是重复的握手。应把 session 缓存到 context.state 并按 TTL 续期 |
| C1-3 | 中 | main.py:442-451 | _bootstrap_session 里硬编码了抓包得到的 "rid": "01Cn_5z8ew6..." 和 "ver": "20250916"。与 signin(A1-3)同一类脆弱性:微博改协议即整体失效,且失败被 _report_bootstrap_error 吞掉后只表现为"取不到数据"。应提为带注释的常量并在 bootstrap 连续失败 N 次时升级日志级别 |
| C1-4 | 低 | main.py:781-793 | _render_cards 在循环里串行 await _download_figure(...)。同一仓库的 twitter._fetch_twitter_images(main.py:628)已经用 asyncio.gather + 信号量做对了,应对齐 |
| C1-5 | 低 | main.py:729-738 | _validate_image_bytes 对同一份最大 8 MiB 的数据 打开两次 Image、调用三次 _validate_image_metadata。这是有意的(verify() 会使对象失效,load() 后才能确认渐进式解码的真实尺寸),但代价是两次完整解码。提取到 core 时应在 docstring 写明这个取舍 |
| C1-6 | 低 | 全文 | 本插件同时使用两套 HTTP 栈:微博走 requests + requests_request_bounded(需要 Cookie 会话),图片走 fetch_public_bytes(aiohttp/safe_http)。选择是合理的,但它是全仓唯一这么做的插件,正好说明正文 P1-7 提到的"两套栈的适用边界"必须写进插件开发文档 |
C-2 [低] twitter(974 行)
整体:GraphQL 响应解析写得很防御(每一层都 isinstance(Mapping) 守卫, 单条媒体项畸形不影响同推文其它图片),配置读取对 headers/cookies 都做了 条数、长度、控制字符和"传输层管理字段"过滤。图片下载是全仓唯一做对并发的。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| C2-1 | 中 | main.py:530-549 | _detect_image_extension 与 url_parser/main.py:159-178 近乎逐行重复,且比 earthquake 弱得多(见 C-0)。应统一到 core |
| C2-2 | 低 | main.py:387-398 | _original_media_url 把 pbs.twimg.com 的图片地址改写成 name=4096x4096 请求最高清版本,随后才用 MAX_IMAGE_BYTES 拒绝超限。也就是说超大图会先被完整下载再丢弃,浪费带宽。应改请求一个受控尺寸档位(如 name=large) |
| C2-3 | 低 | main.py:222-236 | _FETCH_TASK / _MANUAL_NOTIFICATION_TASK 是模块级全局变量(配 global 赋值),而 adnmb、chat、earthquake 把同类运行时状态放在 context.state。两种做法都随插件卸载失效,但应统一——context.state 是框架承认的位置,模块全局不是 |
| C2-4 | 低 | main.py:239 | 手写 _get_config(见正文 R-1) |
| C2-5 | 低 | main.py:454 | for key in ("data","user","result","timeline","timeline") 连续两次 "timeline" 是对 GraphQL 嵌套结构的正确处理,但读起来像笔误。应加一行注释说明是 timeline.timeline 两层 |
小插件(color/astro_tools)(20 条,其中 3 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| D1-1 | 中 | data_manager.py:158 | _load_builtin_colors 返回 [copy.deepcopy(color) for color in cached]——每次 /color 命令都对整个内置色库(上限 1 000 条 TypedDict)做一次深拷贝。缓存的元组在查询路径上从不被修改,深拷贝纯属浪费。应直接返回缓存元组,只在 mutate_custom_colors 里按需复制 |
| D1-2 | 中 | image_gen.py:69 | axes.set_title(name, fontsize=10, fontproperties="SimHei") 硬编码 Windows 专有字体。Linux/容器部署上 matplotlib 会退回默认字体并把中文颜色名渲染成方框(同时刷 findfont 警告)。应做字体探测并降级到 Noto Sans CJK / WenQuanYi,或允许配置 |
| D1-3 | 中 | main.py:147,155,177 | 又一处 core.args.parse 的 "true" 哨兵绕行:raw_value != "true"、if raw_value == "true"、picture_option[1] != "true"。这是继 adnmb(B4-1)之后第二个必须手工过滤该哨兵的插件 |
| D2-1 | 中 | main.py:50 → coord.py:29 | 负赤纬被 core.args.parse 吞掉(见 D-0)。这是该插件最需要修的功能缺陷 |
| D2-2 | 中 | coord.py / time.py / convert.py / formula.py / redshift.py / const.py | 除 obj.py 外,没有任何一个子模块把 astropy 计算卸载到线程。SkyCoord(...)、Time(...) 都在 async 函数里直接调用。astropy 的首次使用要初始化 ERFA 表和单位注册表,可达数百毫秒到数秒,整个事件循环会被卡住;且插件 plugin.json 没有声明 concurrency,默认 parallel,多个用户同时查询会叠加。obj.py 已经示范了正确写法,其余五个模块应对齐 |
| D3-1 | 中 | — | 重依赖的惰性导入只做对了一半**。color/image_gen.py:29 用 @lru_cache 惰性导入 numpy/matplotlib、astro_tools/coord.py:27 在函数内 from astropy import ...——都正确。但 main.py:16 却在进程启动时无条件 import torch(正文 C-14)。同一个仓库里三种重依赖,两种做对了,最贵的那个做错了 |
| D3-2 | 中 | — | CPU 密集计算的线程卸载不成体系**。dict(词典扫描)、color(数据加载、图片渲染)、github(HTML 解析)都用了 run_sync;astro_tools 的六个子模块和 apod 的 mkdir 没有。建议在 docs/03-plugin-development.md 立一条可检查的规则,并配一条测试:handle 及其调用链中出现 astropy/matplotlib/PIL/sqlite3/open() 时必须处于 run_sync 或 to_thread 之内 |
| D1-4 | 低 | main.py:93-126 | _selected_option / _selected_action / _parse_args 的未知选项检查,与 adnmb._selected_action / _request_error(main.py:326-382)在结构上高度相似——两份"同一操作不能同时用短名和长名 + 一次只能选一个操作 + 拒绝未知选项"的实现 |
| D1-5 | 低 | stellar.py:141 | 行长超过其余代码的排版惯例(f-string 单行 100+ 字符),仓库其它位置都在 100 列内 |
| D1-6 | 低 | image_gen.py:26 | MATPLOTLIB_AVAILABLE 在模块导入时用 find_spec 求值。后续安装 matplotlib 需要重载插件才能生效。可接受,但应在用户可见的失败文案里说明"安装后需 /reload" |
| D2-3 | 低 | main.py:51-52 | /astro coord help 返回的是全局帮助而不是 coord 子命令的帮助。七个子命令各自的用法只能从那一大段总帮助里找 |
| D2-4 | 低 | main.py:49 | f"未知命令: {subcommand}" 回显用户输入,未截断(subcommand = parsed.first.lower())。与 signin(A1-1)、apod(A2-3)同一模式 |
| D2-5 | 低 | main.py:50 | parsed.rest(1) 用单空格重新拼接,原始空白被规范化(与 wolframalpha A3-3 相同) |
| D2-6 | 低 | coord.py:20 | handle_coord 是 async def 但函数体内没有任何 await——convert/formula/redshift/const/time 同理。这些是为了统一分发表签名而写的"假 async",会让每次调用多一次协程创建与调度。若同时按 D2-2 改成 to_thread,这一点会自然消失 |
| D3-3 | 低 | — | (path, mtime_ns, size) 作为 lru_cache 身份**已在 dict、color(两处)、stellar 共四处独立实现,写法完全一致。应提炼为 core.plugin_base.file_fingerprint(path) 并在文档中作为"缓存插件内置资源"的标准做法——顺带能修掉 smalltalk 那个没做缓存代际隔离的 @lru_cache(正文 Q-5) |
| D3-4 | 低 | — | "同一操作不能同时用短名和长名 / 一次只能选一个操作 / 拒绝未知选项"** 这组校验在 adnmb(main.py:326-382)和 color(main.py:93-126)各写了一份。若按 A-0 给 core/args.py 加上声明式选项规格,这两份都能删掉 |
| D-0 † | 低 | — | core/args.py 的第三个真实 bug:负赤纬被当成命令选项吃掉 |
| D-1 † | 低 | — | color(1 130 行 / 7 文件) |
| D1-7 | 低 | — | 正文统计的 color 5 处 {exc} 经核对全部安全:都是 raise ColorInputError(str(exc)) 把本插件 convert.hex_to_rgb 自己抛出的 ValueError 文案转给用户,不是外部异常。正文 P0-2 的计数里应把 color 排除 |
| D-2 † | 低 | — | astro_tools(1 314 行 / 9 文件) |
详述(3 条,† 标记的条目)
D-0 [低] core/args.py 的第三个真实 bug:负赤纬被当成命令选项吃掉
A-0 里已经确认 wolframalpha 的 /alpha -1+2 会被误判为选项。 本组在 astro_tools 里发现了同一根因造成的、后果更明确的 bug:
/astro coord 12:34:56 -12:34:56core/args.py:21_is_option_token("-12:34:56"):以-开头、长度 > 1_looks_like_negative_number("-12:34:56"):float("-12:34:56")抛ValueError→False- → 判定为短选项,写入
options["12:34:56"] = "true" astro_tools/main.py:50的parsed.rest(1)只拼接 位置参数- →
coord.py:29的parts只剩一个元素,赤纬彻底消失
天文坐标里负赤纬占半个天球,这是坐标转换工具的核心输入形态。 /astro coord galactic -12.5 45 不受影响(float("-12.5") 成功), 所以问题只出现在六十进制写法上——恰恰是插件帮助里推荐的写法 (/astro coord 12:34:56 +12:34:56)。
至此 core/args.py 已经造成两个可复现的功能缺陷(wolframalpha、astro_tools), 外加三个插件(adnmb、color、dict)各写了几十行代码去绕开它。 建议把正文 A-0 的修复提到 P1。
D-1 [低] color(1 130 行 / 7 文件)
整体:分层清晰(main 路由 / data_manager 持久化 / query 检索 / convert 色彩空间 / image_gen 渲染 / stellar 恒星色表), 数据校验很扎实——_normalize_color_record 会交叉验证 HEX 与 RGB 是否一致 (data_manager.py:98),自定义颜色按 group/user 作用域分文件存储 (_custom_file,data_manager.py:47-59),修改走 mutate_custom_colors 的"读-改-校验-仅在变化时写"事务。 _load_builtin_colors_cached 和 _load_stellar_rows_cached 都用 (路径, mtime_ns, size) 做缓存身份——与 dict 一样是正确写法。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| D1-1 | 中 | data_manager.py:158 | _load_builtin_colors 返回 [copy.deepcopy(color) for color in cached]——每次 /color 命令都对整个内置色库(上限 1 000 条 TypedDict)做一次深拷贝。缓存的元组在查询路径上从不被修改,深拷贝纯属浪费。应直接返回缓存元组,只在 mutate_custom_colors 里按需复制 |
| D1-2 | 中 | image_gen.py:69 | axes.set_title(name, fontsize=10, fontproperties="SimHei") 硬编码 Windows 专有字体。Linux/容器部署上 matplotlib 会退回默认字体并把中文颜色名渲染成方框(同时刷 findfont 警告)。应做字体探测并降级到 Noto Sans CJK / WenQuanYi,或允许配置 |
| D1-3 | 中 | main.py:147,155,177 | 又一处 core.args.parse 的 "true" 哨兵绕行:raw_value != "true"、if raw_value == "true"、picture_option[1] != "true"。这是继 adnmb(B4-1)之后第二个必须手工过滤该哨兵的插件 |
| D1-4 | 低 | main.py:93-126 | _selected_option / _selected_action / _parse_args 的未知选项检查,与 adnmb._selected_action / _request_error(main.py:326-382)在结构上高度相似——两份"同一操作不能同时用短名和长名 + 一次只能选一个操作 + 拒绝未知选项"的实现 |
| D1-5 | 低 | stellar.py:141 | 行长超过其余代码的排版惯例(f-string 单行 100+ 字符),仓库其它位置都在 100 列内 |
| D1-6 | 低 | image_gen.py:26 | MATPLOTLIB_AVAILABLE 在模块导入时用 find_spec 求值。后续安装 matplotlib 需要重载插件才能生效。可接受,但应在用户可见的失败文案里说明"安装后需 /reload" |
| D1-7 | 说明 | — | 正文统计的 color 5 处 {exc} 经核对全部安全:都是 raise ColorInputError(str(exc)) 把本插件 convert.hex_to_rgb 自己抛出的 ValueError 文案转给用户,不是外部异常。正文 P0-2 的计数里应把 color 排除 |
正面:image_gen._renderer_modules 用 @lru_cache(maxsize=1) 惰性导入 numpy/matplotlib(image_gen.py:29-37),只有真正生成色卡时才付出导入代价。 这正是 main.py:16 无条件 import torch(正文 C-14/P1-8)应该采用的模式, 样板同样已经在仓库里了。
D-2 [低] astro_tools(1 314 行 / 9 文件)
整体:子命令分发表(main.py:23-31)+ 统一的 handler(args, context) -> str 契约是本仓最干净的多子命令结构。 SIMBAD 查询侧做得很好:TAP 查询有列名白名单、必需列校验、 _SIMBAD_MAX_* 全套上限、三档超时(连接 3s / 请求 12s / 总 15s), 并且正确地用 asyncio.to_thread(_query_simbad_object, args)(obj.py:427) 把同步 requests_request_bounded 卸载出事件循环。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| D2-1 | 中 | main.py:50 → coord.py:29 | 负赤纬被 core.args.parse 吞掉(见 D-0)。这是本组最需要修的功能缺陷 |
| D2-2 | 中 | coord.py / time.py / convert.py / formula.py / redshift.py / const.py | 除 obj.py 外,没有任何一个子模块把 astropy 计算卸载到线程。SkyCoord(...)、Time(...) 都在 async 函数里直接调用。astropy 的首次使用要初始化 ERFA 表和单位注册表,可达数百毫秒到数秒,整个事件循环会被卡住;且插件 plugin.json 没有声明 concurrency,默认 parallel,多个用户同时查询会叠加。obj.py 已经示范了正确写法,其余五个模块应对齐 |
| D2-3 | 低 | main.py:51-52 | /astro coord help 返回的是全局帮助而不是 coord 子命令的帮助。七个子命令各自的用法只能从那一大段总帮助里找 |
| D2-4 | 低 | main.py:49 | f"未知命令: {subcommand}" 回显用户输入,未截断(subcommand = parsed.first.lower())。与 signin(A1-1)、apod(A2-3)同一模式 |
| D2-5 | 低 | main.py:50 | parsed.rest(1) 用单空格重新拼接,原始空白被规范化(与 wolframalpha A3-3 相同) |
| D2-6 | 低 | coord.py:20 | handle_coord 是 async def 但函数体内没有任何 await——convert/formula/redshift/const/time 同理。这些是为了统一分发表签名而写的"假 async",会让每次调用多一次协程创建与调度。若同时按 D2-2 改成 to_thread,这一点会自然消失 |
小插件(shell)(2 条,其中 0 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| BODY-S2 | 低 | plugins/shell/main.py:566 | shell 的帮助文本里写着 C:/Users/torch/Desktop/a.py,每个执行 /shell help 的管理员都会看到运维账号名。换成中性占位符即可 |
| BODY-S3 | 低 | plugins/shell/ | shell 把普通配置放在 secrets.json 而不是 config.json,与其余插件的配置/密钥分层相反 |
jupyter(12 条,其中 1 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| E1-1 | 中 | jupyter_manager.py:298-322 | _resolve_awaitable 为了在同步清理路径里 await 一个值,新起一个线程跑 asyncio.run(),即每个清理步骤可能创建一整个新事件循环。而且若该 awaitable 是在另一个事件循环上创建的 Future,asyncio.run 会抛 "attached to a different loop"。这是 jupyter_client 同步/异步 API 混杂的无奈之举,但应改为:在异步侧统一用 to_thread 调同步 API,不要反向从同步侧驱动异步 |
| E1-2 | 中 | jupyter_manager.py:324-340 | _invoke_cleanup 用 inspect.signature 反射决定传哪些 kwargs,以适配 jupyter_client 各版本的清理 API。与正文 P0-3(APScheduler/CPython 私有 API)同一类脆弱性,只是这里用反射代替了版本断言。至少应记录探测结果,避免每次清理都重新反射 |
| E1-3 | 中 | main.py:269 | JupyterKernelManager._validate_png_bytes(image_data) ——从类外部调用私有方法。同正文 Q-3(core 里 manager 直接改 finder 私有属性)。这个方法本身应该是模块级公开函数(它是纯函数),或者提升为 core 的共享图片校验(见附录 C-0) |
| E2-1 | 中 | — | "安全错误出口"有两套并存的正确实现,但没有约定**。core/public_errors.py 返回 错误码 + request_id;jupyter/jupyter_audit.py 返回固定文案并把 HMAC 指纹留在日志。两者都对,但一个插件该用哪套、以及"什么算可预期错误"没有任何文档。这正是初版 P0-2 误判的根因——机械统计 public_error_* 调用数无法区分"没有安全出口"和"有另一套安全出口"。文档里应明确两条可选路径和判定标准 |
| E2-2 | 中 | — | 敏感操作审计有三份独立实现**:core/sensitive_audit.py(HMAC 指纹原语)、shell/main.py:67-91 的 _log_command_audit、jupyter/jupyter_audit.py 的 log_sensitive_audit。后两者都是"在 core 原语之上加字段白名单和固定日志格式",结构几乎一样(shell 有 _AUDIT_ID_RE/_AUDIT_STATUS_RE,jupyter 有三条同类正则)。jupyter 的版本更完整(多了 job_id 和 payload 摘要),应上提为 core.sensitive_audit.log_sensitive_operation(...),shell、qingssh、codex 统一调用 |
| E1-4 | 低 | jupyter_manager.py:143-144 | _instances / _quarantined_instances 是无上限的 ClassVar。键是 owner::data_dir,因此每个 (管理员, 群) 组合一个实例,隔离实例只增不减。jupyter 是 admin_only 所以实际规模很小,但 AsyncKeyedLockPool 的 max_keys 模式在这里同样适用 |
| E1-5 | 低 | main.py:222-224 | 依赖不可用时,_dependencies_available() 每次调用都会重新执行一次 lazy_import_jupyter() → 一次失败的 import jupyter_client。未安装依赖的部署会为每条 /py 命令付出一次 ImportError。应缓存"已确认不可用"并只在插件重载时重试 |
| E1-6 | 低 | main.py:555-556 | normalized_action = user_text.strip().casefold() if "\n" not in user_text else "" ——单行输入若恰好等于 run/clear/show/help(或中文别名)就会被当作控制指令,无法作为代码行加入缓冲区。例如想输入一行 run 调用某个名为 run 的函数就做不到。REPL 语义上可接受,但帮助文本没说明,应补一句"以空格开头可强制作为代码" |
| E1-7 | 低 | jupyter_manager.py:168 | _cleanup_legacy_figures() 在 __init__ 里同步执行 figures_dir.iterdir()(上限 4 096 项)。get_instance 在 _broken 时会重建实例,因此这段扫描可能被反复触发。属于冷路径,但可以在完成一次后打标记跳过 |
| E2-3 | 低 | — | 可选依赖的声明方式不统一**:jupyter 在 manifest 里声明 required: false;color 只在模块里 find_spec;astro_tools 的 astropy 是硬依赖但 manifest 里写了 dependencies;arxiv_filter 声明了 10 个依赖。PluginManifest.dependencies 已经支持 required 字段(core/models.py:372-379),应要求所有可选依赖都走 manifest,这样 /plugins 就能显示"哪些插件因缺依赖而降级" |
| E-1 † | 低 | — | jupyter(2 101 行 / 6 文件)— 敏感审计的参考实现 |
| E1-8 | 低 | plugin.json | jupyter 是全仓唯一在 manifest 里把可选依赖声明为 "required": false 并配 description 的插件,运行时缺失就返回 DEPENDENCY_TEXT 引导安装。相比之下 color 对 matplotlib/numpy 的可选依赖完全没在 manifest 里声明(只有模块级 find_spec)。应把 jupyter 的做法定为约定 |
详述(1 条,† 标记的条目)
E-1 [低] jupyter(2 101 行 / 6 文件)— 敏感审计的参考实现
整体:这是全仓处理"必须执行任意代码"这一固有危险场景做得最完整的插件。 它没有假装能沙箱化 Python,而是把力气全部花在"边界收窄 + 隔离 + 可审计"上:
- 审计不含载荷(
jupyter_audit.py:38-77):log_sensitive_audit只记录 operation / request_id / job_id / status / 异常类型名 / 载荷的 kind+length+bytes+HMAC 指纹。代码正文与异常正文永不进日志。 三个字段各有独立正则白名单(_AUDIT_ID_PATTERN/_AUDIT_LABEL_PATTERN/_ERROR_TYPE_PATTERN),连 operation 名都不能注入。 - 内核按身份隔离(
main.py:241-252):_owner_key拒绝缺失身份, 生成user:<id>:private或user:<id>:group:<gid>——不同用户、 同一用户的不同群聊都拿到独立内核,变量不会串。缺 user_id 时直接RuntimeError而不是回退到共享全局内核。 - 输出预算跨流共享(
jupyter_manager.py:88-109):_OutputBudget让 stdout / stderr / 表达式结果 / traceback 共用一份MAX_OUTPUT_BYTES, 并在计入前剥离 ANSI 转义和控制字符。图片另有MAX_IMAGES+MAX_TOTAL_IMAGE_BYTES双预算。 - 清理分阶段可诊断(
KernelCleanupReport):channels / shutdown / resources / fallback / alive_after / orphan 各自记录,且record_error只保留阶段名和异常类型,不带路径。关闭结果不确定时返回 "⚠️ 内核关闭状态无法确认,实例已隔离"并把实例移入_quarantined_instances,而不是假装成功。 - REPL 状态严格校验(
main.py:374-402):行数、单行长度、控制字符、 执行计数、总字节预算逐项检查,任一不符即InvalidReplState→ 结束会话。 与guess_number(A-6)并列为会话状态防御的两个范本。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| E1-1 | 中 | jupyter_manager.py:298-322 | _resolve_awaitable 为了在同步清理路径里 await 一个值,新起一个线程跑 asyncio.run(),即每个清理步骤可能创建一整个新事件循环。而且若该 awaitable 是在另一个事件循环上创建的 Future,asyncio.run 会抛 "attached to a different loop"。这是 jupyter_client 同步/异步 API 混杂的无奈之举,但应改为:在异步侧统一用 to_thread 调同步 API,不要反向从同步侧驱动异步 |
| E1-2 | 中 | jupyter_manager.py:324-340 | _invoke_cleanup 用 inspect.signature 反射决定传哪些 kwargs,以适配 jupyter_client 各版本的清理 API。与正文 P0-3(APScheduler/CPython 私有 API)同一类脆弱性,只是这里用反射代替了版本断言。至少应记录探测结果,避免每次清理都重新反射 |
| E1-3 | 中 | main.py:269 | JupyterKernelManager._validate_png_bytes(image_data) ——从类外部调用私有方法。同正文 Q-3(core 里 manager 直接改 finder 私有属性)。这个方法本身应该是模块级公开函数(它是纯函数),或者提升为 core 的共享图片校验(见C-0) |
| E1-4 | 低 | jupyter_manager.py:143-144 | _instances / _quarantined_instances 是无上限的 ClassVar。键是 owner::data_dir,因此每个 (管理员, 群) 组合一个实例,隔离实例只增不减。jupyter 是 admin_only 所以实际规模很小,但 AsyncKeyedLockPool 的 max_keys 模式在这里同样适用 |
| E1-5 | 低 | main.py:222-224 | 依赖不可用时,_dependencies_available() 每次调用都会重新执行一次 lazy_import_jupyter() → 一次失败的 import jupyter_client。未安装依赖的部署会为每条 /py 命令付出一次 ImportError。应缓存"已确认不可用"并只在插件重载时重试 |
| E1-6 | 低 | main.py:555-556 | normalized_action = user_text.strip().casefold() if "\n" not in user_text else "" ——单行输入若恰好等于 run/clear/show/help(或中文别名)就会被当作控制指令,无法作为代码行加入缓冲区。例如想输入一行 run 调用某个名为 run 的函数就做不到。REPL 语义上可接受,但帮助文本没说明,应补一句"以空格开头可强制作为代码" |
| E1-7 | 低 | jupyter_manager.py:168 | _cleanup_legacy_figures() 在 __init__ 里同步执行 figures_dir.iterdir()(上限 4 096 项)。get_instance 在 _broken 时会重建实例,因此这段扫描可能被反复触发。属于冷路径,但可以在完成一次后打标记跳过 |
| E1-8 | 说明 | plugin.json | jupyter 是全仓唯一在 manifest 里把可选依赖声明为 "required": false 并配 description 的插件,运行时缺失就返回 DEPENDENCY_TEXT 引导安装。相比之下 color 对 matplotlib/numpy 的可选依赖完全没在 manifest 里声明(只有模块级 find_spec)。应把 jupyter 的做法定为约定 |
arxiv_filter(19 条,其中 6 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| F1-1 | 中 | arxiv_today.py:231 | datetime.strptime(f"{day} {month_name} {year}", "%d %B %Y") —— %B 是 locale 相关的。系统 LC_TIME 不是英文时(例如容器里设了 zh_CN.UTF-8),解析 "February" 失败 → check_arxiv_update_date 返回 None → 定时检查永远认为"arXiv 尚未更新" → 每日论文推送静默失效,日志里只有一句 Could not find arXiv update date in page。这是全仓唯一一处 locale 相关的日期解析,应改成显式英文月份映射表 |
| F1-2 | 中 | arxiv_today.py:70-76 | arxiv.use_ssl_verify: false 配合代理时会 verify=False 关闭 TLS 证书校验,并调用 urllib3.disable_warnings(InsecureRequestWarning)。后者是进程级的——它会让整个 bot 里所有库的不安全 TLS 警告一起消失,不只是 arxiv_filter。TLS 关闭本身对企业 MITM 代理是合理需求,但应:(a) 像 core 的 inbound_trusted_tls_proxy 那样要求显式确认语义并在启动时 WARNING;(b) 绝不做进程级 disable_warnings |
| F1-3 | 中 | main.py:189-206 | _load_inference(force_reload=True) 会遍历 sys.modules 并对本插件的子模块调用 importlib.reload()。这直接与 core 的插件代际导入系统抢所有权——core/plugin_manager.py 花了整整一套 _SourceOnlyPluginFinder / _record_owned_module / _validate_owned_namespace 来保证"一个插件代只对应一组确定的模块对象"。经核对,force_reload=True 在生产代码里没有任何调用点(唯一调用者是 tests/plugins/test_arxiv_filter.py:425),init() 走的是 _inference_func = None。建议直接删除这条分支——它是"只在测试里活着、却能破坏核心不变量"的代码 |
| F1-4 | 中 | main.py:620-625 | 本插件是 DeliveryReceipt 的唯一消费者,rollback=lambda: _release_claim(...)。因此它是正文 P0-1 的第三个落地点:HTTP 投递在消息实际发出后超时 → 回滚释放 claim → last_sent_date 未更新 → 下一次定时检查把整份论文列表重新推送一遍。加上 chime、earthquake,P0-1 已确认影响三个插件 |
| F1-5 | 中 | inference/shared.py:213-239 | resolve_model_path 在配置路径不存在时,会依次回退到硬编码的 best_model / best_model_interest / best_model_knn / best_model_title,只打一条 WARNING。也就是说模型路径写错一个字母,插件会静默改用另一个模型继续出结果——用户完全无从察觉推荐来自错误的模型。回退应该只在配置未显式指定时启用;显式配置找不到就该失败 |
| F2-1 | 中 | — | P0-1 的确认受害者增至三个**:chime(B1-1)、earthquake(C1-1)、arxiv_filter(F1-4)。三者都是"定时投递 + 成功后才推进本地游标"的模式,也都用 bool(await context.send_action(...)) 判定结果。修 HTTP 通道的三态语义会一次修好三个插件的重复推送。这条应是整份报告里投入产出比最高的单点修复 |
| F2-2 | 中 | — | "仓库全量 grep" 的结论必须先分层**。本仓至少有三类非运行时代码:train_model/(离线训练,MANIFEST.in prune)、scripts/(运维脚本)、tests/。初版的 print() 和 {exc} 统计都因为没有排除第一类而得出错误结论。后续任何机械化度量都应先按 pyproject.toml 的 packages 白名单过滤 |
| F1-6 | 低 | main.py:530-587 | _load_update_status / _claim_send_today 在 async 调用链(_check_arxiv_update)里直接做 open()/json.load/os.open/os.unlink,未走 run_sync。文件很小,但与同文件其它路径(run_sync(model_artifact_fingerprint)、run_sync(check_arxiv_update_date))的纪律不一致,也与 bot_core B3-1 同类 |
| F1-7 | 低 | main.py:137 | _FILTER_CACHE 的键里包含 inference 函数对象本身。可用(按身份哈希),但含义模糊;_load_inference 换出新函数对象后旧键要靠"跨日清理"才被回收。用模块版本号或指纹字符串更清晰 |
| F1-8 | 低 | arxiv_today.py:37 | _HTML_BODY_LIMITS 的 max_decompression_ratio=100,而全仓其它 12 处都是 20。实际有 max_decoded_bytes=8 MiB 兜底所以不构成风险,但这个 5 倍的偏离没有注释说明理由 |
| F1-9 | 低 | main.py:273 | f"未知命令: {parsed.first}" 未截断回显(同 A7-2) |
| F1-10 | 低 | main.py:563,599 | _should_send_today / _mark_sent_today 在未传 business_date 时调 _business_now() 不带 context,回退到硬编码 Asia/Shanghai;而 _check_arxiv_update 用的是 _business_now(context)(读配置时区)。当前所有调用点都显式传了日期,所以不触发,但这是一个已经埋好的时区分叉(同 A7-1) |
| F2-3 | 低 | — | 后台任务的生命周期管理只有 arxiv_filter 做全了**。twitter 用模块级全局 _FETCH_TASK(C2-3)、chime/earthquake 没有后台任务、codex 待查。core 可以提供 context.spawn_background(coro) 自动登记并在插件卸载时排空,替代每个插件自己维护一个 set |
| F-1 † | 低 | — | arxiv_filter 运行时(2 346 行) |
| F-2 † | 低 | — | concurrency 只有 9/30 声明,且漏了最需要它的几个 |
| F-3 † | 低 | — | watch_files 只有 1/30 使用 |
| F-4 † | 低 | — | 入口钩子签名不一致(同步 / 异步混用) |
| F-5 † | 低 | — | 插件版本号无约定 |
| F-6 † | 低 | — | 一个插件(url_parser)完全不通过命令路由 |
详述(6 条,† 标记的条目)
F-1 [低] arxiv_filter 运行时(2 346 行)
整体:这是全仓唯一同时用到三项 core 高级设施的插件—— core.delivery.DeliveryReceipt(提交后确认)、 capabilities.codex_arxiv_summary(core 签发的跨插件窄桥)、 以及跨进程的原子 claim 文件。缓存分三层且各有正确的失效维度: 业务日期 → 模型产物指纹 → 配置指纹。
model_artifact_fingerprint(inference/shared.py:109-145)值得单独表扬: 小文件(≤1 MiB)做内容哈希、大权重文件只用 size+mtime_ns, 显式避免对多 GB checkpoint 做全量哈希,注释也写明了这个取舍。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| F1-1 | 中 | arxiv_today.py:231 | datetime.strptime(f"{day} {month_name} {year}", "%d %B %Y") —— %B 是 locale 相关的。系统 LC_TIME 不是英文时(例如容器里设了 zh_CN.UTF-8),解析 "February" 失败 → check_arxiv_update_date 返回 None → 定时检查永远认为"arXiv 尚未更新" → 每日论文推送静默失效,日志里只有一句 Could not find arXiv update date in page。这是全仓唯一一处 locale 相关的日期解析,应改成显式英文月份映射表 |
| F1-2 | 中 | arxiv_today.py:70-76 | arxiv.use_ssl_verify: false 配合代理时会 verify=False 关闭 TLS 证书校验,并调用 urllib3.disable_warnings(InsecureRequestWarning)。后者是进程级的——它会让整个 bot 里所有库的不安全 TLS 警告一起消失,不只是 arxiv_filter。TLS 关闭本身对企业 MITM 代理是合理需求,但应:(a) 像 core 的 inbound_trusted_tls_proxy 那样要求显式确认语义并在启动时 WARNING;(b) 绝不做进程级 disable_warnings |
| F1-3 | 中 | main.py:189-206 | _load_inference(force_reload=True) 会遍历 sys.modules 并对本插件的子模块调用 importlib.reload()。这直接与 core 的插件代际导入系统抢所有权——core/plugin_manager.py 花了整整一套 _SourceOnlyPluginFinder / _record_owned_module / _validate_owned_namespace 来保证"一个插件代只对应一组确定的模块对象"。经核对,force_reload=True 在生产代码里没有任何调用点(唯一调用者是 tests/plugins/test_arxiv_filter.py:425),init() 走的是 _inference_func = None。建议直接删除这条分支——它是"只在测试里活着、却能破坏核心不变量"的代码 |
| F1-4 | 中 | main.py:620-625 | 本插件是 DeliveryReceipt 的唯一消费者,rollback=lambda: _release_claim(...)。因此它是正文 P0-1 的第三个落地点:HTTP 投递在消息实际发出后超时 → 回滚释放 claim → last_sent_date 未更新 → 下一次定时检查把整份论文列表重新推送一遍。加上 chime、earthquake,P0-1 已确认影响三个插件 |
| F1-5 | 中 | inference/shared.py:213-239 | resolve_model_path 在配置路径不存在时,会依次回退到硬编码的 best_model / best_model_interest / best_model_knn / best_model_title,只打一条 WARNING。也就是说模型路径写错一个字母,插件会静默改用另一个模型继续出结果——用户完全无从察觉推荐来自错误的模型。回退应该只在配置未显式指定时启用;显式配置找不到就该失败 |
| F1-6 | 低 | main.py:530-587 | _load_update_status / _claim_send_today 在 async 调用链(_check_arxiv_update)里直接做 open()/json.load/os.open/os.unlink,未走 run_sync。文件很小,但与同文件其它路径(run_sync(model_artifact_fingerprint)、run_sync(check_arxiv_update_date))的纪律不一致,也与 bot_core B3-1 同类 |
| F1-7 | 低 | main.py:137 | _FILTER_CACHE 的键里包含 inference 函数对象本身。可用(按身份哈希),但含义模糊;_load_inference 换出新函数对象后旧键要靠"跨日清理"才被回收。用模块版本号或指纹字符串更清晰 |
| F1-8 | 低 | arxiv_today.py:37 | _HTML_BODY_LIMITS 的 max_decompression_ratio=100,而全仓其它 12 处都是 20。实际有 max_decoded_bytes=8 MiB 兜底所以不构成风险,但这个 5 倍的偏离没有注释说明理由 |
| F1-9 | 低 | main.py:273 | f"未知命令: {parsed.first}" 未截断回显(同 A7-2) |
| F1-10 | 低 | main.py:563,599 | _should_send_today / _mark_sent_today 在未传 business_date 时调 _business_now() 不带 context,回退到硬编码 Asia/Shanghai;而 _check_arxiv_update 用的是 _business_now(context)(读配置时区)。当前所有调用点都显式传了日期,所以不触发,但这是一个已经埋好的时区分叉(同 A7-1) |
正面:codex_summary.py:86-93 把 fire-and-forget 任务登记进 context.state["arxiv_background_tasks"] 并加 add_done_callback(discard), shutdown() 里统一 cancel + gather。这是全仓唯一把后台任务生命周期 管理干净的插件,应作为范例写进插件开发文档。
F-2 [低] concurrency 只有 9/30 声明,且漏了最需要它的几个
| 编号 | 级别 | 内容 |
|---|---|---|
| F2-1 | 中 | P0-1 的确认受害者增至三个:chime(B1-1)、earthquake(C1-1)、arxiv_filter(F1-4)。三者都是"定时投递 + 成功后才推进本地游标"的模式,也都用 bool(await context.send_action(...)) 判定结果。修 HTTP 通道的三态语义会一次修好三个插件的重复推送。这条应是整份报告里投入产出比最高的单点修复 |
| F2-2 | 中 | "仓库全量 grep" 的结论必须先分层。本仓至少有三类非运行时代码:train_model/(离线训练,MANIFEST.in prune)、scripts/(运维脚本)、tests/。初版的 print() 和 {exc} 统计都因为没有排除第一类而得出错误结论。后续任何机械化度量都应先按 pyproject.toml 的 packages 白名单过滤 |
| F2-3 | 低 | 后台任务的生命周期管理只有 arxiv_filter 做全了。twitter 用模块级全局 _FETCH_TASK(C2-3)、chime/earthquake 没有后台任务、codex 待查。core 可以提供 context.spawn_background(coro) 自动登记并在插件卸载时排空,替代每个插件自己维护一个 set |
F-3 [低] watch_files 只有 1/30 使用
只有 xiaoqing_chat 声明了 2 个。其他有运维手改 JSON 需求的插件 (color 调色板、smalltalk 语料、minecraft 服务器表)没有热更新路径,改了必须 重载插件。core 已经把这个能力做完了,插件层没用。
F-4 [低] 入口钩子签名不一致(同步 / 异步混用)
init 在 15 个插件里是 def,在 qingpet 里是 async def; call_bot_name_only 在 smalltalk 是 def、在 xiaoqing_chat 是 async def。
core 的 call_plugin_callback(plugin_execution.py:1279)两种都支持, 但同步回调会被丢进 PluginSyncBroker 的线程池。对 smalltalk.call_bot_name_only 这种"读个 JSON 返回一句话"的函数,代价是 一次 contextvars 拷贝 + broker 准入 + 队列跳转 + 工作线程往返——而它还在 闲聊热路径上。
建议:文档里明确"生命周期与热路径钩子一律 async def",或者让 call_plugin_callback 对声明为轻量的回调直接内联执行。
F-5 [低] 插件版本号无约定
范围从 arxiv_filter 的 v0.1.0 到 minecraft 的 v4.0.0,与项目版本无关, 也没有 per-plugin changelog。PluginDefinition.version 目前只在加载时打日志。 要么给它定义(例如"manifest 契约变更时递增"),要么停止手工维护。
F-6 [低] 一个插件(url_parser)完全不通过命令路由
plugins/url_parser/plugin.json 的 commands 为空,handle() 是空壳 (main.py:320-329,只为满足 PluginManager 存在)。它唯一的入口是 dispatcher 里硬编码的 plugin_registry.get("url_parser")。这属于下一节的问题。
qingssh(26 条,其中 7 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| G1-1 | 中 | ssh_manager.py:1172 | except Exception as exc: return False, f"❌ 连接失败: {exc}" —— 裸 except。key_filename 指向的私钥不存在时抛 FileNotFoundError,消息里带完整私钥路径;socket.gaierror 带主机名。这是 P0-2 里最实的一处 |
| G1-2 | 中 | ssh_manager.py:1653 | download_file 的 except Exception 上面几行刚用 summarize_sensitive 把远端/本地路径做成指纹写日志(有意不记录原文),下一行却 return False, f"❌ 下载失败: {exc}" 把带路径的异常原样发给用户。同一函数内自相矛盾 |
| G1-3 | 中 | ssh_manager.py:1594 | execute_command 的 except Exception as exc: return False, f"❌ 执行失败: {exc}" —— 裸 except |
| G1-4 | 中 | ssh_manager.py:1537 | _execute_command_stream_impl 的 await output_callback(f"\n❌ 执行出错: {exc}") —— 把原始异常流式写进 SSH 会话输出。紧接着的日志分支反而做了完整的 summarize_sensitive 脱敏 |
| G1-5 | 中 | ssh_manager.py:1096 | _authentication_failure_message 设计上输出 f" 3. 密钥路径: {server.get('key_path','N/A')}"。配合 G-0(群聊可用),一次认证失败就把 bot 主机上的私钥路径广播给全群 |
| G2-1 | 中 | — | 见 G-0:contexts 字段存在但高权限插件全未使用,bot_core 反而用 Python 硬编码实现同一语义。这条与正文 F-2(manifest 字段一致性)和 S-1(core 硬编码插件名单)是同一类问题——**manifest 已经能表达的策略,却散落在 Python 代码里各写一遍 |
| G2-2 | 中 | — | "日志脱敏做得好、用户文案却泄露"是一个反复出现的组合**。qingssh 有四处(G1-2/G1-4 最典型:同一函数里上一行做指纹、下一行发原文),shell 有一处(正文 P0-2)。根因是两条路径由不同的人在不同时间加固:审计日志走 summarize_sensitive,用户回复没人管。修复时应把"用户可见字符串"也纳入同一条 CI 规则 |
| H2-1 | 中 | output_relay.py:483 | 输出被截断时向聊天发送 f"{label}:{archive_path}" —— 把 bot 主机上的绝对归档路径广播出去。配合 G-0(群聊可用),群成员能看到 bot 的数据目录布局。应只发文件名或一个短 ID,让管理员私聊索取完整路径 |
| H3-1 | 中 | session_handlers.py:776,1041 | action["_bypass_sink"] = True —— 往 OneBot action 里注入一个未在任何 Protocol 或 manifest 中声明的私有协议字段,由 core 侧消费。这与 core/delivery.py 的 _delivery_receipt、core/onebot.py 的 _result_message_id(正文 C-39)属于同一类:用魔法字典键在插件与 core 之间传递控制信息。至少应在 core/interfaces.py 里定义一组具名常量并写明语义 |
| G1-6 | 低 | ssh_manager.py:1103 | _ssh_failure_message 对非 host-key 类 paramiko.SSHException 返回 f"❌ SSH 连接错误: {exc}"。paramiko 的消息通常不含凭据,但会带主机名与协商细节 |
| G1-7 | 低 | path_resolver.py:49-60 | extract_cwd_from_output 从命令输出里倒序找第一个以 / 开头的行当作新 CWD。但触发条件只是 is_cd_command(text)(文本以 cd 开头),因此 cd /tmp && find / -name x 这类命令的输出会污染会话 CWD——最后一条路径样式的输出行会被当成 pwd 结果。应改为用唯一标记包裹 pwd 输出再提取 |
| G1-8 | 低 | ssh_manager.py:1747 | get_manager 每次调用都执行 existing.context = context,把管理器的 self.context 换成最近一次请求的上下文。长时间运行的后台命令随后用 self.context 记日志时,request_id 会指向另一个不相关的请求,破坏审计链路可追溯性 |
| G1-9 | 低 | main.py:196,206 | 从模块外调用 manager._build_connection_key / _parse_connection_key 两个私有方法(同正文 Q-3、附录 E1-3) |
| G1-10 | 低 | ssh_manager.py:1721-1735 | close_all 在事件循环上同步调用 client.close()(经 _disconnect_key)。paramiko 的 close 会做 socket 拆除,可能阻塞。其余路径都用了 asyncio.to_thread,这里没有 |
| G2-3 | 低 | — | qingssh 是 sequential 并发模型的两个使用者之一**(另一个是 signin)。对持有 SSH 连接状态的插件这是正确选择,但它意味着同一插件的所有命令全局串行——一个用户的 30 秒命令会阻塞其它用户的 /ssh list。core 的 PluginExecutionGate 只支持 per-plugin 的 parallel/sequential,不支持"按会话键串行"。这正是 qingssh 自己又实现了一套 _connection_locks / _termination_locks 键控锁的原因——建议 core 提供 concurrency: "keyed" 并由插件声明键函数 |
| H2-2 | 低 | output_relay.py:67-75 | SSHOutputPolicy.from_context 是第 13 处手写 context.config["plugins"][name] 遍历(正文 R-1) |
| H3-2 | 低 | session_handlers.py:1004-1008 | 清理 images_dir 里超过 1 小时的临时图片时,直接在事件循环上 iterdir() + stat() + unlink(),且没有条目数上限。core 已有 BoundedFileCache 做同样的事(url_parser、voice、twitter、earthquake 都在用),这里手写了一份更弱的 |
| H3-3 | 低 | session_handlers.py:109-111 | _COMMAND_JOBS / _CURRENT_JOB_BY_KEY / _SHUTTING_DOWN 是模块级全局。跨会话的任务表放模块级是合理的(context.state 是 per-plugin 而非 per-session,其实也够用),但与 twitter(C2-3)一样缺少统一约定 |
| H3-4 | 低 | session_handlers.py:662 | var_value = export_match.group(2).strip('"').strip("'") —— 朴素去引号,不处理转义。因为 build_command 会对值做 shlex.quote,不构成注入,但 export X='a"b' 之类的值会被改写。应改用 shlex.split 解析赋值右侧 |
| G-0 † | 低 | — | 新发现(跨插件,P1):五个高权限插件全部允许在群聊中使用 |
| G-1 † | 低 | — | qingssh 核心(2 204 行)—— 安全设计明显强于其错误文案 |
| G1-11 † | 低 | — | 又一处对 core/args.py 的明文控诉 |
| H-1 † | 低 | — | 泄露链路已闭合 |
| H-2 † | 低 | — | output_relay.py(541 行)—— 全仓最完整的"大输出投影"实现 |
| H-3 † | 低 | — | session_handlers.py(1 057 行) |
| H-4 † | 低 | — | qingssh 总评 |
详述(7 条,† 标记的条目)
G-0 [低] 新发现(跨插件,P1):五个高权限插件全部允许在群聊中使用
core/models.py:273 把 contexts 的默认值定为 ["private", "group"]。 逐个核对五个高权限插件的 manifest,没有一个声明 contexts:
| 插件 | 命令 | 声明的 contexts | 后果 |
|---|---|---|---|
shell | /shell | 默认(含群聊) | ls /home、cat 的输出发到整个群 |
jupyter | /py、/kernel | 默认(含群聊) | 代码执行结果与 traceback 发到整个群 |
qingssh | 8 个命令 | 默认(含群聊) | 认证失败信息(含 SSH 私钥路径)、远端 shell 输出流式发到整个群 |
codex | /codex | 默认(含群聊) | 同上 |
minecraft | /mc 等 | 默认(含群聊) | RCON 输出发到整个群 |
admin_only: true 只限制谁能发起,不限制谁能看见回复。 群里任何成员都能读到管理员执行的远程命令输出。
有意思的是 bot_core 证明了作者意识到这个问题——但它是在 Python 里硬编码的:
if command == "set_secret":
if event.get("group_id") is not None:
return segments("❌ /set_secret 只能由管理员在私聊中使用")也就是说:manifest 里有 contexts 字段可以声明式表达"仅私聊", 但真正需要它的五个插件没用,唯一用到该语义的插件却绕过它自己写了检查。 核心的 _execute_command(core/dispatcher.py:643-645)已经会执行 contexts 校验并回复"当前会话类型不支持此命令",机制是现成的。
建议:给这五个插件的敏感命令加上 "contexts": ["private"], 并把 bot_core 的硬编码检查替换为同一字段。
G-1 [低] qingssh 核心(2 204 行)—— 安全设计明显强于其错误文案
值得单独表扬的六点
paramiko.RejectPolicy()(ssh_manager.py:1005跳板机、1125目标机) —— 未知 host key 直接拒绝。大量 SSH 机器人实现用的是AutoAddPolicy, 那等于放弃 MITM 防护。这里两处都选对了。- ProxyCommand 只接受安全的
ssh -W形式 (_parse_proxyjump_command,ssh_manager.py:170-253)。任意 ProxyCommand 字符串本质是本地命令执行——一个被篡改的~/.ssh/config就能拿到 bot 进程权限。这里显式解析并在不匹配时抛UnsupportedProxyCommand,是本插件最重要的一道防线。 - 密码绝不落进
servers.json:_load_servers(435-492)把历史遗留的 明文密码事务式迁移到插件密钥库,失败时_rollback_secret_refs回收已创建的引用;add_server(647-726)同样是"先写密钥、再写配置、 任一步失败即回滚"。 - 远端进程组可终止:命令被包装成
setsid sh -c <cmd> & pid=$!; printf '__XQ_PID__%s\n' "$pid"; wait "$pid"(1467-1469),拿到 PID 后用kill -TERM -- -<pid>杀整个进程组。 确认不了时如实报告"⚠️ 远端进程终止状态未知,请登录服务器确认" (1405),而不是假装成功。 - 取消安全:
_finish_blocking_call(113-128)在调用方被反复取消时 仍等待阻塞写完成;execute_command_stream的CancelledError分支 会 shield 住清理任务直到收敛(1407-1428)。 - 连接容量预留:
_reserve_connection_slot(854-862)用connections | _pending_connection_keys一起计数,防止并发拨号突破max_connections。
问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| G1-1 | 中 | ssh_manager.py:1172 | except Exception as exc: return False, f"❌ 连接失败: {exc}" —— 裸 except。key_filename 指向的私钥不存在时抛 FileNotFoundError,消息里带完整私钥路径;socket.gaierror 带主机名。这是 P0-2 里最实的一处 |
| G1-2 | 中 | ssh_manager.py:1653 | download_file 的 except Exception 上面几行刚用 summarize_sensitive 把远端/本地路径做成指纹写日志(有意不记录原文),下一行却 return False, f"❌ 下载失败: {exc}" 把带路径的异常原样发给用户。同一函数内自相矛盾 |
| G1-3 | 中 | ssh_manager.py:1594 | execute_command 的 except Exception as exc: return False, f"❌ 执行失败: {exc}" —— 裸 except |
| G1-4 | 中 | ssh_manager.py:1537 | _execute_command_stream_impl 的 await output_callback(f"\n❌ 执行出错: {exc}") —— 把原始异常流式写进 SSH 会话输出。紧接着的日志分支反而做了完整的 summarize_sensitive 脱敏 |
| G1-5 | 中 | ssh_manager.py:1096 | _authentication_failure_message 设计上输出 f" 3. 密钥路径: {server.get('key_path','N/A')}"。配合 G-0(群聊可用),一次认证失败就把 bot 主机上的私钥路径广播给全群 |
| G1-6 | 低 | ssh_manager.py:1103 | _ssh_failure_message 对非 host-key 类 paramiko.SSHException 返回 f"❌ SSH 连接错误: {exc}"。paramiko 的消息通常不含凭据,但会带主机名与协商细节 |
| G1-7 | 低 | path_resolver.py:49-60 | extract_cwd_from_output 从命令输出里倒序找第一个以 / 开头的行当作新 CWD。但触发条件只是 is_cd_command(text)(文本以 cd 开头),因此 cd /tmp && find / -name x 这类命令的输出会污染会话 CWD——最后一条路径样式的输出行会被当成 pwd 结果。应改为用唯一标记包裹 pwd 输出再提取 |
| G1-8 | 低 | ssh_manager.py:1747 | get_manager 每次调用都执行 existing.context = context,把管理器的 self.context 换成最近一次请求的上下文。长时间运行的后台命令随后用 self.context 记日志时,request_id 会指向另一个不相关的请求,破坏审计链路可追溯性 |
| G1-9 | 低 | main.py:196,206 | 从模块外调用 manager._build_connection_key / _parse_connection_key 两个私有方法(同正文 Q-3、E1-3) |
| G1-10 | 低 | ssh_manager.py:1721-1735 | close_all 在事件循环上同步调用 client.close()(经 _disconnect_key)。paramiko 的 close 会做 socket 拆除,可能阻塞。其余路径都用了 asyncio.to_thread,这里没有 |
G1-11 · 又一处对 core/args.py 的明文控诉
main.py:64-66 的注释:
"
/ssh add后面全是位置参数;Core 的通用选项解析器会把以-开头的主机名 吞成选项,继而让后续字段错位。这里只拆一级子命令,剩余原文完整交给对应 处理器验证。"
这是A-0 的第五个独立确认,也是最直白的一个——qingssh 因为 core.args.parse 会破坏主机名而完全不用它。至此: wolframalpha 与 astro_tools 有可复现 bug, dict/choice/github/guess_number/adnmb/color/qingssh 七个插件 各自绕开。建议把 A-0 的修复提到 P1 并作为一个独立任务排期。
G1-11 [低] 又一处对 core/args.py 的明文控诉
main.py:64-66 的注释:
"
/ssh add后面全是位置参数;Core 的通用选项解析器会把以-开头的主机名 吞成选项,继而让后续字段错位。这里只拆一级子命令,剩余原文完整交给对应 处理器验证。"
这是A-0 的第五个独立确认,也是最直白的一个——qingssh 因为 core.args.parse 会破坏主机名而完全不用它。至此: wolframalpha 与 astro_tools 有可复现 bug, dict/choice/github/guess_number/adnmb/color/qingssh 七个插件 各自绕开。建议把 A-0 的修复提到 P1 并作为一个独立任务排期。
H-1 [低] 泄露链路已闭合
| 产生点 | 传播点 | 用户可见 |
|---|---|---|
ssh_manager.py:1172 f"❌ 连接失败: {exc}" | handlers.py:420 segments(f"❌ {message}") | ✅ |
ssh_manager.py:1103 f"❌ SSH 连接错误: {exc}" | 同上 | ✅ |
ssh_manager.py:1653 f"❌ 下载失败: {exc}" | session_handlers.py:1029 errors.append(f"{filename}: {message}") → 1045-1050 | ✅ |
ssh_manager.py:1537 f"\n❌ 执行出错: {exc}" | output_callback → SSHOutputRelay.feed → QQ | ✅ |
修复点集中在 ssh_manager.py,四处都改成"固定文案 + audit_error_type(exc) 进日志"即可, 不需要动会话层。
H-2 [低] output_relay.py(541 行)—— 全仓最完整的"大输出投影"实现
这个模块解决的是一个真问题:SSH 命令可能吐出几百 MB, 而 QQ 单条消息只有几千字。它的做法值得作为范本:
- 首尾双预算:QQ 侧保留
qq_head_chars开头 +qq_tail_chars结尾,_tail_without_head_overlap()(354-357)还会扣掉两者重叠的部分, 避免短输出被重复展示; - 归档也是首尾预算:
archive_max_bytes减去archive_tail_bytes作为头部, 超出后中段丢弃并写入--- output exceeded archive budget; middle omitted ---标记; - UTF-8 边界安全:
_safe_utf8_prefix(267-271)和_append_archive_tail(273-288)在字节层截断时都先decode(errors="ignore").encode(),不会产生半个字符; - 批量落盘:
_record_archive_payload在事件循环里累积到 256 KiB 才交给线程,且同时持有asyncio.Lock和threading.Lock(取消后仍在跑的后台线程也不能互相踩踏); - 发送限速:
_sender_loop有 per-action 数量、总字符数、 发送间隔和单次发送超时四重约束; - 归档权限与保留:
chmod(0o600)、保留最近 20 个文件、abort()会删掉已提交的归档。 validate()检查预算之间的相容性(137-140),例如qq_head_chars不能超过"预留 action 容量"。这类"配置项之间"的 交叉校验在全仓也很少见。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| H2-1 | 中 | output_relay.py:483 | 输出被截断时向聊天发送 f"{label}:{archive_path}" —— 把 bot 主机上的绝对归档路径广播出去。配合 G-0(群聊可用),群成员能看到 bot 的数据目录布局。应只发文件名或一个短 ID,让管理员私聊索取完整路径 |
| H2-2 | 低 | output_relay.py:67-75 | SSHOutputPolicy.from_context 是第 13 处手写 context.config["plugins"][name] 遍历(正文 R-1) |
H-3 [低] session_handlers.py(1 057 行)
整体:会话代次的 CAS 协议是全仓最复杂也最严谨的一处并发设计。 后台命令任务要连过三道校验才能碰 SSH:
_find_job(job_key, server_name, job_id)且registered.task is current_task且_CURRENT_JOB_BY_KEY[job_key] == job_id(798-805);_session_job_is_current()通过update_session进入会话事务 —— 这会等待父事务提交,父事务回滚/删除/替换后子任务直接返回(807-812);- 收尾用
_commit_job_result_resilient,在反复取消下仍先完成 "比较并交换"再传播取消(238-271)。
双索引设计(_COMMAND_JOBS 按 job_id、_CURRENT_JOB_BY_KEY 按会话键) 的注释也说清了为什么需要两个:新会话代次覆盖索引后,关闭流程仍能找到旧任务。
其它亮点:
- 命令历史的凭据过滤(
_contains_sensitive_history_data,122-133): 四类正则覆盖赋值式(API_KEY=...)、选项式(--token x)、 请求头式(Authorization:)和 URL 内嵌凭据(scheme://u:p@host)。 而且_session_history在读取时也会清洗旧版本遗留的条目, 不只是写入时拦截。 - 密码只能在私聊输入(481-484、501-503):
auth_type选择密码认证时, 若current_group_id is not None直接拒绝并提示改用密钥/Agent。 这说明作者确实考虑过群聊暴露问题——但同样是在 Python 里硬编码, 而不是用 manifest 的contexts字段(G-0)。 - 环境变量四重上限:单值长度、变量个数、总长度、名称正则。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| H3-1 | 中 | session_handlers.py:776,1041 | action["_bypass_sink"] = True —— 往 OneBot action 里注入一个未在任何 Protocol 或 manifest 中声明的私有协议字段,由 core 侧消费。这与 core/delivery.py 的 _delivery_receipt、core/onebot.py 的 _result_message_id(正文 C-39)属于同一类:用魔法字典键在插件与 core 之间传递控制信息。至少应在 core/interfaces.py 里定义一组具名常量并写明语义 |
| H3-2 | 低 | session_handlers.py:1004-1008 | 清理 images_dir 里超过 1 小时的临时图片时,直接在事件循环上 iterdir() + stat() + unlink(),且没有条目数上限。core 已有 BoundedFileCache 做同样的事(url_parser、voice、twitter、earthquake 都在用),这里手写了一份更弱的 |
| H3-3 | 低 | session_handlers.py:109-111 | _COMMAND_JOBS / _CURRENT_JOB_BY_KEY / _SHUTTING_DOWN 是模块级全局。跨会话的任务表放模块级是合理的(context.state 是 per-plugin 而非 per-session,其实也够用),但与 twitter(C2-3)一样缺少统一约定 |
| H3-4 | 低 | session_handlers.py:662 | var_value = export_match.group(2).strip('"').strip("'") —— 朴素去引号,不处理转义。因为 build_command 会对值做 shlex.quote,不构成注入,但 export X='a"b' 之类的值会被改写。应改用 shlex.split 解析赋值右侧 |
H-4 [低] qingssh 总评
4 428 行读完后的判断:这是全仓安全设计最用心的插件, 也是用户可见文案最不设防的插件——两者反差极大。
安全侧:RejectPolicy 拒绝未知 host key、ProxyCommand 只认 ssh -W、 密码事务式迁移进密钥库、远端进程组可终止且如实报告不确定、 命令历史过滤凭据、密码禁止群聊输入、输出归档 0o600 且首尾双预算、 会话代次三重 CAS。这些每一条都是"想过攻击面才会写出来"的代码。
文案侧:四处裸 except Exception 把原始异常发给用户, 认证失败帮助里主动打印私钥路径,输出截断提示里打印 bot 本地归档路径, 而这些消息默认可以发到群聊。
根因是本仓反复出现的那条:审计日志有统一的脱敏纪律 (summarize_sensitive + audit_error_type),用户可见字符串没有。download_file 是最直白的例子——上一行做指纹、下一行发原文。
修复成本很低:四处改文案 + 一处删路径 + 一处改归档提示 + manifest 加 "contexts": ["private"],不触碰任何并发或连接逻辑。
minecraft(10 条,其中 1 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| I1-1 | 中 | main.py:676-679 | _deliver_log_batch 只在 _send_mc_action 返回 True 时才 _commit_log_batch。这使 minecraft 成为正文 P0-1 的第四个受害者:HTTP 投递在消息实际发出后超时 → 返回 False → 日志游标不推进 且令牌桶退款 → 下一个 tick 必然重发同一批 MC 聊天/死亡消息。前三个是 chime / earthquake / arxiv_filter |
| I1-2 | 中 | main.py:206 + .gitignore:94 | RCON 密码存放在 plugins/minecraft/config.json,是全仓唯一把凭据放在 plugin_dir 而不是 secrets.json 的插件。文件确实已被 .gitignore 排除且未被 git 跟踪(已核实),所以不是"密钥入库"问题,但它带来四个后果:(a) core 的 _check_secrets_file_permissions 不覆盖它;(b) 不受 secrets 的失败闭合/热更新机制保护;(c) /get_secret、/set_secret 管不了它;(d) 它位于 plugin_dir 内,会进入 _authorize_plugin_snapshot 的源码指纹——改密码等于改插件指纹并触发重载。应迁到 secrets.plugins.minecraft.<profile> |
| I2-1 | 中 | — | P0-1 受害者增至四个**:chime(B1-1)、earthquake(C1-1)、arxiv_filter(F1-4)、minecraft(I1-1)。四者都是"投递成功才推进本地游标",都用 await context.send_action(...) 的布尔返回值判定。这已经不是个别插件的问题,而是 core 的投递契约缺一个状态导致的系统性缺陷。修 OneBotHttpSender 的三态语义可一次修好四个插件的重复推送 |
| I2-2 | 中 | — | "收窄外部文本"的第六份实现**。minecraft._bounded_text(字符+字节双上限,二分查找,剥 ANSI/控制字符)是目前最完整的;另有 signin._safe_text、chime._extract_scalar、github._clean_element_text、earthquake._extract_clean_text、qingssh 的多处。六份实现、六种强度。应统一为 core.plugin_base.bounded_external_text(value, *, max_chars, max_bytes, strip_ansi=True),直接采用 minecraft 的实现 |
| I1-3 | 低 | rcon.py:434-438 | 多包响应的续读循环在 TimeoutError 时 break,注释说明是因为"Minecraft 对长度恰为整块的响应不发送额外终止包"。代价是慢速服务端会让响应被静默截断当成完整结果。RESPONSE_CHUNK_TIMEOUT = 0.5s 兜底,权衡合理,但应在截断时给用户一个提示位 |
| I1-4 | 低 | rcon.py 全文 | Source RCON 协议本身是明文,密码以 LOGIN 包裸传。这是协议固有属性不是代码缺陷,但 README.md / docs/09-plugins.md 应明确写出"RCON 端口不得暴露在不可信网络,建议只监听 127.0.0.1 并通过 SSH 隧道访问" |
| I1-5 | 低 | main.py:83-86 | _manager / _event_buckets / _delivery_cursor / _schedule_lock 是模块级全局。与 twitter(C2-3)、qingssh(H3-3)同一模式,仍缺统一约定 |
| I1-6 | 低 | main.py:1683 全文 | 与 G-0 一致:plugin.json 未声明 contexts,/mc <server command> 的 RCON 输出默认可发到群聊 |
| I2-3 | 低 | — | 凭据存放位置有三种**:secrets.json(多数插件)、plugin_dir/config.json(minecraft)、plugin_dir/data/servers.json + secrets 引用(qingssh 的混合模式)。docs/06-configuration.md 应给出唯一约定:所有凭据一律进 secrets.json,插件目录只放非敏感配置 |
| I-1 † | 低 | — | minecraft(1 683 行) |
详述(1 条,† 标记的条目)
I-1 [低] minecraft(1 683 行)
整体:这是全仓唯一从零实现二进制协议的插件,而且实现质量很高。 洪泛控制(令牌桶 + 轮转游标 + 批次折叠)也是全仓唯一一处。
值得作为范本的五点
- Source RCON 协议实现严谨(
rcon.py):包长上下界、双 NUL 终止符校验、 载荷内嵌 NUL 拒绝、严格 UTF-8 解码、request_id 与 packet_type 双重匹配 (防止响应错配)、认证拒绝按 Source 约定检测request_id == -1, 还处理了"部分服务端先回一个同 ID 空 RESPONSE"的实现差异(388-394)。 _may_have_continuation同时按 UTF-8 字节数和 Java UTF-16 码元数判断 (rcon.py:409-412)——因为 Minecraft 是按 Java char 而不是字节拆分响应的。 这是一个只有踩过坑才会写出来的细节。- 日志游标的失败模式想清楚了:
_load_saved_position(105-124)在状态文件 损坏时返回current_size(从文件末尾开始)而不是 0,注释写明理由是 "避免重放整份历史日志造成洪泛"。这是把"损坏"和"洪泛"两个风险一起权衡后的选择。 - 日志轮换检测用
dev:ino文件身份,且_read_window用同一个文件句柄 做 fstat 和读取(247 行注释:"避免日志轮换时把两个不同文件拼在一起")。 积压超过 1 MiB 时只读 tail,并统计跳过的字节数和行数一并告知用户。 - 令牌桶 + 退款:
_EventTokenBucket(容量 24、每秒补 0.5)限制事件转发速率, 投递失败时refund(granted)(main.py:679)把配额还回去;_delivery_cursor做轮转,保证多连接场景下没有连接被饿死;scheduled用_schedule_lock.locked()直接跳过而不是排队,避免 tick 堆积。
另外 _bounded_text(main.py:495-516)用二分查找同时满足字符数和 UTF-8 字节数两个上限,并先剥离 ANSI 转义与控制字符——是这一族收窄函数里 写得最完整的一个。
问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| I1-1 | 中 | main.py:676-679 | _deliver_log_batch 只在 _send_mc_action 返回 True 时才 _commit_log_batch。这使 minecraft 成为正文 P0-1 的第四个受害者:HTTP 投递在消息实际发出后超时 → 返回 False → 日志游标不推进 且令牌桶退款 → 下一个 tick 必然重发同一批 MC 聊天/死亡消息。前三个是 chime / earthquake / arxiv_filter |
| I1-2 | 中 | main.py:206 + .gitignore:94 | RCON 密码存放在 plugins/minecraft/config.json,是全仓唯一把凭据放在 plugin_dir 而不是 secrets.json 的插件。文件确实已被 .gitignore 排除且未被 git 跟踪(已核实),所以不是"密钥入库"问题,但它带来四个后果:(a) core 的 _check_secrets_file_permissions 不覆盖它;(b) 不受 secrets 的失败闭合/热更新机制保护;(c) /get_secret、/set_secret 管不了它;(d) 它位于 plugin_dir 内,会进入 _authorize_plugin_snapshot 的源码指纹——改密码等于改插件指纹并触发重载。应迁到 secrets.plugins.minecraft.<profile> |
| I1-3 | 低 | rcon.py:434-438 | 多包响应的续读循环在 TimeoutError 时 break,注释说明是因为"Minecraft 对长度恰为整块的响应不发送额外终止包"。代价是慢速服务端会让响应被静默截断当成完整结果。RESPONSE_CHUNK_TIMEOUT = 0.5s 兜底,权衡合理,但应在截断时给用户一个提示位 |
| I1-4 | 低 | rcon.py 全文 | Source RCON 协议本身是明文,密码以 LOGIN 包裸传。这是协议固有属性不是代码缺陷,但 README.md / docs/09-plugins.md 应明确写出"RCON 端口不得暴露在不可信网络,建议只监听 127.0.0.1 并通过 SSH 隧道访问" |
| I1-5 | 低 | main.py:83-86 | _manager / _event_buckets / _delivery_cursor / _schedule_lock 是模块级全局。与 twitter(C2-3)、qingssh(H3-3)同一模式,仍缺统一约定 |
| I1-6 | 低 | main.py:1683 全文 | 与 G-0 一致:plugin.json 未声明 contexts,/mc <server command> 的 RCON 输出默认可发到群聊 |
一个被正确处理的攻击面
_parse_line(356-384)先试 CHAT_PATTERN,再试 presence / advancement / death。 因为聊天行的 body 形如 <RealPlayer> 任意文本,而玩家名模式被限制为 [A-Za-z0-9_]{1,16}(Minecraft 的真实约束),所以玩家无法通过发送 Notch was slain by Herobrine 这样的聊天内容伪造一条死亡事件—— CHAT_PATTERN 会先匹配并把整句归属给真实发送者。 所有 body 正则都用 fullmatch + \Z 锚定。这条防线没有被注释提及, 但从模式顺序和锚定方式看是有意为之。
codex(40 条,其中 10 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| J-1 † | P1 | — | config.py:10 把开发者个人路径硬编码为出厂默认 |
| J3-1 | 中 | manager.py:435-438 | _rewrite_state_with_backup 靠连续调用两次 self._save() 来同时刷新主文件和 .bak。这依赖 core/atomic_store.AtomicJsonStore.write() "每次写入前把当前内容复制为 .bak" 这一实现细节,而不是任何公开契约。正文 C-08 建议过取消"每次写都产生 .bak"以省 I/O——若照做,这里会静默失去崩溃恢复备份。要么给 AtomicJsonStore 加一个显式的 write_with_backup() API,要么在此处加注释锁定该依赖 |
| J3-2 | 中 | manager.py:165-214 | _ResizableSemaphore 与 core/dispatcher.AdjustableSemaphore(正文 P1-2)是独立写出的同一个东西,并且继承了同一个惊群缺陷:_wake_waiters 换新 Event 并 set 旧的,唤醒全部等待者。这里 max_parallel_jobs ≤ 64 所以影响有限,但两份实现都该收敛到 core 的一个正确版本(codex 版多出的 over_capacity() 语义值得保留) |
| J3-3 | 中 | manager.py:489-502 | _disk_usage_bytes 用 rglob("*") 递归遍历整个插件数据目录统计字节数,每次 enqueue 都调用一次(830 行)。虽然已用 to_thread 卸载,但 codex 的数据目录包含所有会话的 artifacts(配置允许总量到 512 MiB、单图 100 MiB),文件数可能上万。应改为增量维护或按 TTL 缓存 |
| J4-1 | 中 | — | 开发者个人绝对路径出现在两个插件的出厂默认/帮助文本里**:shell/main.py:566(帮助示例)、codex/config.py:10(默认工作目录,且是默认唯一允许根)。建议加一条 CI 规则:拒绝在 plugins/ 与 core/ 中出现 C:/Users/<name> 或 /home/<name> 形式的字面量 |
| J4-2 | 中 | — | "可变容量信号量"被独立实现了两次**(core 的 AdjustableSemaphore、codex 的 _ResizableSemaphore),且两份都有惊群缺陷。同一批里还看到 qingssh._retain_keyed_lock/_release_keyed_lock 与 core 的 AsyncKeyedLockPool 重复。core 的并发原语没有被插件层发现和复用 |
| K2-1 | 中 | arxiv_summary.py:195 | _latest_successful_summary → _conversation_events 在 持有 manager.lock 且位于事件循环上解析最多 8 MiB JSONL。与 K3-2 同类,两处都应 to_thread |
| K3-1 | 中 | manager.py:1409 / 1458 / 1476 / 1642 | 本机绝对路径外发到聊天**共四处:_send_job_result 的"受控输出路径"、list_sessions 的 cwd=、status 的 cwd=、delete_session 的"历史已归档"。加上 qingssh(归档路径 H2-1、私钥路径 G1-5)构成一条清晰的跨插件模式,见 K4-1 |
| K3-2 | 中 | manager.py:1468 | status() 在持有 self.lock 的同时同步调用 _disk_usage_bytes()(对整个数据目录 rglob("*"))。同文件的 enqueue 走的是 _measure_disk_usage_bytes()(to_thread)——同一份计算,两种纪律。/codex status 会卡住事件循环并阻塞所有 codex 操作 |
| K3-3 | 中 | manager.py:1386 | _job_result_content 在非零退出时把 codex 子进程的 stderr 尾部拼进聊天回复。已受 max_stderr_bytes + max_qq_text_chars 约束,对开发工具而言可辩护,但配合 G-0(默认可群聊)会把子进程 stderr 广播到群 |
| K4-1 | 中 | — | "把 bot 主机绝对路径发到聊天"已在三个插件出现七处**:codex 四处(K3-1)、qingssh 两处(G1-5 私钥路径、H2-1 归档路径)、minecraft 的日志路径提示。配合 G-0(高权限插件默认可群聊),等于把 bot 主机的目录布局持续暴露给群成员。建议统一:对外只发文件名或短 ID,完整路径仅在私聊或日志中给出。可用一条 CI 规则检查"用户可见字符串里不得出现 Path/str(path) 插值" |
| K4-2 | 中 | — | "持锁 + 事件循环上做重 I/O"在 codex 出现两次**(K3-2 全目录 rglob、K2-1 8 MiB JSONL 解析),bot_core 一次(B3-1 配置重载 + fsync),arxiv_filter 一次(F1-6 claim 文件)。共同点是:同一个模块里已经有正确的 to_thread 版本,只是部分调用点没用。适合作为一条集中修复项 |
| L1-1 | 中 | runner.py:605、636 | _capture_final_output 与 _archive_large_message 都把 resolved_destination(本机绝对路径)拼进用户可见文本("[完整/受控输出已保存到: …]")。这使 codex 的路径外发点增至 6 处(另 4 处见 K3-1),是 K4-1 跨插件模式里最密集的一个 |
| L3-1 | 中 | — | "把本机绝对路径发进聊天"最终统计:3 个插件 9 处**——codex 6(K3-1×4 + L1-1×2)、qingssh 2(G1-5 私钥路径、H2-1 归档路径)、minecraft 1。建议统一改为"对外只发文件名/短 ID,完整路径进日志",并加一条 CI 规则禁止在用户可见字符串里插值 Path |
| J3-4 | 低 | main.py:211-255 | handle 完全没有 try/except,是全仓 24 个插件里唯一如此的。异常会冒泡到 dispatcher 的 _execute_command 并由 public_error_response 处理——所以结果是安全的(这也是附录 E-0 判定 codex 不泄露的依据之一),但它把错误处理契约隐式外包给了框架。应加注释说明是有意为之,否则下一个维护者会当成遗漏而"补上"一个更差的处理 |
| J3-5 | 低 | main.py:136 | values[0] == "true" —— 第 4 处过滤 core.args.parse 的无值哨兵(前三处:adnmb、color、见附录 A-0)。再次支持把 A-0 提为 P1 |
| J3-6 | 低 | manager.py:451 | _archive_session_dir_locked 用 time.strftime(..., time.localtime()) 生成归档目录名——系统本地时区,与 _business_now 一族(正文 A7-1)的时区分叉是同一问题 |
| J3-7 | 低 | manager.py:630-634 | _append_history 在 manager 锁内同步 open(...,"a") 写 JSONL。每次创建会话/入队/过期都写一次。文件小、频率低,但它位于 async 调用链上且未走 to_thread(与同文件 _measure_disk_usage_bytes 的纪律不一致) |
| J3-8 | 低 | config.py:16 | SANDBOX_VALUES 含 danger-full-access。允许配置是合理的,但选中该值时应在启动日志打一条 WARNING,与 core 的 inbound_trusted_tls_proxy(server.py:2087-2091)保持同样的"危险配置需高声明"风格 |
| J4-3 | 低 | — | 配置热更新只有 codex 做对了**。它用 get_settings_snapshot() 拿到 revision 并做代际控制;其余 23 个插件读的是构造上下文时冻结的 context.config(正文 R-1)。codex 的 reconfigure 可以直接作为文档里的参考实现 |
| K2-2 | 低 | arxiv_summary.py:380 | _build_link_prompt 把 methodology_path(本机绝对路径)写进 LLM prompt。不是聊天泄露,但会把主机目录布局带进模型上下文与上游日志 |
| K2-3 | 低 | arxiv_summary.py:378-391 | 六条硬性输出格式规则硬编码在 Python 源码。插件本身已有 methodology 外置文件的概念,这段 prompt 也应外置,否则调整格式要改代码并重载插件 |
| K3-4 | 低 | manager.py:1622-1627 | delete_session 中 worker is not asyncio.current_task() 的判断保护了自删除场景,但 await asyncio.wait_for(asyncio.shield(worker), timeout=10) 之后紧接 worker.cancel()——shield 使超时不会取消 worker,需要显式 cancel,逻辑正确但值得一行注释说明为什么 shield 后还要手动 cancel |
| K4-3 | 低 | — | codex 是唯一把"失败原因"分类统计并回传用户的插件**(ArtifactCollectionResult.reasons + _append_artifact_notice)。多数插件失败时只给一句笼统文案。这个模式值得推广到 earthquake 的图片下载、twitter 的媒体抓取等路径 |
| L1-2 | 低 | runner.py:1178 | args.extend(["-c", f"approval_policy='{self.config.approval_policy}'"]) —— 把配置值用单引号拼进 CLI 的 -c key='value' 表达式。当前 approval_policy 被 _choice() 限制在四个固定值内,不存在注入;但这种"拼接进配置表达式"的写法在允许值集合一旦放宽时就会变成注入面。应改为传结构化参数或至少加断言 |
| L1-3 | 低 | runner.py:1169 | codex_bin = shutil.which(...) or self.config.codex_bin —— 可执行文件路径完全来自配置/密钥,_clean_string 只校验控制字符与长度。这是必要的(要指向 codex CLI),且能写 secrets.json 的人本来就等同于拥有 bot 权限;但应在文档里明写"codex_bin 等价于任意代码执行权限" |
| L1-4 | 低 | runner.py:1185-1191 | _prompt_with_artifact_instruction 把 artifact_path 与 generated_images_path 两个绝对主机路径写进 LLM prompt(同 K2-2)。这些路径会进入上游模型服务的上下文与日志 |
| L1-5 | 低 | runner.py:479-487 | _monitor_output_file 以 0.1 秒间隔轮询 path.stat(),贯穿整个任务生命周期(job_timeout_seconds 上限 7 天)。即每秒 10 次 stat 持续数天。跨平台文件监视不可移植,轮询可辩护,但间隔应随任务时长退避 |
| L1-6 | 低 | runner.py:871-881 | _close_process_pipe_transports 读取 process._transport.get_pipe_transport —— asyncio 私有 API。已用 getattr + try/except 全面保护,且仅在 communicate() 失败的兜底路径使用,风险可控;但它与正文 P0-3(APScheduler / CPython 私有 API)属于同一类维护负债,应登记在册 |
| L3-2 | 低 | — | 私有 API 依赖登记表**:core 有 2 处(importlib._bootstrap._get_module_lock、APScheduler 十余个私有属性,正文 P0-3),插件有 2 处(jupyter._invoke_cleanup 的签名反射 E1-2、codex._close_process_pipe_transports 的 process._transport L1-6)。四处都有合理动机,但应在 docs/07-advanced.md 建一张表集中登记,升级依赖时逐条复核 |
| L3-3 | 低 | — | "头 2/3 + 尾 1/3 + 中间标记"的大文本截断**在 codex(_qq_preview、_bounded_file_bytes)与 qingssh(SSHOutputRelay 的 head/tail 预算)各实现一次,shell._truncate 是对半版本。三份实现,同一意图,应统一为 core.plugin_base.head_tail_preview(text, max_chars) |
| J-0 † | 低 | — | codex 是全仓权限最大的插件,风险姿态需要写进文档 |
| J-2 † | 低 | — | 值得作为范本的四点 |
| J-3 † | 低 | — | 问题 |
| K-0 † | 低 | — | 修订附录 C-0:图片校验的天花板是 codex/artifacts.py,不是 earthquake |
| K-1 † | 低 | — | artifacts.py 的目录扫描同样是 TOCTOU 感知的 |
| K-2 † | 低 | — | arxiv_summary.py:三重授权 + 链接规范化是关键防线 |
| K-3 † | 低 | — | manager.py 尾段问题 |
| L-1 † | 低 | — | runner.py 是全仓最完整的子进程生命周期管理 |
| L-2 † | 低 | — | codex 总评(5 021 行读完) |
详述(10 条,† 标记的条目)
J-1 [P1] config.py:10 把开发者个人路径硬编码为出厂默认
DEFAULT_CWD = "C:/Users/torch/Desktop/XiaoQing/XiaoQing_Codex"三个后果:
- 泄露运维账号名——这是全仓第二处(第一处是
shell/main.py:566的帮助文本,见正文 P-09); config.py:167allowed_roots = _string_tuple(...) or (default_cwd,)——未配置allowed_cwd_roots时,这个路径成为唯一允许的工作目录根;paths.normalize_cwd在raw_cwd is None时会candidate.mkdir(parents=True, exist_ok=True),因此在别人的 Windows 机器上 会真的创建C:/Users/torch/...目录树;在 Linux 上则因_WINDOWS_ABS_RE检查直接抛CwdError,插件开箱即不可用。
出厂默认应改为相对于 context.data_dir 的路径(例如 data_dir/workspaces), 并要求运维显式配置 allowed_cwd_roots。
J-0 [低] codex 是全仓权限最大的插件,风险姿态需要写进文档
config.py 的出厂默认值:
sandbox = "workspace-write" # 可配置为 danger-full-access
approval_policy = "never" # 不做任何人工确认
skip_git_repo_check = True
job_timeout_seconds = 3600(上限 604800 = 7 天)也就是说 /codex <name> <任务> 会启动一个无人工确认、可写工作区、 最长可跑七天的自主编码代理。SANDBOX_VALUES 还允许配置 danger-full-access。这是有意的自动化姿态,但结合 G-0 (contexts 未声明 → 群聊可用)和 admin_only 只限制发起者, 风险面必须在 docs/09-plugins.md 里显式写清楚:谁能触发、 能写哪些目录、输出会发到哪里。
J-2 [低] 值得作为范本的四点
paths.normalize_cwd是 TOCTOU 感知的包含性校验(paths.py:33-67):- 先解析候选路径和全部允许根,先判断
is_relative_to再mkdir(47 行注释写明"否则错误配置会在允许根之外留下目录"); mkdir之后用resolve(strict=True)重新解析;- 再次检查
is_relative_to(64-65 行),专门防御解析期间的符号链接替换。 这三步顺序是对的,很多路径校验只做第一步。
- 先解析候选路径和全部允许根,先判断
codex 是唯一使用
get_settings_snapshot()原子快照 API 的插件 (config.py:58、manager.py:100-112),而且做到了基于 revision 的配置代际控制:reconfigure(248-297)拒绝比当前更旧的 revision, 并在"同一 revision 却给出不同配置"时抛错。 运行中的任务继续持有启动时捕获的(config, runner)对, 只有之后启动的任务使用新策略。这是全仓最完整的配置热更新处理, 也印证了正文 R-1:core 造好的快照 API 只有一个插件在用。入队是可回滚的事务(
_enqueue_job_locked,671-781): 先递增total_jobs、_save()、写历史、_ensure_worker_locked, 任一步失败即回滚计数器与队列元素并重新_save()。 注释还说明了为什么必须"先确保 worker 可创建,再发布队列元素"。状态文件的三级恢复(
_load,318-346): 主文件损坏 → 隔离到quarantine/sessions-<ns>.json→ 尝试.bak→ 仍失败则清空并重建。_remove_owned_path(601-619)在删除前 校验父目录身份、拒绝目录 junction、拒绝越出 owned root。
J-3 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| J3-1 | 中 | manager.py:435-438 | _rewrite_state_with_backup 靠连续调用两次 self._save() 来同时刷新主文件和 .bak。这依赖 core/atomic_store.AtomicJsonStore.write() "每次写入前把当前内容复制为 .bak" 这一实现细节,而不是任何公开契约。正文 C-08 建议过取消"每次写都产生 .bak"以省 I/O——若照做,这里会静默失去崩溃恢复备份。要么给 AtomicJsonStore 加一个显式的 write_with_backup() API,要么在此处加注释锁定该依赖 |
| J3-2 | 中 | manager.py:165-214 | _ResizableSemaphore 与 core/dispatcher.AdjustableSemaphore(正文 P1-2)是独立写出的同一个东西,并且继承了同一个惊群缺陷:_wake_waiters 换新 Event 并 set 旧的,唤醒全部等待者。这里 max_parallel_jobs ≤ 64 所以影响有限,但两份实现都该收敛到 core 的一个正确版本(codex 版多出的 over_capacity() 语义值得保留) |
| J3-3 | 中 | manager.py:489-502 | _disk_usage_bytes 用 rglob("*") 递归遍历整个插件数据目录统计字节数,每次 enqueue 都调用一次(830 行)。虽然已用 to_thread 卸载,但 codex 的数据目录包含所有会话的 artifacts(配置允许总量到 512 MiB、单图 100 MiB),文件数可能上万。应改为增量维护或按 TTL 缓存 |
| J3-4 | 低 | main.py:211-255 | handle 完全没有 try/except,是全仓 24 个插件里唯一如此的。异常会冒泡到 dispatcher 的 _execute_command 并由 public_error_response 处理——所以结果是安全的(这也是E-0 判定 codex 不泄露的依据之一),但它把错误处理契约隐式外包给了框架。应加注释说明是有意为之,否则下一个维护者会当成遗漏而"补上"一个更差的处理 |
| J3-5 | 低 | main.py:136 | values[0] == "true" —— 第 4 处过滤 core.args.parse 的无值哨兵(前三处:adnmb、color、见A-0)。再次支持把 A-0 提为 P1 |
| J3-6 | 低 | manager.py:451 | _archive_session_dir_locked 用 time.strftime(..., time.localtime()) 生成归档目录名——系统本地时区,与 _business_now 一族(正文 A7-1)的时区分叉是同一问题 |
| J3-7 | 低 | manager.py:630-634 | _append_history 在 manager 锁内同步 open(...,"a") 写 JSONL。每次创建会话/入队/过期都写一次。文件小、频率低,但它位于 async 调用链上且未走 to_thread(与同文件 _measure_disk_usage_bytes 的纪律不一致) |
| J3-8 | 低 | config.py:16 | SANDBOX_VALUES 含 danger-full-access。允许配置是合理的,但选中该值时应在启动日志打一条 WARNING,与 core 的 inbound_trusted_tls_proxy(server.py:2087-2091)保持同样的"危险配置需高声明"风格 |
K-0 [低] 修订附录 C-0:图片校验的天花板是 codex/artifacts.py,不是 earthquake
C-0 把 earthquake._validate_image_bytes 列为全仓最完整的图片校验, 并建议以它为蓝本上提到 core。读完 codex/artifacts.py 后这个结论要修订—— codex 的实现严格更强,多出四层 earthquake 没有的防护:
| 防护 | earthquake | codex/artifacts.py |
|---|---|---|
| MIME/扩展名 ↔ 真实格式一致 | ✅ | ✅ _validate_image_fd:447 |
| 尺寸/像素/帧数上限 | ✅ | ✅ |
| DecompressionBomb 升级为错误 | ✅ | ✅ |
| 容器尾部完整性 | ✅ | ➖(改用逐帧解码覆盖) |
拒绝硬链接(st_nlink != 1) | ❌ | ✅ _safe_open_source:369 |
O_NOFOLLOW + O_CLOEXEC 打开 | ❌ | ✅ :372-373 |
| TOCTOU 三重身份比对(lstat→open→fstat+lstat) | ❌ | ✅ :379-388 |
| 校验的是"将要发布的副本"而非源文件 | ❌ | ✅ _copy_validated_image:555 |
逐帧真实解码(不止 verify()) | ❌ | ✅ _decode_image_frames:418 |
_copy_validated_image(518-583)的完整顺序值得抄写: 安全打开源 → 预算检查 → 目标目录内建临时文件 → 分块复制并逐块检查预算 → 再次验证源未变(fstat+lstat 身份比对)→ 校验复制字节数 == 原始大小 → 校验临时文件大小 → 对临时文件 fd 做图片校验 → fsync → os.replace。 失败路径在 finally 里关闭两个 fd 并删除临时文件。
修订建议:core 的 validate_image_bytes 应以 codex 版为蓝本, earthquake 的容器尾部校验(JPEG FFD9 / PNG IEND / WebP RIFF 长度) 作为补充合并进去。受益方仍是 apod、url_parser、twitter、earthquake。
K-1 [低] artifacts.py 的目录扫描同样是 TOCTOU 感知的
_artifact_images(312-357)用 BFS 而非 rglob,每弹出一个目录都先 _directory_identity_is_current 重新核对 (dev, ino, 类型, size, mtime_ns), 不一致就跳过并记 scan_source_changed;os.scandir 全程 follow_symlinks=False;条目数与深度双预算;结果排序保证确定性。 丢弃原因用 Counter 分类统计(symlink / hardlink / metadata_error / format_mismatch / pixel_limit / frame_limit / total_bytes_limit …), 并通过 _append_artifact_notice 以"[Codex 产物审计]"形式附加到用户回复。 失败是可见且可分类的,这在全仓很少见。
K-2 [低] arxiv_summary.py:三重授权 + 链接规范化是关键防线
- 三重授权(85-119):必须
is_system或is_current_admin, 并且context.plugin_name == "codex"。最后一条阻止其它插件 通过伪造上下文取得 codex 作用域调用——这是在 core_SERVICE_CONTRACTS之上的纵深防御。 - 链接规范化(
_normalize_arxiv_links,61-82):正则fullmatch后 一律重写为https://arxiv.org/abs/<id>,去掉版本号、查询串、片段并去重。 因此进入 LLM prompt 的只可能是规范 arxiv.org URL, 上游arxiv_filter即使被投毒也无法把任意 URL 注入代理提示词。 - 幂等:先查在途任务(按 label+date),再查会话历史里已成功的同日总结并重放, 都没有才入队。
_conversation_events只读文件最后 8 MiB、seek 后丢弃半行、 跳过超长行、拒绝符号链接与非常规文件。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| K2-1 | 中 | arxiv_summary.py:195 | _latest_successful_summary → _conversation_events 在 持有 manager.lock 且位于事件循环上解析最多 8 MiB JSONL。与 K3-2 同类,两处都应 to_thread |
| K2-2 | 低 | arxiv_summary.py:380 | _build_link_prompt 把 methodology_path(本机绝对路径)写进 LLM prompt。不是聊天泄露,但会把主机目录布局带进模型上下文与上游日志 |
| K2-3 | 低 | arxiv_summary.py:378-391 | 六条硬性输出格式规则硬编码在 Python 源码。插件本身已有 methodology 外置文件的概念,这段 prompt 也应外置,否则调整格式要改代码并重载插件 |
K-3 [低] manager.py 尾段问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| K3-1 | 中 | manager.py:1409 / 1458 / 1476 / 1642 | 本机绝对路径外发到聊天共四处:_send_job_result 的"受控输出路径"、list_sessions 的 cwd=、status 的 cwd=、delete_session 的"历史已归档"。加上 qingssh(归档路径 H2-1、私钥路径 G1-5)构成一条清晰的跨插件模式,见 K4-1 |
| K3-2 | 中 | manager.py:1468 | status() 在持有 self.lock 的同时同步调用 _disk_usage_bytes()(对整个数据目录 rglob("*"))。同文件的 enqueue 走的是 _measure_disk_usage_bytes()(to_thread)——同一份计算,两种纪律。/codex status 会卡住事件循环并阻塞所有 codex 操作 |
| K3-3 | 中 | manager.py:1386 | _job_result_content 在非零退出时把 codex 子进程的 stderr 尾部拼进聊天回复。已受 max_stderr_bytes + max_qq_text_chars 约束,对开发工具而言可辩护,但配合 G-0(默认可群聊)会把子进程 stderr 广播到群 |
| K3-4 | 低 | manager.py:1622-1627 | delete_session 中 worker is not asyncio.current_task() 的判断保护了自删除场景,但 await asyncio.wait_for(asyncio.shield(worker), timeout=10) 之后紧接 worker.cancel()——shield 使超时不会取消 worker,需要显式 cancel,逻辑正确但值得一行注释说明为什么 shield 后还要手动 cancel |
正面:_cancel_runtime_job(1487-1545)是全仓最完整的子进程取消流程—— 等 spawn_handoff 收敛(防止取消跑在进程创建之前)→ terminate_process_tree → 校验 tree_confirmed 与 parent_reaped,未确认时 ERROR 记录→ 等 finished_event,每一步都有超时。shutdown(1645-1676)同样是 取消运行任务 → 等 worker(带超时)→ 超时后强制 cancel → 再 _save()。
L-1 [低] runner.py 是全仓最完整的子进程生命周期管理
值得作为范本的五点
两阶段交接(process_handoff → prompt_handoff)(
run,1472-1568): 进程先创建并登记给 manager,之后才把 prompt 写进 stdin。 这样在"已 spawn 但尚未派活"的窗口里到达的取消,可以在 代理拿到任何任务之前就终止进程。_perform_handoff(1265-1301) 在"回调抛异常"和"回调返回 False"两条路径上都会_terminate_and_drain_process,并据此设置process_cleanup_required。进程树终止区分
tree_confirmed与parent_reaped(ProcessTreeTerminationResult,45-50):调用方能分辨 "父进程已回收但子进程可能残留"。两个平台各有完整实现—— Windows 走taskkill /T /F(带 helper 超时与兜底kill()), POSIX 走killpg(SIGTERM)→ 宽限等待 →killpg(SIGKILL)→_process_group_exists确认整组消失。 Windows 上taskkill返回 128(PID 已不存在)被特判为成功, 但仅当父进程未经兜底 kill 就已回收——736-742 行有完整注释说明为何 其它非零码/超时/依赖兜底 kill 的情况仍保持失败语义。取消抵抗的清理:
_settle_owned_task(335-348)与_run_cleanup_cancellation_resistant(351-367)把"调用方的取消" 与"清理任务自身的失败"分开保存,先跑完清理再恢复取消语义。_capture_in_thread(409-436)在取消到达时仍等待线程内的读取收敛, 并把已生成的归档登记进orphan_archives。归档的提交/回滚边界正确:
_cleanup_run_resources(1451-1470) 只在result_committed为假(或清理被取消)时删除 orphan 归档—— 即绝不删除一个路径已经交给用户的归档文件。输出预算是多层的:
_StdoutEventAccumulator同时限制 stdout 总字节和单条 JSON 行字节;_ByteTail只保留尾部 8 KiB;_monitor_output_file独立监视结果文件大小;_bounded_file_bytes/_qq_preview都按 头 2/3 + 尾 1/3 保留并插入标记。
问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| L1-1 | 中 | runner.py:605、636 | _capture_final_output 与 _archive_large_message 都把 resolved_destination(本机绝对路径)拼进用户可见文本("[完整/受控输出已保存到: …]")。这使 codex 的路径外发点增至 6 处(另 4 处见 K3-1),是 K4-1 跨插件模式里最密集的一个 |
| L1-2 | 低 | runner.py:1178 | args.extend(["-c", f"approval_policy='{self.config.approval_policy}'"]) —— 把配置值用单引号拼进 CLI 的 -c key='value' 表达式。当前 approval_policy 被 _choice() 限制在四个固定值内,不存在注入;但这种"拼接进配置表达式"的写法在允许值集合一旦放宽时就会变成注入面。应改为传结构化参数或至少加断言 |
| L1-3 | 低 | runner.py:1169 | codex_bin = shutil.which(...) or self.config.codex_bin —— 可执行文件路径完全来自配置/密钥,_clean_string 只校验控制字符与长度。这是必要的(要指向 codex CLI),且能写 secrets.json 的人本来就等同于拥有 bot 权限;但应在文档里明写"codex_bin 等价于任意代码执行权限" |
| L1-4 | 低 | runner.py:1185-1191 | _prompt_with_artifact_instruction 把 artifact_path 与 generated_images_path 两个绝对主机路径写进 LLM prompt(同 K2-2)。这些路径会进入上游模型服务的上下文与日志 |
| L1-5 | 低 | runner.py:479-487 | _monitor_output_file 以 0.1 秒间隔轮询 path.stat(),贯穿整个任务生命周期(job_timeout_seconds 上限 7 天)。即每秒 10 次 stat 持续数天。跨平台文件监视不可移植,轮询可辩护,但间隔应随任务时长退避 |
| L1-6 | 低 | runner.py:871-881 | _close_process_pipe_transports 读取 process._transport.get_pipe_transport —— asyncio 私有 API。已用 getattr + try/except 全面保护,且仅在 communicate() 失败的兜底路径使用,风险可控;但它与正文 P0-3(APScheduler / CPython 私有 API)属于同一类维护负债,应登记在册 |
L-2 [低] codex 总评(5 021 行读完)
结论:codex 是全仓工程强度最高的插件,也是权限最大的插件。
它把三件难事都做对了:
- 子进程:两阶段交接、跨平台进程树终止、取消抵抗清理、 提交/回滚边界清晰的归档管理(L-1)。
- 文件与制品:
artifacts.py的 TOCTOU 感知路径解析 (lstat→O_NOFOLLOW open→fstat+lstat 三重身份比对)、拒绝硬链接、 校验将要发布的副本而非源文件、逐帧真实解码—— 这套实现严格强于earthquake,是全仓该项能力的天花板。 - 配置代际:唯一使用
get_settings_snapshot()并做 revision 栅栏的插件, 运行中任务持有启动时捕获的(config, runner)对(J-2)。
需要处理的集中在三处,且都不涉及并发或进程逻辑:
DEFAULT_CWD是开发者个人路径(J-1)——出厂即不可用于他人, 且在未配置allowed_cwd_roots时成为唯一允许根。- 6 处本机绝对路径外发到聊天(K3-1 四处 + L1-1 两处), 配合
contexts未声明(G-0)会广播到群。 - 两处"持锁 + 事件循环上做重 I/O"(K3-2 全目录
rglob、 K2-1 8 MiB JSONL 解析),而同模块内已有正确的to_thread版本。
另有一条隐性耦合值得单独记:manager._rewrite_state_with_backup 依赖 AtomicJsonStore.write() "每次写前把当前内容复制为 .bak" 的实现细节 (J3-1)——正文 C-08 若照建议优化掉该行为,codex 会静默失去崩溃恢复备份。
qingpet(34 条,其中 5 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| M2-1 | 中 | database.py:470-478 | 每日金币上限被冻结在建库时刻**。_DAILY_COIN_LIMIT(来自 constants.DAILY_LIMITS["coins"])用 f-string 插进 CREATE TRIGGER IF NOT EXISTS trg_users_daily_coin_cap 的触发器体,而全文件没有任何 DROP TRIGGER。运维改了 constants.py 后:Python 层(1781、1916、2410、3423、3736 五处)用新上限判断,数据库触发器仍按旧上限强制回收金币。两层静默分歧,且只在超限时才显形。修法:迁移时 DROP TRIGGER 再重建,或把上限存进配置表由触发器读取 |
| M2-2 | 中 | database.py:294-300 | 单连接 + 全局 threading.RLock 使 journal_mode=WAL 失去意义**。整个插件共用一个 sqlite3.Connection,每次访问都要抢同一把 RLock,因此 WAL 的"多读者并发"完全用不上。manifest 未声明 concurrency(默认 parallel),命令经 run_sync 进入 core 的有界 worker 池,多个 worker 会在这把锁上排队。对一个 10k 行、5 个定时任务的插件,这是明确的吞吐天花板。改法:读路径用 threading.local() 的每线程连接(WAL 原生支持并发读),RLock 只保护写事务 |
| M2-3 | 中 | database.py:298 | PRAGMA foreign_keys=ON 是空操作——全文件 REFERENCES 出现 0 次,没有任何外键约束。删除用户不会级联清理 pets / inventories / asset_ledger(实际靠 admin_delete_pet_atomic 等手工处理)。这条 pragma 给人"有引用完整性"的错觉,应删掉或补上真正的外键 |
| M3-1 | 中 | — | "配置常量被烧进持久化结构"是一个新的问题类别**。qingpet 把 _DAILY_COIN_LIMIT 烧进 SQL 触发器(M2-1);codex 把 DEFAULT_CWD 烧进出厂默认并成为唯一允许根(J-1)。两者都表现为"改了配置却不生效",且都没有报错。凡是把配置值写入外部持久物(DB schema、触发器、文件名、默认目录)的地方,都需要一条迁移路径 |
| M3-2 | 中 | — | concurrency 未声明但实际依赖内部锁**:qingpet(SQLite RLock)与 pendo(待查)都是如此。manifest 上看不出并发语义,只有读完数据层才知道安全。这强化了正文 F-2 的建议——concurrency 应改为必填,并允许声明 "keyed"(见附录 G2-3) |
| M2-9 | 中 | commands/ 9 处 | str.isdigit() + int() 是一条可复现的异常路径**。'²'.isdigit() 为 True 但 int('²') 抛 ValueError;'٣'(阿拉伯-印度数字)则被静默接受为 3。因此 /宠物 状态 ²、/宠物 购买挂单 ²、/宠物 封禁 <id> ² 等 9 个入口都会抛 ValueError 冒泡到 dispatcher(结果被 public_error_response 兜住,不会崩 bot,但用户拿到的是无意义的通用错误)。位置:basic_commands.py:25,99、admin_commands.py:179,231,233、advanced_commands.py:239,405、new_commands.py:289,320。修法:统一 _parse_int(),用 text.isascii() and text.isdigit() 或直接 try: int(text) |
| M2-10 | 中 | models/user.py:123 | 增量合并可以构造出负余额,且只被 SQL 触发器拦住**。coins 走 latest + desired - original,无下界钳位(Pet.merged_onto 对属性做了钳位,User 没有)。两个并发扣款各自基于 coins=100 扣 60 时,合并结果为 40+40-100 = -20,随后被 trg_users_nonnegative_update 的 RAISE(ABORT) 中止 → update_user 返回 False → 用户看到的是 "召回失败" 而不是 "金币不足"。防御纵深生效了,但错误语义丢了。走 purchase_item_atomic(database.py:2466 WHERE ... coins >= ?)的路径没有这个问题;受影响的是 recall_pet 与 atomic_update_pet_and_user 这类"Python 侧先查余额、再靠合并落账"的路径(pet_service.py:861)。建议在 User._DELTA_FIELDS 合并时对资产字段做 max(0, ...) 并回传一个 insufficient 原因码 |
| M2-11 | 中 | pet_service.py 5 个动作 | 每个互动命令在进入原子事务前有 4~5 次独立取锁的数据库往返**。以 feed_pet 为例:get_group_config(校名,仅领养路径)→ resolve_item → get_or_create_inventory → check_action_quota → get_group_config(取 economy_multiplier)→ commit_pet_action。get_group_config 每次动作都读一次且完全无缓存。这些往返每一次都要抢 M2-2 里那把全局 RLock。前置检查本身是合理的(事务内还会权威复核,返回 reason=cooldown/daily_limit/inventory),但它是纯 UX 优化,代价是把锁竞争放大 5 倍。建议:群配置按 group_id 做带 TTL 的进程内缓存(配置变更时失效),并把 check_action_quota 前置检查降级为可选 |
| M2-12 | 中 | pet_service.py:70-73 | 进化是原子事务提交之后的一次独立写**。_evolution_suffix → check_evolution → _persist_pet_candidate → db.update_pet,发生在 commit_pet_action 已经提交之后。若这次写失败(CAS 冲突、DB 错误),用户会收到"喂食成功 + 获得 N 经验"但宠物没有进化,且没有任何提示——check_evolution 失败分支返回 (False, ""),与"未达进化条件"完全同形。进化应并入 commit_pet_action 的同一事务,或至少把持久化失败与"条件未满足"区分开 |
| M3-4 | 中 | str.isdigit() 当作"可转 int"用,全仓 17 处 / 8 个插件**:qingpet 9、qingssh 3(ssh_manager.py×2、validators.py)、bot_core 2、adnmb 1、pendo 2(ai_parser.py、commands/operations.py)、xiaoqing_chat 2。'²'、'⑤' 等字符 isdigit() 为真但 int() 抛 ValueError;'٣' 等非 ASCII 数字则被静默接受。这与正文 A-0(core/args.py 参数解析缺陷)是同一根因的两个表现:**core 没有提供任何参数取值助手,于是 26 个插件各自手搓**。建议 core.args 增补 parse_int(text, *, min=None, max=None) -> int \ | None,并加一条 CI 规则禁止 isdigit()后紧跟int()` |
| M3-5 | 中 | — | "幂等键退化为随机 nonce"的模式**:qingpet 的 reference_id(M2-17)与 codex 的 _enqueue_job 去重键在拿不到上游 ID 时都会退化到随机值。二者都不报错。凡是幂等键的取值链里出现 or secrets.token_*() / or uuid4(),都应视为幂等保证已失效并至少记一条 WARNING |
| M2-4 | 低 | database.py:560-567 | schema_migrations 表与 PRAGMA user_version 是装饰性的:_record_schema_version 只是 INSERT OR IGNORE 版本 1..5,实际迁移全靠 CREATE TABLE IF NOT EXISTS + _safe_add_column。版本号从不参与决策,因此无法表达"某版本需要一次性数据修复"。M2-1 正是缺少版本化迁移导致的 |
| M2-5 | 低 | main.py:790,861,889,916,971 | 5 个定时任务用 asyncio.to_thread(_run_job) 直接卸载,而 handle 用的是 run_sync(core 的 offload_plugin_sync,走每插件 bulkhead)。定时任务因此绕过了插件级同步并发上限。同一插件两种卸载纪律,应统一到 run_sync |
| M2-6 | 低 | database.py:2197 | str(listing["expires_at"]) <= now 用字符串字典序比较 ISO 时间戳。当前所有写入都走 utc_now().isoformat()(固定 +00:00 偏移)所以正确;一旦混入 naive 或不同偏移的时间串就会静默错判。应比较 datetime 对象 |
| M2-7 | 低 | main.py:229-230 | db_path = Path(data_dir) / "qingpet" / "qingpet.db" —— data_dir 已经是插件私有目录,再套一层 qingpet/ 是冗余嵌套 |
| M2-8 | 低 | main.py:683 | async def handle(..., **kwargs) 带 **kwargs,而 core 固定按位置传四个参数。这个形参只服务于测试直调,属于把测试需求泄漏进生产签名 |
| M3-3 | 低 | — | format_command_catalog 的使用者只有 2 个**(bot_core、qingpet)。core 已把命令目录做成结构化快照,但 23 个插件仍在手写帮助文本。qingpet 的 _wrap_help 是最完整的用法示例,应写进 docs/03-plugin-development.md |
| M2-13 | 低 | models/user.py:108-111、pet.py:104-107 | _persisted_state 为空时,merged_onto 退化为绝对值覆盖,并且 replace(self, version=latest.version) 会把版本号换成最新值——于是后续 WHERE version = ? 的 CAS 必然命中,乐观锁被一并绕过。实践上不可达(mark_persisted 只在 database.py 物化行时调用,凡是从库里读出来的对象都有基线;新建对象走 create_* 而非 update_*),但这是一次静默降级而非报错。应改为 raise 或至少 logger.warning |
| M2-14 | 低 | pet_service.py:541 | weights.append(max(prob, 0.01)) —— 探索事件权重下限被强制抬到 0.01,因此一个被设计为 prob: 0(即关闭)的事件仍会以约 1% 触发。想禁用某事件只能把它从 EXPLORE_LOCATIONS 里删掉。应改为过滤掉 prob <= 0 的事件,仅在全部事件权重为 0 时才回退 |
| M2-15 | 低 | social_service.py:176-182 | leave_message 先做敏感词全文扫描,再检查 200 字长度上限。敏感词表最多 100 词(database.py:1444 已有界,这点是好的),但一条超长留言会先被完整扫描一遍再因长度被拒。两个检查换个顺序即可 |
| M2-16 | 低 | social_service.py:110 | if type(amount) is not int —— 用 type() is 而非 isinstance()。这很可能是有意的(isinstance(True, int) 为真,此写法能挡住 amount=True),但没有任何注释说明。下一个维护者几乎必然会把它"修正"成 isinstance 并悄悄引入 True → 1 个道具 的路径。属于正文第 8 节"注释解释 what 而非 why"的典型 |
| M2-17 | 低 | main.py:587-593 | 幂等 reference_id 的取值链末端是 secrets.token_hex(16)。前三档(event["message_id"] / request_kwargs["request_id"] / context.request_id)在 OneBot 事件下总能命中,所以实践上安全;但一旦落到随机兜底,_minigame_reference / visit_pet 的幂等键就变成了一次性 nonce,UNIQUE (asset_type, reference_id) 的重复结算防护静默失效。这是本插件最强的一条保证被一个 or 分支悄悄取消。应改为落到兜底时记 WARNING,或直接拒绝结算 |
| M2-18 | 低 | models/user.py:162-173 | is_banned_active() 是查询式命名,却会修改 self(清除已过期封禁)。因为 is_banned / ban_until 都在 _MERGE_FIELDS 里,这次内存修改会成为一次合并差异,被之后任何一次 update_user 顺带持久化。这是有意的自动解封,但依赖"调用方稍后恰好会写库"这一隐含链路:basic_commands.py:42 与 main.py:568 的封禁闸门在拒绝请求后就直接返回,那次解封并不落库(下次重算,幂等,无害)。应重命名为 consume_expired_ban() 或拆成查询 + 显式解封两步 |
| M2-19 | 低 | pet_service.py:61 | _sync_state 用 target.__dict__.update(source.__dict__) 回写调用方对象。当前模型是普通 dataclass 所以可行,但它绕过了字段校验,并且一旦模型加上 __slots__ 就会整体失效(__dict__ 不存在)。考虑到这些模型是热路径对象,加 __slots__ 是很自然的后续优化。应改为按 dataclasses.fields() 逐字段 setattr |
| M2-20 | 低 | commands/basic_commands.py:120-128 | handle_status 一次调用里读了 3 次 user:get_or_create_user → check_and_award_titles 内部再 get_user → 有新称号时再 get_user 刷新。加上 get_pet、get_group_config,一条只读命令产生 5~6 次全局锁往返。check_and_award_titles 应接受已读到的 user 对象并返回更新后的实例 |
| M2-21 | 低 | services/pet_service.py | 返回元组的元数不统一**:feed/clean/play/train/explore 返回 (bool, str, int),其余 20 余个方法返回 (bool, str)。main._extract_message 按 result[1] 取文案,因此第三个元素(本次实际发放金币)被静默丢弃,从未展示给用户。要么统一为 2 元组,要么把金币并进文案 |
| M2-22 | 低 | utils/validators.py:16 | validate_sensitive_content 每次调用都重建一次 dict.fromkeys((*DEFAULT_SENSITIVE_WORDS, *extra)) 并做 O(词数) 次子串扫描。词表有界(≤100×64),但改名/留言/展示三条路径都会调用。应按 group_id 缓存编译好的词表 |
| M3-6 | 低 | — | merged_onto 三方合并只有 qingpet 有,但至少三个插件需要它**:pendo(待查,同样是 SQLite + 计数器)、bot_core(配置计数)、qingpet。core 可以提供一个 core.models.ThreeWayMergeable mixin(基线记录 + 增量字段声明 + 集合字段声明),把这套已被验证的语义变成可复用件。这是本次审查中最值得从插件层反向上提到 core 的一件东西,价值高于附录 L3-3 的文本截断 |
| M3-7 | 低 | — | manifest↔代码一致性检查只有 qingpet 做了**(utils/router.py:19-27)。正文 F-2 建议过让 concurrency 必填;同样应让 core 在插件加载时默认校验"目录中声明的每条命令都有可达处理器",把 qingpet 的启动期断言变成框架级保证 |
| M-1 † | 低 | — | qingpet 是全仓唯一把"经济系统正确性"当作工程问题来做的插件 |
| M-2 † | 低 | — | 问题 |
| M-4 † | 低 | — | 两项全仓独有的工程做法 |
| M-5 † | 低 | — | 问题 |
| M-6 † | 低 | — | qingpet 总评(10 004 行读完) |
详述(5 条,† 标记的条目)
M-1 [低] qingpet 是全仓唯一把"经济系统正确性"当作工程问题来做的插件
数据层的五项硬保证
资产账本 + 幂等键:
asset_ledger带UNIQUE (asset_type, reference_id)(database.py:429-436)。 每一笔金币变动都必须携带一个稳定reference_id(如trade-purchase:{listing_id}:buyer),重复结算会被唯一约束挡下。 另有visit_settlements/minigame_settlements以reference_id为主键,group_task_claims/activity_claims以复合主键实现"只能领一次"。数据库级不变量(
database.py:470-485):trg_users_nonnegative_insert/trg_users_nonnegative_update用RAISE(ABORT, 'negative asset balance')在 SQL 层禁止负余额;trg_users_daily_coin_cap在today_coins_earned超限时自动回收超额金币。 即使 Python 层有 bug,余额也不会变负。全仓没有第二个插件把业务不变量 下沉到数据库约束。
真正的 CAS 而非"读后写":以
purchase_trade_listing(2170-2274)为例——BEGIN IMMEDIATE→ 事务内复核挂单 → 用条件 UPDATE 抢占挂单并检查rowcount != 1→ 买卖双方各自UPDATE ... WHERE并校验rowcount == 1,不满足即raise触发回滚 → 双向写入asset_ledger→ 提交。这是正确的乐观并发实现。users/pets/inventories三张表都有version INTEGER列。对账:
asset_reconciliation_checkpoints+CoinLedgerReconciliation(4128-4245)周期性比对 "余额合计" 与 "账本 delta 合计",并落检查点。调度任务租约:
scheduler_runs以(job_name, period_key)为主键, 带status/lease_until/attempt_count(_claim_scheduler_run_in_transaction,3923-3975)。 qingpet 有 5 个定时任务,租约保证同一周期不会被重复结算。
入口层的三点
- qingpet 与
bot_core是仅有的两个真正消费CommandCatalogNode的插件:_catalog_root从 core 已发布的目录取根节点,帮助文本由format_command_catalog(root)生成而非硬编码(main.py:377-406)。 这正是正文 A7-3("帮助文本硬编码、catalog 无人使用")的反例,应作为范本。 - 限流分三层且被拒请求不计数:群级静默上限 → 用户级硬限流 → 奖励指数衰减因子(
_get_anti_spam_state一次读取同时算出两者)。 第 635 行注释点明"被拒绝的请求不计入操作频率,避免限流窗口自行延长"。 - 私聊作用域解析:
_resolve_private_command_group在用户跨多群有宠物时 拒绝并列出群号,而不是随便挑一个;_private_scope_bucket用负数群号 给私聊单独开限流桶,避免所有私聊挤在group_id=0。
M-2 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| M2-1 | 中 | database.py:470-478 | 每日金币上限被冻结在建库时刻。_DAILY_COIN_LIMIT(来自 constants.DAILY_LIMITS["coins"])用 f-string 插进 CREATE TRIGGER IF NOT EXISTS trg_users_daily_coin_cap 的触发器体,而全文件没有任何 DROP TRIGGER。运维改了 constants.py 后:Python 层(1781、1916、2410、3423、3736 五处)用新上限判断,数据库触发器仍按旧上限强制回收金币。两层静默分歧,且只在超限时才显形。修法:迁移时 DROP TRIGGER 再重建,或把上限存进配置表由触发器读取 |
| M2-2 | 中 | database.py:294-300 | 单连接 + 全局 threading.RLock 使 journal_mode=WAL 失去意义。整个插件共用一个 sqlite3.Connection,每次访问都要抢同一把 RLock,因此 WAL 的"多读者并发"完全用不上。manifest 未声明 concurrency(默认 parallel),命令经 run_sync 进入 core 的有界 worker 池,多个 worker 会在这把锁上排队。对一个 10k 行、5 个定时任务的插件,这是明确的吞吐天花板。改法:读路径用 threading.local() 的每线程连接(WAL 原生支持并发读),RLock 只保护写事务 |
| M2-3 | 中 | database.py:298 | PRAGMA foreign_keys=ON 是空操作——全文件 REFERENCES 出现 0 次,没有任何外键约束。删除用户不会级联清理 pets / inventories / asset_ledger(实际靠 admin_delete_pet_atomic 等手工处理)。这条 pragma 给人"有引用完整性"的错觉,应删掉或补上真正的外键 |
| M2-4 | 低 | database.py:560-567 | schema_migrations 表与 PRAGMA user_version 是装饰性的:_record_schema_version 只是 INSERT OR IGNORE 版本 1..5,实际迁移全靠 CREATE TABLE IF NOT EXISTS + _safe_add_column。版本号从不参与决策,因此无法表达"某版本需要一次性数据修复"。M2-1 正是缺少版本化迁移导致的 |
| M2-5 | 低 | main.py:790,861,889,916,971 | 5 个定时任务用 asyncio.to_thread(_run_job) 直接卸载,而 handle 用的是 run_sync(core 的 offload_plugin_sync,走每插件 bulkhead)。定时任务因此绕过了插件级同步并发上限。同一插件两种卸载纪律,应统一到 run_sync |
| M2-6 | 低 | database.py:2197 | str(listing["expires_at"]) <= now 用字符串字典序比较 ISO 时间戳。当前所有写入都走 utc_now().isoformat()(固定 +00:00 偏移)所以正确;一旦混入 naive 或不同偏移的时间串就会静默错判。应比较 datetime 对象 |
| M2-7 | 低 | main.py:229-230 | db_path = Path(data_dir) / "qingpet" / "qingpet.db" —— data_dir 已经是插件私有目录,再套一层 qingpet/ 是冗余嵌套 |
| M2-8 | 低 | main.py:683 | async def handle(..., **kwargs) 带 **kwargs,而 core 固定按位置传四个参数。这个形参只服务于测试直调,属于把测试需求泄漏进生产签名 |
一条确认为"不适用"的结论
qingpet 从不调用 send_action(全插件 grep 为 0),5 个定时任务 返回消息段交由框架按 plugin.json 的 schedule[].group_ids 投递。 因此它不是正文 P0-1(HTTP 投递三态语义)的受害者—— 受害者仍是 chime / earthquake / arxiv_filter / minecraft 四个。
日志纪律
_log_database_failure(38-51)只记录经正则白名单校验的操作名和 异常类型名,绝不让数据库内容或异常正文进日志。 database.py 里 65 处 except 全部导向它并返回哨兵值, 再由命令层的 public_error_message 生成用户可见文案—— 这是本仓"审计脱敏"纪律执行得最彻底的数据层。
M-4 [低] 两项全仓独有的工程做法
1. merged_onto 是真正的三方合并(models/user.py:106、pet.py:102)
User / Pet / Inventory 三个模型都带一个 _persisted_state 基线, 由 mark_persisted() 在数据库物化行之后立即记录(database.py 中 11 处调用, 且只在 database.py 中调用)。merged_onto(latest) 于是拿到了完整的三方素材:
| 角色 | 来源 |
|---|---|
| base(共同祖先) | self._persisted_state |
| theirs(最新) | 事务内重新 SELECT 出的 latest |
| mine(本地修改) | self 当前字段 |
合并规则按字段语义分三类:
- 增量字段(
coins、friendship_points、today_*_count、total_*_count):latest + desired - original,即把本地增量重放到最新值上,而不是覆盖; titles:按集合语义合并——先从latest.titles中剔除本地删除的, 再追加本地新增的,天然幂等;- 其余(时间戳、封禁、托管到期):last-writer-wins。
Pet.merged_onto 还在增量合并后对 _STAT_FIELDS 做 min(MAX, max(0, value)) 钳位。 合并结果再进入 UPDATE ... WHERE version = ? 的 CAS(update_user 完整实现见 database.py,BEGIN IMMEDIATE → SELECT → merged_onto → 条件 UPDATE)。
这是全仓唯一一处把"并发写冲突"当作可合并语义来处理的实现,其余插件在冲突时 要么覆盖要么整体失败。它同时解释了 M-1 里"经济系统正确"的另一半原因: 即使两个请求同时到达,金币增量也不会互相吞掉。
附带更正:我在读
user_service.check_and_award_titles(读 user →add_title→update_user)时一度判定为"破坏本插件 CAS 纪律的读-改-写"。这个判定是错的——update_user内部会merged_onto,称号走集合合并,并发不会丢失。不作为问题记录。
2. CommandRouter.__init__ 在启动时校验"目录 ↔ 处理器"一致性(utils/router.py:19-27)
catalog_names = {child.name for child in root.children}
if catalog_names != set(handlers):
raise ValueError(f"qingpet command catalog mismatch missing_handlers=... extra_handlers=...")plugin.json 的命令目录多一条或代码少一个处理器,插件在加载时就炸, 而不是等到用户敲了那条命令才 404。全仓 26 个插件里只有这一个做了 manifest↔代码的漂移检测。别名表也完全从 core 的目录快照派生, 不存在"代码里的别名和 manifest 里的别名不一致"的可能。
建议上提到 core:PluginBase 可提供 assert_handlers_match_catalog(root, handlers),让所有声明式命令插件都能一行接入。
其它值得记的纪律
- 候选-提交模式:所有写路径都是
target = pet; pet = replace(pet)→ 修改副本 → 原子事务 → 成功后才_sync_state(target, pet)。调用方持有的对象在事务失败时 保持事务前状态。这是正确的失败原子性。 - SQL 完全收敛在
database.py:commands/与其余services/中execute(/SELECT/INSERT/UPDATE出现 0 次。 ItemService._ITEMS是 import 期构建的MappingProxyType,不可变且零重复构造。main.py:580-597的_build_handler_kwargs显式按命令白名单注入spam_decay_factor/message_id/context,注释写明"避免宽泛 kwargs 掩盖契约错误" ——与 J3-4(codex 把错误处理隐式外包给框架)相反的正面例子。
M-5 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| M2-9 | 中 | commands/ 9 处 | str.isdigit() + int() 是一条可复现的异常路径。'²'.isdigit() 为 True 但 int('²') 抛 ValueError;'٣'(阿拉伯-印度数字)则被静默接受为 3。因此 /宠物 状态 ²、/宠物 购买挂单 ²、/宠物 封禁 <id> ² 等 9 个入口都会抛 ValueError 冒泡到 dispatcher(结果被 public_error_response 兜住,不会崩 bot,但用户拿到的是无意义的通用错误)。位置:basic_commands.py:25,99、admin_commands.py:179,231,233、advanced_commands.py:239,405、new_commands.py:289,320。修法:统一 _parse_int(),用 text.isascii() and text.isdigit() 或直接 try: int(text) |
| M2-10 | 中 | models/user.py:123 | 增量合并可以构造出负余额,且只被 SQL 触发器拦住。coins 走 latest + desired - original,无下界钳位(Pet.merged_onto 对属性做了钳位,User 没有)。两个并发扣款各自基于 coins=100 扣 60 时,合并结果为 40+40-100 = -20,随后被 trg_users_nonnegative_update 的 RAISE(ABORT) 中止 → update_user 返回 False → 用户看到的是 "召回失败" 而不是 "金币不足"。防御纵深生效了,但错误语义丢了。走 purchase_item_atomic(database.py:2466 WHERE ... coins >= ?)的路径没有这个问题;受影响的是 recall_pet 与 atomic_update_pet_and_user 这类"Python 侧先查余额、再靠合并落账"的路径(pet_service.py:861)。建议在 User._DELTA_FIELDS 合并时对资产字段做 max(0, ...) 并回传一个 insufficient 原因码 |
| M2-11 | 中 | pet_service.py 5 个动作 | 每个互动命令在进入原子事务前有 4~5 次独立取锁的数据库往返。以 feed_pet 为例:get_group_config(校名,仅领养路径)→ resolve_item → get_or_create_inventory → check_action_quota → get_group_config(取 economy_multiplier)→ commit_pet_action。get_group_config 每次动作都读一次且完全无缓存。这些往返每一次都要抢 M2-2 里那把全局 RLock。前置检查本身是合理的(事务内还会权威复核,返回 reason=cooldown/daily_limit/inventory),但它是纯 UX 优化,代价是把锁竞争放大 5 倍。建议:群配置按 group_id 做带 TTL 的进程内缓存(配置变更时失效),并把 check_action_quota 前置检查降级为可选 |
| M2-12 | 中 | pet_service.py:70-73 | 进化是原子事务提交之后的一次独立写。_evolution_suffix → check_evolution → _persist_pet_candidate → db.update_pet,发生在 commit_pet_action 已经提交之后。若这次写失败(CAS 冲突、DB 错误),用户会收到"喂食成功 + 获得 N 经验"但宠物没有进化,且没有任何提示——check_evolution 失败分支返回 (False, ""),与"未达进化条件"完全同形。进化应并入 commit_pet_action 的同一事务,或至少把持久化失败与"条件未满足"区分开 |
| M2-13 | 低 | models/user.py:108-111、pet.py:104-107 | _persisted_state 为空时,merged_onto 退化为绝对值覆盖,并且 replace(self, version=latest.version) 会把版本号换成最新值——于是后续 WHERE version = ? 的 CAS 必然命中,乐观锁被一并绕过。实践上不可达(mark_persisted 只在 database.py 物化行时调用,凡是从库里读出来的对象都有基线;新建对象走 create_* 而非 update_*),但这是一次静默降级而非报错。应改为 raise 或至少 logger.warning |
| M2-14 | 低 | pet_service.py:541 | weights.append(max(prob, 0.01)) —— 探索事件权重下限被强制抬到 0.01,因此一个被设计为 prob: 0(即关闭)的事件仍会以约 1% 触发。想禁用某事件只能把它从 EXPLORE_LOCATIONS 里删掉。应改为过滤掉 prob <= 0 的事件,仅在全部事件权重为 0 时才回退 |
| M2-15 | 低 | social_service.py:176-182 | leave_message 先做敏感词全文扫描,再检查 200 字长度上限。敏感词表最多 100 词(database.py:1444 已有界,这点是好的),但一条超长留言会先被完整扫描一遍再因长度被拒。两个检查换个顺序即可 |
| M2-16 | 低 | social_service.py:110 | if type(amount) is not int —— 用 type() is 而非 isinstance()。这很可能是有意的(isinstance(True, int) 为真,此写法能挡住 amount=True),但没有任何注释说明。下一个维护者几乎必然会把它"修正"成 isinstance 并悄悄引入 True → 1 个道具 的路径。属于正文第 8 节"注释解释 what 而非 why"的典型 |
| M2-17 | 低 | main.py:587-593 | 幂等 reference_id 的取值链末端是 secrets.token_hex(16)。前三档(event["message_id"] / request_kwargs["request_id"] / context.request_id)在 OneBot 事件下总能命中,所以实践上安全;但一旦落到随机兜底,_minigame_reference / visit_pet 的幂等键就变成了一次性 nonce,UNIQUE (asset_type, reference_id) 的重复结算防护静默失效。这是本插件最强的一条保证被一个 or 分支悄悄取消。应改为落到兜底时记 WARNING,或直接拒绝结算 |
| M2-18 | 低 | models/user.py:162-173 | is_banned_active() 是查询式命名,却会修改 self(清除已过期封禁)。因为 is_banned / ban_until 都在 _MERGE_FIELDS 里,这次内存修改会成为一次合并差异,被之后任何一次 update_user 顺带持久化。这是有意的自动解封,但依赖"调用方稍后恰好会写库"这一隐含链路:basic_commands.py:42 与 main.py:568 的封禁闸门在拒绝请求后就直接返回,那次解封并不落库(下次重算,幂等,无害)。应重命名为 consume_expired_ban() 或拆成查询 + 显式解封两步 |
| M2-19 | 低 | pet_service.py:61 | _sync_state 用 target.__dict__.update(source.__dict__) 回写调用方对象。当前模型是普通 dataclass 所以可行,但它绕过了字段校验,并且一旦模型加上 __slots__ 就会整体失效(__dict__ 不存在)。考虑到这些模型是热路径对象,加 __slots__ 是很自然的后续优化。应改为按 dataclasses.fields() 逐字段 setattr |
| M2-20 | 低 | commands/basic_commands.py:120-128 | handle_status 一次调用里读了 3 次 user:get_or_create_user → check_and_award_titles 内部再 get_user → 有新称号时再 get_user 刷新。加上 get_pet、get_group_config,一条只读命令产生 5~6 次全局锁往返。check_and_award_titles 应接受已读到的 user 对象并返回更新后的实例 |
| M2-21 | 低 | services/pet_service.py | 返回元组的元数不统一:feed/clean/play/train/explore 返回 (bool, str, int),其余 20 余个方法返回 (bool, str)。main._extract_message 按 result[1] 取文案,因此第三个元素(本次实际发放金币)被静默丢弃,从未展示给用户。要么统一为 2 元组,要么把金币并进文案 |
| M2-22 | 低 | utils/validators.py:16 | validate_sensitive_content 每次调用都重建一次 dict.fromkeys((*DEFAULT_SENSITIVE_WORDS, *extra)) 并做 O(词数) 次子串扫描。词表有界(≤100×64),但改名/留言/展示三条路径都会调用。应按 group_id 缓存编译好的词表 |
M-6 [低] qingpet 总评(10 004 行读完)
结论:qingpet 是全仓领域建模最扎实的插件,也是唯一把"并发正确性"做到模型层的插件。
三层都有明确纪律,且三层各自的强项不同:
- 数据层(M-1):幂等键、SQL 触发器守不变量、真 CAS、对账、调度租约;
- 模型层(M-4):三方合并 + 版本 CAS,冲突可合并而非丢弃;
- 入口层(M-1 / M-4):
CommandCatalogNode真实消费、目录↔处理器启动校验、 三级限流且被拒请求不计数、显式 kwargs 白名单。
需要处理的集中在四处,都不涉及推倒重来:
- 吞吐:单连接 + 全局 RLock(M2-2)叠加每动作 4~5 次前置往返(M2-11) 与
handle_status的重复读(M2-20)。这是本插件唯一的结构性性能问题。 - 配置无法生效:每日金币上限烧死在 SQL 触发器里(M2-1)。
- 两处静默降级:
_persisted_state缺失时 CAS 被绕过(M2-13)、 幂等键落到随机兜底(M2-17)。都是"最强的保证被一个分支悄悄取消"。 - 输入解析:9 处
isdigit()+int()(M2-9)。
pendo(716 条,其中 185 条附完整推导)
索引 · —(28 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-2 † | P1 | — | 数据库和签名密钥都放在插件源码目录内,而不是 context.data_dir |
| N5-1 | P1 | main.py:139、auth.py:26 | 主数据库与 JWT 签名密钥写在包目录内,忽略 context.data_dir(详见 N-2) |
| N5-2 | 中 | main.py:709/820、scheduled.py:261/790 | 同一插件三种 send_action 结果纪律,两种给出错误的用户提示(详见 N-3) |
| N5-3 | 中 | plugin.json | manifest 未声明 contexts、concurrency、admin_only 中的任何一个**。pendo 管理日记、账本、笔记这类最私密的数据,却默认群聊可用。插件层用 privacy_mode 做了补救(N-4),但那是运行期用户设置,而 contexts: ["private"] 是声明式保证——两者不等价:is_public 判定(main.py:478-479)把"无参数子命令"也算作公开,于是 /pendo ledger、/pendo diary 的帮助与分类概览仍会出现在群里。这是 G-0 主线中隐私后果最重的一例 |
| N5-4 | 中 | auth.py:273、config.py:87 | Widget 令牌是 180 天有效的无状态 JWT,且没有任何吊销机制**:payload 无 jti,服务端无黑名单,revoke_web_session* 只作用于 Cookie 会话。令牌被嵌在手机端 Scriptable 小组件里(web/scriptable/pendo_widget.js),泄露面真实存在。唯一补救是轮换 PENDO_WEB_TOKEN_SECRET,而那会同时使所有用户的所有会话和令牌失效。建议加 jti + 一张持久化的吊销表,或把 Widget 令牌改为可按设备撤销的服务端记录 |
| N5-5 | 中 | auth.py:92-99 | _prune_expired 对 _LOGIN_CODES 和 _SESSIONS 两个字典各做一次全量扫描,而它被 get_web_session 调用——也就是每一个已认证请求都触发一次 O(n) 扫描并持全局 _AUTH_LOCK。两个字典还都没有任何容量上限(无每用户会话数上限、无全局上限),增长只受"速率 × TTL"约束(会话 TTL 8 小时)。应改为惰性/定时清理 + 每 owner 会话数上限 |
| N5-6 | 中 | main.py:149-150、config.py:79 | WEB_ENABLED = True 是纯源码常量:既不能由 config.json 设置,也没有环境变量。插件一加载就自动监听 127.0.0.1:12001,要关掉只能改源码。同时 from_global_config(config.py:130-141)只认 web_demo_enabled 一个键,WEB_HOST/WEB_PORT/WEB_SESSION_COOKIE_SECURE 全部只能走环境变量——包括那个决定是否允许公网绑定的安全开关。配置面应统一到 config.json,与 docs/06-configuration.md 描述的机制一致 |
| N5-7 | 中 | main.py:656-659 | _run_scheduled_task 捕获 asyncio.CancelledError 后返回 [] 而不重新抛出。这会破坏协作式取消:关停时任务对外表现为"正常完成",asyncio.gather/wait_for 的取消传播被吞掉。已记日志,但应在记录后 raise |
| N5-8 | 中 | main.py:153-170 | _start_web_server 同步调用 web_server.is_running()(内含 urlopen 阻塞探测,0.3 s 超时)与 web_server.start()(内含最多 1 秒的 time.sleep 轮询,且全程持 _STATE_LOCK),并直接在 init() 里执行。同文件已经有 _stop_web_server_async = asyncio.to_thread(_stop_web_server)——作者清楚这个问题,只是启动路径没用上。这是正文 K4-2(同模块内已有正确的 to_thread 版本,部分调用点未使用)的第 6 处 |
| N7-1 | 中 | — | 插件私有数据的落盘位置没有框架级约束**。qingpet 用 data_dir(但多套一层冗余目录,M2-7),codex 用 data_dir,pendo 用 os.path.dirname(__file__)(N-2)。core 已经通过 context.data_dir 提供了正确答案,却没有任何机制阻止插件绕开它。建议:PluginBase 提供 plugin_data_path(*parts),并加一条 CI 规则禁止在 plugins/ 中出现 dirname(__file__) + 写操作的组合 |
| N7-2 | 中 | — | 配置来源在插件间有三套互不相同的做法**:13+ 个插件手写 _get_config(正文 R-1)、codex 用 get_settings_snapshot() + revision 栅栏(J-2)、pendo 用类属性 + 环境变量(PendoConfig,且只有 1 个键能从 config.json 读)。第三套是新发现的,也是唯一让安全开关只能走环境变量的一套(N5-6) |
| N5-9 | 低 | main.py:484 | context._pendo_reply_private = reply_private —— 插件给 core 拥有的 context 对象挂了一个私有属性。context 是每消息构建的,因此可用;但这是一条未文档化的旁路通道,且下划线前缀用在外部对象上。应改为通过返回值或显式参数传递 |
| N5-10 | 低 | main.py:482 + :698 | 同一条命令内 _get_user_privacy_mode 被调用两次(路由前判定 reply_private,格式化时再判一次),每次都读一次数据库设置。应在请求内缓存 |
| N5-11 | 低 | main.py:811 | f"文件已保存在本地: {file_path}" —— bot 主机绝对路径外发到聊天。L3-1 主线的第 4 个插件、第 10 处 |
| N5-12 | 低 | main.py:1167-1180 | _show_help_overview 用的是 _local_catalog_root()(lru_cache 的本地 manifest 解析),而 _build_command_router 用的是 _catalog_root(context)(core 发布的目录快照)。同一份帮助有两个数据源;若 core 按权限或上下文过滤了目录,总览仍会列出用户不可用的命令 |
| N5-13 | 低 | main.py:914-1138 | HELP_MAP 是 225 行硬编码帮助文本,而 plugin.json(652 行)里同样带每条命令的 help_text。pendo 因此是"半消费 catalog"的形态:路由与别名走目录,详细帮助另写一份。两份文本没有任何一致性校验,漂移只能靠人工发现。对比 qingpet / bot_core 由 format_command_catalog 生成帮助(正文 M3-3) |
| N5-14 | 低 | main.py:1247-1284 vs :778-782 | 两处相邻的缓存决策互相矛盾**:_build_command_router 的 docstring 明写"不再按 group_id 做全局缓存,避免闭包捕获过期上下文";而紧邻的 _get_services 把 AIParser(context) 连同首次请求的 context 一起缓存进 context.state,之后所有请求复用。若 AIParser 读取 context 上的任何请求相关字段,就会拿到陈旧值。另外 ai_parser.db = db 是构造后属性注入,应作为构造参数 |
| N5-15 | 低 | config.py:151 | cls.WEB_PORT = int(os.environ["PENDO_WEB_PORT"]) —— 无 try/except、无 1..65535 范围校验。畸形环境变量会让 init() 抛 ValueError,插件直接加载失败 |
| N5-16 | 低 | auth.py:232 | atomic_write_text(_SECRET_FILE, generated_secret) 未对签名密钥文件做权限收紧(POSIX 下按 umask 通常是 0644,同机其它用户可读)。应在写入后 os.chmod(_SECRET_FILE, 0o600) |
| N5-17 | 低 | plugin.json | 8 个定时任务中有 3 个是每分钟执行**(pendo_reminders、pendo_daily_briefing、pendo_diary_reminder),各自独立扫库。三者都是"按用户本地时间判断是否到点",语义高度重叠,应合并为一个每分钟任务内部分派,把每分钟的数据库往返从 3 次降到 1 次 |
| N5-18 | 低 | deps.py:59 | Widget 路径的资源限制用 request.url.path.startswith("/api/widget/") 判断原始路径,而不是 FastAPI 匹配到的路由。当前 include_router(prefix="/api") 与 widget 子路由前缀一致所以正确,但前缀一旦调整,这个检查会静默失配(变成允许或全拒)。应改为依赖路由标记(如给 widget 路由打 tag/dependency)而非字符串前缀 |
| N5-19 | 低 | deps.py:87-89 | get_current_session 内部 from .services.demo_space import ensure_demo_access —— 函数内延迟导入,显然是绕循环依赖,但没有注释说明。且 demo 会话每个请求都要走一次 ensure_demo_access(get_db(), ...)(含数据库访问) |
| N7-3 | 低 | — | init() 里做阻塞 I/O 的插件不止 pendo**:pendo 启动 HTTP 服务器(N5-8)、qingpet 建库建表、codex 加载并可能隔离状态文件。core 的插件加载路径是否在事件循环上执行、是否有加载超时,应在 docs/02-architecture.md 明确,否则每个插件都要自己猜 |
| N-1 † | 低 | — | pendo 的 Web 认证是全仓最强的一处安全实现 |
| N-3 † | 低 | — | [P0-1 新增受害者] pendo 在同一个插件里用了三种互相矛盾的 send_action 结果纪律 |
| N-4 † | 低 | — | 隐私设计本身是对的,值得单独记 |
| N-5 † | 低 | — | 问题 |
| N-6 † | 低 | — | 一处值得表扬的异常处理分层 |
索引 · 校验层与时间层(20 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-8 † | P1 | — | pendo 有一套完整正确的时区体系,然后在 41 个地方绕开了它 |
| N10-1 | P1 | 全插件 41 处 | 朴素 datetime.now() 绕开自有时区体系(详见 N-8) |
| N11-1 | P1 | — | "时区正确的助手已存在,但绝大多数调用点不用它"——pendo 41 : 7(N-8)、core 的 _business_now 分叉(正文 A7-1)、codex 归档目录名用 time.localtime()(J3-6)。这与 K4-2(to_thread 版本已存在但部分调用点不用)、正文 R-1(get_settings_snapshot() 只有 1 个用户)是同一种失败模式**:正确的抽象被写出来了,却没有任何机制强制使用。三者都只能靠 CI 规则收口,建议合并为一条"已有正确抽象未被采用"的专项修复项 |
| N10-2 | 中 | validators.py:305-321 | normalize_template_answers 的条目数没有上限**:每条 answer 限 50 000 字符,但列表长度不限。同一文件里笔记引用被三层限制(N-9 第 4 点),日记模板答案却完全不限——同一份代码里两种边界纪律。已认证的 Web API 请求可提交十万条答案构造内存放大。应比照 MAX_NOTE_REFERENCES 加一个 MAX_TEMPLATE_ANSWERS |
| N10-3 | 中 | validators.py:176-179 | sanitize_text 的注释写着"规范化Unicode字符",紧跟的却只有 text.strip()——没有任何 unicodedata.normalize。这不只是注释错误:缺少 NFC 归一化意味着视觉相同的分类/标签会成为不同的键(组合字符 vs 预组合字符),并且 validate_tag 的 [一-龥...] 正则会把分解形式直接判为非法字符。应真正做 unicodedata.normalize("NFC", text),或删掉这句注释 |
| N10-4 | 中 | validators.py:204、:230 | 分类/标签的字符白名单用 [一-龥]——这是 CJK 基本区,不含扩展 A/B(如 𠮷)、不含日文假名与韩文谚文、不含 emoji。而同一文件的 DIARY_MOOD_ALIASES 接受 emoji(😊→happy)。于是"心情可以是 emoji,标签不能"。对一个个人笔记/日记应用,这是会被真实触发的可用性缺陷。应改为按 Unicode 类别排除(拒绝控制符/标点/分隔符)而不是枚举中文码位 |
| N10-5 | 中 | validators.py:281 | sanitize_search_keyword 用黑名单剥离 FTS5 语法字符 "'*😦){}[]+-,但漏掉了 FTS5 的**裸词操作符 AND/OR/NOT/NEAR**。搜索 NOT或foo OR会被 FTS5 当作查询表达式解析,产生语法错误或非预期结果;而由于"也被剥离,调用方**无法**用短语引号规避。正确做法是把整个关键词转义后包成"..."短语(内部"` 加倍),而不是删字符 |
| N10-6 | 中 | time_utils.py:243-245 | _parse_time_range_core 在 strict=False 时把任何无法解析的范围静默回退为"今天"。于是 /pendo todo list 2026-13-45、/pendo ledger list 上个月 都会安静地列出今天的记录,用户完全看不出自己的筛选条件被丢弃了。至少应在回退时把"未识别范围,已按今天显示"并入返回文案 |
| N11-2 | 中 | — | [一-龥] 作为"中文"的判据**:pendo 2 处(N10-4)。建议全仓搜一遍并统一替换——这个码位区间既漏掉扩展区汉字,也漏掉日韩文字,是中文项目里最常见的一个隐性缺陷 |
| N10-7 | 低 | validators.py:439 | validate_item_data 的 {k: v for k, v in data.items() if value is not None} 静默丢弃所有 None,因此经这条路径无法把字段显式置空。而同文件的 normalize_item_fields 路径是保留显式 None 语义的。两个校验入口的空值语义不同,424-429 的 docstring 解释了为什么要拆成两条路径,却没提这个差异 |
| N10-8 | 低 | validators.py:440-451 | 同一个函数里用了三种"字段是否存在"的判断方式:if "title" in data(成员)、if data.get("category")(真值)、if isinstance(data.get("tags"), list)(类型)。三者对空字符串/空列表/None 的行为各不相同,读者无法从形式上判断哪个是有意的 |
| N10-9 | 低 | validators.py:688-705 | derive_reminder_rules 对 offset < 0(即"开始之后再提醒")静默丢弃,而相邻的 normalize_reminder_rules:673-674 对同一情况抛 ValueError。同一不变量两种处理。副作用是"会议开始后 10 分钟提醒我"这类需求无法表达,且用户得不到任何提示 |
| N10-10 | 低 | validators.py:808-817 vs :852-869 | 事件的 timezone 字段被校验(ZoneInfo(timezone_name) 能否构造),但 _normalize_event_time_fields 只要求 start_time/end_time 的带时区形式彼此一致,并不用 timezone 去解释朴素的 start_time。因此一个 timezone="America/New_York" + 朴素 start_time 的事件,其绝对时刻取决于读取方如何解释,而不是记录本身。要么用 timezone 强制本地化,要么不要存这个字段 |
| N10-11 | 低 | time_utils.py:155-158 | _day_range 的右端点是 23:59:59(闭区间)。存储用 timespec="seconds" 时正确,但任何带亚秒的历史值会落在"今天"之外。应改为半开区间 [day, day+1) |
| N10-12 | 低 | time_utils.py:286-323 | parse_delay_time 的返回值有时带时区、有时朴素:走 now(TimezoneHelper.now())时带时区,走 current_due(fromisoformat,可能朴素)时朴素。这正是 N-8 里"同一列混两种 ISO 形式"的来源之一 |
| N10-13 | 低 | validators.py:18 | DEFAULT_EVENT_TIMEZONE = ZoneInfo(PendoConfig.DEFAULT_TIMEZONE) 在模块导入期求值。缺少 tzdata 的环境(未装 tzdata 包的 Windows)会在 import 阶段抛 ZoneInfoNotFoundError,表现为插件加载失败而非一条可读的配置错误。同文件其它地方都用 try/except 包了 ZoneInfo(...),唯独这里没有 |
| N10-14 | 低 | validators.py:476-484 | ledger_cents_to_amount 对 value <= 0 抛异常。这是写入路径的合理约束,但该函数也用于读取展示;若库里存在历史遗留的 0 分记录,读取时会直接抛错而不是显示 0.00。读写应使用不同的严格度 |
| N11-3 | 低 | — | FTS5 关键词清洗用黑名单**(N10-5)。凡是把用户输入送进 MATCH 的地方都应改为"短语引号包裹 + 内部引号加倍"的白名单式转义。需在 N-3 读 services/db.py 时确认 pendo 的 MATCH 调用形态,届时再定级 |
| N-9 † | 低 | — | validators.py 值得作为范本的五点 |
| N-10 † | 低 | — | 问题 |
索引 · 撤销 / 审计日志 / 设置 / 子命令路由(21 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-12 † | P1 | — | 审计日志保留策略被时区偏移整体平移,且方向随部署地反转 |
| N19-1 | P1 | — | "只把比较的一侧改对"是比双侧都错更危险的一类修改**(N-12)。prune_operation_logs 严格拒绝朴素时间,却与朴素存储列比较,使一条隐私保留策略按部署时区平移甚至反转。凡是"时间戳以字符串入库 + SQL 字符串比较"的地方(pendo 全部、qingpet M2-6),都必须把存储形式而不是单个函数当作契约来固定 |
| N-13 † | 中 | — | snapshot_redacted 标记写入后从未被读取,撤销失败没有可解释的原因 |
| N-14 † | 中 | — | 数据库路径被独立推导了两次 |
| N-15 † | 中 | — | save_user_setting 是无 CAS 的读-改-写,而 pendo 有两个前端同时写它 |
| N-16 † | 中 | — | 命令目录一致性检查只做了一半 |
| N18-1 | 中 | config.py:120、validators.py:324、settings_utils.py:48/108 | 一个插件里有四个布尔解析函数、三套不同的真值集合**:_parse_bool→{1,true,yes,on};normalize_bool_flag→{1,true,yes,y,on,是,收藏};_coerce_setting_bool 与 parse_toggle_value 共用 {on,true,1,yes,是,开启,开}。于是 /pendo settings privacy 开 有效,而同样写"开"的其它输入路径无效。应收敛为一个 coerce_bool(value, *, default) |
| N18-2 | 中 | db_ops.py:98-121 | _snapshot_item_values 对非 JSON 安全类型存 str(old_val)。撤销时这些字段会被以字符串形式写回,类型与原值不同(例如 datetime → "2026-07-25 14:30:00")。当前 Item 字段是否全为 JSON 安全类型需在 N-4 读 models/item.py 时确认;若不是,这是一条静默数据损坏路径 |
| N18-3 | 中 | operations.py:159-185 | _apply_snooze 先 update_item 写新提醒集合、再 confirm_reminder 确认旧提醒,两步不在同一事务。第二步失败时代码诚实地回报"提醒时间已更新,但原提醒状态记录失败",但数据库已处于不一致状态——旧提醒仍未确认,会再次触发。诚实报告 ≠ 一致性,应合并为一个事务 |
| N19-2 | 中 | — | 两个插件为同一问题写了两个 CommandRouter,成熟度不同**(N-16):qingpet 双向校验目录↔处理器,pendo 只校验一个方向。加上正文 M3-3(只有 2 个插件消费 catalog)、N5-13(pendo 另有 225 行硬编码帮助),说明 core 的命令目录发布了但没有配套的消费工具。建议 core 直接提供 assert_catalog_matches_handlers() 与 render_command_help() 两个函数 |
| N19-3 | 中 | — | "标记写入但从未读取"(N-13 的 snapshot_redacted)。这与 codex 的 ArtifactCollectionResult.reasons(K4-3,分类统计并回传用户**)形成对照:同样是记录失败原因,codex 接到了用户可见路径,pendo 停在了数据库里。建议审查时把"某字段是否有读取方"作为一条机械检查 |
| N18-4 | 低 | operations.py:273 | parsed.first.isdigit() + int(...) —— M3-4 主线在 pendo 的第 1 处(另 2 处在 ai_parser.py、本文件)。/pendo undo ² 抛 ValueError |
| N18-5 | 低 | operations.py:9 | pendo 确实使用 core.args.parse(parsed.options / .first / .second / len()),是少数没有手搓参数解析的插件之一。但这也意味着它继承了正文 A-0 的缺陷:任何以 - 开头的实参会被判成 option,触发 if parsed.options: return _error_result(...)。当前条目 ID 是十六进制形式所以不受影响,属于**正确使用了有缺陷的公共件 |
| N18-6 | 低 | core/router.py:125 | if not provided.startswith("❌ 未知命令") —— 用错误文案的前缀做控制流。_show_help 一旦改动那句提示的措辞,这里的回退分支就会静默失效。应让 help_provider 返回 str \ None |
| N18-7 | 低 | operations.py:277-286 | handle_undo 先 get_latest_undoable_operation 判断类型,再由 undo_delete/undo_edit 各自重新查询一次最近操作。既是 2 倍查询,也是一个 TOCTOU 窗口(两次调用之间用户又删了一条时,撤销的可能不是刚才判定的那条) |
| N18-8 | 低 | db_ops.py:30 | get_database(_context) 的参数完全未使用(下划线命名)。这是把"我不需要上下文"固化进签名,正是 N-14 的根源;参数应删除或真正用起来 |
| N18-9 | 低 | db.py:3168-3193 | prune_operation_logs 把全部待擦除行 fetchall() 进内存后逐行 json.loads + UPDATE。保留期 90 天、日志覆盖所有增删改,行数可观;应改为分批游标处理 |
| N19-4 | 低 | — | 布尔解析函数在单个插件内重复**(N18-1,pendo 4 个 / 3 套真值集合)。core 的 plugin_base 应提供 coerce_bool,与 M3-4 建议的 parse_int 成对 |
| UTC-5 | 低 | — | 每次清理都把全部快照擦掉,包括刚写入几秒的——撤销功能在每晚 00:15 之后彻底失效 |
| N-17 † | 低 | — | 值得作为范本的两点 |
| N-18 † | 低 | — | 其余问题 |
索引 · 数据层连接模型与搜索实现(14 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N32-1 | P1 | — | SQLite 连接模型应当上提到 core**(N-28)。仓库里现在有三种做法:pendo 每线程连接 + WAL 生效(正确)、qingpet 单连接 + 全局 RLock(WAL 失效,M2-2)、其余插件各自零散。core 应提供 core.sqlite_base.ThreadLocalDatabase,把连接登记、WAL/synchronous/busy_timeout、close_all_connections 的失败汇总、以及带嵌套计数或 SAVEPOINT 的 transaction()(N30-1 正是缺了这一半)一次做对 |
| N30-1 | 中 | db.py:598-609 | transaction() 的嵌套保护只做了一半**。进入时有 if immediate and not conn.in_transaction 的守卫——说明作者预见到了嵌套;但退出时是无条件 conn.commit(),异常时是无条件 conn.rollback()。于是一旦 transaction() 嵌套(外层已开事务,内层再进一次),内层退出会提交外层尚未完成的事务,内层失败会回滚外层已完成的工作。既没有嵌套计数也没有 SAVEPOINT。在一个 4 000 行、方法之间大量互相调用的数据层里,这是一颗结构性的雷。与附录 N-12(prune_operation_logs 只把比较的一侧改对)是同一种失败形态:**守卫加在了入口,没加在出口 |
| N30-2 | 中 | db.py:2217-2257 | 每次搜索都必然执行一次全表 LIKE 扫描**,且是 12 个列的 LIKE '%kw%'(前导通配符,任何索引都用不上);type 未过滤为非 event 时还要再跑一次 event_collections JOIN items 的第二次全表扫描。也就是说 FTS 索引只用来排序,并不减少扫描量。个人库规模下可接受,但这意味着搜索开销随条目总数线性增长且没有任何上限——DEFAULT_SEARCH_LIMIT = 50 是在取回全部 ID 之后才截断的 |
| N32-2 | 中 | — | "守卫只加在入口、没加在出口"是本次审查反复出现的形态**:transaction() 的 in_transaction 检查(N30-1)、prune_operation_logs 的 tz-aware 断言(N-12)、derive_reminder_rules 与 normalize_reminder_rules 对负偏移的两种处理(N10-9)。共同点是成对的操作只有一侧被加固,而单侧加固往往比两侧都不加固更危险,因为它制造了"已经处理过"的错觉 |
| N30-3 | 低 | db.py:2264 | f"i.type = '{ItemType.EVENT.value}'" —— 全文件唯一一处把字面量插进 SQL 而非用占位符。值来自枚举常量,不构成注入;但它混在一段其余部分完全参数化的代码里,是最容易被下一个人照抄的写法 |
| N30-4 | 低 | db.py:2287-2297 | 当存在账目/待办类筛选时,代码往 collection_where 追加 "1 = 0" 让查询恒假,而不是跳过整段查询。SQLite 仍会执行一次注定无结果的 JOIN。应改为条件跳过,并且 "1 = 0" 这种写法会让人误以为是调试残留 |
| N30-5 | 低 | db.py:654-666 | cache_invalidate(pattern) 用 pattern in k 做子串匹配。_cache_key 以 \ 拼接各部分,因此失效 "user1" 会连带命中 "user11"、"user123"。方向是安全的(多失效不会读到脏数据),但会在用户量增长后造成不必要的缓存抖动。应改为按分隔符做前缀/字段匹配 |
| N30-6 | 低 | db.py:633、:647 | LRU 缓存在存入和取出时各做一次 deepcopy。注释解释得很对(防止调用方就地修改污染缓存),但对搜索结果这类大列表,两次深拷贝的代价可能超过它省下的那次查询。应改为存不可变结构,或只对已知会被就地修改的类型拷贝 |
| N30-7 | 低 | db.py:599-604 | transaction() 默认是 DEFERRED,只有显式 immediate=True 才 BEGIN IMMEDIATE。读-改-写流程若走默认路径,在并发写下会拿到 SQLITE_BUSY(升级锁失败),且代码里看不到 busy_timeout 设置。建议在 get_connection 里加 PRAGMA busy_timeout,并把所有读-改-写默认改为 immediate=True |
| N32-3 | 低 | — | 搜索实现的"FTS + LIKE 并集"模式值得写进文档**。CJK 场景下 FTS5 的 unicode61 分词器确实无法做子串匹配,pendo 的处理(FTS 负责排序、LIKE 负责召回、按 bm25 合并)是一个可复用的正确答案。xiaoqing_chat 若也做检索(下一批确认),应比对是否重复实现 |
| N-28 † | 低 | — | pendo 的 SQLite 连接模型正是 qingpet M2-2 需要的修法 |
| N-29 † | 低 | — | 结清挂账:FTS5 关键词问题(N10-5 / N11-3)应降级 |
| N-30 † | 低 | — | 问题 |
| N-31 † | 低 | — | 值得记的三点纪律 |
索引 · 批量软删除与待办迁移(10 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N52-1 | P1 | — | "正确实现已在仓库内、只是没被复用"已累计三处**:qingpet 的连接模型问题(M2-2)↔ pendo 的 threading.local 实现(N-28);pendo 的 save_user_setting 丢更新(N-15)↔ 同仓 json_patch upsert(N-50 第 4 点);update_item(expected_version=) 未被聊天路径使用(N-33)↔ migrate_undone_tasks_to_date 里正确的逐行 CAS(N-50 第 3 点)。这三条的修复成本极低(改调用点,不用设计新方案),应当在正文的修复排序里提到最前面。 它们同时说明一个共性问题:这个仓库缺的不是能力,而是把已验证的做法固化成唯一入口的机制 |
| N51-1 | 中 | db.py:1154-1156 | batch_soft_delete 同样无条件删光提醒历史**:DELETE FROM reminder_logs WHERE item_id IN (...),不带 sent_at IS NULL。这确认附录 N-45 的缺陷存在于两条删除路径(delete_item:2082 与此处),不是个例。同一文件的 _sync_reminder_logs 明确保留已发送记录 |
| N51-2 | 中 | db.py:1950-1951 | 相邻两行用了两种时间纪律**:item_timestamp = datetime.now().isoformat()(朴素本地,写 items.updated_at)settings_timestamp = datetime.now(timezone.utc).isoformat()(UTC 带偏移,写 user_settings.updated_at)于是 items.updated_at 与 user_settings.updated_at 是两种 ISO 形式。这是附录 N-8/N-12 主线迄今最直观的一处——两种写法相隔一行,且没有任何注释说明为什么不同。任何跨这两张表比较或排序时间的代码都会静默出错 |
| N52-2 | 中 | — | json_patch + ON CONFLICT DO UPDATE 是 SQLite 上做"并发安全的局部设置更新"的标准答案**,值得写进 docs/07-advanced.md。qingpet 用 Python 层的 merged_onto 三方合并解决同类问题(M-4),pendo 用 SQL 层的 json_patch。两者各有适用面(前者能表达增量语义,后者不需要读取即可合并),应当并列记录而不是二选一 |
| N51-3 | 低 | db.py:1142-1157 | 批内 UPDATE 之后 if cursor.rowcount: deleted_ids.extend(batch) —— 只要该批有任意一行被更新,就把整批 ID 都记为已删除。若批内混有已删除或不属于该用户的 ID,deleted_ids 会包含它们,进而为它们写审计日志、失效缓存。虽然 _select_owner_item_ids 已在前面筛过一次(1135-1141),使得实践中不会发生,但这层保护是在事务内的另一条语句里做的,两者之间没有断言。应改为逐 ID 收集或比对 rowcount 与 len(batch) |
| N51-4 | 低 | db.py:1176-1179 | 缓存失效仍在事务之外(N35-1 同类,本文件第 4 处) |
| N51-5 | 低 | db.py:2036-2039 | 迁移成功后失效了 settings 缓存键,但提前返回的分支(1971-1972 幂等命中)没有——这条路径不改数据所以正确,只是两个出口的收尾不对称,读者需要自行确认 |
| N52-3 | 低 | — | created_at 被用作"批次标识"是有意设计**(N-49),_log_operation_with_cursor 的 created_at 参数就是为此存在。审查时把可选参数默认当成测试钩子是个容易犯的错——本次即因此误判过一次,记录以备后续批次自省 |
| N-50 † | 低 | — | migrate_undone_tasks_to_date 是 db.py 里工程质量最高的一个方法 |
| N-51 † | 低 | — | 问题 |
索引 · 用户设置与调度投递外发箱(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N63-1 | P1 | — | 正文关于 pendo 时区问题的表述需要按 N-59 改写**。它不是"41 处疏忽",而是一次进行到一半的迁移:租约/外发箱/日志保留子系统已迁至 UTC 并加了 10 处 raise 级断言,条目 CRUD 尚未迁移,prune_operation_logs 是两者交界处因而出错。这直接决定修复方式——必须按子系统边界整体迁移 + 历史数据转换,不能逐点把 datetime.now() 改成 UTC,否则每改一处就多一个交界 bug |
| N62-1 | 中 | settings_utils.py:157-163 + db.py:2982 | 逐条 db.confirm_reminder,无事务包裹。中途失败 → 一部分提醒已确认、一部分没有,且返回的 confirmed_count 只统计成功前的部分。同文件 _set_collection_reminders 走的是批量原子的 update_event_collection_reminders,纪律相反 |
| N62-2 | 中 | db.py:2998、3031 | get_user_settings / get_user_settings_batch 在用户无持久化记录时返回 _default_user_settings(...),且不写入缓存。对一个从未改过设置的用户,每次读设置都要打一次数据库(三个每分钟任务 × 全部用户,见 N22-2)。默认值也应入缓存 |
| N62-3 | 中 | db.py:3009-3011 | %m-%d 格式先按 1900 年 strptime 再 replace(year=...)。02-29 在 1900 年非法 → ValueError → continue → 闰年里用 02-29 选提醒永远选不中。应改为先补年份再解析 |
| N63-2 | 中 | — | 本仓库现有三份"周期任务只执行一次"的实现**:qingpet scheduler_runs(M-1)、pendo reminder_logs 租约(N-21)、pendo scheduled_delivery_outbox(N-60)。第三份最完整(幂等建行 + 三态可领 + 过期租约回收 + token 校验 + 退避 + failure_count + COALESCE(sent_at))。core 应据此提供统一的 outbox 能力,三处收敛为一处 |
| N63-3 | 中 | — | "原子原语被调用方的全量快照废掉"是一个值得单列的反模式**(N-61)。json_patch / merged_onto / CAS 这类原语都只保证"合并动作本身不被撕裂",不保证输入是新鲜的。判据:调用方传给原子方法的数据,是不是它自己刚读出来的整个对象? 若是,原子性就只是装饰。这条应与 N52-1 合并进正文——三处"正确实现已在仓库内"里,有两处(N-15、N-33)都是这个形状 |
| N62-4 | 低 | db.py:3047-3061 | _hydrate_user_settings_row 的 elif raw_settings: 分支里,decoded_settings 在 try 成功时于 else: 块赋值、失败时于 except: 块赋值——三个分支各自赋值同一个变量,静态检查器容易误报未绑定。逻辑正确但可读性差,应改为在 try 前先赋默认值 |
| N62-5 | 低 | db.py:3371、3413、3447 | 外发箱三个方法都用 with conn: 而非 self.transaction()——与附录 N-34 记的四种事务写法一致。这里每个方法只有一两条语句所以安全,但它们跨越了 INSERT 与 UPDATE 两条语句(claim_scheduled_delivery),依赖 with conn: 的隐式事务边界;若将来被包进外层事务,会在 claim 中途提交 |
| N62-6 | 低 | db.py:3394 | if cursor.rowcount != 1: return None 写在 with conn: 之外。此时事务已提交,cursor 仍可用,rowcount 也有效——因此行为正确;但把结果判定放在事务块外,读起来像是可能读到未提交状态。应把判定移进块内 |
索引 · handlers/task.py 全文(1348 行)(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-70 † | P1 | — | 同一张表的同一列被两个构造函数写成两种时间形式 |
| N-66 † | 中 | — | 【中】同一行内 created_at 是用户本地朴素、completed_at 是 UTC |
| N-71 † | 中 | — | 7 处 cache_invalidate(f"event_collections|{owner}") 是永不匹配的空操作 |
| N70-1 | 中 | list_all_categories:778-781 / list_tasks:945-952 | 两处都是 get_all_items 全量拉取后在 Python 侧过滤、分组、排序、分页。_build_task_list_filters 已经把 category/tag/status 下推到了 DB(这点是对的),但优先级、时间范围、overdue/upcoming/inbox 四类筛选全在 Python 侧,且分页发生在最后。与附录 N-248(笔记的日期/分类/标签筛选全在 Python 侧)同形 |
| N70-2 | 中 | delete_task:1244 / _delete_category_tasks:1264 | 走 _db_soft_delete_with_log / _db_batch_soft_delete_with_log → 落到 db.delete_item:2082 与 batch_soft_delete:1154-1156,两者都无条件删光该条目的 reminder_logs(N-45 / N51-1)。待办支持 remind: 字段,因此这是 N-45 的第一个可由待办路径触发的实例:删除待办再撤销,已发过的提醒会重新发一次 |
| N70-3 | 低 | _transition_task_status:1157 / edit_task:1332 | 两处都往 updates 里塞 "type": ItemType.TASK.value,即把 type 列更新成它已有的值。看不出意图(update_item 并不要求这个字段),且 type 属于概念上的不可变列 —— 应当进 _IMMUTABLE_UPDATE_FIELDS(db.py 已有该机制,附录 N-7 记为正面)而不是被反复写回 |
| N70-4 | 低 | _parse_task_priority:210-214 与 _parse_task_text:724-726 | 同一条 re.fullmatch(r"[1-5]", ...) 校验写了两遍,错误文案也各写一遍。N18-1「一个插件 4 个布尔解析函数」的同形物 |
| N70-5 | 低 | _delete_category_tasks:1253-1256 | 入口再次 _validate_task_category,而唯一调用方 delete_task:1231 已经校验过。防御性重复本身无害,但两处的异常处理路径不同(一处返回 error dict,一处也返回 error dict)——说明作者不确定该由谁负责 |
| N70-6 | 低 | edit_task:1330 | if not any(getattr(task, field) != value for field, value in updates.items()) —— 对 tags 这类 list 字段比较的是顺序敏感的相等。#a #b 改成 #b #a 会被判为"有变化"并写库(触发 version 自增 + updated_at 刷新)。与 N-259 的后果同类,只是触发条件更窄 |
| N-65 † | 低 | — | 【P1·主线收敛】混合时间形式的产生侧,实际只有 handlers/event.py 一个文件 |
| N-68 † | 低 | — | 【中】_task_sort_key 对 deadline_at 做字符串排序 |
| N-69 † | 低 | — | 【低】_user_local_now 是 get_user_local_wall_time 的第二份实现 |
索引 · 日程集合 CRUD 与缓存键一致性(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N73-1 | P1 | — | N-70 把"时区迁移未完成"的严重性提升了一级**:此前发现的都是"不同表/不同子系统用不同形式",现在确认同一张表的同一列内部就混着两种形式,且 items.created_at(主表主排序列)也受影响。正文必须写明:在动手修之前,需要先跑一段探测脚本统计存量数据中两种形式各占多少,否则无法制定迁移方案 |
| N72-1 | 中 | db.py:1266-1281 | create_event_collection(单独建集合)不写审计日志,而 create_event_collection_with_children 写(1330-1338)。因此单独创建的集合既无审计记录、也无法被 /pendo undo 撤销(撤销依赖 operation_logs)。两个创建入口的可撤销性不一致,用户无从预知 |
| N72-2 | 中 | db.py:1226-1236 | _prepare_new_event_collection 的 id_length 参数允许把 uuid4().hex 截断到任意长度。默认用全长 32 位是对的,但这个参数的存在意味着某处会传短值——与附录 N22-1(条目 ID 8 位十六进制、全库主键、无碰撞处理)是同一风险面。应确认所有调用点传的长度,并统一到不少于 12 位 |
| N73-2 | 中 | — | 子串匹配的缓存失效是一种脆弱设计**(N-71 + N30-5)。它同时产生两类错误:漏失效(单复数不匹配)与过失效(user1 命中 user11)。而两类错误的表现相反——前者是脏读、后者只是性能损失——因此漏失效很难被发现。建议 core 若提供缓存能力,键必须结构化(tuple 或带分隔符的前缀),失效按字段而非子串 |
| N72-3 | 低 | db.py:1348、1396 | get_event_collection 的缓存键含 owner_id or "*",因此同一个集合会按"带 owner"和"不带 owner"缓存两份。两份内容相同,但失效时靠 cache_invalidate(collection_id) 的子串匹配同时命中(因为 ID 在两个键里都出现),所以正确;只是缓存条目被无谓地占用了两份,而 CACHE_MAX_SIZE = 1024 是全局共享的 |
| N72-4 | 低 | db.py:1275-1279 | create_event_collection 用 with conn:(N-34 的四种写法之一),而 create_event_collection_with_children 用 self.transaction(immediate=True)。同一对兄弟方法采用了不同的事务写法,正如它们采用了不同的时间纪律(N-70)。这两处差异同时出现,提示这两个方法是在不同时期写的,且没有回头统一 |
| N72-5 | 低 | db.py:1249-1254 | _prepare_new_event_collection 对 kind / owner_id / title 做了 fail-closed 校验,是好的;但校验发生在 setdefault 之后,读起来像是"先补默认再验必填"。逻辑上无碍(这三个字段没有默认值),顺序上应把校验提前 |
| N73-3 | 低 | — | "一对兄弟方法在时间纪律、事务写法、审计行为三个维度上都不同"**(N-70 / N72-1 / N72-4,全部发生在 create_event_collection 与 create_event_collection_with_children 之间)。这类分叉是代码演进的自然产物,但三个维度同时分叉说明缺少"新增写入方法时的检查清单"。建议在 docs/03-plugin-development.md 给数据层写入方法列一张必查表:时间来源、事务边界、审计日志、缓存失效、FTS 同步 |
索引 · 集合更新与多节点提醒的原子重写(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N82-2 | P1 | db.py:1416、1472 vs 1291 | 同一行的 created_at / updated_at 可以是两种时间形式(详见 N-81) |
| N82-1 | 中 | db.py:1437 | changed 读到的是审计 INSERT 的 rowcount(详见 N-80) |
| N83-1 | 中 | — | db.py 的可靠性与"是否按同一模式写"高度相关**。四个按"先校验 → 写入 → 每步 rowcount 断言 → 失败即抛回滚"模式写的方法(N-79 提醒重写、N-50 待办迁移、N-60 外发箱、N-21 提醒租约)没有发现任何并发或一致性缺陷;而本次审查在 db.py 里记下的中/高级问题(N30-1 事务嵌套、N-80 rowcount 被覆盖、N35-1 缓存失效在事务外、N-45/N51-1 软删除硬删提醒历史、N-70/N-81 时间形式分叉)全部出现在不按该模式写的方法里。这是一条可操作的结论:**与其逐条修,不如把那四个方法的写法提炼成模板并逐个改写其余写入方法 |
| N83-2 | 中 | — | "事后读 rowcount"应作为一条机械检查项**。判据:cursor.rowcount 的读取点与产生它的 execute 之间,是否隔了另一条 execute、或跨出了 with 块?pendo 有两处(N-80 隔了语句、N62-6 跨出块)。这类问题不会被测试覆盖到,因为正常路径下取值恰好相同 |
| N82-3 | 低 | db.py:1559 | ORDER BY COALESCE(event_index, 999999), start_time, id —— 用 999999 作为"无序号"的哨兵。多节点日程的节点数远达不到这个量级,实践安全;但魔数应提取为常量并注释,否则读者无法判断它是上限还是哨兵 |
| N82-4 | 低 | db.py:1521-1522、1536-1537 | raise RuntimeError(f"event reminder update lost item: {item_id}") —— 异常文案里带 item_id。是用户自己的 ID、且经 public_error_response 脱敏后才到用户手上,风险很低;但这是 db.py 里少数把变量插进异常文案的地方,与该文件其余部分(_log_database_failure 式的"只记类型不记内容",见 qingpet 附录 M)纪律不同 |
| N82-5 | 低 | db.py:1409-1415 | update_event_collection 在 clean_updates 为空时返回 False,与"更新未命中"返回同一个值。调用方无法区分"你没给任何合法字段"和"这个集合不存在或不属于你"。前者是调用方 bug,后者是正常业务分支,应当区分 |
| N82-6 | 低 | db.py:1421 | update_event_collection 用 with conn:,而 update_event_collection_reminders 用 self.transaction(immediate=True)——又一对兄弟方法采用不同事务写法(附录 N72-4 记的是 create_* 那一对)。db.py 里 create 与 update 两组集合方法各自内部都存在这种分叉 |
索引 · 日程实例与集合的级联删除(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N90-1 | P1 | — | N-84 的注释应当直接引用进正文**。它证明 pendo 的两套时间体系是被有意维护的并存状态,而非退化。这改变了修复的性质:不是"清理技术债",而是"完成一次被搁置的迁移",需要包含存量数据转换、并且要先移除那些显式依赖旧格式的地方(如本处注释所描述的排序依赖) |
| N89-1 | 中 | db.py:1674-1701 + _undo_delete_from_log 回落分支 | 两套控制字符定义**:UNSAFE_CONTROL_RE = [\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f](含 C1 区 \x80-\x9f)与 sanitize_text 的 [\x00-\x08\x0b\x0c\x0e-\x1f\x7f](不含 C1)。前者用于"拒绝",后者用于"清洗"→ C1 控制字符能通过清洗路径进库,但会让之后任何走拒绝路径的编辑操作报错。同一条数据,创建时被接受、编辑时被拒绝 |
| N89-2 | 中 | db.py:1686-1690 | _row_to_item 的第 4 条"损坏行静默丢弃"分支(附录 N68-2 只记了 3 条):未知 TaskStatus → logger.warning("Unknown task status while decoding Pendo row") + return None。同样不带 item_id / owner_id / 实际状态值。而 handlers/task.py:163-166 专门写了 _task_status_value 去兼容纯字符串状态——一层容忍、另一层丢弃,且丢弃的那层在前 |
| N89-3 | 中 | db.py:1622、1692-1694 | item.status.value 直接取枚举属性,而 task.py:163 认为该字段可能是纯字符串并专门写了兼容函数。经核对 _row_to_item 确实会强制转成枚举,因此 DB 路径安全;但两处对同一字段的信任程度相反,且只有一处写了理由 |
| N90-2 | 中 | — | "本次操作影响的对象"必须显式记录,不能靠事后查询重建**(N-85)。_undo_delete_from_log 的回落查询用"当前所有已删除的子节点"近似"这次删除的子节点",这在有历史删除时必然出错。同类风险出现在任何"撤销/回滚靠重新查询当前状态"的实现里——qingpet 的 asset_ledger 用 reference_id 显式绑定每一笔(附录 M-1),是正确的对照 |
| N89-4 | 低 | db.py:1590-1601 | "删除最后一个 leaf 时连带删除空集合头"的判断是 sibling_count == 1(即当前这条就是最后一条)。逻辑正确且有好注释;但它只对 multi_node 生效,recurring 集合删到最后一个 occurrence 时会留下一个空的集合头。若这是有意的(重复日程的集合头承载 rrule,需要保留),应当写进注释;否则是一处遗漏 |
| N89-5 | 低 | db.py:1653-1660 | 只在 page > 1 时给出 (第N页),从不给总页数;show_all 分支直接返回原列表且 has_more=False,因此 all 对任意规模都不设上限(与 N-83 相关) |
| N90-3 | 低 | — | 同一类操作的四条实现路径 WHERE 子句各不相同**(N-86:四条删除路径在 deleted = 0、version + 1、type = ? 上各有取舍,无一处注释说明)。这是 N83-1 那条结论的又一面:缺的是模板,不是能力 |
索引 · services/rule_parser.py(288) + services/reminder.py 余(356)(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-97 † | P1 | — | 混合 ISO 格式的问题作者已知并已在一处正确解决,但简报取数没用那套解法 |
| N-92 † | 中 | — | 集合导入的 update 分支没有 rowcount 校验,条目导入有 |
| N96-1 | 中 | rule_parser._detect_type:142-155 | 判定顺序是 NOTE → 重复词 → EVENT → TASK → 时间表达。因为 NOTE_KEYWORDS 含 "记录","记录一下明天3点和张三开会" 会被判成笔记,时间与地点全部丢弃。判定顺序即优先级,但五组关键词有大量语义重叠且无冲突处理 |
| N-98 † | 低 | — | 简报的"还有 N 项"会少算 |
| N96-2 | 低 | rule_parser.parse:77 | "title": text[:50] —— 又一处静默截断(N-74 主线第 5 处) |
| N96-3 | 低 | rule_parser._extract_absolute_start:195 | 时分正则在全文里搜 (\d{1,2})[点::],与日期不要求相邻。"2026-05-01 提交,参考 12:30 那版" 会把 12:30 当成日程时刻 |
| N96-4 | 低 | rule_parser._extract_relative_time:179 | 结束时间正则 到\s*(\d{1,2})[点::],而 DEADLINE_HINTS(:39) 又把 "到" 当作截止语义提示 → 同一个"到"字在两处含义相反,先命中哪个取决于 _extract_time 的调用顺序 |
| N96-5 | 低 | reminder._next_quiet_hours_end:586-587 | 读设置失败时返回 current + 1 分钟,无退避;配合 N-93 的 fail-open,一个损坏的设置行会造成每分钟一次的重试 |
| N96-6 | 低 | reminder._get_event_collection_for_item:466-483 | 每条提醒消息都会为集合子条目查一次集合表(未走批量接口)。handlers/event.py:_load_reminder_list_metadata 有现成的批量版本(N-63 正面),投递路径没用 |
| N-93 † | 低 | — | 【中】静默时段是 fail-open,而同插件的过期判定是 fail-closed |
| N-94 † | 低 | — | 【中】本地降级解析器产出的 rrule 是安全子集,LLM 路径却不受任何约束 |
| N-95 † | 低 | — | 【中】_extract_relative_time 把"今晚/明晚"解析成上午 9 点 |
索引 · 日期范围查询与每日简报取数(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N100-1 | P1 | db.py:2876-2887 | get_briefing_items 直接对混合格式的 start_time / end_time 做字符串比较,而同文件 get_events_for_range 已有正确解法。后果:每日简报漏掉带偏移时间的日程(详见 N-97) |
| N101-1 | P1 | — | N-97 给了时区主线一个"可以立刻做、且不需要数据迁移"的修复入口**。此前 N63-1 / N90-1 的结论是"必须整体迁移",那是个大工程;但 get_events_for_range 证明存在一种在混合格式下也正确的查询写法(日期前缀粗筛 + Python 精确判定)。因此修复可以分两步:第一步把所有对 start_time / end_time / created_at 做字符串范围比较的查询改写成这个形状(不动数据,立即消除用户可见的漏查);第二步再做存量数据的格式统一。正文的修复排序应当据此调整 |
| N101-2 | 中 | — | "同一份逻辑的两个实现,新的那个正确、旧的那个还在被调用"——get_events_for_range(正确)与 get_briefing_items(不正确)。这与附录 N52-1 的三处(连接模型、json_patch、CAS)是同一形状,本次审查累计第四处**。共同修法都是"把调用点指向正确实现并删除旧的",成本极低。建议正文把这四处合成一张表,作为最优先的修复清单 |
| N100-2 | 低 | db.py:2904、2919 + scheduled.py:1072、998 | LIMIT 10 与"还有 N 项"文案冲突,计数会少算(详见 N-98) |
| N100-3 | 低 | db.py:85-97、2264、2829、2878、2898、2917、2945 | 类型字面量 f-string 拼接是全文件普遍写法(详见 N-99,修订 N30-3) |
| N100-4 | 低 | db.py:2953-2976 | query_items_by_date_range 不使用缓存,而它是周/月财务总结每次都要调的取数入口(_generate_finance_summary_content)。财务总结每周/每月一次,代价可接受;但同一个方法若被列表页复用会成为热点 |
| N100-5 | 低 | db.py:2830 | AND (event_role IS NULL OR event_role IN ('single', 'multi_node_child', 'recurring_occurrence')) —— 这三个值正是 validators.EVENT_ROLES 的全部内容,即该条件等价于"event_role 是合法值或为空",实际过滤不掉任何有效数据。若意图是"排除集合头",那么集合头本来就不在 items 表里(它在 event_collections),这个条件是冗余的;若是为了防御脏数据,应加注释说明 |
| N100-6 | 低 | db.py:2816-2817 | TimezoneHelper.parse(start_date, user_timezone) —— start_date 参数名暗示是日期(YYYY-MM-DD),但传给了一个解析 datetime 的函数。TimezoneHelper.parse 内部用 datetime.fromisoformat,对纯日期串会得到当天 00:00,行为正确但参数命名与实际契约不符 |
索引 · web/services/bundle_import.py 余(140-313) + utils/settings_utils.py(183)(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-112 † | P1 | — | 【中】save_user_setting 的丢更新机制,逐行确认 |
| N113-1 | 中 | settings_utils.py:39 | ai_consent 的帮助文本写的是"是否允许把日记正文发送给已配置的外部 AI" —— 确认了 N26-1:设置项名叫 ai_sensitive_data_consent(听起来覆盖全部敏感数据),实际只管日记;日程文本经 parse_event_with_ai 外发不受它约束,帮助文本也没有提示这一点。用户按字面理解会以为关掉它就没有任何内容外发 |
| N113-2 | 中 | format_plugin_settings_message:138-140 | 设置页展示"🔕 静默时段: 23:00 - 07:00",没有任何地方告诉用户"标题含重要/紧急/会议/deadline/截止的条目会穿透静默时段"(N-92)。确认该行为对用户完全不可见 |
| N113-3 | 低 | settings_utils.py:44-45 | _TRUTHY/_FALSY 被 _coerce_setting_bool 与 parse_toggle_value 共用,且写了注释说明共用。这是 N18-1(一个插件 4 套真值集合)里唯一做了收敛的一处;另三套是 diary._TRUE_METADATA_VALUES(含"是"/"收藏")、validators.normalize_bool_flag、transfer.ImportOptionsModel 的 StrictBool。收敛目标应当是后者(拒绝宽松真值),而不是这一处 |
| N113-4 | 低 | normalize_settings_json:78-93 | daily_report_enabled → daily_briefing_enabled 的键改名迁移写在规范化器里,并在最后 pop 掉旧键(附录 N-4 已记为正面)。补充一点:partial=True 时若两个键都不存在则整段跳过,因此旧键不会在部分更新里被意外复活 —— 这个细节是对的 |
| N113-5 | 低 | resolve_default_category:172-179 | 捕获 Exception 后回退到插件默认分类,只记异常类型不记内容。与 _is_in_quiet_hours(N-93)同为 fail-open,且都是"读设置失败"。按 N98-3 的判据(失败只导致多做/做错一件小事)可以 fail-open,但两处仍应统一成一个 get_settings_or_default 助手 |
| N113-6 | 低 | bundle_import:251 | datetime.fromisoformat(end_time) <= datetime.fromisoformat(start_time) 依赖两者都已被 _normalize_source_datetimes 转成 UTC 带偏移。若将来有字段被移出 _COLLECTION_DATETIME_FIELDS,这里会抛 TypeError(naive vs aware),而 inspect_bundle_bytes 只捕 ValueError → 单条坏记录会 500 整个导入。建议显式断言或改捕 (ValueError, TypeError) |
| N-110 † | 低 | — | 【P1·主线定案】混合时间形式的第 4 个产生源,而且这一个是**对的 |
| N-114 † | 低 | — | 问题 |
索引 · 提醒确认补录与每分钟全表扫描(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N114-1 | P1 | db.py:3798-3835、3837-3865 | 每分钟两次跨用户全表扫描且无索引支撑(详见 N-112) |
| N115-1 | P1 | — | "每分钟任务的成本与历史数据量成正比"是定时任务设计的一个通病**(N-112)。正确形态是让每分钟的查询只触碰"即将到期"的小集合——物化 next_fire_at 列并建索引。pendo 的 scheduled_delivery_outbox 已经有 next_attempt_at 与配套索引(附录 N-60),同一个插件里已经存在正确形态,只是提醒主队列没用。这是附录 N101-2"新实现正确、旧实现还在被调用"清单的第 5 处 |
| N114-2 | 中 | db.py:3861 | get_all_events_with_reminders 用 SELECT * 取回全部 55 列(含 content,上限 5 万字符),而提醒判定只需要其中 8 列左右 |
| N114-3 | 中 | db.py:3754-3766 | get_reminder_logs(item_id) 不带 owner_id 过滤,任何调用方传入一个 ID 就能读到该条目的全部提醒历史。同文件的 get_reminder_logs_by_item_ids 是带归属校验的(3786 WHERE i.owner_id = ?)。两个相邻方法一个校验一个不校验,且不校验的那个签名上看不出来——与附录 N110-1(危险的默认参数)同族 |
| N115-2 | 中 | — | 相邻的一对方法,一个做归属校验一个不做**(N114-3)。这与附录 N110-1(危险默认参数)、N-16(单向校验)构成同一类:"安全性是逐个方法决定的,而不是由 API 形状保证的"。建议正文提出一条结构性建议:所有按 ID 读写的数据层方法,owner_id 一律必填;确实需要跨用户访问的(如清理任务)另开显式命名的方法 |
| N114-4 | 低 | db.py:3818-3830 | 判定"该提醒是否仍在条目的 remind_times 里"是在 Python 侧逐行 json.loads(row["remind_times"])。同一条目有多条提醒日志时,同一份 JSON 会被重复解析多次。SQLite 的 json_each 可以把这个判定下推到 SQL:AND EXISTS (SELECT 1 FROM json_each(i.remind_times) WHERE value = rl.remind_time) |
| N114-5 | 低 | db.py:3847、3849、3850 | 又三处类型字面量 f-string 拼接(附录 N-99 家族,累计约 19 处) |
| N114-6 | 低 | db.py:3689-3696 | _insert_confirm_for_unsent 在 remind_times 为空、非法 JSON、非列表三种情况下都静默 return,调用方 confirm_reminder 随后仍返回 {"status": "success", "message": f"已记录: {user_action}"}。即用户确认了一条不存在的提醒,会得到"已记录"的成功提示而实际什么都没发生。这是附录 N78-3"静默 vs 报错不一致"清单的第 5 处 |
索引 · web/services/demo_space.py(344) + 工具链门禁交叉核对(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-116 † | P1 | — | amount_cents 有 ADD COLUMN 迁移但没有回填,回填代码不在发行包里 |
| N117-1 | 低 | :129,163,307 | 三处 now = now or datetime.now() —— 朴素服务器本地,_iso(expires_at) 也把朴素串写进 settings_json.demo_expires_at。整个演示过期子系统自洽(_is_expired 还专门兼容了混合),但它使 pendo 的"时间世界"变成三个:条目 CRUD 朴素、租约/外发箱/导入 UTC、演示空间朴素。修 N-268 时这一处也要一并迁移,否则 _is_expired 的混合兼容分支会变成永久遗留 |
| N117-2 | 低 | create_demo_session:308 | 全局 _DEMO_CREATE_LOCK 覆盖了 purge_expired_demo_users(全表扫描所有 demo_web_% 用户)+ 整包播种(数百条 insert)。演示创建因此完全串行。有容量上限兜底所以不致命,但把回收挪到锁外(或改成定时任务)成本很低 |
| N117-3 | 低 | _recent_demo_requests:288-292 | 每次调用遍历全部 key 做清理(N5-5 同形),只是这里 n 有上界所以无害。若将来 _DEMO_REQUESTS 的登记条件放宽,这一行会立刻变成 O(n) 热路径 |
| N117-4 | 低 | _shift_datetime_text:200-201 / _shift_date_text:184-185 | 解析失败时返回原值(不平移)。模板已在加载时校验,所以当前不可达;但若模板里出现一个坏时间,演示数据会静默出现一条"2026 年 4 月"的旧条目而不是当前窗口的 —— 属于 N69-1 形态的潜在实例 |
| N-119 † | 低 | — | 打包核对(与 N-66 相反的一次确认) |
索引 · 类常量、白名单派生与金额列的迁移缺口(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N121-1 | P1 | — | "打包规则把必需的迁移/修复能力排除在运行时之外"已出现两次**(N-66 的 rebuild_fts_index、N-116 的 amount_cents 回填),且第二次涉及金额正确性。这应当在正文里升级为一条独立结论:prune 掉的 scripts/ 目录里不能存放任何"生产环境可能需要执行"的逻辑。审查打包配置时必须双向检查——不该进包的有没有进(附录 F-0 的教训),该能用的是不是被排除了 |
| N119-1 | 中 | db.py:1912-1929 | get_all_items 用 page_size=200 循环调用 get_items 直到取完全部结果。而 get_items 每页都会写入共享 LRU 缓存(_cache_set,含一次 deepcopy)。导出一个有 10 000 条记录的用户会:发起 50 次查询、往 1 024 容量的全局缓存里塞 50 条(把其他用户的条目与设置缓存全部挤出)、并在内存里同时持有 10 000 个 Item。应给它一条"不写缓存"的取数路径,或直接用游标流式产出 |
| N119-2 | 中 | db.py:1931-1936 | get_active_user_ids 是 SELECT DISTINCT owner_id FROM items WHERE deleted = 0。idx_owner_type(owner_id, type, deleted) 的前导列是 owner_id,因此 SQLite 可以走索引扫描而非表扫描——比预期好;但它仍是 O(索引大小),且每分钟被三个定时任务各调一次(附录 N22-2)。用户数远小于条目数,应当维护一张 owners 小表或至少加缓存 |
| N121-2 | 中 | — | 从模型派生白名单**(N-117 的 _ITEM_FIELDS)是一个应当推广的小设计。qingpet 的字段校验是手写常量、pendo 的条目层是派生、pendo 的集合层是手写——三种做法并存于两个插件。凡是"必须与另一处保持一致的集合",都应当从那一处派生而非复制 |
| N121-3 | 中 | — | 缺少 dataclass 的那一层,各项纪律都会退化**(N-117):event_collections 没有模型,于是白名单手写、返回类型是无结构 dict、字段名拼错只能运行时暴露、_row_to_event_collection 也无法做 _row_to_item 那样的类型降级。建议补一个 EventCollection dataclass,收益会同时体现在这四处 |
| N119-3 | 低 | db.py:1902-1910 | _resolve_item_sort 对非法 sort_field / sort_order 静默回退到 created_at DESC。防注入方向正确,但用户拼错排序字段时不会得到任何提示,结果按另一种顺序返回。这是附录 N78-3"静默 vs 报错不一致"清单的第 6 处 |
| N119-4 | 低 | db.py:3212-3222 | log_transfer 的 return 语句写在 with conn: 块内。Python 会先求值再退出上下文管理器(提交),因此行为正确;但"在事务块内 return"是一种容易被误读为"未提交就返回"的写法,且与同文件其它方法(先算后返)不一致 |
| N119-5 | 低 | db.py:450 | _LEDGER_AMOUNT_CENTS_EXPR 是一个裸 SQL 片段常量,被 f-string 插入三处查询。当前值不含用户输入所以安全;但它是全文件唯一一个"以字符串形式流转的 SQL 表达式",与 _quote_col / 白名单那套纪律不同源 |
索引 · 前端 · components/ledger_insights.js(529) + pages/tasks.js(1397)(15 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-141 † | P1 | — | Web 任务编辑器把带时区的 deadline_at 静默改写成朴素串 |
| N-142 † | 中 | — | 看板复选框与待办页对同一动作用了两套并发纪律 |
| N-143 † | 中 | — | items.py 的「无变化」短路被派生字段绕开,这正是 N-132 的机制 |
| N-144 † | 中 | — | delta_vs_previous == 1.0 是落在合法值域内的哨兵,真实的翻倍会被显示成「首次有支出」 |
| N145-1 | 中 | ledger_insights.py:94 vs :267/:272 | 同一个文件里,分类的「聚合键」与「筛选键」用了两套规则**:GROUP BY 用 COALESCE(NULLIF(TRIM(ledger_category),''),'未分类'),而 WHERE 用裸 ledger_category = ?。于是这个面板自己产出的图例名字不能拿回来当筛选条件:点「未分类」必然筛出 0 条(库里存的是 NULL/空串,不是字面量「未分类」),带首尾空格的分类同理。对照 db.py:2346 走的是 TRIM(category) = ?(N47-1)→ **全插件对同一概念存在三种匹配规则 |
| N145-2 | 中 | ledger_insights.py:282 | 后端为每个分类算了 share 字段,前端 normalizeCategories(ledger_insights.js:63-71)只取 category/total/count,从不读 share,自己用 displayTotal 重算一遍。两处分母不同(后端用 focus_total,前端用 max(focus_total, 分段合计))→ 属于「能力已具备但没接线」(N37-1)的**反向形态:接了线但两端各算各的 |
| N145-3 | 中 | tasks.js:304-310 taskSortKey | 排序键第 3 位是 deadline_at 原始字符串,缺省哨兵 '9999-12-31T99:99:99'。混合 ISO 形式下字符串比较不等于时刻比较(UTC 串在 UTC+8 下字典序偏小 8 小时)→ ISO 串排序第 14 处;且哨兵 T99:99:99 是非法时刻,靠「字典序恰好最大」成立,任何一次把它改成 parseDate 比较都会静默退化 |
| N145-5 | 中 | tasks.js:402-408 完成节奏柱状图 | 用 dateKey(task.completed_at task.updated_at) 分 7 天桶,dateKey → parseDate → new Date() → 浏览器时区。三重不一致:值本身两种形式、解释按浏览器时区、而 pendo 的语义时区是用户设置的那个(N-121) |
| N145-6 | 中 | tasks.js:711 + task_overview.py:101-110 | 待办页没有分页:/stats/tasks/overview 一次返回 all_tasks 全量,筛选、排序、分桶、渲染全在客户端。_load_all_tasks 按 _ITEM_BATCH_SIZE 分批读但不设总量上限 → 任务积累到数千条后,每次筛选变更都是一次全量 sort + 全量 innerHTML。N-83 分页主线在前端的确认点 |
| N145-7 | 低 | tasks.js:1063-1072 | 每次筛选变更、每次状态更新都整页 innerHTML 重建(含滚动位置与焦点丢失、自定义下拉重新初始化)。与 dashboard.js(N133-2)同一问题,**前端第 2 处 |
| N145-8 | 低 | tasks.js:1092-1094 | finally 里先用旧的 _overview 重渲染一次,随后 emitTaskChanged 触发的重新拉取才把新状态盖上 → 勾选后有一次「弹回旧状态」的闪烁 |
| N145-9 | 低 | ledger_insights.js 全部 SVG | 四张图都是 aria-hidden="true",而唯一的数值信息(<title> 提示)恰好在被隐藏的子树里 → 对读屏用户这四张卡片等于不存在,也没有等价的表格回退。tasks.js 的柱状图(:782-793)同样只有视觉表达 |
| N145-10 | 低 | ledger_insights.js:56-61 formatAxisLabel | 只识别 YYYY-MM-DD,bucket_mode === 'month' 时(区间 > 62 天)键是 YYYY-MM,直接原样显示 → 横轴在两种模式下标签风格不一致(5/1 vs 2026-05)。后端 _bucket_label 其实已经算好了 label,前端在 label 为空时才回退到这个函数——又一处两端各算一遍 |
| N145-11 | 低 | utils/ui.js:62,72-73 | subscribeDataChanges 里的 active 标志写了但 handler 从不读(真正生效的是 removeEventListener)。死变量,且会让读者以为存在「已注销但仍在队列里的事件」防护 |
| N-146 † | 低 | — | 问题 |
索引 · 日记创建/查看/列表(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N146-1 | P1 | diary.py:246-247 | created_at / updated_at 用用户本地朴素时间,构成 items.created_at 的第三种形式,且与第一种形状相同(详见 N-144) |
| N147-1 | P1 | — | "两种不同语义的时间戳拥有相同的字符串形状"是时间迁移里最难处理的情形**(N-144)。带偏移与不带偏移至少可以用正则区分;而"服务器本地朴素"与"用户本地朴素"在数据层面完全不可分辨,只能靠"哪条代码路径写的"来推断。这把附录 N63-1 的迁移方案从"两步"变成"三步":先按写入路径重建真实时刻 → 再统一查询写法 → 最后统一存储格式。正文必须写明这一点,否则按现有方案迁移会把跨时区用户的日记时间整体偏移 |
| N146-2 | 低 | diary.py:221-225 | if manual_mood or manual_score not in (None, ""): —— 用户只写 score:8 而不写 mood: 时,AI/规则情绪分析被整体跳过,结果 mood=None 而 mood_score=8。日记于是有分数没情绪,展示时 if diary_item.mood: 为假,连分数也不显示(274-279 行的分数嵌在 mood 分支里)。用户写了 score:8 却什么都看不到。应改为"手动值优先、缺失的那一项仍走分析" |
| N146-3 | 低 | diary.py:355-356 | 列表先按日期范围取回全部日记,再在 Python 侧用 _matches_list_filters 过滤 mood / category / tag。三个筛选都有对应的数据库列(mood、category、tags),_apply_filters 也支持它们(附录 N-46)。默认范围是"本月"所以量不大,但改用 range=year 就会把全年日记读进内存再丢掉大部分 |
| N146-4 | 低 | diary.py:310-315 | 按日期查看时把当天所有条目拼进一条消息,无数量上限、无截断。content 上限 5 万字符,同一天可写多篇(帮助文本明说"同一天可多篇")。一天写 20 篇长日记会产生一条超长消息,交由投递层去截断 |
| N146-5 | 低 | diary.py:245 | "context": {"group_id": group_id} if group_id else {} —— 把写日记时所在的群号存进条目。对一个隐私优先的日记功能(privacy_mode 默认开、AI 外发需同意),记录来源群是一个未被文档说明的元数据。它确实有用(可追溯),但应在 docs/09-plugins.md 里写明"日记会记录创建时所在的群" |
| N147-2 | 低 | — | "手动指定部分字段就跳过全部自动推导"是一个常见的小缺陷**(N146-2)。正确形态是逐字段判断:手动给了 mood 就不推 mood,没给 mood_score 就仍然推 mood_score。同类写法值得在 event.py / task.py 的解析路径里一并检查(那里也有"AI 解析 vs 用户显式参数"的合并逻辑) |
索引 · 前端 · pages/diary.js(1519) + pages/notes.js(1519)(14 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-148 † | P1 | — | 日记 entry_time 是 N147-1 的第 2 例,且失效方式与待办**不同 |
| N-149 † | 中 | — | Web 笔记编辑器会永久抹掉关联条目的类型与标题快照 |
| N-150 † | 中 | — | Web 端全部四条写入路径都不带 version,notes.js/diary.js 还外加全量快照回写 |
| N-151 † | 中 | — | 日记做了原型污染防护,笔记没有 —— 同一处风险两页两种待遇 |
| N152-1 | 中 | notes.js:346-352 dateTimeRangeForQuery | 把日期范围拼成 ${start}T00:00:00 / ${end}T23:59:59 朴素串去比较 created_at 列,而该列混有 UTC 带偏移值(N-70)。字符串比较下 2026-05-01T10:00:00+00:00 排在 2026-05-01T00:00:00 之后、...T23:59:59 之前看似正确,但对 UTC+8 用户,本地 5 月 1 日 06:00 写的条目存成 2026-04-30T22:00:00+00:00 → 落在 4 月 30 日的范围里。范围筛选第 2 处受害点(第 1 处 N-127) |
| N152-2 | 中 | diary.js:252-257 sortItems | 排序键 ${diary_date} ${itemEntryTimestamp(item)} 再 localeCompare —— itemEntryTimestamp 会依次回退到 entry_time → created_at → updated_at,三列的时间形式各不相同(N-131/N-224)。ISO 串排序第 15 处,且这一处混的是**三个不同语义的列 |
| N152-3 | 中 | diary.js:184-190 itemEntryTimestamp | entry_time 缺失时静默用 created_at 顶替,而界面把它标成"记录时间"(renderEntryCard:359 / 视图弹窗 🕒)。两者语义不同(用户声明的记录时刻 vs 系统落库时刻),界面无任何区分。formatEntryTime 里那个 '全天' 分支因此几乎不可达(created_at 一定带时分) |
| N152-4 | 中 | notes.js:1329-1336 | 编辑保存时 related_items 与 references 都由同一个文本框重新推导,因此聊天端建立的、只存在于 references 而未进 related_items 的引用会被强制对齐。方向上是好的(消除两个字段不一致),但与 N-149 叠加后等于"用信息量最少的一侧覆盖另一侧" |
| N152-5 | 中 | diary.js:348-371 fetchItems | while (true) 全量翻页把当月日记一次全部拉进内存(pageSize=80),循环内不检查 _loadVersion → 快速切月时旧月份的分页会继续跑完才被丢弃。两个退出条件(items.length >= total 与 batch.length < pageSize)写得对,但缺少页数上限 |
| N152-6 | 低 | diary.js:467-468 moodPalette(index) | 心情颜色按当月排序下标取色 → 同一种心情在不同月份显示不同颜色,用户无法建立"紫色=平静"的记忆。应按 mood id 做稳定哈希取色 |
| N152-7 | 低 | diary.js:808-815 | 月份输入框同时绑 keydown(Enter) 与 blur,Enter 后失焦会再提交一次;靠 updateMonth 的"同年同月直接返回"兜住,属于依赖下游幂等而非上游去重(对照 notes.js:1448-1454 的 commit 在上游比对,注释还写明了理由) |
| N152-8 | 低 | notes.js:737 | total: Math.max(items.length, total) —— 后端 total 偏小时用本页条数顶替。防御是对的,但会让分页组件算出的页数与真实不符(本页 18 条 → total>=18 → 至少 1 页),属于"用展示层数据修正统计层数据" |
| N152-9 | 低 | diary.js:1012-1041 / notes.js:1100-1132 | 两页同样整页 innerHTML 重建(前端第 3、4 处),notes.js 每次重建还重新 renderPagination 并重绑全部监听 |
| N152-10 | 低 | diary.js:382-391 loadTemplates | 失败时把 _templates 置空但不置 _templatesLoaded → 每次开弹窗重试一次(设计如此),而成功后 _templatesLoaded 在 destroy() 里不重置 → 模板列表跨页面生命周期缓存,配置改了要刷新整页才生效 |
索引 · 前端 · pages/events.js(1672) + pages/transfer.js(1565)(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-155 † | P1 | — | 日程编辑器是 N147-1 的第 3 例,也是后果最重的一例 |
| N-156 † | 中 | — | 从 Web 编辑多节点日程的节点,不会更新集合自身的起止时间 |
| N-157 † | 中 | — | "取回全部再在内存里筛"是 pendo 的默认数据访问惯用法,共 9 处 |
| N-158 † | 中 | — | _resolve_note_references 是 N+1 查询 |
| N159-6 | 低 | transfer.js:1356 | 下载时若用户在等待期间改了选择,exportSelection().signature 不匹配 → 直接 return false,静默丢弃已经生成好的 blob,既不提示也不下载。服务端已经完成了一次全量导出。至少应提示"选择已变化,请重新导出" |
| N159-7 | 低 | transfer.js:1138-1145 | 操作记录时间用 toLocaleString('zh-CN', {month, day, hour, minute}) —— 不含年份,而这份日志保留 50 条且可能跨年;同时按浏览器时区渲染(N-121) |
| N159-8 | 低 | transfer.js:1136 | 新增 ${summary.inserted} 没有 0,而同一行的 updated/skipped 都有。normalizeLogs 已保证是非负整数,所以三处都不需要 —— **不一致的防御反而暗示 inserted 与另两个来源不同 |
| N159-9 | 低 | events.js:975 | 新建单次日程时,提醒行默认预填 [startValue](即提醒时间 = 开始时间,偏移 0)。reminderRulesFromTimes 会生成 offset_seconds: 0 的规则 → 默认给每条新日程加一个"准点提醒",而界面上没有任何文案说明这是默认行为 |
索引 · get_all_items 全量物化模式的普查(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N159-1 | P1 | 9 处 get_all_items 调用 | 三个筛选器的回调把下拉值原样写进 _state.filters 再发给后端,而文件顶部就定义了 EVENT_KINDS、REMINDER_STATUSES 两个白名单集合,且 normalizedKind / normalizedReminderStatus 已经实现。对照 tasks.js:1198/1218/1228 每个回调都先过白名单 —— 同一份代码库里,同一种控件,一页校验一页不校验(N37-1:能力已具备但没接线) |
| N160-1 | P1 | — | "缓存写入发生在最底层的通用读方法里"是缓存污染的根因**(N-157)。get_items 既服务于"用户翻一页"(值得缓存)也服务于 get_all_items 的批量遍历(绝不该缓存),而它无法区分。通用规则:缓存决策必须由调用方声明,不能由被调用方假定。 core 若提供缓存能力,读方法应当默认不缓存,由调用点显式开启 |
| N159-2 | 中 | note.py:332 | 日程编辑同样不带 version → Web 第 5 条无乐观锁写入路径(承 N-142 / N-150)。日程是唯一一种"改一个字段会连带重算三个字段"的条目,丢更新的破坏面最大 |
| N160-2 | 中 | — | "数据层提供了正确的批量方法,业务层却退化成全量读或逐条读"在 pendo 出现两次**(get_all_items vs LIKE/json_each 查询、_db_get_item 循环 vs get_items_by_ids)。这两处与附录 N101-2 清单的其余五处共同说明:pendo 的数据层能力强于业务层对它的使用程度。正文应把"业务层未使用数据层已有能力"单列一节,而不是把每一处当作独立缺陷 |
| N159-3 | 低 | note.py:258、268-269 | created_at / updated_at 用 get_user_local_wall_time——items.created_at 的"用户本地朴素"形式现已覆盖 diary / ledger / note 三类(附录 N-144、N155-5)。附录 N147-1 的"按 type 重建真实时刻"迁移脚本需要把这三类都算进去,而 event / task 走 insert_item 的服务器本地形式 |
| N159-4 | 低 | note.py:26 | 号称"接口边界",但 calendar_days 直接原样透传(只判了是不是对象),events 与 timeline_days[].items 只过滤了"是不是对象",字段一律不收敛 → 后续 renderCalendarCell / renderTimelineEntries 仍要逐处 String(...)、finiteCount(...)。对照 dashboard.js(N-134)/tasks.js 收敛到底、渲染层零防御,这一页是"半个边界",反而让读者以为已经收敛过 |
| N159-5 | 低 | note.py:267 | 详情对象命名为 event,而随后 content.addEventListener('click', async (event) => ...) 的 DOM 事件参数同名遮蔽了它。当前处理器不需要外层 event,所以没出错;但这是一个"下次在这个闭包里想用条目对象"就会踩的坑。同样的遮蔽在 openCollectionDetail:1372 再出现一次 |
索引 · 待办创建流程,与 created_at 时间形式的完整测绘(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N163-1 | P1 | event.py:406、498、616 | 三处裸 datetime.now() 使 event 成为唯一写服务器本地时间的条目类型(详见 N-161)。修法:改用 _user_local_now 式的用户时间,先与其余四类对齐,再整体迁 UTC |
| N164-1 | P1 | — | 时间形式测绘必须覆盖全部写入点,不能从少数样本外推**(N-161)。本次先看到 diary 便推断"用户本地是少数形式",看到 insert_item 便推断"event/task 走服务器本地",两次都错。只有把五个 handler 加两个数据层入口全部列出来,分布才成立。 正文在给出迁移方案前,必须附上这张完整的写入点表格 |
| N163-2 | 中 | task.py:494-497 | local_now.hour >= 20 的阈值硬编码在展示逻辑里,而产生这个行为的 default_task_plan_date(validators.py:236-240)里也有一份 current.hour >= 20。同一个业务规则在两个模块各写一遍,改其一即不一致(回执标注"(明天)"的条件与实际顺延条件脱钩) |
| N164-2 | 中 | — | 同一业务规则在两处各写一遍**(N163-2 的"晚 8 点顺延")。与附录 N-76(12 列 LIKE 谓词写两遍)、N155-2(元→分换算三份)构成 pendo 的"业务规则副本"清单。这类重复的危险不在于冗余,而在于两份副本的漂移不会报错,只会让展示与实际行为悄悄脱钩 |
| N163-3 | 低 | task.py:479 | "context": {"group_id": group_id} if group_id else {} —— 用真值判断,与 note/ledger 的 is not None 不同(附录 N155-6、N159-5)。四个 handler 里两种写法各占两处 |
| N163-4 | 低 | task.py:581-587 | _step_task_plan_date 用 except ValueError 包住 _create_task_from_parsed,失败时提示"请重新输入计划日期"。但该函数内部真正会抛 ValueError 的是 normalize_task_fields(标题、优先级、标签等任何字段非法都会抛),因此标题过长之类的错误也会被报成"请重新输入计划日期",**把用户引向错误的字段 |
| N164-3 | 低 | — | except ValueError 的捕获范围与提示文案不匹配**(N163-4)。凡是把一大段逻辑包进 except SomeError 再给出针对某一个字段的提示,都会在其它字段出错时误导用户。应当让底层异常携带字段名,或缩小捕获范围 |
索引 · 更正 N-165:事件循环上的同步数据库读取共约 10 处(3 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N173-1 | P1 | — | "某个看似纯计算的辅助函数其实会读数据库"是同步阻塞检查的最大盲区**(N-170)。now_in_timezone 的名字完全看不出它要查用户设置表。审查时按"函数名是否暗示 I/O"来筛选必然漏检;正确做法是从数据访问层反向追溯:列出所有直接调用 db.* 的函数,再递归找出它们的全部调用者。这条应写进正文的审查方法说明,与附录 N156-1(不能只看数据层、要核调用方)配对——两者是同一件事的两个方向 |
| N173-2 | 中 | — | 两个"中"级问题叠加成一个"P1"**(N-171:缓存污染 × 事件循环上的缓存读取)。单独评估每个缺陷会低估真实影响。正文的严重性排序应当在最后加一轮"组合效应"复核,至少检查:缓存失效类问题 × 依赖缓存命中的热路径、锁竞争类问题 × 持锁时长类问题 |
| N173-3 | 低 | — | FastAPI 同步 def 路由是这里的正确选择**(N-170 末表):web/api/* 全部用同步 def,因此 FastAPI 自动放进线程池,同步 SQLite 调用不阻塞事件循环。这是一个有意且正确的一致决定(三个文件 async def 计数为 0),值得在文档里写明,否则后人可能"顺手"把某个路由改成 async def 而引入阻塞 |
索引 · 前端 · scriptable/pendo_widget.js(1156) + css/app.css(1717) + static/index.html(104)(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-174 † | P1 | — | Widget 令牌以明文常量存放在会被 iCloud 同步的脚本文件里,且没有吊销手段 |
| N-175 † | 中 | — | Widget 的"今天/明天"按插件默认时区计算,是全仓唯一绕过 now_in_timezone 的接口 |
| N-176 † | 中 | — | 日历同步只增不改不删,因此任何一次时间错误或改期都会在系统日历里留下永久错误副本 |
| N-177 † | 中 | — | Web 端完全没有深色模式,而同一套系统的 iOS 组件有完整的日夜切换 |
| N178-7 | 低 | pendo_widget.js:1132 | textValue(TOKEN, '', 4096) 在同一个 if 里算了两次;同函数 fetchData:484 第三次。小事,但这是唯一处理凭据的表达式,重复计算让"令牌只在一处被读"这一性质不成立 |
| N-180 † | 低 | — | 补充:全仓"非原子写文件"普查结果 |
索引 · 导入路径里藏着整套时区迁移所需的正确实现(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-190 † | P1 | — | _resolve_source_wall_time 是全仓唯一正确处理夏令时折叠的代码 |
| N-191 † | P1 | — | 这段代码**正是附录 N147-1 迁移第 0 步所需要的 |
| N194-1 | P1 | — | "某个边缘功能里藏着核心问题的正确解法"是大型代码库的常见现象**(N-191)。bundle_import.py 是一个只在 Web 导入时才执行的模块,却包含了全仓唯一正确的 DST 折叠处理;而每天运行的创建路径反而写着裸 datetime.now()。审查时不能按"模块重要性"分配注意力——正确实现常常出现在被迫处理边界情况的地方(跨时区导入必须面对 DST,日常创建可以假装不存在)。正文的"可复用件"一节应当以此为例 |
| N-192 † | 中 | — | 但这也意味着**导入的条目与本地创建的条目时间形式不同 |
| N193-1 | 中 | bundle_import.py:123 vs 全部创建路径 | 导入写 UTC、创建写用户本地朴素,"导出→导入"会改变形式并持续制造混合格式(详见 N-192) |
| N194-2 | 中 | — | 数据格式的不一致会被正常操作持续再生产**(N-192)。一次性迁移只能清理存量;只要"导入写 UTC / 创建写本地"这对矛盾还在,增量就会继续污染。凡是提出"做一次数据迁移"的整改建议,都必须同时指出所有会重新产生该问题的写入路径,否则迁移只是把问题推迟 |
| N193-2 | 低 | bundle_import.py:49 | _DEFAULT_SOURCE_ZONE = ZoneInfo(PendoConfig.DEFAULT_TIMEZONE) 在模块导入期求值——与附录 N10-13(validators.py:18 的 DEFAULT_EVENT_TIMEZONE)同型,缺 tzdata 的环境会在 import 阶段抛 ZoneInfoNotFoundError。文件已 import ZoneInfoNotFoundError(第 8 行)却没在这里用上 |
| N193-3 | 低 | bundle_import.py:73-93 | _resolve_source_wall_time 对"歧义时刻"直接抛错。对导入而言这是正确的(宁可让用户知道),但对未来复用到数据迁移时需要另一种策略——一次迁移不能因为某一行落在秋季回拨的那一小时里就整体失败。复用时应加一个 on_ambiguous: Literal["raise","earliest","latest"] 参数 |
| N193-4 | 低 | bundle_import.py:105-106 | if value == "": return "" —— 空字符串原样返回,因此空的 start_time 会以 "" 而非 None 进入后续 normalize_item_fields。validators._normalize_event_time_fields 对 end_time in (None, "") 有处理,但 start_time 为 "" 时会走 if not start_time: raise ValueError("Event start_time is required")——结果正确(确实该报错),只是 "" 与 None 两种空值在整条链路上一直并存 |
| N194-3 | 低 | — | 模块导入期构造 ZoneInfo 已出现两处**(validators.py:18、bundle_import.py:49)。缺 tzdata 时插件在 import 阶段就失败,错误信息指向 zoneinfo 而非"请安装 tzdata"。应统一改为惰性构造 + 明确的错误提示 |
| N-193 † | 低 | — | 问题 |
索引 · Web 分析层的时间基准是第三种假设,且对非上海用户是错的(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-195 † | P1 | — | Web 分析层假定"库里存的是 DEFAULT_TIMEZONE 墙钟",而这个假定不成立 |
| N197-1 | P1 | widget.py:44、event_schedule.py:24 | Web 分析层以 DEFAULT_TZ 为时间基准,与实际存储形式全都不符;同一响应内还与四个 *_overview 的用户时区基准混用(详见 N-195) |
| N198-1 | P1 | — | 一条注释可以把错误的假定固化下来**(N-195)。widget.py:43 的"数据库日程使用默认时区的无时区墙钟值"读起来像是对既有约定的说明,因此后续维护者会信任它而不去核实;而它描述的约定从未真正存在。审查时应当把**"注释断言了某个全局不变量"当作重点核查对象——这类注释的错误比代码的错误传播得更远。正文的时区一节应把这条注释与 delete_event_collection:1661(附录 N-84,那条是正确**描述现状的注释)并列展示,说明同一份代码里两条注释一对一错 |
| N-196 † | 中 | — | DEFAULT_TIMEZONE 常量有两份,其中一份是硬编码字面量 |
| N197-2 | 中 | transfer.py:49 | DEFAULT_TIMEZONE 硬编码第二份,不跟随配置(详见 N-196) |
| N198-2 | 中 | — | 同一层内部两种时间基准并存**(N-195:四个 *_overview 用用户时区、widget/event_schedule 用 DEFAULT_TZ),且它们在同一个 HTTP 响应里被组合。这类"层内不一致"比"层间不一致"更难发现,因为读者通常假定同一目录下的模块共享约定。建议正文对 web/analytics/ 单独给一条"统一时间基准为 now_in_timezone(owner_id, db)"的整改项 |
| N197-3 | 低 | widget.py:101、103 | start_time[11:16] / end_time[11:16] 用字符串切片取时分,与附录 N188-2(items.py:843)、附录 N-19(_get_item_time_info 的 entry_time[:16])同型。全插件已有 4 处用切片解析 ISO 时间,而 ItemFormatter.format_datetime 是现成的正确实现 |
| N197-4 | 低 | widget.py:38-49 | _parse_now 接受客户端传入的 now 参数(?now=...)。这只影响该次响应的展示,不写库,因此不构成安全问题;但它意味着小组件可以请求任意时刻的"今日摘要",而 _section_for 的 now.hour % 3 也随之可控。若将来用于缓存键或审计,需要重新评估 |
| N197-5 | 低 | widget.py:67-80 | _title_text / _preview_text 用 len(text) 判断截断长度。对含 emoji 或组合字符的标题,len() 计的是码位而非显示宽度,小组件里可能仍然溢出。这是展示层的常见取舍,记录备查 |
| N198-3 | 低 | — | ISO 时间的字符串切片解析已达 4 处**(widget.py ×2、items.py:843、search.py 的 entry_time[:16]),而 ItemFormatter.format_datetime 是现成实现。与附录 N101-2 的清单同源,可一并整改 |
| N-197 † | 低 | — | 问题 |
索引 · Web 认证路由 / 搜索 / 设置端点 + 聊天端设置命令(22 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N215-1 | P1 | — | 附录 N101-2「正确实现已在仓库内」清单第 11 处**,且是目前距离最近的一处:web/api/settings.py:117 的增量补丁与 utils/settings_utils.py:163 的全量快照隔着一个包目录,调用的是同一个方法。这再次印证 N-186 的结论——pendo 的问题不是某一层不行,而是缺少跨层共享的写入契约。N189-1 建议的 services/item_service.py 下沉方案应当扩大到设置:services/settings_service.py,两个前端都只调它 |
| N-204 † | 中 | — | a · 对附录 N-15 窗口描述的修正(勿再沿用旧说法) |
| N-205 † | 中 | — | PUT /api/settings 可写入任意键、任意大小的 settings_json |
| N-206 † | 中 | — | 设备会话列表提供不了它存在的理由所需的信息 |
| N214-1 | 中 | commands/settings.py:87-89、:111-114、utils/settings_utils.py:159-163 | 三处以全量快照调用 update_user_settings,废掉其事务内新读;Web 的 settings.py:117 是同仓现成的正确写法(详见 N-204) |
| N214-2 | 中 | web/api/settings.py:31 + settings_utils.py:67-95 | settings_json 是 extra="forbid" 模型上的一个 allow-anything 洞,无键数/字节数上限,且该行处在每分钟全用户扫描 + 每次读深拷贝两条热路径上(详见 N-205) |
| N214-3 | 中 | auth_routes.py:43-51 + web/auth.py:142 | 设备会话列表只有随机 device_id 与时间戳,无 UA/IP/标签,且每次登录新建 device_id → 用户无法识别可疑会话(详见 N-206) |
| N215-2 | 中 | — | 新形态:「原子方法的正确性由调用方的传参形态决定,而这一约束没有写在任何地方」**。update_user_settings 传增量则正确、传全量则失效,签名 (user_id, settings: dict) 对两者完全一视同仁,docstring 里也没说。判据(可作为审查检查项):一个方法内部有"读取当前值并合并"的逻辑时,必须在签名或断言层面拒绝全量输入——例如参数改名为 patch、或在方法入口断言补丁键集合。这与 N63-3 是同一件事的两个方向:那条说"调用方别传全量",这条说"被调用方应当让全量传不进来" |
| N215-3 | 中 | — | CSRF 令牌存在会话里,就必然保护不了创建会话的端点**(N-207)。这不是 pendo 的疏忽而是该方案的结构边界。通用规则:任何以会话为载体的请求校验机制,都需要一个不依赖会话的兜底层来覆盖前置端点——Sec-Fetch-Site / Origin 校验放在中间件里是最省事的一层,且顺带覆盖将来新增的所有前置端点。建议写进 docs/07-advanced.md 的 Web 插件小节 |
| N-207 † | 低 | — | 两个创建会话的 POST 天然在 CSRF 体系之外 |
| N-208 † | 低 | — | 认证是逐路由声明的,不是路由器级的 |
| N-209 † | 低 | — | 同一个 HH:MM 字段,两层两套接受集合 |
| N-210 † | 低 | — | update_user_settings 恒返回 True,Web 的失败分支是死代码 |
| N-211 † | 低 | — | default_category 只能从 Web 设置,却只在聊天端生效 |
| N214-4 | 低 | auth_routes.py:109-139 | POST /auth/demo 无请求体 → 跨站表单 POST 是简单请求、不触发预检、会真的执行,可用一个演示会话顶掉受害者的现有会话 Cookie。默认关闭,但属"启用 demo 前必须修"(详见 N-207) |
| N214-5 | 低 | web/api/__init__.py:17-31、server.py:92 | 认证逐路由声明而非路由器级,config_routes.py 三个端点无依赖;当前无泄露,但缺少默认拒绝(详见 N-208) |
| N214-6 | 低 | commands/settings.py:35 vs web/api/settings.py:38 | 同一 HH:MM 字段两套接受集合(聊天要求补零,Web 不要求);时区校验则是可直接合并的完全重复(详见 N-209) |
| N214-7 | 低 | services/db.py:3110 + web/api/settings.py:128 | update_user_settings 恒返回 True,Web 的 500 分支是死代码(N37-1 家族第 6 处,详见 N-210) |
| N214-8 | 低 | commands/settings.py:133-181、settings_utils.py:23-41 | default_category 只能从 Web 设置,却只在聊天端生效(详见 N-211) |
| N214-9 | 低 | web/api/settings.py:125-127 | _settings_patch_changes 拿 30 秒 LRU 快照与补丁比较,相等即返回"无变化"直接跳过写入。多进程部署下(该场景是 update_user_settings 注释自己承认的)会出现"网页说无变化、库里其实是另一个值"。属附录 N189-2「读-比较-条件写,读那步必须绕过缓存」的第 2 例,且比 N188-3 更糟——那里 DB 层 CAS 还能兜底,这里写入被整个跳过 |
| N214-10 | 低 | auth_routes.py:101-106 | /auth/exchange 覆盖 Cookie 时不撤销旧会话,_SESSIONS 每次重新登录留一条孤儿记录到过期(附录 N5-5 的又一产生路径) |
| N-214 † | 低 | — | 问题 |
索引 · 多轮会话分发 + Web 日程集合端点 + 日程概览(16 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-217 † | P1 | — | 对附录 N-70 的重要更正:子条目从未拿到过 UTC 时间 |
| N-216 † | 中 | — | 多轮补充日程信息时,中间轮次的输入会被静默丢弃 |
| N-218 † | 中 | — | 多节点集合的子条目数量没有上限,而重复日程有 |
| N-219 † | 中 | — | GET /events/overview 的日期范围没有上限,且代码里已经承认了这一点 |
| N220-1 | 中 | commands/session.py:183-185 | need_info 分支不更新会话数据,多轮补充只保留首轮 base + 末轮增量(详见 N-216) |
| N220-2 | 中 | web/api/events.py:162-165 | 多节点集合子条目数无上限,整批在一个 IMMEDIATE 事务内插入(详见 N-218) |
| N220-3 | 中 | web/analytics/events_overview.py:59-71 | 概览日期范围无跨度上限,按天物化并整份返回(详见 N-219) |
| N220-4 | 中 | db.py:1290 + handlers/event.py:476-494,599-611 | 集合头 created_at 三种形式(聊天 UTC / Web 用户本地朴素 / 单建头服务器本地朴素);operation_logs.created_at 两种形式(详见 N-217) |
| N222-2 | 中 | — | "新增一个便宜端点绕开昂贵端点"不等于修好了昂贵端点**(N-219)。/events/categories 的 docstring 诚实地记录了动机,这很好;但真正的缺陷(overview 无范围上限)原封不动。审查检查项:**当发现一个端点的 docstring 在解释"为什么不用另一个端点"时,必须去看那个被绕开的端点是否仍然可达 |
| N222-3 | 中 | — | 批量创建路径的上限应当由持有事务的那一层强制**(N-218)。pendo 的 rrule 展开有界、多节点无界,差别在于前者的边界写在展开循环里、后者指望调用方。通用规则与附录 N215-2 同源:**约束要放在唯一入口处,而不是每个调用方各自遵守 |
| N220-5 | 低 | web/api/events.py:142 | _normalize_collection_updates 用字面量 "2000-01-01T00:00:00" 作时间锚点喂给共享校验器。当前安全——返回值只取 updates 里的字段,不含从锚点算出的 remind_times(那些在 :310 用真实 child.start_time 重算);但共享校验器对"已过期偏移"的处理是静默丢弃(附录 N26-4),一旦将来有人从这里取用时间派生字段就会踩中 |
| N220-6 | 低 | web/api/events.py:214 | 子条目 ID 形如 {32位hex}_{m01}(36 字符),是本插件第三种 ID 形式(模型 8 位、db.py:758 32 位、此处 36 位复合)。这一种是确定性的,反而是三者里最好的——同一集合重建不会产生重复行,可作为统一时的参考方向(附录 N22-1 / N35-6) |
| N220-7 | 低 | commands/session.py:173 | 会话内的冲突迁移用 safe_create_session,而入口 handlers/event.py:233 用的是 safe_create_reply_scoped_session。当前没有 bug——后续消息必然从已收窄的私聊通道进来,此时 _pendo_reply_private 为假、两者行为一致;但这依赖一条没写下来的不变量,一旦将来允许群内继续会话就会分叉 |
| N220-8 | 低 | web/api/events.py:351 | delete_collection 在删除之前取 child_ids 写审计日志,取和删不在同一事务内。并发新增的子条目会被级联删除但不出现在日志里 → 撤销时恢复不全 |
| N222-4 | 低 | — | 多轮会话应当遵守"每一轮都要把新状态写回会话"**(N-216)。判据很容易机械检查:会话处理函数的每一条返回路径,要么结束会话、要么替换会话,不应存在"什么都不做"的分支。pendo 这一处的 elif status != "need_info" 正是把"什么都不做"写成了默认行为。建议作为 core 侧 SessionManager 文档里的一条约定(docs/07-advanced.md) |
| N-220 † | 低 | — | 问题 |
索引 · web/api/stats.py 全文 + 全仓 SQLite 时间函数普查(14 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-224 † | P1 | — | SQLite 的 date() / strftime() 会把带偏移的时间串按 UTC 解释——附录 N-192 的可观察症状在这里 |
| N226-1 | P1 | stats.py 27 处 + 全仓 87 处 | SQLite date()/strftime() 把带偏移的时间串按 UTC 解释,与导入产生的 UTC 形式叠加后统计口径整体位移(详见 N-224) |
| N228-1 | P1 | — | 存储层的时间形式问题,会在 SQL 的日期函数处二次放大**(N-224)。审查检查项:凡是数据库里存的是字符串时间,就必须同时统计两件事——写入方产生几种形式、读取方用了多少处引擎侧日期函数。只查前者会严重低估影响面(pendo 是 4 种写入形式 × 87 处引擎侧解析)。这条应当写进正文时区一节的开头,因为它决定了迁移方案的形状 |
| N-223 † | 中 | — | stats.py 的"今天"是服务器本地日期——Web 层第四种时间基准 |
| N-225 † | 中 | — | 为了六个直方图桶把整段历史的支出行全部取回 |
| N226-2 | 中 | stats.py:56 | _today() 用服务器本地日期,是 Web 层第四种"今天"的定义(详见 N-223) |
| N226-3 | 中 | stats.py:220-228 | 支出直方图取回范围内全部支出行(range=all 即全部历史)只为算六个桶,且带一个用不上的 ORDER BY(详见 N-225) |
| N228-2 | 中 | — | "聚合成固定行数的输出"是一个可机械检查的信号**(N-225):如果一个查询的返回值最终被折叠成常数条记录(直方图桶、状态计数、Top-N),却没有 LIMIT / GROUP BY,那么聚合就放错了层。pendo 在同一个文件里既有正确示范(EVENT_TIME_SLOT_SQL + GROUP BY)又有错误示范(金额直方图),可直接对照整改 |
| N226-4 | 低 | stats.py:433,464,497 vs :141、web/api/events.py:96 | 同一类"日期参数非法"错误,三个 overview 端点映射为 400,_resolve_stats_range 与 /events/overview 映射为 422。同一个 API 两种约定,前端要写两套判断 |
| N226-5 | 低 | stats.py:28 | LEDGER_AMOUNT_EXPR = Database._LEDGER_AMOUNT_CENTS_EXPR —— 跨模块引用另一个类的私有属性,并拼进 f-string SQL。值是常量所以不构成注入,但 Database 改这个私有名会静默改变统计口径。应在 Database 上提供公开常量 |
| N226-6 | 低 | stats.py:344 | plan_key = str(plan_date or "").strip() or str(deadline_at or "")[:10] —— 用字符串切片从 deadline_at 取日期,是附录 N197-3「ISO 串切片解析」的第 5 处(另四处:widget.py×2、items.py:843、search.py)。这一处尤其脆:deadline_at 若是带偏移形式,[:10] 拿到的日期与 date(deadline_at) 的结果可能差一天,而同一个响应里两种取法并存 |
| N228-3 | 低 | — | 同一 API 内的错误码约定应当统一**(N226-4)。400 与 422 在这里没有语义区别,纯粹是两次实现的习惯差异。建议正文把"错误码约定"列进 docs/05-api-reference.md 的插件 Web UI 小节,与附录 N-1 记录的统一响应包络({ok, data, message})放在一起 |
| N228-4 | 低 | — | 冗余校验不是坏事,缺少理由才是**(N-227 第 2 点,同时修正附录 N-212 里的说法)。判据:一个路由函数如果也会被测试或内部代码直接调用,那么它就不能只依赖框架层的参数约束。正确做法是保留显式校验**并写明它保护的是哪条调用路径 |
| N-226 † | 低 | — | 问题 |
索引 · web/api/transfer.py 全文(导出 / 导入 / 审计)(15 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N257-1 | P1 | — | "外部标识符永不作为内部主键"应当写成一条硬规则**(N-252)。pendo 的导入路径通过三层做到了这一点,且每层都有注释。任何接受外部数据的功能(导入、同步、webhook、第三方 ID 映射)都应当照此检查:外部 ID 只能出现在映射表的键上,不能出现在任何 WHERE id = ? 的参数位置。建议连同这三处行号一起写进 docs/07-advanced.md |
| N-253 † | 中 | — | 导入锁的粒度比它要保护的状态更细 |
| N-254 † | 中 | — | 导出对每条可疑记录都产出可定位告警,导入却静默丢弃关系 |
| N255-1 | 中 | transfer.py:127-136 | _resolve_timezone 只捕获 ZoneInfoNotFoundError,漏掉 ValueError。ZoneInfo("../foo") 抛的是 ValueError(键必须是规范化相对路径)→ 未捕获 → 500 而非 422。而 timezone 是用户可控字段(ExportSelection.timezone,max_length=128)。同一判断在本仓库有四处实现,只有这一处漏了:web/api/settings.py:55 捕 (ZoneInfoNotFoundError, ValueError)、commands/settings.py:56 捕 (ValueError, ZoneInfoNotFoundError)、utils/validators.py:814-816 也捕了。修法是补上 ValueError |
| N255-2 | 中 | transfer.py:117-124 + :1033-1037 | 导入锁按 (owner, bundle) 分片,规划快照按 owner 取 → 同一用户并发导入两个含相同来源记录的 bundle 时 skip/overwrite 失效(详见 N-253) |
| N255-3 | 中 | transfer.py:774-827,1060-1075 | 三处关系重写静默丢弃不可解析的引用,而同文件的导出路径对每条问题记录都产出带 ID 的告警(详见 N-254) |
| N257-2 | 中 | — | 锁的粒度必须不细于它所保护的状态的粒度**(N-253)。判据可机械检查:列出临界区内所有读取的 WHERE 条件,锁键必须覆盖其中最粗的那个维度。这里临界区读的是 WHERE owner_id = ?,锁键却带了 bundle_id,粒度倒挂。这条与附录 N32-2(守卫只加在一侧)同属"看起来加固了、实际留了缝" |
| N257-3 | 中 | — | 同一功能的两个方向应当有同样的告知纪律**(N-254)。pendo 导出侧对每条可疑记录产出带 ID 的告警,导入侧对丢弃的关系一言不发。审查检查项:**凡是有"导出/导入""发送/接收""编码/解码"成对路径的功能,都要对比两侧的错误与告警处理是否对称 |
| N255-4 | 低 | transfer.py:938-959 | /transfer/import/samples 每翻一页都要求客户端重新上传整个 ZIP 并完整重新解析(_read_upload_body + _inspect_bundle_data),然后只切出 20 条。分页的本意是减少工作量,这里反而让工作量随页数线性增长;叠加附录 N201-1(解压完整进内存)后,每一页都付一次完整解压代价。若要保持无服务端状态,至少应在响应里提示客户端"样例已一次性返回前 N 条" |
| N255-5 | 低 | transfer.py:1275 | filename 取自 x-transfer-filename 请求头,无长度和字符校验(Header(max_length=4096) 只加在 x_transfer_options 上),直接进 db.log_transfer 的审计记录。它不参与任何文件系统操作,所以不是路径穿越;但审计表里可以被写入任意长度的任意字符串 |
| N255-6 | 低 | transfer.py:942 | import_samples 注入了 db: Database = Depends(get_db) 但函数体从未使用。docstring 说"认证和数据库依赖仍是访问控制边界"——认证依赖确实是边界,get_db 在这里不是。应删除或说明 |
| N255-7 | 低 | transfer.py:493-511 | _get_item_identity 返回 owner_id / type / deleted 三个字段,而唯一调用方 _new_import_item_id 只判断返回值是否为 None。三个字段全是死字段(无害,但它们的存在会让读者以为某处做了所有者校验——实际的所有者边界在 _index_imported_item_sources,见 N-252 ②) |
| N255-8 | 低 | transfer.py:648 | _new_import_collection_id 用 uuid4().hex[:16] —— 本插件第 4 种 ID 形式(模型 8 位 / db.py:758 32 位 / 集合子条目 36 位复合 / 此处 16 位)。这一处有唯一性循环所以安全,但四种形式并存仍应在正文统一整改项里列出(附录 N22-1 / N35-6 / N220-6) |
| N257-4 | 低 | — | 未使用的返回字段会伪造出安全感**(N255-7)。_get_item_identity 返回 owner_id 却没人读,会让读者误以为这里做了所有者校验。规则:辅助函数返回的字段应当与实际被消费的字段一致,多余字段要么删掉,要么注明"预留" |
| N-255 † | 低 | — | 问题 |
索引 · handlers/web.py 全文 + 「读路径上的写」专项(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-258 † | P1 | — | /pendo web token 是 P0-1 的第 6 个受害者,也是后果最重的一个 |
| N-259 † | P1 | — | 查看一条笔记会改写它的 updated_at、version 和时间形式 |
| N260-1 | P1 | handlers/web.py:166,181,205,222 | 凭据投递状态由 send_action 返回值决定(P0-1),两个方向的误判都会造成"提示与事实相反",其中"提示失败但实际已送达"会诱导用户反复生成有效登录码(详见 N-258) |
| N260-2 | P1 | handlers/note.py:825 + db.py:811-814 | 查看笔记触发通用更新入口,无条件刷新 updated_at(服务器本地)并自增 version → 破坏"最近更新"排序、造成 Web 端假 409、产生混合时间形式(详见 N-259) |
| N261-1 | P1 | — | "读操作不得写库"应当作为一条硬规则**(N-259)。一旦读路径上有写,就会连带影响排序、乐观锁、缓存和时间戳语义——本例四项全中,而它们表现为四个看起来毫不相关的 bug(旧笔记置顶 / 网页保存莫名 409 / 时间形式漂移 / 条目缓存失效)。审查检查项:把所有名为 view_* / get_* / show_* 的处理函数过一遍,确认其中没有写入调用。 若产品确实需要"最后查看时间",它必须走一条不触碰 updated_at / version 的专用写入 |
| N261-2 | P1 | — | 凭据投递是 P0-1 语义缺陷后果最严重的场景**(N-258)。正文 P0-1 一节应当按"后果分级"重排受害者:提示不准(chime/earthquake)< 重复投递(pendo 提醒)< 凭据状态误报(本条)。三值枚举 DELIVERED/REJECTED/UNKNOWN 的收益在最后这一档最大,因为 UNKNOWN 在凭据场景下有一个明确且安全的渲染方式("已尝试发送,未收到请重试"),而布尔值没有 |
| N261-3 | 中 | — | except Exception 与 except BaseException 的区别在本仓库里出现了正反两例**:handlers/web.py:356 捕 Exception 并注释说明 CancelledError 会正常传播(对);main.py 的 _run_scheduled_task 吞掉 CancelledError(附录 N5-7,错)。修后者时直接引用前者的注释即可。建议正文把这两处并列,并在 docs/07-advanced.md 补一条"插件中捕获异常的边界" |
| N261-4 | 中 | — | 通用写入入口的副作用会渗透到所有调用方**(N-259)。db.update_item 把"刷新 updated_at + 自增 version"写死在方法体内,任何调用方都无法退出。这与附录 N215-2(原子方法的正确性由调用方传参形态决定)是一对:一个方法既不该让调用方能破坏它的不变量,也不该强迫调用方接受它的副作用。两者的解法都是把语义显式化到签名上 |
| N260-3 | 低 | handlers/web.py:262-273 | /pendo web start 失败时把 server.get_last_error() 的原文(仅做空白折叠)放进聊天回复。内容通常是"端口被占用",但也可能是 OSError 的完整消息(含绑定地址)。由于 pendo 未声明 contexts(附录 N5-3),这条回复可能出现在群聊里 |
| N260-4 | 低 | handlers/web.py:344-348 | _send_private_text 用 int(user_id) 判定收件人,非数字 ID 直接返回 False → 对这类用户,/pendo web token 永远显示"无法私聊发送",但登录码已经生成。当前 QQ 侧 ID 都是数字,但 Web 演示用户是非数字 ID(附录 N178-4),两套 ID 空间共存时这条会成为真问题 |
| N260-5 | 低 | handlers/web.py:158-163 | 登录码在尝试投递之前生成。若改成"先确认可投递再生成",方向二的重试累积问题会自然消失(与 N-258 的三值枚举方案二选一即可) |
| N-260 † | 低 | — | 问题 |
索引 · 混合时间形式的「局部防线」全仓普查(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N269-1 | P1 | 全仓(产生侧) | 49 处时区感知检查全部位于消费侧,产生侧(insert_item / update_item / 三条集合创建路径 / handlers/event.py 三处裸 datetime.now())零防御。这是整条时区主线能长期存在的结构性原因(详见 N-267) |
| N269-2 | 低 | handlers/search.py:491 | f"📔{item.entry_time[:16].replace('T', ' ')}" —— ISO 串切片解析的第 8 处(附录 N197-3)。与附录 N244-4(diary_overview._entry_label)是同一展示语义的两份实现,且 entry_time 在导入后是 UTC 带偏移 → 搜索结果里的日记时间会比日记页显示的还多一种可能取值 |
| N-266 † | 低 | — | 全仓有 49 处时区感知检查,分布在 14 个运行时文件 |
| N-267 † | 低 | — | 这 49 处防线才是时区主线最有力的证据 |
| N-268 † | 低 | — | 对修复方案的直接影响(应写进正文的时区一节) |
| N-269 † | 低 | — | 问题 |
索引 · 定时投递 / 提醒租约 / 条目模型(16 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N22-1 | 中 | models/item.py:33 | 条目 ID 只有 8 个十六进制字符(32 bit),且是全库 id TEXT PRIMARY KEY**(db.py:131),跨所有用户共享命名空间。生日碰撞:1 万条约 1.2%、10 万条约 69%。全文件搜不到任何 IntegrityError 处理或碰撞重试,因此一次碰撞就是一条 sqlite3.IntegrityError 冒泡,用户那条记录直接丢失。更麻烦的是 ID 生成有两套:模型默认 str(uuid.uuid4())[:8](8 位,用户看到的就是这种),db.py:758 的兜底却是 uuid.uuid4().hex(32 位)。应统一,并至少扩到 12~16 位或加唯一约束重试 |
| N22-2 | 中 | scheduled.py:363-392、462-491 | 三个每分钟任务各自做一次全用户扫描**:_get_active_user_ids(db) 取出所有有数据的用户,get_user_settings_bundle_map 再把所有人的设置读进内存,然后逐个判断 _is_scheduled_minute。即每天 3 × 1 440 = 4 320 次全表设置读取,其中绝大多数用户在绝大多数分钟都不匹配。正确做法是让 SQL 直接筛出"本地时刻等于配置时刻"的用户,或至少对设置表做带失效的缓存。这是附录 N5-17(3 个每分钟任务)的量化代价 |
| N22-3 | 中 | scheduled.py:320-324、408-412 | _is_scheduled_minute 要求 HH:MM 精确相等,没有任何补发窗口**。只要那一分钟的调度被错过(进程重启、APScheduler misfire、上一轮因 N22-2 的全表扫描超过 60 秒),当天的简报/日记提醒就静默跳过,且 marker 未写、claim 未领,用户不会收到任何提示。功能正确性因此直接依赖"每分钟任务永不迟到",而该任务的开销又随用户数增长——两者耦合在一起 |
| N22-4 | 中 | scheduled.py:506-527 | 周报/月报的周期由用户本地日期算,触发却是服务器本地 cron**。day_of_week: sun, hour: 21 按服务器时间触发;_finance_period 用 user_now(用户时区)算范围。对本地日期已经翻篇的用户:user_now.weekday() 变成 0(周一)→ start_date = end_date - 0 天 → "本周总结"只覆盖一天,且 period.key 落到下一个 ISO 周。月报同理:本地已到 1 号时 start_date = end_date.replace(day=1) → "本月总结"只覆盖 1 天。这是 N-8 时区主线中后果最明确的一处 |
| N23-1 | 中 | "最认真使用某个 API 的插件,被该 API 的缺陷伤得最重"**。pendo 的提醒租约(N-21)把 send_action 的返回值当作**事务提交信号**来用——这正是 core 应当鼓励的用法——结果因为 bool | None的三态而产生重复投递。相反,把返回值直接丢弃的_export_cmd反而"没事"。这条应写进正文 P0-1 的论证:**当前语义在惩罚正确用法**。修复方向不是让插件更小心,而是让send_action 返回一个三值枚举(DELIVERED / REJECTED / UNKNOWN`),迫使调用方显式处理"不确定" |
| N23-2 | 中 | — | 短 ID 的碰撞风险需要全仓普查**(N22-1)。pendo 用 8 位十六进制作全局主键;qingpet 用自增整数;codex 用 uuid4 全长。建议核对其余插件是否存在同类截断 ID,并在 docs/03-plugin-development.md 给一条"用户可见 ID 至少 12 位、且必须处理唯一约束冲突"的规范 |
| N23-3 | 中 | — | "只写不读的字段"在 pendo 出现两次**(snapshot_redacted N-13、visibility N22-7),且第二个带有访问控制语义。附录 N19-3 建议的"机械检查某字段是否有读取方"应升级为一条常规审查项——尤其针对名字暗示安全语义的字段 |
| N22-5 | 低 | scheduled.py:660-672 + reminder.py:112-121 | 所有者 ID 非数字时(Web demo 用户)_build_private_action 返回 None → 记为投递失败 → 释放租约并退避 5 分钟。由于 failure_count > 0 后重试窗口放宽到 24 小时,这条提醒会每 5 分钟徒劳重试约 288 次,然后落到窗口外被永久搁置(既不确认也不清理,行永远留在 reminder_logs 里 pending)。应在构造动作失败时直接标记为不可投递 |
| N22-6 | 低 | models/item.py:39-40 | created_at / updated_at 的 default_factory 用朴素 datetime.now()。这是 N-8 那 41 处里最靠近数据源头的两处——每一个新建条目的时间戳都由此产生 |
| N22-7 | 低 | models/item.py:43、db.py:141 | visibility: str = "private" # private/group_scope 被定义、被建表、被列进 SELECT 列表(db.py:498/525)、被 setdefault(1243),但全仓没有任何 WHERE 子句或访问检查引用它。这是继 snapshot_redacted(N-13)之后 pendo 的第二个"只写不读"字段,而且这一个的名字直接暗示访问控制语义,容易让阅读者以为存在一层并不存在的可见性隔离 |
| N22-8 | 低 | db_ops.py:110 + models/item.py:93 | _snapshot_item_values 的跳过名单是 ("type", "updated_at")——排除了 ItemType 枚举,却没有排除 TaskItem.status 的 TaskStatus 枚举。一旦某个 edit_* 动作的 updates 里含 status,快照会存成 str(TaskStatus.OPEN) = "TaskStatus.OPEN",撤销时写回该值会被 _normalize_task_priority_and_status 判为 Invalid task status。当前 /pendo todo edit 的参数集不含状态字段,因此暂不可达;但跳过名单应改为"排除所有 Enum 实例"而不是枚举字段名(这条同时解答了附录 N18-2 的挂账:模型其余字段确为 JSON 安全类型) |
| N22-9 | 低 | scheduled.py:316、406、543 | 简报/日记/财务三处都先查 custom_settings 里的 marker,再走 _claim_periodic_delivery 的数据库租约——两层去重。真正的保证来自租约(按 period_key 唯一),marker 只是省一次查询的优化,而它的写入走的是无 CAS 的 save_user_setting(N-15)。marker 写丢时不会导致重复投递(租约会拒),但会导致**每分钟白跑一次领取尝试直到当天结束 |
| N22-10 | 低 | models/item.py:50-57 | to_dict 用 asdict() 递归展开后只在顶层把 Enum 转成 .value。当前嵌套结构都是纯 dict/list 所以正确,但 asdict 的递归语义与这个顶层循环不匹配,一旦有人往 context / ai_meta 里放枚举就会静默产生 <enum ...> 字符串 |
| N23-4 | 低 | — | "用户本地时区算范围、服务器时区触发调度"是一类可复现的错误**(N22-4)。凡是 cron 的粒度大于一天(weekly / monthly / day: last),而周期范围又按用户本地日期计算的地方,都必须把触发时刻也换算到用户时区后再判断,或者干脆按 UTC 定义周期。该模式在 pendo 出现 2 次(周报、月报) |
| N-21 † | 低 | — | 提醒子系统是全仓最完整的"投递后确认"实现 |
| N-22 † | 低 | — | 问题 |
索引 · AI 解析层与外发同意闸门(14 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N26-1 | 中 | ai_parser.py:299 及三处调用点 | 日程文本无同意闸门(详见 N-24) |
| N26-2 | 中 | ai_parser.py:472-482 | _normalize_event_datetimes 用 dateutil.parser.parse 解析 LLM 返回的时间字符串。dateutil 对缺失的日期部分默认补今天,而它内部取的是服务器本地当天。于是模型只回 "14:00" 时,日程会被排到服务器时区的今天 14:00,而不是用户时区的。这是 N-8 时区主线经由第三方库渗入的一条路径,grep datetime.now() 查不到它 |
| N26-3 | 中 | ai_parser.py:130、:145 | RateLimiter.call_history 是 defaultdict(str -> list[float]),第 145 行只清理过期时间戳,从不删除用户键。每见过一个 user_id 就永久多一个字典项。与 web/auth.py 的 _SESSIONS(N5-5)同类——pendo 第 2 个无上限的模块级结构 |
| N26-4 | 中 | ai_parser.py:597 | build_remind_times_from_offsets 对 remind_time > now 不成立的偏移静默丢弃。用户为明天 9 点的日程写"提前 2 天提醒",结果是一个提醒都不会有,且没有任何提示。这是提醒链路上第 3 处静默丢弃(另两处:derive_reminder_rules 的负偏移 N10-9、_parse_time_range_core 的范围回退 N10-6)。三处都应改为"照常创建 + 告知用户被跳过的项" |
| N27-1 | 中 | — | "同意闸门覆盖面小于用户预期"是一类独立于实现缺陷的隐私问题**(N-24)。pendo 的实现无可挑剔(双失败闭、日志脱敏、降级不失能),问题出在范围:开关叫"日记",而同样敏感的日程走了另一条无闸门的路。审查这类开关时必须做完整的"内容外发调用图",而不是只看开关本身是否生效。同样的检查应施加于 xiaoqing_chat(下一批)和 codex 的 arxiv_summary |
| N27-2 | 中 | — | 时区缺陷可以经由第三方库渗入**(N26-2,dateutil 默认补服务器当天)。N-8 的 41 处普查是 grep datetime.now() 得到的,查不到这类。修复 N-8 时必须同时排查 dateutil.parser.parse、datetime.strptime 后不带 tz 的结果、以及任何"缺省补今天"的解析器 |
| N26-5 | 低 | ai_parser.py:132-153 | check_rate_limit 是查询式命名却有副作用(history.append(now)),且在 parse_event_with_ai 里于 try 之前调用——后续 LLM 调用失败时配额已被消耗。与 qingpet is_banned_active(M2-18)同一类命名/副作用错配 |
| N26-6 | 低 | ai_parser.py:141 | 速率窗口用 time.time()(挂钟)而非 time.monotonic()。系统时钟回跳会延长限制,前跳会提前解除 |
| N26-7 | 低 | ai_parser.py:216-226 vs main.py:1254-1255 | AIParser.__init__ 本来就接受 db 参数,而 main._get_services 写的是 ai_parser = AIParser(context); ai_parser.db = db。构造后属性注入是完全不必要的——这坐实了附录 N5-14 里对该处的判断 |
| N26-8 | 低 | ai_parser.py:160 | 速率限制 20 次/60 秒是 ClassVar 硬编码,不在 PendoConfig 里,也无法配置。而同文件其它参数(AI_PARSE_TIMEOUT、AI_MAX_TOKENS、AI_PARSE_TEMPERATURE)都在 PendoConfig 中——同一个子系统的参数分散在两处。另注:AI_PARSE_TIMEOUT / AI_MAX_TOKENS 在本文件中从未被引用(_call_llm 只传 temperature),属于配置项定义了但没接线 |
| N27-3 | 低 | — | 无上限的模块级结构在 pendo 已出现 3 个**:web/auth.py 的 _SESSIONS / _LOGIN_CODES(N5-5)、ai_parser 的 call_history(N26-3)。三者都是"按 key 累积、只清理 value"。建议 core 提供一个带 TTL + 容量上限的 BoundedKeyedCache,与已有的 bounded_file_cache.py 成对 |
| N27-4 | 低 | — | 配置项定义了却没接线**:AI_PARSE_TIMEOUT、AI_MAX_TOKENS 在 PendoConfig 里,ai_parser._call_llm 从不使用(N26-8)。这与"只写不读的字段"(N-13 snapshot_redacted、N22-7 visibility)是同一类死代码,只是发生在配置层。建议把"配置项是否有读取方"也纳入那条机械检查 |
| N-24 † | 低 | — | 同意闸门核查结果:**对日记有效,对日程完全没有 |
| N-26 † | 低 | — | 问题 |
索引 · 条目写入路径与并发原语(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-33 † | 中 | — | 乐观锁存在、实现正确,但两个前端只有一个在用 |
| N-34 † | 中 | — | 数据层有四种事务写法,靠约定而非机制避免互相踩踏 |
| N35-1 | 中 | db.py:769-783、850-851 | 缓存失效发生在事务提交之后,两者之间有窗口**。with conn: 退出(提交)→ 然后才 cache_invalidate(...)。在这个窗口里,一个并发读线程若在提交前读到旧值、在失效后才写入缓存,旧值就会被缓存下来并存活到 CACHE_TTL 到期。正确顺序是"先失效再提交"或"提交后再失效一次"(双失效)。当前是单次后置失效 |
| N35-2 | 中 | db.py:854-867 | _invalidate_item_cache 在调用方未提供 owner_id 时执行一次 SELECT 去查 owner——一次缓存失效附带一次数据库查询,且这条查询在事务之外、不带 deleted = 0 过滤。应由调用方一律传入 owner_id(写路径本来就知道) |
| N35-3 | 中 | db.py:760-761、811、885-886 | 5 处朴素 datetime.now(),写入 created_at / updated_at——这是附录 N-8 那 41 处里离数据源头最近的几处:每一次插入和每一次更新的时间戳都由此产生。同时也解释了为什么 db.py 独占 23 处 |
| N37-1 | 中 | — | "能力已具备但没接线"在 pendo 已达 4 处**:snapshot_redacted 只写不读(N-13)、visibility 只写不读(N22-7)、AI_PARSE_TIMEOUT/AI_MAX_TOKENS 定义未使用(N26-8)、expected_version 仅 1/2 前端使用(N-33)。四者的共同点是代码读起来像是有保护,实际没有。建议把"某个字段/参数/配置项是否有真实消费方"作为一条独立的 CI 或审查检查项——它比找 bug 便宜得多,且这四处里有两处(visibility、expected_version)带安全或并发语义 |
| N37-2 | 中 | — | 同一数据层内多种事务写法并存,靠约定维系**(N-34)。qingpet 只有一种(全局 RLock + BEGIN IMMEDIATE),pendo 有四种。core 若提供 ThreadLocalDatabase(N32-1),应当只暴露一个 transaction() 入口,并让辅助方法必须接收 cursor——把 pendo 现有的这条正确约定固化成 API 形状 |
| N35-4 | 低 | db.py:734-783 | insert_item 没有任何 sqlite3.IntegrityError 处理,坐实附录 N22-1:8 位十六进制 ID 与全库主键相撞时,异常直接冒泡,用户那条记录丢失且没有重试 |
| N35-5 | 低 | db.py:919-920 | raise RuntimeError(f"导入记录 {item_id} 失败") from exc —— item_id 直接来自导入包(用户上传的 .pendo.zip),此时尚未经过 validate_item_data(校验在 884 行,但这个 except 覆盖了包括 882 行 raise ValueError("import item requires id") 在内的整段)。该文案会经 Web API 返回。属于把未清洗的外部输入放进错误消息,虽然是用户自己的数据,仍应先 sanitize_text 或只回显长度 |
| N35-6 | 低 | db.py:757-758 | elif "id" not in item_dict: item_dict["id"] = uuid.uuid4().hex —— 32 位十六进制,而 models/item.py:33 的默认工厂是 str(uuid.uuid4())[:8](8 位,用户实际看到的就是这种)。同一个 ID 空间里并存两种长度,且没有任何地方说明哪种才是规范。修 N22-1 时必须一并统一 |
| N-35 † | 低 | — | 其余问题 |
| N-36 † | 低 | — | 值得记的两点 |
索引 · 撤销实现与审计日志完整性(10 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-39 † | 中 | — | 撤销会删除原始审计日志,审计记录因此可被用户操作抹除 |
| N41-1 | 中 | db.py:2703-2718 | undo_edit 恢复快照时不带 expected_version,直接 UPDATE ... WHERE id IN (...) AND owner_id = ? AND deleted = 0。若用户在被撤销的那次编辑之后又编辑过一次,撤销会把两次修改一并回退,且不提示。与 N-33 是同一个未接线的 CAS |
| N43-1 | 中 | — | "审计日志可被业务操作删除"应作为一条独立检查项**(N-39)。qingpet 的 asset_ledger 是 append-only 且有幂等键(附录 M-1),pendo 的 operation_logs 却会被 undo 物理删除。两者都自称审计/账本,语义强度差一个量级。凡是命名为 log / ledger / audit 的表,都应确认**是否存在 DELETE 语句、由谁触发 |
| N43-2 | 中 | — | "二选一分派"会掩盖另一个可行选项**(N-38 第 3 点)。get_latest_undoable_operation 在 delete/edit 间只取最近的一个,最近的那个不可用时不回退到次优。这是一种容易被忽视的可用性缺陷:候选集合被过早收敛为单值。审查其它"取最近一条然后处理"的路径时应一并检查(qingpet 的 get_last_unconfirmed_remind_time、codex 的会话历史重放都属同一形状) |
| N41-2 | 低 | db.py:2654 | str(log_row["created_at"] or "") >= str(item_row["deleted_at"] or "") —— 跨两张表比较 ISO 时间字符串。当前两侧都由朴素 datetime.now().isoformat() 写入所以正确,但这正是 N-12 出问题的那种形状,属于同一颗定时炸弹 |
| N41-3 | 低 | db.py:2760-2762 | 缓存失效仍在事务之外、提交之后(N35-1 同类),本文件第 3 处 |
| N41-4 | 低 | db.py:2739-2747 | 批量恢复按 _SQLITE_ID_BATCH_SIZE 分批以规避 SQLite 变量数上限——这一点是对的;但 affected += cursor.rowcount 之后没有与 len(item_ids) 比对,部分恢复失败(例如某条已被再次删除)不会被察觉,返回给用户的 affected 与 instance_count 不一致时也没有说明 |
| N-40 † | 低 | — | db.py 23 处朴素 datetime.now() 的完整定位(N-8 的核心证据) |
| N-41 † | 低 | — | 其余问题 |
| N-42 † | 低 | — | 值得记的一点 |
索引 · 删除路径、撤销删除与过滤器构造(10 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-44 † | 中 | — | 整个撤销子系统都在物理删除 operation_logs,不只是单条路径 —— 审计记录可被普通用户操作抹除 |
| N-45 † | 中 | — | 软删除会硬删除提醒历史,且与同文件的另一半纪律矛盾 |
| N47-1 | 中 | db.py:2134-2136 | 分类过滤用 TRIM(category) = ?,注释说明是为了兼容规范化之前写入的首尾空格。方向可以理解,但 TRIM(column) 会让该列上的任何索引失效,而分类是列表页最常用的筛选维度。正确做法是做一次性数据迁移把历史值 trim 掉,然后恢复裸列比较;保留 TRIM 等于为一批历史脏数据永久付出全表扫描的代价 |
| N48-1 | 中 | — | "一张表同时承担队列与审计两种角色"是一个可识别的设计缺陷**(N-44)。判据很简单:这张表存在业务触发的 DELETE 吗? 若有,它就不是审计日志,不应为它配置保留期与脱敏策略(那会给出虚假的合规感)。qingpet 的 asset_ledger 是正例(append-only + 幂等键),pendo 的 operation_logs 是反例。这条应与 N43-1 合并为正文的一条检查项 |
| N48-2 | 中 | — | "同一文件里两个相邻函数对同一张附属表采取相反纪律"**(N-45:_sync_reminder_logs 保留 sent_at IS NOT NULL,delete_item 全删)。这与 K4-2(同模块已有 to_thread 版本但部分调用点不用)、N-12(比较的一侧被改对)同属一个大类:局部正确、整体不一致。建议正文把这三者合并成一条"一致性缺口"专项,因为它们的修复方式相同——把纪律固化成唯一入口,而不是靠每个调用点自觉 |
| N47-2 | 低 | db.py:2556、2590、2611、2631 | 四条撤销路径全部调用 self.cache_clear()——清空整个缓存,而 insert_item / update_item / delete_item 用的是精确的 cache_invalidate(item_id) + cache_invalidate(f"items\ {owner}")。方向安全但纪律不一致;撤销恢复的条目 ID 是已知的(restored_ids),完全可以精确失效 |
| N47-4 | 低 | db.py:2079 | 硬删除分支 DELETE FROM items WHERE id = ? AND owner_id = ? 不带 deleted = 0,而软删除分支带。这是对的(硬删除应能清理已软删的行),但两个分支的 WHERE 差异没有注释,容易被后来者"统一"掉 |
| N47-5 | 低 | db.py:2512-2614 | _undo_delete_from_log 的三条分支各自 with conn: + 各自 cache_clear() + 各自返回字典,重复度高;三者只在"恢复哪些 ID"和"文案"上不同。可抽出一个 _finish_undo(conn, cursor, owner_id, item_ids, log_ids, message_builder) |
| N48-3 | 低 | — | 靠时间戳字符串相等来分组批量操作是脆弱的**(N47-3)。若要表达"同一批",应显式生成一个 batch_id 并写入日志行。此模式在 codex 的会话历史、qingpet 的 scheduler_runs 里都用了显式键,pendo 这里是唯一用时间戳当分组键的地方 |
| N-47 † | 低 | — | 其余问题 |
索引 · Schema、索引与迁移机制(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N57-1 | 中 | db.py:238 | 迁移版本号取自列表下标,_ADD_COLUMN_MIGRATIONS 必须永远只追加,但无注释、无断言、无测试(详见 N-54)。应利用已存的 sql 原文做一致性校验 |
| N57-2 | 中 | db.py:305-308 + 6 处调用点 | FTS 索引同步全靠手工,且存储翻倍(详见 N-55) |
| N57-3 | 中 | db.py:130-189 | items 表零 CHECK 约束,业务不变量只在 Python 层;而同文件 event_collections 有 CHECK(详见 N-53) |
| N57-4 | 中 | _ITEM_INDEXES 全表 | 没有任何索引支持 ORDER BY updated_at DESC, created_at DESC**,而列表页与搜索合并结果几乎全都按这个顺序排。因此每次列表/搜索都要对命中集合做一次 filesort。配合 N30-2(搜索必然全表 LIKE 扫描),列表与搜索的代价随条目总数超线性增长。至少应加 (owner_id, deleted, updated_at DESC) 一条 |
| N58-1 | 中 | — | 迁移机制的成熟度在两个 SQLite 插件间差距明显**:qingpet 的 schema_migrations 是装饰性的(M2-4,版本号不参与决策,导致 M2-1 的触发器无法更新);pendo 的是真在用并且保存了 SQL 原文(N-54)。core 若提供 ThreadLocalDatabase(N32-1),应当把迁移账本一并纳入,并直接采用 pendo 的形态 + 一条"版本号与 SQL 原文一致性校验"来堵住 N57-1 |
| N58-2 | 中 | — | "业务不变量放 Python 层还是 SQL 层"两个插件走了相反的路**:qingpet 下沉到触发器(M-1),pendo 留在校验器(N-53)。两者各有道理(触发器难以给出好的错误文案,校验器无法防止绕过),但应当在文档里给出选择标准,而不是各写各的。建议:**资产类不变量(余额、金额、计数)必须下沉到 SQL;结构类不变量(字段组合、枚举取值)可留在校验层 |
| N57-5 | 低 | db.py:251 | applied_at 用 datetime.now(timezone.utc)(带偏移),而 items.created_at 用朴素本地、user_settings.updated_at 用 UTC(N51-2)。同一个文件里第三种时间纪律。虽然 schema_migrations.applied_at 不参与任何比较所以无害,但它进一步说明 N-8 主线里"没有统一约定"这一点 |
| N57-7 | 低 | db.py:141、278 | visibility TEXT DEFAULT 'private' 在 items 与 event_collections 两张表都有定义,而附录 N22-7 已确认该字段全仓只写不读。死字段被复制到了第二张表 |
| N58-3 | 低 | — | 部分索引(partial index)按类型切分单表继承**是 pendo 的一个可复用做法(N-56)。qingpet 的 pets/users/inventories 是分表设计因此用不上,但若 xiaoqing_chat 也有宽表(下一批确认),应比对是否缺这层优化 |
索引 · 行编解码、SQL 标识符防护与 FTS 维护(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N68-1 | 中 | db.py:4012 + MANIFEST.in:19 | FTS 重建工具运行时不可达(详见 N-66) |
| N68-2 | 中 | db.py:3928、3948、3957 | 三条"损坏行跳过"的 WARNING 都不记录 item_id / owner_id,导致既无法定位也无法修复;同时记录对用户完全不可见(详见 N-65) |
| N69-1 | 中 | — | "损坏数据静默消失"是个人数据类插件的一个共性风险**。pendo 的 _row_to_item 跳过坏行(N-65)、qingpet 的 _log_database_failure 返回哨兵值(附录 M)、arxiv_filter 的条目解析失败也是跳过。三者都只留服务端日志。对用户自己录入的数据,静默消失比报错严重得多。建议统一约定:解码失败的记录必须(a)日志带主键,(b)在用户可见结果里以某种形式出现。codex 的 reasons 分类回传(K4-3)是现成的范式 |
| N69-2 | 中 | — | 修复/维护工具被打包规则排除在运行时之外**。rebuild_fts_index 只能由 prune 掉的脚本调用(N-66)。这与附录 F-0 的教训相反方向——那次是我把 prune 掉的训练脚本误算进运行时缺陷;这次是真正需要在运行时可用的能力被 prune 挡在了外面。审查打包配置时两个方向都要看:不该进包的有没有进,该能用的是不是被排除了 |
| N68-3 | 低 | db.py:4031-4039 | rebuild_fts_index 一次 fetchall() 全量行含 content,无分批 |
| N68-4 | 低 | db.py:3993-4010 | _update_fts 用 DELETE + INSERT 两条语句更新单条索引。对独立内容 FTS5 表可行,但每次更新都产生一次删除+插入;update_item 已用 _FTS_FIELDS 命中判断减少调用次数,此处仍可考虑仅在内容确有变化时才重写 |
| N68-5 | 低 | db.py:3937-3938 | data["deleted"] = bool(...) 与 data["is_favorite"] = normalize_bool_flag(...) 对两个同为整数存储的布尔列用了两种转换方式。normalize_bool_flag 是给外部输入用的宽松解析器(附录 N18-1 那四个布尔解析器之一),用在数据库读取路径上属于杀鸡用牛刀,且两列行为不一致 |
| N68-6 | 低 | _restore_deleted_item_ids | 与 batch_soft_delete 同样使用 if cursor.rowcount: restored_ids.extend(batch) 的整批记账(附录 N51-3 同型),同样靠前置的 _select_owner_item_ids 筛选保证正确 |
| N69-3 | 低 | — | 动态 SQL 的三道防线(字段白名单 + 标识符正则 + 排序列白名单)值得上提为 core 的 SQLite 基类能力**(N-64),与 N32-1 的 ThreadLocalDatabase 建议合并。_quote_col 与 _like_contains_pattern 的实现可以直接搬 |
索引 · handlers/ledger.py 全文(1290 行)(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-75 † | 中 | — | 【中】账目列表/汇总的排序键第 2、3 位是原始 ISO 字符串 |
| N-76 † | 中 | — | 【中】_datetime_for_rule_delta 把带偏移时间换算到**硬编码的默认时区 |
| N-74 † | 低 | — | 【中】账目的标题/备注被静默截断,而待办的同名字段会报错 |
| N-79 † | 低 | — | update_event_collection_reminders 是"多行原子重写"的正确写法(正面) |
索引 · 条目查询、分页与金额聚合(10 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N77-1 | 中 | db.py:1895-1900 | 两处都是先 query_items_by_date_range 全量拉取范围内所有账目,再在 Python 侧过滤与求和。汇总(summary)的输出是常数条,输入却完全由用户给的范围决定 → N240-1 与 N228-2 的又一实例:SUM(amount_cents) GROUP BY ledger_category 本该下推到 SQL,而 db.py 已有 _LEDGER_AMOUNT_CENTS_EXPR(N226-5 记过它被两个模块各拷贝一次)说明 SQL 侧聚合能力是现成的 |
| N77-2 | 中 | db.py:1885-1893 与 2222-2229 | 与 handlers/task.py:1330 不同,没有"内容无变化"检查:edit abc123 cat:餐饮(改成同一个值)也会写库 → updated_at 刷新 + version 自增 + 缓存失效 + 一条审计日志。task 侧已经写对了(N-71 正面),此处未跟进 |
| N77-3 | 中 | db.py:1798 | get_items 的缓存键包含用户可控的 filters(keyword、category、tags 等,经 json.dumps(sort_keys=True))。而 _cache 是全插件共享的单个 LRU,CACHE_MAX_SIZE = 1024。一个用户连续做不同关键词的列表查询,会把其他用户的条目缓存、设置缓存全部挤出去。缓存应当按用户分区,或至少把带 keyword 的查询排除在缓存之外(它们本来命中率就极低) |
| N77-4 | 中 | db.py:1798-1801 vs 1832、1850 | 又一处"5分钟内可用 /pendo undo 撤销"。至此该文案已在 task.py:1248、task.py:1273、ledger.py:1184、event.py:1241 四处出现,而真实窗口是 5 分钟~24 小时抖动(N-13)。四处硬编码同一个错误承诺 → 应改为由 operation_logs 保留策略计算出的动态文案,或至少提取成常量 |
| N78-1 | 中 | — | 共享的 LRU 缓存 + 用户可控的缓存键 = 跨用户干扰**(N77-3)。这是缓存设计里一条通用规则:键里含用户输入时,容量必须按用户分区。qingpet 无进程内缓存(每次读库,M2-11 的代价)、pendo 有全局共享缓存但键含用户输入——两者是同一问题的两个极端。core 若提供缓存能力,应内建按 owner 分区的容量上限 |
| N78-2 | 中 | — | "同一段非平凡 SQL 被复制两份"** (N-76)在 pendo 出现于搜索与列表两条路径。这与附录 L3-3(三个插件各写一份 head/tail 截断)、J4-2(信号量被实现两次)同属"缺少可复用件",但这一处更危险:**两份 SQL 的漂移不会报错,只会让两个入口的结果集悄悄不同 |
| N77-5 | 低 | db.py:1729-1734 | 纯数字账户名无法输入:自定义账户 "7" 会被当成越界编号拒绝。同理 _step_category:458 的数字分支。属于"编号与自由文本共用一个输入位"的固有歧义,值得在提示语里说明 |
| N77-6 | 低 | db.py:1736-1755 | 遍历 _EDIT_LEDGER_LABELS 时用 normalized.get(key, ''),对被清空的字段(如切换类型后 counter_account_name 被置空)会显示"转入 → "(空值)。应跳过或显示"已清空" |
| N77-7 | 低 | db.py:1794-1795 | 重复字段抛 ValueError(f"字段不能重复: {key}"),但别名重复(如同时给 cat: 和 category:)要到 _parse_edit_updates:1052 才被发现,且只有编辑路径检查;_parse_quick_ledger_data:702 用 fields.get("cat") or fields.get("category") 静默取前者。同一插件对"别名冲突"两种处理 |
| N78-3 | 低 | — | 同一函数内对非法参数一半抛错一半静默**(N77-7:offset < 0 抛错、limit <= 0 返回空)。这与附录 N10-6(范围解析失败静默回退"今天")、N26-4(过期提醒偏移静默丢弃)构成 pendo 的第 4 处"静默 vs 报错"不一致。建议正文把这类合并为一条"输入非法时的统一处置约定"建议 |
索引 · handlers/note.py(1204) + handlers/diary.py(796)(13 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-80 † | 中 | — | changed 读的是审计 INSERT 的 rowcount,不是 UPDATE 的 |
| N-85 † | 中 | — | 级联删除的撤销可能"多恢复"用户并未删除的节点 |
| N-86 † | 中 | — | 级联删除的子节点**不递增 version |
| N84-1 | 中 | note._delete_category_notes:1182-1204 | 批量删除分类下的笔记,不清理其它笔记里指向它们的 references / related_items。这些引用是带标题快照的(_resolve_note_references:336-343 存 title),因此 view_note 会继续把已删除的笔记显示成"🔗 关联条目",标题还是删除前的。同理,被引用条目改名后引用里仍是旧标题 —— 与 N-58(集合字段冗余不同步)同形,pendo 第 2 处 |
| N84-2 | 中 | note._extract_tag_args:977-990 | 找不到 #tag 时把整段文本按空格全当成标签。/pendo note tag abc 这是 一段 说明 会加三个标签。而同文件 _split_note_named_tokens:144 对列表里的标签用 TAG_TOKEN_RE.fullmatch 严格校验 —— **同一文件对同一概念一严一松 |
| N84-3 | 中 | diary._fetch_diaries:96-104 | 排序次级键写作 diary.entry_time or diary.created_at or diary.updated_at:不同的行可能用不同字段参与比较(有 entry_time 的用 entry_time,没有的用 created_at)。三者语义不同(记录时刻 / 创建时刻 / 修改时刻),且都是原始 ISO 串(N-224)。这是 ISO 串参与比较的第 12 处,也是唯一一处**比较双方可能不是同一语义字段 |
| N84-4 | 低 | note.delete_note:1161-1165 | cat: 分支只检查非空,不做 validate_category;而 task.delete_task:1231 会校验。两处同样的"按分类批量删除",一处校验一处不校验 |
| N84-5 | 低 | note._load_notes:686-691 | 时间筛选固定用 date_field = "created_at",而列表排序用 updated_at。用户用 /pendo note list month 得到的是"本月创建的笔记按修改时间排序",帮助文本(:769 "按本月创建时间筛选")只说明了前一半 |
| N84-6 | 低 | diary.handle_session_message:547-549 | session.set("answers", ...) 与 session.set("step", ...) 是两次独立写入,中间失败会使 len(answers) != step;该状态会被 :542 的自检判定为"会话损坏"并丢弃整个进行中的模板日记。自检是对的,但两次写入应合并成一次 |
| N84-7 | 低 | note.view_note:843 / diary._format_diary_entry_detail | 引用列表截断到 10 条无提示;日记详情输出完整正文无上限。同一文件内一处截断一处不截断 |
| N-81 † | 低 | — | [P1 补充] 同一行数据的 created_at 与 updated_at 可以是两种时间形式 |
| N-82 † | 低 | — | 【中】三个元数据解析器,三种锚定强度;最弱的那个会静默改写日记正文 |
| N-83 † | 低 | — | 【中】五个列表命令里有两个没有分页 |
索引 · 数据导入与迁移审计(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N94-1 | 中 | db.py:1107-1113 | 集合导入 update 分支缺 rowcount 校验,失败静默(详见 N-92) |
| N94-2 | 中 | db.py:1078、947、1033 | 导入路径的三处时间戳全部是朴素本地 datetime.now()。按附录 N-84 的口径,transfer_logs / imported_bundles 属于"审计与撤销体系",用本地时间是自洽的;但 _apply_event_collection_import_operations 的 now 会成为导入集合的 created_at——而正常创建路径 create_event_collection_with_children 写的是 UTC(N-70)。于是 event_collections.created_at 现在有三个来源、两种形式(正常单建=朴素、正常带子节点=UTC、导入=朴素) |
| N94-3 | 中 | db.py:965 vs 993 | batch_insert_or_update 用 with conn:,execute_import_bundle 用 self.transaction(immediate=True)。两个都是导入入口,事务写法不同(N-34 主线)。更要紧的是 batch_insert_or_update 不做 bundle 幂等占位、不写迁移审计——它是一条绕过 execute_import_bundle 全部保护的旁路。应确认是否还有调用方;若只是历史遗留应删除 |
| N95-1 | 中 | — | "同一功能存在一条绕过全部保护的旁路"**(N94-3:batch_insert_or_update 相对 execute_import_bundle)。这与附录 N-33(update_item 的 CAS 只有一个调用方用)、N-15(save_user_setting 绕开 json_patch 的正确用法)是同一形状:加固版与原始版并存,而原始版仍可调用。建议正文把这类"旧入口未随新入口一起收口"列为一条检查项——它们的危险在于代码看起来已经加固了 |
| N94-4 | 低 | db.py:1070 | cache_invalidate(f"event_collections {owner_id}") —— 附录 N-71 那 7 处永不匹配的空操作之一(第 7 处) |
| N94-5 | 低 | db.py:1104 | data["updated_at"] = collection.get("updated_at") or now —— 导入包里的 updated_at 被原样采信。导入方可以写入任意时间戳(包括未来时间),进而影响 ORDER BY updated_at 的排序与"最近修改"类展示。条目侧 _apply_batch_operations:885-886 用的是 setdefault,同样保留外部值。属于导入信任模型的一部分(数据是用户自己的),但应在文档里写明"导入会保留原始时间戳" |
| N94-6 | 低 | db.py:1035-1036 | raise DuplicateBundleImportError(bundle_id) —— 异常携带用户提供的 bundle_id。与 N82-4 同类,风险低但与 db.py 其余部分的"异常不带内容"纪律不一致 |
| N95-2 | 低 | — | ID 生成策略在单个插件内有四档长度**(N-93)。与附录 N18-1(四个布尔解析器、三套真值集合)、N-34(四种事务写法)并列,构成 pendo 的"同一件事有 N 种做法"清单。这三条建议在正文合并成一节,因为它们的成因相同(缺少唯一入口)且修复方式相同 |
索引 · services/exporter.py(893)(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-103 † | 中 | — | db.py 有两套并存的迁移机制,只有一套带版本号 |
| N-104 † | 中 | — | operation_logs 没有任何索引,而撤销路径反复全表扫描它 |
| N102-1 | 中 | _collect_items:314-340 | 全量拉取后在 Python 侧做范围过滤,而 db.query_items_by_date_range 是现成的下推实现(handlers/ledger.py:786 与 diary.py:87 都在用)。导出是批量操作,代价可接受,但**同一个能力第 3 次被绕开 |
| N102-2 | 中 | _get_sort_datetime:362-387 | 每种类型的排序时间按候选列表回退(event: start_time → created_at;task: plan_date → deadline_at → created_at)。因此同一个 section 内不同条目可能按不同字段排序,与附录 N84-3(diary 排序键)是同形第 2 处。且 _render_type_section:492 对取不到时间的条目用 datetime.min 排到最前 |
| N102-3 | 低 | _render_markdown_document:425 | 档案头的"导出时间"用 datetime.now()(服务器本地),而档案内所有条目时间是用户墙钟 → 同一个文件里两种时区语义。这是 handlers/ 之外第 2 处裸 datetime.now()(N-65 清单需补上这一处,但它只影响展示,不入库) |
| N102-4 | 低 | _sanitize_export_filename:71-81 | 清洗后强制 .md 后缀,但不限长度。用户传 3000 字的文件名 → 多数文件系统 ENAMETOOLONG,write_text 抛 OSError,而 export_markdown 没有捕获 → 冒泡成通用错误 |
| N102-5 | 低 | _sanitize_user_id:66-68 | re.sub(r"[^a-zA-Z0-9_-]", "_", user_id) 是多对一映射:a.b 与 a_b 会落进同一个目录。QQ 号场景下不会撞,但这是一个静默的身份合并 |
| N102-6 | 低 | _parse_type_spec:277-312 | 未知类型时给出可选值列表(好),但 _EXPORT_TYPE_MAP 里 "全部" / "*" 没有出现在提示的 allowed 列表里 → 文档与实现不一致 |
| N-99 † | 低 | — | 【P1】N5-1 的第三次独立推导:导出文件也写在包目录里,且从不清理 |
| N-100 † | 低 | — | 【中】导出文件的绝对路径会被回显到聊天里(可能是群聊) |
| N-101 † | 低 | — | 【中】_parse_datetime 的 replace(tzinfo=None) 在这里是对的——但它和 N-67 是同一行代码 |
索引 · 提醒日志迁移、外发箱/审计建表与索引缺口(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N105-1 | 中 | db.py:388-426 | operation_logs 零索引,撤销与清理路径全表扫描(详见 N-104) |
| N105-2 | 中 | db.py:311-358 | 提醒日志的聚合扫描与状态回填 UPDATE 每次插件加载都执行(详见 N-103) |
| N105-3 | 中 | db.py _init_database | 八个建表/迁移函数中只有 _ADD_COLUMN_MIGRATIONS 走版本账本,其余七个是"每次重跑"(详见 N-103)。附录 N-54 对 schema_migrations 的正面评价应限定在其覆盖范围内 |
| N106-1 | 中 | — | "部分表有索引、访问最频繁的那张没有"是一类容易漏检的性能缺陷**(N-104)。它不会在功能测试里暴露,只在数据积累后表现为"用久了变慢"。建议正文加一条机械检查:列出每张表的查询谓词,与其索引定义比对;qingpet 与 xiaoqing_chat 的表也应过一遍 |
| N106-2 | 中 | — | 迁移机制"一半版本化、一半每次重跑"** (N-103)在两个 SQLite 插件里都出现:qingpet 全靠 IF NOT EXISTS 重跑(M2-4),pendo 只把 ADD COLUMN 版本化。core 若提供数据层基类(N32-1 / N58-1),迁移账本应当覆盖全部结构变更与数据修复,而不只是加列 |
| N105-4 | 低 | db.py _init_database | 用 self.transaction()(DEFERRED)而非 immediate=True,与文件中其余 9 处写事务的纪律不一致 |
| N105-5 | 低 | db.py:378-379 | scheduled_delivery_outbox 同时有 PRIMARY KEY (task_name, owner_id, period_key) 和 UNIQUE (delivery_key)。由于 delivery_key 由 scheduled_delivery_key(task, owner, period) 从主键三元组派生,这条 UNIQUE 在逻辑上冗余;它实际起的作用是断言派生函数是单射的。这个意图值得写一行注释,否则容易被当作多余约束删掉 |
| N105-6 | 低 | db.py:192-206 | reminder_logs 表没有任何保留或清理策略(对比 operation_logs 的 90 天)。每条提醒(含重复提醒)一行,随使用时长单调增长,而 N105-2 的启动扫描成本正比于它的大小。应为已确认且早于某时限的行加清理 |
| N106-3 | 低 | — | 本次审查第二次撤回"崩溃会丢数据"类怀疑**(N-102 的全表重写、附录 N-67 的 FTS 恢复)。两次的共同点是:危险的语句序列被更外层的事务或调用顺序保护着,而保护关系需要跨函数阅读才能确认。这本身说明该文件的事务边界不是在语句附近可见的——支持附录 N-34 关于统一事务入口的建议 |
索引 · web/services/transfer_bundle.py 余(180-676)(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-108 † | 中 | — | confirm_reminder 的默认参数就是破坏性最强的那个选项 |
| N-105 † | 低 | — | 【中】导出上限 100 000 / 导入上限 20 000 —— pendo 能导出自己无法导入的包 |
| N-106 † | 低 | — | 【中】写出用 "\n".join,读入用 splitlines() —— 不是同一套行边界 |
| N-107 † | 低 | — | 【低】MAX_ARCHIVE_MEMBERS = len(TYPE_FILE_NAMES) + 2 |
索引 · 提醒租约族与确认语义(5 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N109-1 | 中 | db.py:3610-3617 | confirm_reminder 的 remind_time 与 owner_id 默认值都是最宽松的选项(详见 N-108) |
| N110-1 | 中 | — | "危险的默认参数"应当作为一条独立检查项**(N-108)。判据:省略这个参数时,行为是最保守的还是最宽松的? pendo 的 confirm_reminder(remind_time=None → 全确认、owner_id=None → 不校验归属)、delete_event_collection(cascade=True 默认级联删子数据,附录 N89-5)都是反例;而 get_item(owner_id=None)、update_item(owner_id=None) 同样默认不校验归属。这类默认值把安全性外包给了每一个调用点,与附录 N-16(core/router.py 单向校验)、N37-1(能力未接线)同属"看起来有保护"的家族 |
| N109-2 | 低 | db.py:3634-3652 | confirm_reminder 用 SELECT 取 id 列表 + UPDATE ... WHERE id IN (...) 两条语句,而同族其余五个方法都是单条件 UPDATE。原因可以理解(需要 JOIN items 校验 owner_id,SQLite 不支持 UPDATE ... JOIN),且两条语句在同一 with conn: 事务内因而原子;但应加一行注释说明为何此处偏离了同族写法,否则读者会以为是疏漏。另外 id IN (...) 没有按 _SQLITE_ID_BATCH_SIZE 分批——一个条目的未确认提醒数通常很小,但同文件其它地方一律分批 |
| N109-4 | 低 | db.py:3499、3533、3557、3574、3598、3622 | 六个方法全部用 with conn: 而非 self.transaction()(附录 N-34 主线)。它们各自只有一到两条语句,当前安全;但 confirm_reminder 已经是两条语句,且未来若在租约方法里追加审计写入就会跨语句 |
| N110-2 | 低 | — | repeat_count 当版本号用(N-107)是一个值得记录的手法**:不额外加 version 列,直接用业务上已有的单调计数器做 CAS。qingpet 的 merged_onto 需要显式 version 列(附录 M-4),pendo 的外发箱用 claim_token,这里用业务计数器——三种 CAS 载体各有适用场景,可一并写进 docs/07-advanced.md |
前端 · 基础设施层 + 全站转义审计(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-124 † | 中 | — | 登录链接用的是绑定地址,在多数部署下不可用 |
| N-125 † | 中 | — | 凭据投递是 P0-1 的第 6 处,方向安全但提示与事实相反 |
| N123-1 | 中 | api.js:138-141 | new URLSearchParams(params \ \ {}) —— JS 会把 undefined 序列化成字符串 "undefined"。任何调用方写 api.get(path, { q: maybeUndefined }) 都会向后端发 ?q=undefined。后端 web/api/search.py 的 q 只有 min_length=1(附录 N-262),会把它当成合法关键词执行一次全表 LIKE 扫描(N30-2)。应在此处过滤 undefined/null |
| N123-2 | 中 | api.js:49 | fetch(\api${path}`)使用**相对路径**(无前导/)。当前 hash 路由下 base 始终是挂载根,可用;但一旦引入任何真实路径段(子目录部署、SPA history 路由),全部 API 会解析到错误的 base。同文件 getSession/exchangeLoginCode用的是'api/auth/...'`,同样相对 |
| N123-3 | 中 | api.js:54-57 | 401 抛 new Error('Unauthorized') —— 靠错误消息字符串传递语义。调用方要区分"会话失效"与"其它失败"必须字符串比较。应改为带 status 字段的自定义 Error |
| N123-4 | 低 | app.js:38-39 | extractLoginCode 的正则要求 20+ 字符,但不匹配时 return text——即把用户粘贴的任意内容当作登录码提交。服务端会拒绝,但前端的长度校验因此形同虚设 |
| N123-5 | 低 | format.js:formatDateTime | 解析失败统一返回 '未知时间'。与后端 ItemFormatter.format_datetime(解析失败返回原始串,附录 N-90 记为可接受)纪律相反:前端把坏数据变成"未知时间",用户与运维都看不到原值 → N69-1 形态在前端的一处 |
| N123-6 | 低 | router.js:80 | 加载失败时把 cause.message 转义后写进页面。该消息可能是后端返回的 detail(附录 N-100 是同一形态的后端版本)。此处是用户自己的会话,风险低 |
| N123-7 | 低 | notes.js:226-236 | ``` 代码块未闭合时 codeOpen 保持 true,函数结束不补 </code></pre> → 依赖浏览器自动闭合。不影响安全,但会让该笔记之后的内容全部落进 <pre> |
| N-120 † | 低 | — | 全站 XSS 面普查:未发现可利用漏洞,但纪律是约定式的 |
| N-121 † | 低 | — | 【P1】前端是混合时间形式的最终受害者,且两类值在界面上无法区分 |
| N-122 † | 低 | — | 【中】10 个路由模块只有 1 个带缓存击穿参数,且服务端没有 Cache-Control |
索引 · Web 命令与搜索参数解析(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N127-1 | 中 | web.py:162-163、server.get_url() | 登录链接使用绑定地址,多数部署不可用(详见 N-124) |
| N127-2 | 中 | web.py:355-358 | 凭据投递是 P0-1 第 6 处;方向安全但会让用户陷入"每次都说失败、其实每次都成功"的重试循环(详见 N-125) |
| N128-1 | 中 | date_ranges.js:38-46 | derivePresetRange 的全部周期基于浏览器本地时区的 new Date(...)。用户的 pendo 时区与浏览器时区不一致时,"本周/本月"的边界与后端 _parse_time_range_core(按用户设置时区)算出来的不是同一段。与 N-121 同源:前端把"本地"等同于"浏览器所在地" |
| N128-2 | 中 | P0-1 的类型契约会沿调用链传播**(N-125):send_action 返回 bool \ | 两次 /items 查询是 Promise.all 并发,但它们发生在渲染路径上。若某类型无数据,两次查询都返回空,仍会各走一次完整的查询链(N30-2 每次搜索必然全表 LIKE 不适用于此路径,但 get_items 的排序仍无索引支持,见 N57-4) |
| N127-3 | 低 | web.py:215、225 | Widget token 的有效期用小时展示:⏳ 有效期: 4320 小时。应换算为"180 天"。同一个值在私聊消息和公开回复里各出现一次,都是小时 |
| N127-4 | 低 | web.py:239、285、307 | _start / _stop / _status 都用 await run_sync(server.is_running) 正确卸载了那个阻塞的 urlopen 探测。这与 main.init() 里同步调用 web_server.is_running()(附录 N5-8)形成直接对照——同一个函数,命令路径卸载了、初始化路径没有。这条对照使 N5-8 更明确:不是"作者不知道要卸载",而是初始化路径漏了 |
| N127-5 | 低 | web.py:243-248、312-318 | Web 地址与端口出现在可能发往群聊的回复里(manifest 未声明 contexts,附录 N5-3)。默认 loopback 时信息价值有限,但配置成公网地址后就是基础设施暴露 |
| N127-6 | 低 | search.py:118-125 | search 固定 offset=0、limit=_SEARCH_RESULT_LIMIT(15),没有翻页入口。而 search_items_page 明确支持 offset,total 也被取回并展示。用户看到"共 N 条"却无法翻到第 2 页——与 /pendo todo list page:2、/pendo note list page:n 支持翻页不一致 |
| N128-3 | 低 | custom_select.js:44-49 | shlex.split 的 comments 默认值与 #tag 语法冲突**(N-126)。本仓库其它使用 shlex 的地方(若有)应一并检查;同类"库默认值与业务语法冲突"的还有 dateutil.parser.parse 默认补当天(附录 N26-2)。建议正文把这类合并为一条"第三方默认值审计"检查项 |
前端 · 组件层 + 日期范围工具(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-130 † | 中 | — | 任务优先级为 NULL 时搜索结果渲染会抛 TypeError |
| N128-4 | 低 | form.js:56 | rows="${escapeAttr(field.rows \ \ 4)}" —— field.rows 若为 0 会因 \ \ 落到 4(预期行为),但若为字符串 "abc" 会原样进属性(被转义,无安全问题,只是渲染异常)。同文件其余数值属性同样没有类型收敛 |
| N-126 † | 低 | — | 【重要修正 N-120】"不用 textContent"是页面层独有的,组件层用得很对 |
| N-127 † | 低 | — | 【中】fetchItemRangeBounds 用排序取首尾,落在混合时间形式上会取错 |
前端 · pages/dashboard.js(748)(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-131 † | 中 | — | 【P1·新增混合时间形式产生点】completed_at 按前端不同写成两种形式 |
| N-135 † | 低 | — | 同一文件里 fromisoformat 一半有防护一半没有 |
| N133-4 | 低 | dashboard.js:288 | eventsAgenda 在 data.events_agenda 不是数组时静默回退到 events_month —— 两个语义不同的字段(当日议程 vs 本月全部)互为兜底,接口变更时会静默换掉展示内容而不是报错 |
| N-132 † | 低 | — | 【中】看板的"完成任务"绕过了聊天侧的状态机 |
索引 · 搜索筛选归一化与结果渲染(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N132-1 | 中 | search.py:398 | 任务 priority 为 NULL 时渲染抛 TypeError(详见 N-130) |
| N132-2 | 中 | search.py:230 | 无 type= 时按 created_at 筛日期,语义意外且撞上混合时间格式(详见 N-131) |
| N133-1 | 中 | dashboard.js:420 renderMonthlyAgenda | "同一字段的 None 防护在一处有、另一处没有"是本次审查反复出现的形态**(N-130:priority 在简报有防护、在搜索没有;此前 N-45:reminder_logs 删除在编辑路径保留已发送、在删除路径不保留;N-80:rowcount 在一处立即保存、在另一处没有)。共同根因是缺少共享的渲染/写入辅助函数,每个调用点各自决定防护与否。建议正文把"字段访问必须经由统一的安全取值助手(如 ItemFormatter.priority_of(item))"作为一条结构建议 |
| N133-2 | 中 | dashboard.js:714 | strict 参数的默认值决定了缺陷是否发生**(N-129 ③):parse_search_date_range 默认 strict=False(静默回退),搜索路径显式传 True。这与附录 N110-1(危险的默认参数)是同一族——默认值是宽松的,安全依赖每个调用点记得收紧。应把默认改为 strict=True,让需要宽松的调用点显式声明 |
| N132-3 | 低 | search.py:463 | idx = content.casefold().find(query.casefold()) —— 用 casefold 后的字符串求出的下标,回头去切原始 content。casefold() 对个别字符会改变长度('ß'.casefold() == 'ss'),此时下标会错位,预览窗口偏移。中文场景几乎不会触发,但这是一个真实的字符串处理陷阱:**大小写折叠后的索引不能用于原串 |
| N132-4 | 低 | search.py:465-471 | 预览窗口固定为"命中位置前 10 字 + 共 SEARCH_CONTENT_PREVIEW_LENGTH(50) 字"。当关键词本身接近 50 字时(上限是 100,见 _SEARCH_QUERY_MAX_CHARS),窗口装不下完整命中,用户看到的片段里可能不含完整关键词。应按关键词长度动态扩窗或至少保证命中完整可见 |
| N132-5 | 低 | search.py:344-347 | 结果按 _SEARCH_TYPE_ORDER 固定顺序分组展示,而数据库返回的是按相关性(bm25)排序的一页。分组渲染会打散相关性顺序:最相关的一条若是 note,会被排到最后一组。对搜索而言,相关性顺序通常比类型分组更重要;至少在未指定 type= 时应保留原顺序 |
| N132-6 | 低 | search.py:421 | tags = [single_line_text(tag) for tag in item.tags[:2]] —— 只展示前 2 个标签且不提示还有更多。相邻的 remaining 处理(360-362)与 ...还有 N 条 都做了溢出提示,此处没有 |
| N133-3 | 低 | dashboard.js:429-436 | upcoming 取前 4、past 取后 4,而输入 eventsAgenda 来自 /dashboard 接口且前端不知道它有没有上限。输出有界、输入无界(N240-1 在前端的一处);renderRecentLedger:583 同样 .slice(0, 5) |
索引 · 日程提醒重算与消息格式化(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N138-1 | 中 | search.js:582-591 searchParams | 混合时间格式的应对在 pendo 里是"逐点打补丁"而非"统一处理"**:db.py:get_events_for_range 在查询层补(N-97)、event_support.py 的两个函数在计算层补(N-134 ①)、而 get_briefing_items(N-97)与搜索的 created_at 筛选(N-131)没补。已经写了三处应对代码,仍有两处漏网——这恰好说明"逐点打补丁"不可能覆盖完全,支持附录 N63-1 / N101-1 提出的两步修复:先统一查询写法,再统一存量格式 |
| N137-1 | 低 | event_support.py:128、152、153 | 三处无防护的 fromisoformat(详见 N-135) |
| N137-2 | 低 | event_support.py:64 | 默认提醒静默丢弃已过期偏移(详见 N-136) |
| N137-3 | 低 | event_support.py:204、226 | event['id'] 用直接下标,而同一行上下文的 event.get('title', '无标题') 用 .get()。同一个 dict、同一个函数里两种访问方式;id 缺失时抛 KeyError 而非显示占位符 |
| N137-4 | 低 | event_support.py:235 | for conflict in conflicts[:3] —— 只展示前 3 条冲突且不提示还有更多。与附录 N132-6(tags[:2] 无溢出提示)同型;而同插件的搜索结果(N-129 ④)与"还有 N 条"都做了溢出提示。三处截断,两处提示、一处不提示 |
| N137-5 | 低 | event_support.py:23-27、243-253 | _REMINDER_STATUS_LABELS 用emoji 字符作字典键("✅" / "📩" / "⏳"),由 get_remind_status 返回。功能正确(两个函数一一对应),但用显示字形当内部枚举键,改文案(比如把 📩 换成 📨)就要同时改两处才不 KeyError。应改为 "confirmed" / "sent" / "pending" 这类语义键,展示层再映射 |
| N138-2 | 低 | search.js:753-756 | subscribeDataChanges(null, ...) 订阅全局数据变更并 force: true 重搜。因此搜索页处于打开状态时,应用内任何一次增删改都会触发一次全表 LIKE 搜索。应改为只订阅与当前结果类型相关的变更(subscribeDataChanges 本身支持传 expectedType,能力已具备但没接线 —— N37-1 在前端的一处) |
索引 · 日记入口与元数据解析(部分覆盖)(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N143-1 | 中 | — | 修正附录 N18-1 的结论**:同一概念在不同场景采用不同宽严是设计而非冗余(N-140)。审查"重复实现"时必须先分辨:是同一语义的重复(应合并),还是不同语义恰好形状相似(应保留但共享字面量)。本次审查此前把 pendo 的四个布尔解析器一概判为冗余,属于判断过粗,已在此更正 |
| N143-2 | 中 | — | "默认值不好的函数,仍有调用点是对的"已出现三次**(parse_date_optional 的 now、parse_search_date_range 的 strict、update_item 的 expected_version)。这意味着修复这类问题不能靠全局替换,必须逐个调用点判断当前行为是有意还是遗漏。正文应把"调用点普查"作为这类修复的前置步骤写明 |
| N142-1 | 低 | diary.py:56-57 | _TRUE_METADATA_VALUES 与 validators.normalize_bool_flag 的真值集合逐字相同,是第二份拷贝(详见 N-140)。应提取为 utils 里的共享常量,各解析器只复用字面量、保留各自的宽严策略 |
| N142-2 | 低 | diary.py:87-94 | _fetch_diaries 调用 query_items_by_date_range,而该方法不走缓存(附录 N100-4)。日记列表是高频只读命令,每次都打库 |
| N142-3 | 低 | diary.py:156-157 | 模板匹配用 if args.strip() in self.templates —— 用的是完整 args 而非已拆出的 command。因此 /pendo diary three_good 可以进模板会话,而 /pendo diary three_good 附加内容 会落到"未知日记命令"分支并报出 command = "three_good"。两种写法在同一个 handle 里混用(前面五个分支用 command,这里用 args) |
| N142-4 | 低 | diary.py:765 | 标签分隔用 re.split(r"[,,]", value),支持中英文逗号——但不支持顿号 、,而顿号是中文里更常见的并列分隔符。帮助文本写的是 tags:a,b,因此不算缺陷,但用户很可能写 tags:运动、复盘 而得到一个名为"运动、复盘"的单标签(validate_tag 会因 、 不在允许字符集而报错,用户得到的是"标签名只能包含中文、英文、数字…"这样一条看不出原因的提示) |
索引 · 日记删除、模板会话与列表渲染(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N150-1 | 中 | diary.py:410-428 | _format_diary_list 对结果数量没有任何上限,逐条输出"情绪+日期+时间/内容预览/ID/空行"四行。默认范围是本月(约 30 条 ≈ 120 行),但 /pendo diary list year 会把全年日记渲染进一条消息。同插件的搜索结果限 15 条并给"还有 N 条"提示(附录 N-129 ④)、待办列表支持 page:n,唯独日记列表既无上限也无分页。与附录 N146-4(按日期查看无上限)是同一处缺口的两个入口 |
| N151-1 | 中 | — | "歧义时交还用户"与"歧义时猜一个"在同一插件内并存**(N-149):delete_diary 列出候选让用户选,而 get_latest_undoable_operation(附录 N43-2)在 delete/edit 之间只取最近的一个、失败也不回退。前者是正确形态,应作为后者的修改蓝本。判据可以写成一条检查项:**当候选集合大小 > 1 时,代码是缩减为 1 个还是把选择权交回用户? |
| N150-2 | 低 | diary.py:458、474 | 删除成功文案固定写"💡 5分钟内可用 /pendo undo 撤销"。对删除而言这是保守的(undo_delete 依赖 operation_logs 行本身,而日志保留 90 天,因此 /pendo undo 30 同样有效)。但它与编辑撤销的真实窗口不同——编辑依赖 details.old_values 快照,而快照会被每天 00:15 的清理任务擦除(附录 N-13),窗口在 5 分钟到 24 小时之间抖动。两种撤销的窗口语义不同,而用户看到的都是"5分钟内"。应在文案里区分,或让两者的窗口真正一致 |
| N150-3 | 低 | diary.py:578 | 返回 {"status": "question", ...} —— "question" 是第 4 个状态值,而 utils/error_handlers.py 只提供 success_result / error_result / info_result 三个构造器。main._handle_active_session 用 result.get("status") != "error" 判断,因此工作正常;但状态取值集合没有单一定义处,新增一个值不会被任何地方校验。应把状态定义为 Literal["success","error","info","question"] 并让三个构造器扩成四个 |
| N150-4 | 低 | diary.py:591-595 | template_answers 过滤条件是"prompt 或 answer 至少有一个非空",因此用户跳过某题直接回车时会留下 {"prompt": "今天做了什么:", "answer": ""} 这样的空答案条目,并原样进入 content(596 行拼成 **问题**\n 后跟空行)。应在拼接 content 时跳过空答案,或提示用户该题必填 |
| N150-5 | 低 | diary.py:485 | diary_date 在会话启动时固定为当天,若用户跨过午夜才答完最后一题,日记仍记在开始那天。这多半是有意的(一次模板填写属于同一天),但没有注释说明;SESSION_TIMEOUT_SECONDS = 300 使跨天窗口很窄,风险低 |
| N151-2 | 低 | — | 列表类命令的输出上限没有统一约定**:搜索 15 条 + 溢出提示、待办/笔记支持 page:n、日记无上限无分页、简报硬 LIMIT 10 但计数错误(附录 N-98)。四种做法。建议正文提出统一的"列表输出预算":单条消息条目数上限 + 溢出提示 + 分页参数,三者成套 |
索引 · 记账交互流程,以及两条挂账的结清与更正(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N155-1 | 中 | ledger.py:814-817 | 金额筛选在取回全部结果后于 Python 侧过滤,而 amount_cents 是有索引可用的整数列(idx_ledger_*)。与附录 N146-3 同型,是 pendo 第 2 处"有列可下推却在内存过滤" |
| N155-2 | 中 | 三处换算 | 元→分换算有三份实现(详见 N-152),聊天与 Web 可能得到不同分值 |
| N156-2 | 中 | — | isdecimal() 是 M3-4 的推荐修法**(N-154)。'²'.isdigit() 为真而 '²'.isdecimal() 为假,'٣' 两者皆真但 int() 可正确解析。因此把全仓 17 处 isdigit() 改为 isdecimal() 即可消除 ValueError 分支,且改动最小。ledger.py:458 是现成的正确用例 |
| N155-3 | 低 | ledger.py:557-560 | 自定义账户名不校验直接接受,随后被静默截断到 80 字(详见 N-154) |
| N155-4 | 低 | ledger.py:668-682 | 交易类型解析有两条来源且后者覆盖前者:先取 type: 字段(668-671),再遍历正文 token 逐个尝试 parse_ledger_transaction_type 并覆盖(676-681)。于是 /pendo ledger quick 50 收入的午饭 type:expense 会因为描述里出现"收入"二字而被改判为收入,且没有任何提示。应让显式 type: 字段优先,或在冲突时报错 |
| N155-5 | 低 | ledger.py:586-587 | created_at / updated_at 用 get_user_local_wall_time——与 diary.create_diary 相同,是附录 N-144 那"第三种时间形式"的第二个来源。因此 items.created_at 的用户本地朴素形式覆盖 diary 与 ledger 两种类型,附录 N147-1 提出的"按 type 重建真实时刻"需要把 ledger 也算进去 |
| N155-6 | 低 | ledger.py:585 | "context": {"group_id": group_id} if group_id is not None else {} —— 与 diary 一样记录来源群(附录 N146-5)。注意此处用 is not None,而 diary 用真值判断(if group_id),群号为 0 时两者行为不同。虽然 QQ 群号不会是 0,但同一语义两种写法 |
| N156-3 | 低 | — | "用户可写文本字段"的越界处置在插件内有两种纪律**:记账账户名静默截断到 80 字、搜索筛选值超长报错(N-129 ②)。判据应当是该值是否参与匹配:参与匹配的必须报错(截断会改变语义),仅作展示的可以截断 |
索引 · 前端 · pages/ledger.js(1922)(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-162 † | 中 | — | _explicit_fields 是"显式设置 vs 默认值"的正确做法(正面) |
| N-163 † | 中 | — | 任何一次重绘都会清空"快速记一笔"里已经填好的内容 |
| N-164 † | 中 | — | 同一个金额字段在同一个文件里有两套校验,且都比后端的钱模型宽 |
| N-165 † | 中 | — | 同一个函数,note.py 卸载了、event.py 没卸载 |
| N165-1 | 中 | ledger.js:1804 | 编辑保存 PUT 整个表单且不带 version → Web 第 6 条无乐观锁写入路径。账本是唯一带金额语义的条目类型,丢更新等于金额被静默回退 |
| N165-2 | 中 | ledger.js:1477 | _dateFilter = DATE_FILTER_LABELS[value] ? value : 'month' —— 用普通对象下标的真值当白名单。DATE_FILTER_LABELS 由 Object.fromEntries 生成,value === 'constructor' / 'toString' 时取到原型链上的函数(真值)→ 非法值被放行进 derivePresetRange。N-151 之后前端第 2 处裸下标当白名单,而同文件 _transactionTypeFilter 用的是 TRANSACTION_TYPES.has(...)(Set,正确) |
| N165-3 | 中 | ledger.js:1851 | fetchInsights().catch(() => null) —— 洞察接口失败时四张卡片直接消失,无任何提示。用户会以为"这个范围没数据",而不是"请求失败了"。对照同一 Promise.all 里 fetchItems/fetchAggregate 失败会走整体 catch 并弹 toast |
| N165-4 | 中 | ledger.js:206-222 | fetchCategories / fetchAccounts 各自 catch 后返回 [] / ['现金']。分类下拉静默变空时,界面表现为"这个账本没有分类",与"真的没有分类"不可区分(N69-1 家族第 3 例) |
| N165-5 | 低 | ledger.js:168-178 groupByDate | 分组键在缺失日期时用中文字面量 '未知日期',随后与 YYYY-MM-DD 一起 localeCompare 排序 —— 中文串与数字串的相对位置取决于 ICU 排序规则,"未知日期"分组会落在一个不确定的位置。应把它固定排在末尾 |
| N165-6 | 低 | ledger.js:1858-1862 | 页码越界时递归调用 loadAndRender。正确性靠 ++_loadVersion 让外层的 finally 提前返回(写得对),但递归深度没有显式上界,依赖"maxPage 单调收敛"这一隐含前提 |
| N165-7 | 低 | ledger.js:1530 | _amountDebounceTimer 是模块级单例,被最小/最大两个输入框共用。当前行为正确(回调重新读两个框的值),但这依赖"回调不使用触发它的那个输入框"这一约定,没有断言也没有注释 |
| N165-8 | 低 | ledger.js:196 | total: Math.max(items.length, total) —— 与 notes.js:737(N152-8)完全相同的写法在第 2 个文件出现。两处都是"用展示层数据修正统计层数据",且都没有注释说明为什么不信任后端的 total |
索引 · 前端 · pages/stats.js(2095)(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-168 † | 中 | — | 统计页是唯一不订阅数据变更的页面 |
| N-169 † | 中 | — | 全站唯一一处故意输出原始 HTML 的地方,恰好有一个值同时跳过了转义和数值收敛 |
| N-170 † | 中 | — | 每次切换时间范围都会重发两个与范围无关的请求 |
| N172-1 | 中 | stats.js:1418 | 日记概览请求传 today: diaryRange.end,而笔记概览传 overviewReferenceDay(notesRange, today)(当今天落在范围内时用今天)。同一个 today 参数,两个模块两种取法,于是"日记连续天数"与"笔记近 7 天新增"的参照点在同一屏上不一致 |
| N172-2 | 中 | stats.js:197-200 resolveHeatmapYear | 热力图年份取自 range.end 的年份。自定义范围跨年(如 2025-06-01 → 2026-03-01)时,底图只显示 2026 年,而 clampRangeToYear 把高亮裁到 2026-01-01 起 —— 用户选的范围有一半在图上不可见,卡片副标题只说了"当前时间范围仅用于高亮",没说范围会被裁 |
| N172-3 | 中 | stats.js:2060 | render() 把 _range 固定重置为 'month',destroy() 也是。因此从统计页跳到别的页再回来,用户选的时间范围丢失。其余页面(notes.js / ledger.js / events.js)同样重置筛选,但那些页面的筛选是"随手改"的,统计页的范围更接近"我要看的视角" |
| N172-4 | 低 | stats.js:896 | diarySummary.current_streak 0 与同函数内 formatCount(diarySummary.total_words 0) 写法不同(见 N-169);renderSummary:1875-1876 里所有同类取值都过了 formatCount —— **同一份数据在两个函数里的收敛强度不同 |
| N172-5 | 低 | stats.js:701 | renderActivityHeatmap 判断"最后一天"用 d === normalizedDays[normalizedDays.length - 1](引用相等)。当前成立(元素是 map 新建的对象),但只要有人把 .map(...) 换成原地过滤,这个判断就会静默失效并少画一周 |
| N172-6 | 低 | stats.js:1899 | 加载态判断 _loading && _activeRequestSignature !== _dataSignature 依赖 finally 里把 _activeRequestSignature 清成 ''(:2042)。若将来有人在 catch 里提前 return,这个空串就不会被写回,页面会永远处于"保留旧内容"分支。三个信号量之间的关系没有注释 |
| N172-7 | 低 | stats.js 全部 SVG | renderSparkline 有 role="img" aria-label="趋势图"(固定文案,不含数据),其余 11 个原语的数值只存在于 title= 属性里。读屏用户拿不到任何具体数字,也没有表格回退 —— 与 N145-9 同一问题,但这一页有 12 张图 |
| N-171 † | 低 | — | 与 N-157 的叠加才是真正的问题 |
索引 · 日程创建分派与多节点校验(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N168-1 | 中 | event.py:284 | resolve_default_category 未经 run_sync,阻塞事件循环(详见 N-165) |
| N168-2 | 中 | event.py:225 | parse_event_with_ai 无 AI 外发同意闸门——附录 N26-1 的三个调用点之一,此处是主入口(/pendo event add)。用户文本直接进 prompt |
| N169-1 | 中 | — | "同一辅助函数在相邻模块一处卸载一处不卸载"是 run_sync/to_thread 类缺陷的典型形态**(N-165)。它的可检测性很高:同一个被调用者,在不同调用点是否都被同样地卸载? 这可以做成一条机械检查——收集所有 run_sync(f, ...) 的 f,再搜索这些 f 是否存在未包裹的直接调用。本仓库已知 3 处(pendo 2、core 相关 1),值得全仓跑一遍 |
| N169-2 | 中 | — | "不信任 LLM 输出、在下游收敛"在本仓库已有三处一致实现**(N-167 的分派优先级、附录 N-25 的字段白名单、附录 K-2 的 arxiv 链接规范化)。这是全仓 LLM 集成里做得最一致的一件事,应当在 docs/07-advanced.md 归纳成一节:**prompt 是建议,校验层才是契约 |
| N168-3 | 低 | event.py:264-268、292 | create_event 就地修改传入的 parsed_data,而 add_event:239/250 又把同一个字典存进会话(result.get("data", parsed))。会话数据与被修改的字典是同一个对象。当前流程下二者恰好需要一致,但这种别名关系没有任何注释,后续若在 create_event 里增加临时字段,会连带写进会话 |
| N168-4 | 低 | event.py:328、333 | 两处 datetime.fromisoformat(...) 无 try/except。此处安全(值来自 normalize_event_fields),但与附录 N-135 记录的 event_support.py 三处无防护是同一形态:**同一代码库里,fromisoformat 有时包有时不包,读者无法区分"确认安全"与"忘记" |
| N168-5 | 低 | event.py:283-284 | if not str(parsed_data.get("category") or "").strip(): 才取默认分类。因此 AI 若返回 category: "未分类",会被当作用户已指定而跳过 resolve_default_category,用户设置的默认分类失效。应把 "未分类" 也视为未指定 |
索引 · 导出服务与重复日程展开(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N178-1 | 中 | exporter.py:86 | 无法识别方向时默认返回 'income'(绿色、正向)。金额的默认解释应当是中性或与后端一致,而不是"收入"。上游 normalizePanel:299 已经把无法识别的 transaction_type 规范成 '',正是这个空值让兜底分支变得可达 |
| N178-2 | 中 | exporter.py:154 | 用 new Date(raw) 解析后端来的 start_time。混合 ISO 形式下(N-224 主线),朴素串按设备时区解释、带偏移串按偏移换算 —— 而这个结果直接写进 iOS 系统日历(N-176)。**N-121 的影响范围因此超出了浏览器 |
| N179-1 | 中 | — | 同一个数据根目录被三处独立推导**(N-175),且写法各不相同。这类"常量被复制三份"比单纯的硬编码更危险:修复者容易只改到搜索得到的那一两处。建议正文的修复项直接列出全部三处行号,并建议改为单一的 paths.py 模块 |
| N178-3 | 低 | exporter.py:71-81 | 请求失败时渲染错误组件,没有任何"上次成功数据"的缓存回退。iOS 组件在无网络时会长时间停留在错误态;Scriptable 有 FileManager.local() 可缓存最后一次成功响应。这是组件类应用的标准做法 |
| N178-4 | 低 | exporter.py:66-68 | _sanitize_user_id 把非 [a-zA-Z0-9_-] 一律替换为 _,因此 a.b 与 a_b 清洗后同名,两个用户的导出会落进同一目录。QQ 用户 ID 是纯数字所以实践中不可达,但 Web demo 用户的 owner_id 格式需确认(附录 N-4 提到过 demo 用户是非数字 ID)。应改为哈希或加长度/字符集断言 |
| N178-5 | 低 | exporter.py:137-142 | JS 有手写版本号 ?v=20260430,CSS 完全没有**。该版本号是负资产(审查时已是三个月前的值);此外入口脚本有(陈旧的)缓存击穿,样式表一次都没有,而 web/server.py:96 用默认 StaticFiles 不发 Cache-Control → 改了 CSS 的部署最容易出现"新 JS + 旧 CSS" |
| N178-6 | 低 | exporter.py:128、132 | self._event_collection_cache 是实例级缓存,在 export_markdown 开头 clear()。而 ExporterService 由 main._get_services 缓存在 context.state 里长期存活(附录 N5-14),因此这个字典在两次导出之间一直持有上一次的集合数据直到下次 clear()。数据量不大,但"实例长期存活 + 每次调用开头清空"的组合意味着它实际只在单次导出内有效,写成局部变量更清晰 |
| N179-2 | 低 | — | atomic_write_text 在本仓库的使用率**值得单独核一遍:pendo 有 1 处用了(web/auth.py)、1 处该用没用(exporter.py)。core 提供了这个助手,但没有任何机制阻止直接 write_text。可加一条 CI 规则:plugins/ 下的 .write_text( / open(..., "w") 需显式豁免注释 |
| N179-3 | 低 | — | Windows 保留设备名**(N-177)在全仓其它写文件路径上也应检查:codex 的归档、qingssh 的输出落盘、arxiv_filter 的模型产物。本项目主平台是 Windows,这条不是理论风险 |
索引 · Web 演示空间的隔离与回收(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N183-1 | 中 | demo_space.py:123 | purge_demo_owner 结尾调用 db.cache_clear()——清空全插件共享缓存。而 purge_expired_demo_users 会在一次调度里循环 purge N 个过期用户,即 N 次全量清空。演示用户的数据与其他用户完全隔离(独立 owner_id),完全可以只失效该 owner 的键。与附录 N47-2(撤销路径用 cache_clear)同型,是第 5 处过度失效 |
| N183-2 | 中 | demo_space.py:106 | purge_demo_owner 用 with conn: 包住 8 条 DELETE。它由 ensure_demo_access 调用,而后者在 deps.get_current_session 里每个演示请求都会执行(web/deps.py:88-90)。当前 ensure_demo_access 只在过期时才 purge,所以不会每请求都删;但这条路径意味着一次普通的页面请求可能触发 8 条 DELETE + 一次全量缓存清空,且发生在 FastAPI 的请求线程里。应把回收移交给那个每 6 小时的定时任务,请求路径只做拒绝 |
| N184-1 | 中 | — | cache_clear() 在 pendo 已被调用 5 次以上**(撤销四条路径 N47-2、演示回收 N183-1、rebuild_fts_index)。全局缓存清空是"正确但昂贵"的偷懒做法,在多用户场景下让一个用户的操作影响所有人。建议正文把"精确失效 vs 全量清空"列为一条统一整改项——所有调用点都已知道要失效哪个 owner_id |
| N184-2 | 中 | — | "同一位作者在一处写对、在另外三处写错"是本次审查反复出现的形态**(N-182 的 key 清理、N-165 的 run_sync 卸载、N-149 的歧义处理、N-162 的逐字段判断)。这四组的共同点是:正确写法与错误写法在同一个代码库里并存,且正确的那份可以直接复制。正文应把这类整改单列一节并给出"照抄哪一处"的具体行号,其修复成本远低于需要重新设计的问题 |
| N183-3 | 低 | demo_space.py:129 | now = now or datetime.now() —— 朴素服务器本地时间。_is_expired 能处理混用所以不出错,但它是附录 N-8 那 41 处普查在 web 层的落点之一 |
| N183-4 | 低 | demo_space.py:288 | 速率限制的清理循环每次都遍历全部键(for key, request_times in tuple(_DEMO_REQUESTS.items())),与附录 N5-5(_prune_expired 每请求 O(n) 全扫)同型。演示请求频率低(WEB_DEMO_REQUESTS_PER_HOUR = 3)所以影响可忽略,但两处应统一为带时间戳的惰性清理 |
| N183-5 | 低 | demo_space.py:19 | _DEMO_TEMPLATE_PATH = Path(__file__).resolve().parent / "assets" / "demo_bundle.pendo.zip" —— 又一处 __file__ 相对路径。此处是只读的包内资源(pyproject.toml:210 有 "plugins.pendo.web.services" = ["assets/*.zip"],确实随包分发),因此不属于附录 N-175 记的那三处数据目录问题,是正确用法 |
| N184-3 | 低 | — | 匿名/演示入口的安全清单**(保留命名域、破坏性操作前置守卫、禁止对外发消息、失败关闭的过期判定、速率限制、容量上限)在 pendo 是完整的,可作为其它插件开放匿名入口时的检查表写进 docs/07-advanced.md |
| N-183 † | 低 | — | 问题 |
索引 · Web API 写入路径,以及"Web 比聊天更严谨"的三处证据(8 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N189-1 | 中 | — | 同一插件的两个前端实现同一套写入语义,且严谨程度不同**(N-186)。这不是简单的重复代码,而是契约缺失:没有一个共同的"条目写入服务"层,于是 Web 与聊天各自实现校验、并发控制与默认值填充。正文应把这一条作为 pendo 的结构性建议:把 create_item / update_item 的这套流程下沉为 services/item_service.py,两个前端都调它——这同时解决 N-33(CAS 未用)、N168-5(默认分类)、以及未来两层继续漂移的风险 |
| N188-1 | 低 | items.py:824 | 默认分类只对 event / note 生效,task / diary / ledger 不套用用户设置的默认分类。聊天侧同样只有 event.py 与 note.py 调用 resolve_default_category,所以两层是一致的;但 default_category 这个设置项的适用范围没有写进帮助文本(/pendo settings 的说明里只说"默认分类"),用户会以为它对全部类型生效 |
| N188-2 | 低 | items.py:841-844 | 日记标题缺省值用字符串切片取时分:entry_label = entry_time[11:16] if len(entry_time) >= 16 else ""。这依赖 entry_time 恰好是 YYYY-MM-DDTHH:MM… 的形状。同样的逻辑在 diary.py:257-258 是用 datetime.fromisoformat(...).strftime("%H:%M") 实现的——两层对同一个展示值用了两种解析方式,其中 Web 这份在时间形式变化时会静默产出空字符串 |
| N188-3 | 低 | items.py:867、889 | 先 db.get_item() 再 item_to_dict(item) 取 version,而 get_item 会走 30 秒 LRU 缓存(附录 N77-5)。因此 current_version 可能来自缓存快照,比数据库实际值旧。所幸第 ⑦ 步的数据库 CAS 会以真实值判定并返回 409,最终结果正确;但客户端会看到一次"莫名其妙"的 409(它带上来的 version 其实是对的,只是服务端读到了旧值)。应在这条路径上跳过缓存读取 |
| N189-2 | 低 | — | 缓存读取混入乐观并发流程**(N188-3)是一个容易被忽略的组合缺陷:CAS 本身正确,但"读版本"的那一步走了缓存,导致误报冲突。凡是"读取当前版本 → 比较 → 条件写入"的流程,读版本那一步必须绕过任何缓存。这条与附录 N173-2(组合效应复核)同源 |
| N-186 † | 低 | — | Web 层在三处比聊天层更严谨(值得作为整改依据) |
| N-187 † | 低 | — | Web 创建路径的时间形式与聊天层一致(对 N-161 的补充) |
| N-188 † | 低 | — | 问题 |
索引 · 传输包上传与解压防护(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-200 † | 中 | — | 但两侧检查之间是完整解压进内存,声明大小造假仍可放大 |
| N201-1 | 中 | transfer_bundle.py:407-418 | 成员读取用 zf.read(info) 一次性解压进内存,两侧大小检查无法阻止"声明小、实际大"的压缩炸弹(详见 N-200) |
| N202-1 | 中 | — | "读取前后各查一次大小"是一个看起来很稳、实际留有缺口的模式**(N-200)。它能挡住"声明大"的攻击,挡不住"声明小、实际大"的攻击,因为后一次检查发生在资源已被消耗之后。通用规则:边界检查必须发生在资源消耗之前或过程中,事后检查只能用于断言,不能用于防护。这条与附录 K-0(codex 的 _copy_validated_image 分块复制并逐块检查预算)形成对照——codex 那份是过程中检查,是正确形态。建议正文把两者并列,作为"资源边界防护"的正反例 |
| N201-2 | 低 | transfer.py:435-437 | try: size = int(content_length) except: size = 0 —— 畸形 Content-Length 被当作 0 从而跳过前置检查。这是安全的降级(后置检查仍会拦住),但把"解析失败"和"文件为空"混成同一个值;至少应记一条日志 |
| N201-3 | 低 | transfer.py:950-955 | page_size 从 x-transfer-page-size 头读取,max(1, min(100, int(...))) 钳位,解析失败回退 (1, 20)。钳位是对的;但用自定义请求头传分页参数而非查询串,会绕过大多数网关的日志与限流规则,且不便于调试。应改为 query 参数 |
| N201-4 | 低 | transfer_bundle.py:392 | MAX_ARCHIVE_MEMBERS = len(TYPE_FILE_NAMES) + 2 —— +2 是魔数(推测是 manifest.json 与 event_collections 文件)。派生方式很好,但那个 +2 应写成具名常量或注释说明,否则新增一个非类型文件时会静默超限 |
| N202-2 | 低 | — | zip 处理的检查清单**(成员数上限、重名拒绝、加密拒绝、精确名索引而非路径拼接、解压大小有界读取、记录数上限、不落盘)在 pendo 已完成 6/7 项,唯一缺的就是 N-200 那一项。这份清单可直接写进 docs/07-advanced.md,供其它需要处理用户上传的插件复用 |
| N-201 † | 低 | — | 问题 |
| N-203 † | 低 | — | 结清 N-200 的挂账:演示用户可以触达导入端点 |
索引 · widget.py 余段 + web/utils.py + 分析层全量物化普查(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-229 † | 中 | — | 分析层有四份独立的"取回全部条目"循环,小组件一次请求触发两次 |
| N-230 † | 中 | — | 小组件把错误的"今天"强行塞进了两个本来正确的函数 |
| N231-1 | 中 | task_overview.py:101、notes_overview.py:71、dashboard_overview.py:28 | 三份独立的全量物化循环,无日期/数量收窄,且被小组件高频端点串联调用(详见 N-229) |
| N231-2 | 中 | widget.py:249-250,285,359-360 | 把 DEFAULT_TZ 基准的 today 传给两个 overview,使其正确的用户时区回退永不执行(详见 N-230) |
| N233-1 | 中 | — | "正确原语被调用方废掉"在 pendo 已达三处**(N-230 的表格)。三处的共同结构是:原语把正确行为放在了某个参数的默认分支里(不传 expected_version / 传全量 settings / 传 today)。归纳出的设计规则:如果一个参数的"缺省"比"传值"更正确,那它就不该是参数——应当反过来,让调用方显式声明它要覆盖默认行为(例如 override_today= 而不是 today=)。这条建议写进正文的 core API 设计小节 |
| N233-2 | 中 | — | 高频端点的代价要按"它调用了什么"而不是"它自己写了什么"来评估**(N-229)。widget.py 本身没有任何昂贵操作,全部代价来自它串联的三个 build_* 函数。审查检查项:对每个高频/轮询端点,展开一次完整调用树,标出其中所有无界读取。这与附录 N173-1(从数据访问层反向追溯调用者)是同一件事的另一个方向 |
| N231-3 | 低 | web/utils.py:90-100 | item_to_dict 对异常形状返回 {},连一条日志都没有。API 响应里会出现空对象条目,前端无从判断是"没有数据"还是"序列化失败"。这比 db._row_to_item 的三条 WARNING(附录 N68-2,至少有日志)更弱,是附录 N69-1「损坏数据静默消失」在 API 层的形态 |
| N231-4 | 低 | web/utils.py:75-80 vs :26-72 | 同一个模块里两种错误纪律:normalize_item_type_query / normalize_choice_query / infer_item_query_type 抛 HTTPException(422),而 amount_filter_cents 抛 ValueError。调用方必须逐个记住哪个是哪种,漏包一层就是 500 |
| N231-5 | 低 | widget.py:121 | due_key = plan_date or deadline_at[:10] —— ISO 串切片解析的第 6 处(附录 N197-3)。与 stats.py:344 是同一行逻辑的两份拷贝,两处都在同一个"任务到期日"语义上 |
| N231-6 | 低 | widget.py:305-316 | recent_ledger 在 DB 已经 ORDER BY ledger_date DESC LIMIT 5 之后,又在 Python 里连排两次(先按日期、再按"是否支出")。结果正确(Python 排序稳定,第二次排序保留了第一次的相对顺序),但依赖稳定排序这一性质而没有注释;且第一次排序完全冗余——DB 已经按同一个键排过 |
| N233-3 | 低 | — | 同一模块内的错误纪律必须统一**(N231-4)。web/utils.py 是被多个路由共用的辅助模块,其中一个函数抛 ValueError 而其余抛 HTTPException,等于把"要不要包一层 try"的决定推给每个调用点。规则:**辅助模块要么全部抛领域异常由路由层转换,要么全部抛 HTTP 异常,不能混 |
| N-231 † | 低 | — | 问题 |
索引 · dashboard_overview.py + ledger_insights.py(15 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-234 † | 中 | — | 看板把全部未完成任务和当月全部账目取进内存,只为得到一个计数和一条按日求和 |
| N-235 † | 中 | — | /stats/ledger/insights 不带日期参数时会扫描该用户的全部账目历史 |
| N-236 † | 中 | — | 看板按 ISO 字符串排序日程,而同插件的创建路径专门规避了这一点 |
| N-237 † | 中 | — | 纯日期列与日期时间列在导入之后不再指向同一天 |
| N238-1 | 中 | dashboard_overview.py:216,248 | 首屏接口全量物化未完成任务与当月账目,用途只是一个计数和一条按日求和;同文件 _recent_counts 是正确写法(详见 N-234) |
| N238-2 | 中 | ledger_insights.py:193-210 + stats.py:257-297 | 日期参数可选,两者都不给时逐行取回全部历史账目(输出有界、输入无界)(详见 N-235) |
| N238-3 | 中 | dashboard_overview.py:284,291 | 按 ISO 字符串排序 start_time,与 web/api/events.py:216-226 显式规避的写法矛盾(详见 N-236) |
| N238-4 | 中 | 导入路径 + 全部混用日期列的统计 | 纯日期列不随导入换算、日期时间列换算,导入后两者差一天(详见 N-237) |
| N240-1 | 中 | — | "输出有界"不等于"输入有界"**(N-235)。ledger_insights 的响应体大小与数据量无关(桶数固定),但它为此扫描了全部历史。审查检查项:看到分桶/聚合逻辑时,要分别确认输入行数上界和输出行数上界——只看响应体大小会漏掉这一整类问题,而这类问题在压测里也不容易暴露(响应快,但内存和 IO 随用户数据增长) |
| N240-2 | 中 | — | 同一份数据的"日期列"与"时间戳列"必须一起迁移或一起不迁移**(N-237)。pendo 选择了只迁一半,理由是对的(纯日期不该被时区平移),但没有人记录这个决定,也没有任何一致性检查。建议:**任何存在 X_date 与 X_at 成对列的表,都应当有一条断言或体检查询确认两者指向同一天 |
| N238-5 | 低 | ledger_insights.py:180-182 | if previous_total == 0: return None if current_total == 0 else 1.0 —— 上一周期为零时一律返回 1.0,前端渲染成"较上一周期 +100%"。从 ¥0 到 ¥10 与从 ¥0 到 ¥10000 显示完全相同,且 "+100%" 会被读成"翻了一倍"。应当返回一个独立的"无基线"状态(例如 delta_vs_previous: null 配合 delta_label: "上一周期无数据"),而不是一个会被误读的数字 |
| N238-6 | 低 | ledger_insights.py:28 | _LEDGER_AMOUNT_EXPR = Database._LEDGER_AMOUNT_CENTS_EXPR —— 跨模块引用私有属性的第 2 处(附录 N226-5 是 stats.py:28)。两处一字不差地重复了同一行,包括那个基于它派生的 _..._TOTAL_EXPR。应在 Database 上提供公开常量并让两处引用它 |
| N238-7 | 低 | dashboard_overview.py:263 | day = str(getattr(item, "ledger_date", None) or today) —— 没有 ledger_date 的账目被静默计入今天。ledger_date 在写入路径上是必填的,所以当前不可达;但一旦出现(例如导入了外部数据),一笔历史账会悄悄出现在今天的支出趋势里。应当跳过并计数,而不是归到今天(附录 N69-1 家族) |
| N240-3 | 低 | — | or default 用在数据完整性缺口上是一种静默损坏**(N238-7 的 or today)。与附录 N222-1(setdefault 可能从不执行)配对:**or / setdefault / get(k, d) 这三种缺省写法,既可能掩盖"从不发生",也可能掩盖"发生了但被填成了错的值",两个方向都要查 |
| N-238 † | 低 | — | 问题 |
索引 · diary_overview.py + task_overview.py(13 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-241 † | 中 | — | 日记概览的粒度保护存在,但默认是关闭的 |
| N-242 † | 中 | — | _build_cadence 按天循环,而隔壁文件按桶循环 |
| N-243 † | 中 | — | 任务概览的两个列表没有上限,而同一响应里另外三个都截断到 8 |
| N244-1 | 中 | stats.py:478 + diary_overview.py:110-126 | 粒度自动粗化默认关闭("day" 而非 "auto"),叠加无跨度上限 → 可请求 7.3 万个桶(详见 N-241) |
| N244-2 | 中 | diary_overview.py:142 | _build_cadence 按天循环而非按桶循环,隔壁 notes_overview._cadence_slots 是正确写法(详见 N-242) |
| N244-3 | 中 | task_overview.py:181,185 | focus_tasks 与 all_tasks 无上限,同一响应另三个列表截断到 8;widget 每次轮询构造全部 payload 后丢弃(详见 N-243) |
| N246-1 | 中 | — | "保护逻辑存在但默认关闭"应当与"没有保护"同级对待**(N-241)。cadence_granularity="auto" 是安全的、"day" 是危险的,而默认值选了后者。审查检查项:凡是某个参数的取值决定了资源上界,必须确认默认值落在安全的一侧。这与附录 N37-1 家族(看起来有保护、实际没有)是同一件事的变体——那些是"接错线",这些是"开关没打开" |
| N246-2 | 中 | — | 同一约束在 N 处各写各的,就会得到 N 种保护程度**(N-241 的四端点表)。pendo 的四个日期范围端点分别是"有保护无开关""有保护无开关""有保护但默认关""完全没有"。这不是四次疏忽,而是没有共享的范围解析器。整改应当是提炼一个 resolve_date_range(start, end, *, max_span, granularity) 让四处都调它——与附录 N189-1(下沉 item_service)、N215-1(下沉 settings_service)是同一类结构性建议,**建议正文把三者合并成一节"pendo 缺的是共享契约层" |
| N244-4 | 低 | diary_overview.py:83 | _entry_label 用 raw[11:16] 从 ISO 串切出 HH:MM —— 附录 N197-3「ISO 串切片解析」的第 7 处。且 _entry_sort_key 会在 entry_time 缺失时回退到 created_at/updated_at,这三个字段都在 bundle_import._ITEM_DATETIME_FIELDS 里(附录 N-237),导入后是 UTC 带偏移 → 日记列表显示的"时间"变成 UTC 小时。这是附录 N-224 在用户界面上最直接可见的一处 |
| N244-5 | 低 | task_overview.py:58-82 | _normalize_task 把越界 priority 静默改成 3、未知 status 静默改成 "open"、非法 version 静默改成 0,全部无日志。写入侧有校验所以当前不可达,但一旦发生,一个 cancelled 的任务会以"进行中"的样子永久显示,且没有任何线索。属附录 N69-1 家族;至少应记一条带 item_id 的 WARNING |
| N244-6 | 低 | diary_overview.py:67 | _load_diary_days 对无法解析的 diary_date 静默跳过(同上家族)。影响限于连续天数统计偏低,但用户会认为"我明明写了却断签了" |
| N246-3 | 低 | — | "逐单位循环"与"逐桶循环"的差异只在大跨度时暴露**(N-242)。这类问题在功能测试里完全看不出来(结果一模一样),只有在性能或大范围输入下才显形。判据很简单:分桶函数的循环变量应当是桶,不应当是桶的下层单位。 可作为一条代码评审的机械检查 |
| N-244 † | 低 | — | 问题 |
索引 · notes_overview.py 余段 + events_overview.py 余段(web/analytics/ 完成)(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-247 † | 中 | — | 笔记概览返回完整正文,而唯一的下游立刻把它截到 22 个字符 |
| N-248 † | 中 | — | 笔记的分类、标签、日期过滤全部在 Python 侧完成 |
| N249-1 | 中 | notes_overview.py:306 | recent_notes 返回完整正文(上限 50000 字符 × 6),唯一下游立刻截到 22 字;同层 diary_overview.py:278 用的是 80 字预览(详见 N-247) |
| N249-2 | 中 | notes_overview.py:237-252,328 | 日期/分类/标签筛选与 all_categories 全部在 Python 侧完成,而 db.get_items 支持前两者下推、list_event_categories 是现成的 DISTINCT 写法(详见 N-248) |
| N249-3 | 低 | events_overview.py:472-479 | build_event_collection_detail 返回全部子条目、无上限。对 recurring 集合受 EVENT_MAX_RRULE_COUNT=365 约束,但对 multi_node 集合没有任何上界(附录 N-218:创建侧也没有)。即 N-218 的无界输入会一路流到这个详情端点的输出 |
| N249-4 | 低 | events_overview.py:428-437 | related_instances 的 [:12] 截断发生在列表推导完成之后——db.get_collection_events 返回全部子条目、逐个构造字典,最后只留 12 个。与 N-243、N-247 同属"为丢弃的数据付费",但规模小得多 |
| N249-5 | 低 | events_overview.py:191-194 | _summarize_reminders_in_range 对解析失败的提醒时间 continue 跳过、无日志。后果是该提醒不计入 reminder_summary.total,进而影响 reminder 筛选(with/none 的判定)—— 一条坏时间会让一个有提醒的日程被 reminder=none 筛选选中。属附录 N69-1 家族,但这一处的静默跳过会直接改变筛选结果 |
| N-249 † | 低 | — | 问题 |
| N-251 † | 低 | — | web/analytics/ 收尾小结 |
索引 · handlers/search.py + 聊天/Web 两条搜索路径的输入约束对照(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-262 † | 中 | — | 同一个搜索功能,聊天端有六道输入约束,Web 端一道都没有 |
| N263-1 | 中 | web/api/search.py:107-114 | Web 搜索端点的 q 无长度上限、四个文本筛选无任何约束,而聊天端同一功能有六道约束;叠加附录 N30-2(每次搜索必然全表 LIKE)后成为可放大的代价(详见 N-262) |
| N265-1 | 中 | — | "哪一层更严谨"是个错误的问题**(N-262)。同一个插件里,写入路径 Web 更严(N-186),搜索输入聊天更严(本条),时区处理分析层更严(N-223),异常边界聊天层更严(N261-3)。四组对照方向各不相同,说明差异来源不是分层设计而是逐处的注意力分配。正文应把这四组并列,结论统一为一句:凡是同一语义在两处各写一遍,就一定会分叉;唯一的结构性解法是提炼共享契约(N189-1 item_service / N215-1 settings_service / N246-2 date_range / 本条 search_input,四个建议应合并成一节) |
| N263-2 | 低 | handlers/search.py:230-238 | _apply_date_filter 对 plan_date / diary_date / ledger_date 三个纯日期列做 [:10] 截断(正确),但对 start_time / created_at 两个日期时间列直接用完整 ISO 串做字符串比较。在附录 N-192 描述的混合格式下(同一列既有本地朴素又有 UTC 带偏移),start_date <= created_at <= end_date 的字符串比较会把导入数据错误地排除或纳入。这是附录 N-224 在聊天搜索路径上的表现 |
| N263-3 | 低 | handlers/search.py:230 | 未指定 type 时,range= 一律作用于 created_at。这是个合理默认,但帮助文本(:99-107)没有说明——用户以为 range=today 会按"日程发生时间/记账日期"筛选,实际按"创建时间"。指定 type= 后行为又变了(按 _DATE_FIELD_BY_TYPE),即**同一个筛选参数的语义随另一个参数变化且未文档化 |
| N265-2 | 低 | — | "同一参数的语义随另一参数变化"应当在帮助文本里写明**(N263-3)。range= 在有无 type= 时作用于不同的列,这是合理的设计,但用户无从得知。判据:凡是参数 A 的行为依赖参数 B 的取值,帮助/文档必须显式描述这个依赖,否则用户只能靠试 |
| N-263 † | 低 | — | 问题 |
索引 · 恢复(编号冲突丢失)(1 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| BODY-S4 | 中 | plugins/pendo/ | pendo 用 None 作为 context 调用 public_error_message,错误码与 request_id 因此无法与本次请求关联 |
索引 · pages/search.js(782) + pages/settings.js(502) + 前端时区去向普查(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N-136 † | 低 | — | 【P1】导出包的 source.timezone 取的是浏览器时区,不是用户设置的时区 —— 往返会平移全部时间 |
| N138-3 | 低 | search.js:629-635 | 请求页码超出范围时再发一次搜索(用 lastPage)。即两次全表 LIKE。可以让后端在越界时直接返回末页,或前端先用上一次的 _total 夹取页码 |
| N138-4 | 低 | settings.js:9 | DEFAULT_SETTINGS.timezone = 'Asia/Shanghai' —— 前端第 4 处硬编码该字面量(另三处 transfer.js:347/719/760/770)。后端有 PendoConfig.DEFAULT_TIMEZONE,前端没有对应常量模块 |
| N138-5 | 低 | settings.js:68 mergeSavedSettings | 保存成功后把响应与请求 payload 合并成新状态。若后端对某字段做了规范化(如时区大小写、quiet_hours 补零),以谁为准取决于合并顺序;应当直接采用响应值 |
| N-137 † | 低 | — | 【中】时区是纯文本输入框,而它决定整个插件的时间语义 |
| N-140 † | 低 | — | 命令元数据的布尔值故意比 API 输入更严格 |
索引 · handlers/event.py 全文(2168 行)(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N62-7 | 低 | _delete_single_instance:1241 | 文案写"5分钟内可使用 /pendo undo 撤销",而附录 N-13 已确认真实窗口是"距上次 00:15 清理的时长"(5 分钟 ~ 24 小时)。同一插件第 3 处不同的撤销时长承诺(帮助文本 30 分钟 / 此处 5 分钟 / 实际抖动) |
| N-52 † | 低 | — | -0 · 先说三条主线的复核结论 |
| N-53 † | 低 | — | items 是 55 列的单表继承,业务不变量只存在于 Python 层 |
| N-54 † | 低 | — | schema_migrations 在 pendo 是真的(对照 qingpet M2-4),但用列表下标当版本号 |
| N-55 † | 低 | — | 【中】重复日程展开忽略集合自己的 timezone 列,改用固定偏移 —— DST 下墙钟会漂 |
| N-56 † | 低 | — | 【中】rrule 是 LLM 自由文本,零校验直接进 rrulestr —— 且子日粒度会撞主键 |
| N-57 † | 低 | — | 【P1】创建重复日程 = 最多 365 次「拉取该用户全部日程」 |
| N-58 † | 低 | — | 【中】编辑集合不同步子条目,两者字段是冗余存储 |
| N-59 † | 低 | — | 重新理解 N-8:这不是"疏忽",是一次**没做完的时区迁移 |
| N-60 † | 低 | — | scheduled_delivery_outbox 是一份教科书式的事务性外发箱 |
| N-61 † | 低 | — | 【中】_check_conflict 在重复日程里回传的是原始 parsed_data,不是冲突实例 |
索引 · 命令层收尾(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N89-6 | 低 | event_support.ensure_start_time_reminder:128 | datetime.fromisoformat(start_time) 无 try/except,而同文件其它三个函数都包了。start_time 来自 AI 解析结果,格式不保证 |
| N89-7 | 低 | core/runtime.py:11-19 | get_plugin_runtime_state(create=True) 在 context.state 不是 dict 时静默返回一个新的空 dict(而非报错),调用方对它的写入会被丢弃。注释强调"不创建全局后备状态"是对的,但失败路径应当可观测 |
| N-87 † | 低 | — | 【P1】「安全版本已存在但用的是不安全版本」—— 一次普查找到三组 |
| N-88 † | 低 | — | 【中】默认提醒会给一个朴素时间的日程产出带偏移的提醒时间 |
详述(185 条,† 标记的条目)
N-2 [P1] 数据库和签名密钥都放在插件源码目录内,而不是 context.data_dir
最终结论:严重度由 [P1] 降为 [中]:
context.data_dir本身就在包目录内,四条后果中两条对所有插件成立、一条(remote-sync 不覆盖)不成立。见主线 M-12。以下推导保留原始分析,与该结论有出入处以本行为准。
# main.py:139
db_path = os.path.join(os.path.dirname(__file__), "data", PendoConfig.DB_FILENAME)
# auth.py:26
_SECRET_FILE = Path(__file__).resolve().parents[1] / "data" / "web_token_secret.txt"init(context=None) 拿到了 context,却从不读 context.data_dir。 对比 qingpet 的 Path(data_dir) / "qingpet" / "qingpet.db"(正文 M2-7)—— pendo 是全仓唯一把用户主数据写进包安装目录的插件,而它恰恰是 存放日程、账本、日记、笔记这类最私密数据的插件。四个后果:
- 升级即丢数据:
pip install --upgrade/ 重建容器镜像 / 重装到新的 site-packages,都会让plugins/pendo/data/pendo.db变成孤儿或直接消失; - 只读安装目录下无法启动:
os.makedirs与 SQLite 写入都会失败; - 不受框架数据面覆盖:
docs/remote-sync.md描述的同步与备份以data_dir为单位,pendo 的库不在其中; - 密钥同源:
web_token_secret.txt一并丢失 ⇒ 所有会话与 180 天有效期的 Widget 令牌在一次升级后静默全部失效。
.gitignore:106 的 plugins/pendo/data/* 挡住了误提交, pyproject.toml 的 packages 白名单也未包含 plugins.pendo.data, 所以打包与版本库是干净的——问题纯粹在运行期路径选择。 修法:db_path = Path(context.data_dir) / PendoConfig.DB_FILENAME, 密钥同理,并为已有部署提供一次性迁移。
N-8 [P1] pendo 有一套完整正确的时区体系,然后在 41 个地方绕开了它
最终结论:改述为「一次进行到一半的子系统迁移」,修复必须按三步顺序进行。见主线 M-2。以下推导保留原始分析,与该结论有出入处以本行为准。
utils/time_utils.py 里的基础设施是对的,而且不止是对的——它是主动防御的:
# time_utils.py:60-66
@staticmethod
def format_for_storage(dt: datetime) -> str:
if dt.tzinfo is None:
raise ValueError(
"naive datetime is not accepted for storage; supply an explicit timezone"
)
return dt.astimezone(timezone.utc).isoformat()配套还有 TimezoneHelper.get_user_timezone(user_id, db)(读用户设置)、 now_in_timezone(user_id, db)、get_user_local_wall_time(经 run_sync 卸载)、 get_user_now_from_settings。这套东西正是三个每分钟定时任务 (pendo_reminders / pendo_daily_briefing / pendo_diary_reminder,N5-17) "按用户本地时间判断是否到点"所依赖的基础。
然后全插件有 41 处直接调用朴素的 datetime.now() / date.today() (已排除 scripts/ 离线迁移脚本),而上述 tz 感知助手全插件只被调用 7 次:
| 文件 | 朴素调用数 |
|---|---|
services/db.py | 23 |
utils/validators.py | 4 |
web/services/demo_space.py | 3 |
utils/time_utils.py | 3 |
handlers/event.py | 3 |
models/item.py | 2 |
web/services/transfer_bundle.py、web/api/stats.py、services/exporter.py | 各 1 |
23 处在持久化层意味着写进数据库的时间戳大多是服务器本地朴素时间, 而 format_for_storage 的存在说明作者本来的意图是"入库一律 UTC 且必须带时区"。
同一个文件里就能看到两种纪律并存:
# time_utils.py:293 —— 正确
now = now or TimezoneHelper.now()
# time_utils.py:111 / :226 / :275 —— 朴素
now = now or datetime.now()后三处正是 parse_date_optional、_parse_time_range_core、parse_diary_range 的默认基准时间,也就是说 /pendo todo list today、/pendo ledger list month、 /pendo diary list 在调用方不显式传 now 时,"今天/本月"按服务器时钟计算。
validators.py 的四处则决定了新记录的默认落点:
| 位置 | 后果 |
|---|---|
default_task_plan_date:238-240 | "晚上 8 点后自动计划到明天"用的是服务器的 20 点 |
_normalize_ledger_date_and_currency:541 | 新账目的默认 ledger_date 是服务器当天 |
_sync_task_terminal_timestamps:946、:953 | completed_at / cancelled_at 写入朴素本地 ISO 串 |
最后一条尤其要紧:start_time / remind_times 经 _normalize_iso_datetime 可以是带偏移的,而 completed_at 一定是朴素的—— 同一张表的相邻列因此混着两种 ISO 形式。任何按字符串排序或比较的地方 都会静默出错(这正是 qingpet M2-6 的加强版:那里所有写入形式一致所以侥幸正确, 这里形式本身就不一致)。
用户可见症状:UTC+9 的用户在当地 00:30 记一笔账,服务器在 UTC+8, 这笔账被记到前一天;每日简报与日记提醒按用户时区触发, 但简报里"今天的待办"按服务器时区筛选,两者可能差一天。
建议:format_for_storage 已经定义了正确契约,应该让它成为唯一入口—— 禁止 plugins/pendo/ 出现裸 datetime.now()(CI 规则), 所有需要"当前时间"的地方显式传入 user_id + db 或已解析的用户时区。 这条与正文 A7-1(_business_now 时区分叉)、codex J3-6(归档目录名用本地时区) 是同一主线,但 pendo 是后果最严重的一个,因为它是日程与账本插件。
N-12 [P1] 审计日志保留策略被时区偏移整体平移,且方向随部署地反转
这是N-8(41 处朴素 datetime.now())的第一个可推导出确定后果的实例。
写入侧(db.py:711,_log_operation_with_cursor):
created_at or datetime.now().isoformat() # 朴素、服务器本地
# → "2026-07-25T14:30:00"清理侧(db.py:3144-3166,prune_operation_logs):
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
raise ValueError("operation-log prune time must be timezone-aware") # ← 严格要求带时区
redact_before = (current - timedelta(minutes=undo_snapshot_minutes)) \
.astimezone(timezone.utc).isoformat()
# → "2026-07-25T06:25:00+00:00"两者随后由 SQLite 做字符串比较: WHERE created_at < ?。由于两边都是 YYYY-MM-DDTHH:MM:SS… 前缀, 字典序≈时序,因此比较是"本地墙钟时间"对"UTC 墙钟时间"—— 整条保留策略被平移了一个 UTC 偏移量:
| 部署时区 | redact_before 实际含义 | 后果 |
|---|---|---|
UTC+8(DEFAULT_TIMEZONE = Asia/Shanghai) | 比预期早 8 小时 | 正文快照(日记正文、账目金额、编辑前旧值)在 operation_logs.details 里多留 8 小时——隐私保留策略未按配置执行 |
| UTC-5 | 比预期晚 5 小时 | 每次清理都把全部快照擦掉,包括刚写入几秒的——撤销功能在每晚 00:15 之后彻底失效 |
| UTC±0 | 正确 | — |
delete_before(90 天)同样被平移,但 8 小时相对 90 天可忽略。
讽刺之处:prune_operation_logs 恰恰是 pendo 里少数把时间做对的函数—— 它显式 raise ValueError("operation-log prune time must be timezone-aware") 拒绝朴素输入。但它比较的那一列本身是朴素的。 在一个比较的一侧做对,比两侧都做错更糟:两侧都用本地朴素时间时结果是正确的 (get_latest_undoable_operation 的 threshold 就是朴素本地,因此撤销查找本身没问题), 而只有一侧改成 UTC 之后,正确性反而被打破了。
修复必须成对:要么把 created_at 改为 UTC 存储并一次性迁移历史行, 要么让 prune_operation_logs 也用本地朴素时间。不能只改一侧。
N-70 [P1] 同一张表的同一列被两个构造函数写成两种时间形式
最终结论:子条目从未拿到过 UTC 时间;该部分已按 N-217 更正。以下推导保留原始分析,与该结论有出入处以本行为准。
这是N-59("没做完的时区迁移")迄今最尖锐的一处证据。 event_collections 有两个创建入口,相隔 20 行:
# db.py:1266-1269 create_event_collection
def create_event_collection(self, payload):
now = datetime.now().isoformat() # ← 朴素本地
collection = self._prepare_new_event_collection(payload, now=now)
# db.py:1283-1292 create_event_collection_with_children
def create_event_collection_with_children(self, payload, children, *, operation_action=...):
now = datetime.now(timezone.utc).isoformat() # ← UTC 带偏移
collection = self._prepare_new_event_collection(payload, now=now)两者都把 now 交给同一个 _prepare_new_event_collection,后者做 collection.setdefault("created_at", now) / setdefault("updated_at", ...)。
于是 event_collections.created_at 的形式取决于用哪个入口创建:
| 入口 | created_at 形式 |
|---|---|
create_event_collection(单独建集合头) | "2026-07-25T14:30:00" |
create_event_collection_with_children(建集合 + 子节点) | "2026-07-25T06:30:00+00:00" |
而且影响不止于集合表——create_event_collection_with_children 把同一个 UTC now 也写进了子条目(1318-1319):
item_data.setdefault("created_at", now) # ← UTC
item_data.setdefault("updated_at", now)而 insert_item(760-761)写的是朴素本地。 所以 items.created_at 这一列本身就混着两种 ISO 形式, 取决于该条目是经普通新建还是经多节点/重复日程创建的。
后果是具体的:
- 任何
ORDER BY created_at/ORDER BY updated_at(列表页、搜索合并、get_latest_undoable_operation的时间比较)在混合数据上排序错乱—— UTC+8 部署下,UTC 串的字典序会比同一时刻的本地串小 8 小时; - 按日期范围筛选
created_at >= ? AND created_at <= ?会漏掉或多出 经集合路径创建的条目; - N-12 的
prune_operation_logs只是"交界处"的一个例子, 这里是同一列内部就已经不一致。
这条应当在正文里单独列出,并且它进一步支持 N63-1 的结论: 修复必须是一次统一迁移 + 历史数据转换,不能逐点改。 在做迁移之前,甚至需要先写一段探测脚本统计库里到底有多少行是哪种形式。
N-97 [P1] 混合 ISO 格式的问题作者已知并已在一处正确解决,但简报取数没用那套解法
get_events_for_range(2813-2857)带着本轮审查中最关键的一段注释:
# ISO 文本可能混合无偏移和带偏移值,字典序不等于绝对时间。
# 全球合法偏移最多可相差 26 小时,因此 SQL 用前后各两天的日期前缀
# 缩小候选集;最终重叠判定仍在 Python 完成。
lower_date = (range_start - timedelta(days=2)).date().isoformat()
upper_date = (range_end + timedelta(days=2)).date().isoformat()对应的 SQL 只用日期前缀做粗筛:
AND start_time IS NOT NULL AND substr(start_time, 1, 10) <= ?
AND ((end_time IS NOT NULL AND end_time != '' AND substr(end_time, 1, 10) >= ?)
OR ((end_time IS NULL OR end_time = '') AND substr(start_time, 1, 10) >= ?))然后在 Python 里用 TimezoneHelper.parse(..., user_timezone) 做真正的重叠判定:
if event_start <= range_end and event_end >= range_start:
matches.append((event_start, item))
matches.sort(key=lambda pair: pair[0])这是对N-70 / N-81 那个问题的正确解法,而且注释里连 "全球合法偏移最多相差 26 小时"这种细节都写清楚了。 它同时再次证明:混合格式是已知状况,不是被忽视的缺陷。
但 get_briefing_items 没有用这套解法
紧邻其后的 get_briefing_items(2859-2929)查的是同一张表的同一列, 用途也是"取出与今天重叠的日程",写法却是直接字符串比较:
SELECT * FROM items WHERE owner_id = ? AND type = 'event' AND deleted = 0
AND start_time < ?
AND ((end_time IS NOT NULL AND end_time != '' AND end_time >= ?)
OR ((end_time IS NULL OR end_time = '') AND start_time >= ?))
ORDER BY start_time- 没有 ±2 天的候选窗口放宽;
- 没有
substr(..., 1, 10)的日期前缀比较; - 没有 Python 侧的时区感知复核(
_generate_briefing_content在 Python 层的today <= start_time < tomorrow二次过滤只覆盖已被 SQL 选中的行, 见N-20); - 而调用方传入的
today_iso/tomorrow_iso是朴素的用户本地时间 (scheduled.py:964:user_now.replace(..., tzinfo=None))。
因为 _normalize_iso_datetime(N10-10)同时接受带偏移和不带偏移的 start_time,items.start_time 这一列确实混着两种形式。 于是每日简报会漏掉那些 start_time 带偏移、字典序落在窗口外的日程—— UTC+8 用户的一条 2026-07-25T09:00:00+08:00 日程, 其字符串与朴素的 2026-07-25T00:00:00 边界比较时行为完全取决于字符位置, 而不是绝对时间。
这是混合格式问题唯一一处已确认的用户可见后果: 早上的简报里少了几条日程,且没有任何提示。
修法在同一个文件里:把 get_briefing_items 改写成 get_events_for_range 的形状(日期前缀粗筛 + Python 精确判定), 或者直接让 _generate_briefing_content 调用 get_events_for_range—— 两者的语义本来就是同一个。
N-112 [P1] 【中】save_user_setting 的丢更新机制,逐行确认
plugin.json 的 pendo_reminders 每分钟执行一次 → check_reminders → reminder_service.check_and_send_reminders, 其取数入口是本组读到的这两个方法,两个都没有 owner_id 过滤、没有 LIMIT:
① get_unconfirmed_sent_reminders(3798-3835)
SELECT rl.id, rl.item_id, rl.remind_time, rl.repeat_count,
COALESCE(rl.last_sent_at, rl.sent_at) AS last_sent_at, i.remind_times
FROM reminder_logs rl
JOIN items i ON rl.item_id = i.id AND i.deleted = 0
AND (i.type != 'task' OR COALESCE(i.status, 'open') = 'open')
WHERE rl.sent_at IS NOT NULL AND rl.confirmed_at IS NULL
ORDER BY rl.sent_at- 谓词是
sent_at IS NOT NULL AND confirmed_at IS NULL; reminder_logs上只有两条索引:UNIQUE(item_id, remind_time)与idx_reminder_logs_claim(state, claim_expires_at, next_attempt_at)—— 都不覆盖这个谓词,因此是全表扫描 + 与items的 JOIN;- 而
reminder_logs没有任何保留或清理策略(N105-6), 每条提醒(含每次重复)一行,随使用时长单调增长。
结果集本身是有界的(_repeat_is_due 会在超过 REMINDER_MAX_REPEATS 或 24 小时陈旧后自动确认,N-4),但扫描量不是—— 它正比于 reminder_logs 的历史总行数。
② get_all_events_with_reminders(owner_id=None)(3837-3865)
SELECT * FROM items WHERE type IN ('event','task') AND deleted = 0
AND (...event_role...) AND (...status...)
AND remind_times IS NOT NULL AND remind_times != '[]'owner_id 是可选参数,提醒服务按全局调用(不传 owner)。 SELECT * 取回全部列(含 content,上限 50 000 字符), 条件里没有任何时间范围收窄——docstring 解释了原因 ("不按条目的开始时间截断:'提前数天'的提醒可能已到期"), 理由成立,但代价是每分钟把所有用户所有带提醒的日程与待办整行读进内存。
与已记问题的叠加
N22-2 已记录:三个每分钟任务各做一次全用户设置扫描(每天 4 320 次)。 本组补上的是同一分钟内还有两次全表扫描。 合计每分钟的固定开销是:
| 次数 | 操作 |
|---|---|
| 3 | get_active_user_ids + get_user_settings_batch(全用户设置) |
| 1 | get_unconfirmed_sent_reminders(全表 reminder_logs JOIN items) |
| 1 | get_all_events_with_reminders(全表 items,SELECT *) |
对单用户个人部署完全无感;但这是与数据量、而非与待办事项数量成正比的开销, 且 reminder_logs 无清理策略使它只增不减。
修法(按收益排序):
- 给
reminder_logs加CREATE INDEX ON reminder_logs(confirmed_at, sent_at)或部分索引WHERE confirmed_at IS NULL——后者更好, 因为已确认的行占绝大多数; - 给
reminder_logs加保留策略(已确认且早于 N 天的行可删), 与operation_logs的 90 天保持一致; get_all_events_with_reminders改为只SELECT提醒判定真正需要的列 (id、owner_id、type、title、start_time、remind_times、status、deadline_at),不要SELECT *;- 长远:把"下一次待处理提醒时刻"物化成一列或一张小表, 使每分钟的查询变成"取出
next_fire_at <= now的行",可走索引。
N-116 [P1] amount_cents 有 ADD COLUMN 迁移但没有回填,回填代码不在发行包里
演示空间是"给未认证的公网访客一个可写的隔离数据区"—— 在本仓所有功能里攻击面最大的一个。它的实现如下:
| 防护 | 位置 | 做法 |
|---|---|---|
| 命名域隔离 | _is_demo_owner:50-53 | 所有演示所有者以 demo_web_ 前缀 + uuid4().hex 构成 |
| 硬删除前置守卫 | purge_demo_owner:101-102 | 函数第一件事是 if not _is_demo_owner(owner_id): return ——这是全仓唯一一处对 DELETE FROM ... WHERE owner_id=? 做命名域断言的地方。即使调用方传错 ID,也不可能删到真实用户 |
| 清理完整性 | :108-122 | 8 张表逐一清理(含 items_fts、reminder_logs、scheduled_delivery_outbox、operation_logs、transfer_logs、imported_bundles),且注释说明为何用子查询而非 IN 占位符("避免动态占位符上限") |
| 过期判定失败关闭 | _is_expired:70-82 | expires_at is None → 已过期;任何 OSError/OverflowError/TypeError/ValueError → 已过期。兼容 naive/aware 混合(两侧同 awareness 直接比,否则各自转 UTC) |
| 损坏设置视为过期 | purge_expired_demo_users:141-147 | settings_json 解析失败或 demo_mode is not True → 直接回收。"读不懂就销毁"而不是"读不懂就放行" |
| 访问即校验 | ensure_demo_access:157-173 | 每次访问都验;失效时先销毁数据再抛 AuthError |
| 主动能力关闭 | _demo_settings:88-95 | 演示用户 reminder_enabled=False、daily_briefing_enabled=False、privacy_mode=True → 匿名访客无法让机器人主动向外发消息(否则演示空间就是一个免费的定时推送服务) |
| 创建路径串行化 | create_demo_session:308-337 | 全局锁内依次:回收过期 → 限流 → 容量 → 建号 → 播种;播种失败 purge_demo_owner 回滚后再 raise |
| 模板走同一条不可信路径 | _load_demo_template_records:209 | 内置演示包经 inspect_bundle_bytes 解析,与用户上传的包用完全相同的 21 道校验(N-108),且额外断言"不含集合""每种类型恰好一个文件且非空" |
| 演示数据不可跨会话碰撞 | _seed_demo_items:272-274 | 条目 ID 重写为 f"{owner_id}_{原ID}",related_items 同步重映射 |
其中最值得单独指出的一点
_recent_demo_requests 的限流桶不会泄漏 key,而 ai_parser.RateLimiter 会。
normalized_key, request_times = _recent_demo_requests(client_key, now) # get(key, deque())——不落库
if len(request_times) >= WEB_DEMO_REQUESTS_PER_HOUR:
raise DemoCapacityError(...) # ← 被拒绝的请求在这里返回,从未写入 _DEMO_REQUESTS
...
request_times.append(now)
_DEMO_REQUESTS[normalized_client] = request_times # ← 只有创建成功才登记_recent_demo_requests:296 用 .get(key, deque()) 返回一个未挂载的新 deque, 只有走到 :337 才真正写回字典。因此被限流或被容量拒绝的请求不会留下任何桶, 字典规模被 WEB_DEMO_MAX_ACTIVE_SESSIONS 间接封顶。 同时 :288-292 每次调用会删除所有空桶。
对照N26-3:services/ai_parser.py 的 RateLimiter.call_history按 user_id 无限增长(只清 value 不清 key)。
→ 同一个插件里两个限流器,一个正确一个泄漏。 N26-3 的修法不需要设计:照抄 _recent_demo_requests:288-296 的 "先清空桶再删 key + 未命中不落库"两行即可。N101-2 清单第 23 处。
N-141 [P1] Web 任务编辑器把带时区的 deadline_at 静默改写成朴素串
pages/tasks.js:1106:
if (field.name === 'deadline_at') value = task.deadline_at.slice(0, 16);deadline_at 交给 type="datetime" 表单控件,因此必须裁成 YYYY-MM-DDTHH:MM。 问题是裁掉的正是时区偏移,而后端 _normalize_iso_datetime (utils/validators.py:635-645)是 fromisoformat → isoformat 的原样往返: 朴素进朴素出、带偏移进带偏移出。它不会补回被裁掉的偏移。
于是保存一次编辑,就把库里的 2026-05-01T10:00:00+00:00 永久变成 2026-05-01T10:00:00(朴素)。
三层后果,一层比一层重
① 显示层:同一个值在同一个页面上被显示成两个时刻
- 列表行走
formatShortDate→parseDate→new Date(text), 带偏移串会被正确换算成浏览器本地时间(N-121); - 编辑弹窗走
.slice(0, 16),显示的是串里的原始墙钟,不做换算。
用户在列表里看到「18:00 截止」,点开编辑框看到「10:00」。
② 存储层:混合时间形式主线又多一个产生点
N-110 已确认导入路径(bundle_import)写的是 UTC 带偏移值。 因此任何一条导入进来的任务,只要在 Web 上编辑过一次, deadline_at 就从「正确的 UTC」变成「数值是 UTC 但被标注为本地墙钟」—— 形式合法、校验通过、无任何告警,且此后与原本就是本地墙钟的值再也无法区分。 这与 N-136(source.timezone 取浏览器时区)是同一族: 不是值错了,是值的「归属声明」被悄悄换掉了(N140-1)。
③ 提醒层:偏移直接变成提醒时刻的偏移
utils/validators.py:648-652:
def _datetime_for_rule_delta(value, field_name):
parsed = datetime.fromisoformat(_normalize_iso_datetime(value, field_name))
if parsed.tzinfo is not None:
return parsed.astimezone(DEFAULT_EVENT_TIMEZONE).replace(tzinfo=None)
return parsed带偏移时先换算到默认时区再取朴素值;朴素时直接当本地用。 UPDATE_FIELD_DEPENDENCIES["task"](items.py:163-165)声明了 改 deadline_at 必须连带重算 reminder_rules / remind_times。 因此 Web 上编辑一次任务 → 偏移被裁掉 → 重算走另一条分支 → 提醒时刻整体平移一个时区差(UTC+8 下 8 小时), 而界面上截止时间那一栏看不出任何变化。
修法
datetime-local 控件天然只能表达墙钟,所以不能靠控件解决。 应在装载与回写两侧成对处理:装载时把带偏移值按用户设置时区 (/settings 已返回 timezone,settings.js:58 正在用)换算成墙钟再 slice; 回写时用同一时区把墙钟补回偏移。只改一侧会制造 N32-2 的第 5 例。
与 N-131(
completed_at按前端不同写成两种形式)合并看: 任务这一种条目,它的三个时间列(deadline_at/completed_at/cancelled_at) 各自都有两个写入方,且两个写入方形式不同。 N-268 的产生点清单必须按「列 × 写入方」 画成矩阵,而不是按文件。
N-148 [P1] 日记 entry_time 是 N147-1 的第 2 例,且失效方式与待办**不同
diary.js 的装载侧走 toDatetimeLocal(:219-223):
function toDatetimeLocal(value) {
const date = parseDiaryTimestamp(value); // → new Date(text)
if (!date) return '';
return `${isoDate(date)}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}回写侧(:706-707):
const currentTime = toDatetimeLocal(data.entry_time).match(/T(\d{2}:\d{2})/)?.[1];
data.entry_time = currentTime ? `${diaryDate}T${currentTime}` : defaultEntryTime(diaryDate);与 tasks.js 的 .slice(0, 16)(N-141)对照,两页对同一问题给出了两种不同的错法:
| 装载 | 保存 | 带偏移值的净效果 | |
|---|---|---|---|
tasks.js:1106 | 截断字符串,保留原墙钟 | 原样回写 | 墙钟不变,标签丢失 |
diary.js:601,706 | new Date() 换算到浏览器时区 | 写浏览器墙钟 | 墙钟被改,标签丢失 |
日记这一侧更重:条目 2026-05-01T22:00:00+08:00,用户出差纽约打开日记页, 编辑框显示 2026-05-01T10:00,只要点一次保存,库里就变成朴素的 2026-05-01T10:00 —— 用户并没有修改时间字段,值却变了 13 小时(DST 期间)。
而且这一改是不可逆且不可检测的:改完之后它是一个完全合法的朴素串, 与用户本来就在上海写下的 10:00 无法区分(同 N-141 第 ② 层)。
_selectedDate / diary_date 不受影响(纯日期,两侧都走 isoDate), 所以日历格子不会跳天,只有卡片上那行"记录时间"会静静地变—— 用户几乎不可能发现。
附带确认:defaultEntryTime 的静默替换
toDatetimeLocal 解析失败返回 '' → currentTime 为 undefined → 回退到 defaultEntryTime(diaryDate) = 保存时的浏览器当前时刻。 即:一个后端存进去但前端解析不了的 entry_time, 在用户保存任意一次编辑时会被悄悄替换成"现在",无任何提示。 这是 N69-1「损坏数据静默消失」在前端的第 1 例。
N-155 [P1] 日程编辑器是 N147-1 的第 3 例,也是后果最重的一例
events.js:104-109:
function toInputDateTime(value) {
if (hasInvalidDatePrefix(value)) return '';
const date = parseDate(value); // new Date(text)
if (!date) return '';
return `${isoDate(date)}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}inputToIso(:112-122)回写的是朴素串(YYYY-MM-DDTHH:MM:SS,无偏移)。 失效方式与 diary.js 相同(改墙钟 + 丢标签),但代价不同:
diary.js改的是"记录时间",只影响一行展示文字;events.js改的是start_time,而start_time是UPDATE_FIELD_DEPENDENCIES["event"](items.py:151-160)的触发字段, 会连带重算end_time/reminder_rules/remind_times。
即:打开一条带偏移的日程、什么都不改、点保存 → 日程本身和它的全部提醒 一起平移一个时区差,而界面上从头到尾显示的都是"正确"的本地时间。
三条时间线合起来是一个数据损坏循环
本组把 N-136(导出)、bundle_import(导入)与本条(编辑)串起来看:
① 导出:source.timezone = 浏览器时区(N-136),而库里是"用户设置时区的墙钟"
② 导入:bundle_import.py:120-123 把朴素墙钟按 source.timezone 解释并转成
UTC-aware(这一步实现无可挑剔:fold 双候选 + 不存在/歧义时刻显式报错)
③ 编辑:Web 编辑器把 UTC-aware 值按浏览器时区渲染,保存回朴素串
④ 回到 ①每绕一圈,数据就被平移一次,而每一步单独看都"处理了时区"。bundle_import 是全仓唯一正确的时间处理(N-110), 但它正确地转换了一个被错误标注的输入(N140-1), 然后转换结果又被编辑器悄悄降级回朴素串。
→ 正文修复顺序必须是:先修 ①(source.timezone 取 /settings) 和 ③(编辑器两侧成对换算),再谈 N-268 的存储层统一迁移。 只做存储层迁移而不修 ③,迁移完成的当天就会被 Web 编辑重新污染。
N-174 [P1] Widget 令牌以明文常量存放在会被 iCloud 同步的脚本文件里,且没有吊销手段
// pendo_widget.js:8
const TOKEN = 'PASTE_WIDGET_TOKEN_HERE';用户按注释把 /pendo web widget-token 生成的令牌粘进这一行。 Scriptable 的脚本默认存放在 iCloud Drive/Scriptable/ 目录,因此这个令牌:
- 明文写入一个会自动同步到 iCloud 的
.js文件; - 进入 iCloud 备份、设备迁移包、以及任何"分享脚本"操作的产物 (Scriptable 的分享功能直接分享脚本源码);
- 出现在用户可能贴到论坛/群里求助的脚本正文中。
叠加N5-4 已确认的事实——该令牌有效期 180 天,无 jti、无黑名单、无吊销端点 ——泄露后除了等它自然过期或改服务端密钥(会同时使全部会话失效)之外没有补救手段。
Scriptable 提供了现成的 Keychain.set(key, value) / Keychain.get(key), 存放在 iOS 钥匙串里、不随脚本文件同步。脚本已经用了 Scriptable 的 Calendar / Notification / DrawContext / SFSymbol 等多个原生 API, 唯独没用 Keychain。
修法(两步都便宜):
- 脚本首次运行时用
Keychain存令牌,源码里只保留占位; - 服务端给 widget 令牌加
jti+ 吊销列表(N5-4 的原建议), 并把有效期从 180 天降到可接受的量级。
这也是本轮审查里唯一一处"凭据存储介质"层面的问题—— Web 端的令牌处理是全仓最强的(HttpOnly cookie + CSRF 只存内存 + 401 立即清空,N-5 / N-119),而同一套系统的 iOS 入口把长期令牌写进了明文文件。
N-190 [P1] _resolve_source_wall_time 是全仓唯一正确处理夏令时折叠的代码
导入路径需要把"来源时区里的墙钟时间"转成 UTC。 这件事有两个几乎所有代码都会做错的边界情况—— 春季跳变造成的不存在时刻与秋季回拨造成的歧义时刻—— 而这里两个都处理了:
def _resolve_source_wall_time(parsed, field_name, source_zone) -> datetime:
"""为无偏移墙钟选择唯一有效的来源时区 fold。"""
candidates: dict[tuple[timedelta | None, timedelta | None], datetime] = {}
for fold in (0, 1):
aware = parsed.replace(tzinfo=source_zone, fold=fold)
roundtrip = aware.astimezone(timezone.utc).astimezone(source_zone).replace(tzinfo=None)
if roundtrip == parsed: # ← 往返一致才算这个 fold 有效
candidates[(aware.utcoffset(), aware.dst())] = aware
if not candidates:
raise ValueError(f"Nonexistent local time for {field_name} in source timezone")
if len(candidates) > 1:
raise ValueError(f"Ambiguous local time for {field_name} in source timezone")
return next(iter(candidates.values()))做法解析:datetime.replace(tzinfo=zone) 在 zoneinfo 下不会报错, 它对不存在的时刻也会给出一个偏移量。判断方式是往返—— 把 aware 转成 UTC 再转回来,如果得到的墙钟与原值不同, 说明这个墙钟在该时区里不存在。两个 fold 都不通过 → 不存在时刻; 两个都通过且偏移不同 → 歧义时刻。两种情况都抛错而非猜一个。
配套的 _normalization_context 同样严格:
if offset is None:
raise ValueError("import clock must be timezone-aware")与 db._as_utc(N-59)、prune_operation_logs(N-12)一脉相承。
N-191 [P1] 这段代码**正是附录 N147-1 迁移第 0 步所需要的
N-144 / N-161 / N-187 逐步确定了一件事: pendo 绝大多数 created_at 是用户本地朴素时间, 要迁到 UTC 就必须"按该行 owner 的时区把朴素墙钟解释成绝对时刻"。 而这个操作恰恰会遇到不存在与歧义两种边界—— 一年里各有一小时的数据可能落在其中。
N147-1 当时写的是"迁移前必须先按写入路径重建每一行的真实时刻", 但没有说这一步该怎么正确实现。现在答案就在仓库里:
# 迁移脚本可以直接复用
from plugins.pendo.web.services.bundle_import import _resolve_source_wall_time需要做的只是把 source_zone 换成"该行 owner_id 的用户时区" (TimezoneHelper.get_user_timezone),并决定遇到 Nonexistent / Ambiguous 时的策略(记录并人工复核,或按 fold=0 兜底)。
这是本轮审查中"正确实现已在仓库内、只是没被复用"清单的第 10 处, 也是其中价值最高的一处——它把一个"需要谨慎设计"的迁移步骤 降级成了"复用一个已有函数"。
N-195 [P1] Web 分析层假定"库里存的是 DEFAULT_TIMEZONE 墙钟",而这个假定不成立
# web/api/widget.py:38-49
def _parse_now(value: str | None) -> datetime:
"""把输入时间统一为 Pendo 默认时区下的无时区本地时间。"""
text = str(value or "").strip()
if not text:
# 数据库日程使用默认时区的无时区墙钟值,不能受宿主机时区影响。
return datetime.now(TimezoneHelper.DEFAULT_TZ).replace(tzinfo=None, microsecond=0)
...
# web/analytics/event_schedule.py:24
return parsed.astimezone(TimezoneHelper.DEFAULT_TZ).replace(tzinfo=None)第 43 行那条注释把作者的心智模型写得很清楚:
"数据库日程使用默认时区的无时区墙钟值,不能受宿主机时区影响。"
前半句是一个关于存储格式的断言,而N-161 / N-187 / N-192 已经把真实情况测绘完毕:
| 实际写入方 | 实际形式 |
|---|---|
聊天 diary / ledger / note / task + Web API create_item | 用户本地朴素 |
| 聊天 event(单次/编辑) | 服务器本地朴素 |
create_event_collection_with_children、Web 导入 | UTC 带偏移 |
没有任何一条写入路径产生"DEFAULT_TIMEZONE 墙钟"。 这三种形式与 Asia/Shanghai 墙钟重合,当且仅当 用户时区 == 服务器时区 == Asia/Shanghai——也就是开发者本机的配置。
后半句"不能受宿主机时区影响"本身是对的诉求(比直接用 datetime.now() 好), 只是选错了基准:正确的基准是该 owner_id 的用户时区, 而 now_in_timezone(owner_id, db) 就在隔壁, web/analytics/task_overview.py:147、diary_overview.py:192、 ledger_insights.py:325、notes_overview.py:109 四处正是这么做的。
即 web/analytics 内部就有两种时间基准并存: 四个 *_overview 用用户时区,widget 与 event_schedule 用 DEFAULT_TZ。
用户可见后果
对一个时区为 Asia/Tokyo(UTC+9)、服务器在 UTC+8 的用户:
_parse_now给出的"现在"比该用户实际本地时间慢 1 小时;_section_for按now.hour % 3轮换板块,切换点因此偏移 1 小时(无害);- 但
build_task_overview等构建器拿的是用户时区的today(task_overview.py:147),而widget传进去的now是上海墙钟—— 同一个响应里两个时间基准混用; - 跨日边界的 1 小时窗口内,小组件会显示错误的"今天": 东京时间 00:30 时,上海墙钟还是前一天 23:30, 小组件展示的仍是昨天的待办与日程。
N-217 [P1] 对附录 N-70 的重要更正:子条目从未拿到过 UTC 时间
N-70 记录的是"create_event_collection_with_children 把同一个 UTC now 也写进子条目(db.py:1318-1319),因此 items.created_at 这一主排序列 本身就混着两种 ISO 形式"。这一条不成立,必须更正—— 它会把后续的数据迁移探测引到错误的列上。
db.py:1318-1319 确实是:
item_data.setdefault("created_at", now) # now = datetime.now(timezone.utc).isoformat()
item_data.setdefault("updated_at", now)但关键在于 setdefault——三个调用方全部显式提供了子条目的 created_at, 这两行因此从未生效:
| 调用方 | 子条目 created_at 的来源 | 形式 |
|---|---|---|
handlers/event.py:498 _create_recurring_event | created_at = datetime.now().isoformat(),逐个传给 EventItem(created_at=...) | 服务器本地朴素 |
handlers/event.py(多节点,同一变量) | 同上 | 服务器本地朴素 |
web/api/events.py:209-210 | now = now_in_timezone(owner_id, db).replace(tzinfo=None).isoformat() | 用户本地朴素 |
所以 items.created_at 没有被这条路径污染成 UTC。 N-187 那句"除 handlers/event.py 三处裸 datetime.now()(服务器本地)外, 全插件所有创建路径写的都是用户本地朴素时间"依然成立, 而且 Web 集合创建路径(events.py:178-183)用的正是 now_in_timezone(owner_id, db), 是又一条把用户时区做对了的写入路径。
那个 UTC 的 now 实际落到了两个地方,都不是 items:
(1)event_collections 的集合头——经 _prepare_new_event_collection 的 setdefault("created_at", now),而这次 setdefault 是生效的,因为 两个聊天调用方的 collection_payload(handlers/event.py:476-494 与 :599-611) 都没有 created_at 字段,只有 Web 那份有(events.py:243-244)。于是:
| 路径 | event_collections.created_at |
|---|---|
| 聊天 重复日程 | UTC 带偏移 |
| 聊天 多节点 | UTC 带偏移 |
| Web 多节点 | 用户本地朴素 |
create_event_collection(单独建头,db.py:1268) | 服务器本地朴素 |
同一张表的同一列有三种形式,取决于是谁建的。 这比 N-70 原来描述的情况范围更小(不涉及 items),但形式更多(三种而非两种)。
(2)operation_logs.created_at——_log_operation_with_cursor(..., created_at=now) (db.py:1336)传的是同一个 UTC 串,而该方法在其它所有调用点默认取 datetime.now()(朴素本地,N-40)。
第二点值得单独强调,因为它改变了N-12 的性质: N-12 说的是"prune_operation_logs 用 UTC-aware 阈值比较朴素本地 created_at 列"。 现在看,operation_logs.created_at 这一列本身就有两种形式—— 集合创建写 UTC,其余全部写朴素本地。即 prune_operation_logs 对少数行是正确的、对多数行是错的,而不是整列一致地错。
对修复方案的影响(这是本条被定为 P1 的原因): N-40 给出的修复顺序是"先修 N-12 使两侧一致 → 再考虑整体迁 UTC"。 现在必须补一步:在做任何比较形式的统一之前,先把 create_event_collection_with_children 的 created_at=now 改成与其它调用点一致, 否则"把两侧改一致"这个动作本身会踩在一个已经不一致的列上。 迁移探测脚本也必须把 event_collections.created_at 与 operation_logs.created_at 纳入统计范围——N-70 原来的结论只让人去看 items。
N-224 [P1] SQLite 的 date() / strftime() 会把带偏移的时间串按 UTC 解释——附录 N-192 的可观察症状在这里
N-192 记录了"导出→导入会把 created_at 从用户本地朴素变成 UTC 带偏移", 当时给出的后果是"排序与范围筛选受影响"。本组可以把这条落到具体行为上。
SQLite 的日期函数对输入字符串的处理是有分支的:
| 存储形式 | date('...') 的结果 |
|---|---|
2026-07-26T07:00:00(朴素) | 2026-07-26 —— 原样取日期部分 |
2026-07-25T23:00:00+00:00(带偏移) | 2026-07-25 —— 先换算到 UTC 再取日期 |
而这两行可以是同一个用户在同一时刻创建的同一个条目, 区别只在于它有没有经历过一次"导出→导入"(N-192)。 即:北京时间 7 月 26 日早 07:00 创建的任务, 本地创建时存 2026-07-26T07:00:00 → date() 得到 7-26; 导入后存 2026-07-25T23:00:00+00:00 → date() 得到 7-25。
凡是 UTC+N 时区的用户在当地 00:00~0N:00 之间创建的条目, 导入之后在所有统计里都会掉到前一天。
这不是理论问题,因为 pendo 到处在用这些函数——全仓 87 处date() / strftime() 作用在存储的时间列上:
| 文件 | 处数 |
|---|---|
web/api/stats.py | 27 |
web/api/widget.py | 8 |
handlers/event.py | 7 |
handlers/diary.py | 7 |
utils/time_utils.py | 6 |
utils/validators.py / commands/scheduled.py | 各 5 |
web/analytics/event_schedule.py | 4 |
| 其余 12 个文件 | 各 1~3 |
其中 stats.py 这 27 处覆盖了几乎所有统计口径:
date(created_at) BETWEEN ? AND ?(任务范围,:313-316,四个时间列各一次)strftime('%Y-W%W', created_at / completed_at / cancelled_at)(任务周趋势,:363-376)date(start_time)(日程周趋势 / 时段矩阵 / 星期矩阵 / 分类分布,:517,528,550,578)strftime('%H', start_time)(EVENT_TIME_SLOT_SQL,六个时段桶)date(created_at)与date(start_time)(活动热力图,:692-694)
strftime('%H', ...) 那一处尤其直观:一个上午 9 点的会议, 本地创建时落在 09-12 桶;导入之后 start_time 是 01:00+00:00, strftime('%H') 得到 01,落进 ELSE '21-24' 这个"深夜"桶。 用户看到的是"我大半的会议都在深夜"。
这条对修复方案的影响(因此定为 P1):
- N-192 说"迁移必须与写入路径统一同时进行"。本组补充: 还必须同时决定 SQL 侧的读取形式。即便把存量数据全部迁成 UTC 带偏移、 并把所有写入路径也改成 UTC,这 87 处
date()/strftime()仍然会按 UTC 分组——那时统计口径会从"服务器本地的一天" 变成"UTC 的一天",对 UTC+8 用户同样是错的,只是错法不同。 - 正确的终局是:存储统一为 UTC 带偏移 + 所有按天/按小时的分组显式带上用户时区偏移, 例如
date(start_time, ?)传入'+8 hours'之类的修饰符, 或者干脆把分组挪到 Python 侧用now_in_timezone那套助手做。 - 在做出这个决定之前,不要逐点把写入改成 UTC—— 那会让今天只影响导入数据的问题扩散到全部数据(与N-59 的结论一致)。
N-258 [P1] /pendo web token 是 P0-1 的第 6 个受害者,也是后果最重的一个
P0-1 记录的是 context.send_action 的返回值被当成"已送达"使用。 前五个受害者的症状分别是重复提醒、错误提示和假成功 (chime / earthquake / arxiv_filter / minecraft / pendo 提醒链路)。 本组发现的第六个受害者性质不同:它决定的是一条凭据的投递状态。
handlers/web.py:150-191 的 _generate_token:
code = issuer(user_id, expires_seconds=PendoConfig.WEB_LOGIN_CODE_EXPIRE_SECONDS)
login_url = f"{base_url}/?code={code}"
...
token_sent = await self._send_private_text(context, user_id, "\n".join([... login_url ...]))
return self._build_token_result(token_sent=token_sent, ...)而 _send_private_text:338-358 的返回值就是 send_action 的返回值:
try:
accepted = await context.send_action(action)
except Exception:
return False
return accepted变量名 accepted(已受理)是准确的, 但 _build_token_result 把它当成 token_sent(已送达)来用:
if token_sent:
lines.extend([private_hint, private_copy_hint]) # "🔒 一次性登录链接已单独私聊发送"
else:
lines.extend(["❌ 无法通过私聊安全发送凭据。",
"请先允许 Bot 向你发送私聊消息,然后重新执行此命令。"])两个方向的错误后果都是实质性的:
方向一(返回真、实际未送达):用户看到"已私聊发送", 但私聊里什么都没有。用户会去检查私聊设置、怀疑自己,而凭据其实已经生成并在计时。
方向二(返回假、实际已送达):更值得注意。 公开回复说"❌ 无法安全发送凭据,请重新执行此命令", 而一条有效的一次性登录链接此刻正躺在私聊里。 用户按提示重试 → 又生成一条。每一次"看起来失败"的尝试 都会在 _LOGIN_CODES(N5-5:无上限、只在访问时惰性清理)里 留下一条仍然有效的登录码,直到过期为止。
同一段逻辑还服务于 _generate_widget_token(:193-234), 而 Widget 令牌的有效期以小时/天计(N5-4 记录其无吊销机制), 方向二在那里的窗口更长。
这条应当写进正文 P0-1 那一节,作为"为什么必须改成三值枚举"的最强论据: N23-1 已经指出"当前 send_action 语义在惩罚正确用法", 本条给出了它的极端形式——越是认真按'返回值即投递结果'来写的代码, 在凭据投递场景下越危险。DELIVERED / REJECTED / UNKNOWN 三值枚举下, 这里的正确处理是把 UNKNOWN 渲染成 "已尝试私聊发送;若未收到请检查私聊设置后重试", 而不是二选一地断言成功或失败。
顺带记一条本文件的正面(它说明作者对这段逻辑是非常用心的):
# 凭据投递错误不能泄露到群聊回复;取消异常继承 BaseException,仍会正常向上传播。
try:
accepted = await context.send_action(action)
except Exception:
return False捕获 Exception 而不是 BaseException,并在注释里说明了原因—— asyncio.CancelledError 继承自 BaseException,因此仍会正常传播。 这正是N5-7(_run_scheduled_task 吞掉 CancelledError)做错的那件事, 同一个插件里,这一处做对了并写明了理由。 修 N5-7 时应当直接引用这两行作为目标形态。
N-259 [P1] 查看一条笔记会改写它的 updated_at、version 和时间形式
handlers/note.py:805-825 的 view_note:
note, wrong_type = await self._db_get_typed_item_or_message(...)
...
current = await get_user_local_wall_time(user_id, self.db)
last_viewed = normalize_note_fields({"last_viewed": current.isoformat()}, partial=True)
await self._db_update_item(note_id, last_viewed, owner_id=user_id)记录"最后查看时间"本身是合理功能。问题在于它走的是通用写入入口, 而 db.update_item:811-814 是无条件的:
update_dict["updated_at"] = datetime.now().isoformat()
...
set_clause = f"{set_clause}, version = version + 1"于是一次纯读取操作产生了三个副作用:
① 排序被读操作破坏
updated_at 被刷新成当前时间。而"最近更新"是笔记列表的主排序: notes_overview.py:299 的 recent_records 按 (updated_at or created_at, id) 倒序, db.py 的列表页与搜索合并结果也几乎全按 ORDER BY updated_at DESC(N57-4)。
结果:翻看一条两年前的旧笔记,它就变成了"最新更新的笔记"并置顶。 用户没有修改任何内容。
② version 自增,隔壁标签页的乐观锁莫名失效
N-186 第 1 行记过 Web 的 update_item 是双层 CAS (body.version 前置检查 + expected_version 交给 DB), N-185 还把"无变化不 bump 版本"列为它的优点之一。 但那个优化在 Web 路由层,db.update_item 这一层是无条件自增的。
于是:用户在网页上打开笔记编辑器(拿到 version=7), 随后在聊天里 /pendo note view <id> 看了一眼同一条笔记(version 变成 8), 再回到网页点保存 → 409「内容已被其他地方修改,请刷新后重试」。 没有任何人修改过这条笔记。
③ 它是混合时间形式的第 4 个产生源,且由读操作触发
get_user_local_wall_time 取的是用户本地墙钟(正确), 但它写进的是 last_viewed; 而同一次写入被 db.update_item 附带写进的 updated_at 用的是 datetime.now()——服务器本地朴素时间。
N-187 的结论是"全插件所有创建路径写的都是用户本地朴素时间"。 现在必须补充:updated_at 在任何一次 db.update_item 之后都会变成服务器本地时间, 包括这次纯查看。对于用户时区 ≠ 服务器时区的部署, 看一眼笔记就会让这一行的 created_at 与 updated_at 分属两种基准。
作者其实知道 last_viewed 是特殊字段
web/api/items.py:551:
if item_type == "note" and field in NOTE_MUTABLE_FIELDS and field != "last_viewed"Web 层显式把 last_viewed 排除在客户端可写字段之外。 也就是说,"这个字段是内部维护的、不该由普通更新流程写"这个判断已经做出来了, 只是只加在了 Web 这一侧——聊天侧不但写它,还是在读路径上写。
这是N-186「Web 层比聊天层更严谨」表格的第 4 行, 也再次印证 N189-1 / N215-1 的结论:pendo 缺的是跨层共享的写入契约。
修法(三选一,按代价排序)
- 最小改动:给
db.update_item加一个touch: bool = True参数,last_viewed这类"元数据写入"传touch=False,跳过updated_at刷新与version自增; - 更干净:
last_viewed单独一条UPDATE items SET last_viewed = ? WHERE id = ? AND owner_id = ?, 完全不走通用更新入口(它本来也不需要校验、审计和 FTS 刷新); - 产品层面:把
last_viewed挪出items表, 放进一张单独的浏览记录表——它是访问日志,不是条目属性。
无论选哪种,都应当同时确认:这是全仓唯一一处"读路径上的写"吗? 本组已核对 last_viewed 的全部引用点(模型、校验器、迁移脚本、 Web 可写字段排除、导入字段清单、演示清理清单), 运行时写入只有 note.py:825 这一处; task / diary / ledger / event 的查看命令都不写库。 即这是一个孤例,修起来没有连带影响。
N-13 [中] snapshot_redacted 标记写入后从未被读取,撤销失败没有可解释的原因
prune_operation_logs:3187 在擦除快照后写入 details["snapshot_redacted"] = True。 全仓 grep 该键:只有这一处写,零处读。
而 get_latest_undoable_operation 只按 action LIKE 'edit_%' 和时间窗筛选, 不检查 snapshot_redacted。于是流程是:
- 用户敲
/pendo undo 30; get_latest_undoable_operation找到一条 25 分钟前的编辑日志(行还在,日志保留 90 天);handle_undo据此走_undo_edit;db.undo_edit发现details里已经没有old_values;- 用户得到一句笼统的"撤销编辑失败"。
那个标记的存在说明作者预见到了这个状态,只是没有把它接到用户可见的路径上。 应在第 2 步就跳过已擦除的日志,或在第 5 步返回 "该操作的撤销快照已按隐私策略清除(保留 {N} 分钟)"。
顺带一处文档与实现不符:main.py 的帮助文本写 • /pendo undo [分钟] - 撤销最近一次删除或编辑 (默认5分钟内) 并给出 例: /pendo undo 30,而 LOG_OPERATION_UNDO_SNAPSHOT_MINUTES = 5 意味着清理任务每次运行都会把 5 分钟以前的快照擦掉。 由于清理任务只在每天 00:15 跑一次,实际可撤销窗口是 "从上次 00:15 到现在"——最长约 24 小时,最短 5 分钟, 取决于用户在一天中的哪个时刻操作,且用户完全无从知晓。 配置项名(UNDO_SNAPSHOT_MINUTES)暗示的是一个固定分钟窗口, 实现给出的却是一个随时间抖动的窗口。
N-14 [中] 数据库路径被独立推导了两次
N-2 记的是 main.py:139。本组发现 utils/db_ops.py:30-39 又算了一遍:
def get_database(_context: Any) -> Database: # ← _context 带下划线,完全不用
if _db_singleton is None:
db_path = Path(__file__).resolve().parent.parent / "data" / PendoConfig.DB_FILENAME
_db_singleton = Database(str(db_path))两处用不同的写法(os.path.join(os.path.dirname(__file__), ...) vs Path(__file__).resolve().parent.parent / ...)算出同一个路径, 且都不读 context.data_dir。参数名 _context 的下划线是显式声明"我不需要上下文"—— 这不是疏忽,是一个被固化下来的决定。
后果:按 N-2 修 main.py 的人如果没同时改这里, 懒加载路径(init 未跑或单例被清后的任何一次 get_database) 会创建并使用第二个数据库,症状是"数据时有时无"。 修复时这两处必须一起改,并且应当只保留一个真实来源。
N-15 [中] save_user_setting 是无 CAS 的读-改-写,而 pendo 有两个前端同时写它
# settings_utils.py:157-163
def save_user_setting(user_id, key, value, db):
settings = db.get_user_settings(user_id)
custom = parse_custom_settings(settings)
custom[key] = value
settings["settings_json"] = normalize_settings_json(custom, partial=True)
db.update_user_settings(user_id, settings)没有事务、没有版本号、没有条件更新。pendo 同时暴露聊天命令和 Web UI (web/api/settings.py),两边改不同设置项时会互相覆盖: 用户在 Web 上关掉每日简报、同时在群里发 /pendo settings privacy on, 后提交的那次会把前一次的改动整个写回旧值。
对比 qingpet 的 merged_onto 三方合并(M-4)——那正是为解决同一类问题而写的。 本插件的设置是单行 JSON,最省事的修法是把整个读-改-写放进 db.transaction(immediate=True)。
N-16 [中] 命令目录一致性检查只做了一半
core/router.py:59:
missing = sorted((set(handlers) | {"help"}) - set(catalog_by_name))
if missing:
raise ValueError(f"Pendo handlers and command catalog disagree: missing={missing}")只检查"处理器有、目录没有"。反方向——目录里声明了、代码没有处理器—— 被 selected_names = (*handlers, "help") 直接跳过,不报任何错。
后果:往 pendo/plugin.json 加一条命令而忘了写处理器时, core 会把它发布进 /help pendo,resolve_catalog_invocation 会成功解析它, 最后 route() 因为它不在 alias_map 里而回一句"❓ 未知命令"—— 一条 manifest 声明过的命令静默 404。
对比 qingpet 的同名类(plugins/qingpet/utils/router.py:19-27):
if catalog_names != handler_names:
raise ValueError(f"... missing_handlers={missing} extra_handlers={extra}")双向校验,两个方向的差集都报出来。两个插件为同一个问题写了两个 CommandRouter, 一个做对了一半,一个做全了。这是"可复用件缺失"最直白的一个证据—— 应把 qingpet 的版本上提为 core.router.assert_catalog_matches_handlers() (与M3-7 同一条建议)。
N-33 [中] 乐观锁存在、实现正确,但两个前端只有一个在用
update_item 提供了完整的 CAS 能力(db.py:788-834):
def update_item(self, item_id, updates, owner_id=None, *,
expected_version: int | None = None, ...):
...
set_clause = f"{set_clause}, version = version + 1"
if expected_version is not None:
where.append("version = ?")
params.append(expected_version)
...
affected = cursor.rowcount
return affected > 0version 列每次更新自增,条件 UPDATE + rowcount 判定——写法是对的。
但全插件搜索 expected_version,只有一个调用点传了它: web/api/items.py:923(配合 891 行的 body.version != current_version 预检)。
也就是说:
| 写入来源 | 是否有并发保护 |
|---|---|
| Web UI 编辑 | ✅ 传 expected_version |
全部聊天命令编辑(db_ops._db_update_item → update_item) | ❌ 从不传 |
_apply_snooze 改提醒时间 | ❌ |
导入覆盖(_apply_batch_operations) | ❌(策略上是有意覆盖,可接受) |
pendo 恰恰是同时暴露聊天与 Web 两个前端的插件。 于是 version 列在聊天路径上只是个"被自增但从不被检查"的计数器—— 这是 pendo 第 3 个"能力已具备但没接线"的地方 (前两个:snapshot_redacted 只写不读 N-13、visibility 只写不读 N22-7), 而且这一个关系到并发正确性。
db_ops._db_update_item 应当把 expected_version 透传出去, 并让 handler 在读取条目时一并带上 item.version。
N-34 [中] 数据层有四种事务写法,靠约定而非机制避免互相踩踏
db.py 里的事务边界写法统计:
| 写法 | 出现次数 | 语义 |
|---|---|---|
with conn: | 25 | sqlite3 隐式事务,退出时 commit,异常时 rollback,不 BEGIN IMMEDIATE |
self.transaction(immediate=True) | 9 | 显式写事务 |
self.transaction() | 1 | DEFERRED |
裸 cursor.execute 无事务 | 若干(如 get_latest_undoable_operation) | 只读 |
主流写法是 with conn:(25 次),而不是自家封装的 transaction()。 两者不能嵌套:内层 with conn: 退出时会 commit 外层 transaction() 尚未完成的工作(这正是N30-1 指出的出口未加守卫)。
实际没有踩到,是因为文件里贯彻了一条约定: 所有辅助方法(_update_fts、_refresh_fts、_log_operation_from_spec、 _sync_reminder_logs、_apply_batch_operations)都接收调用方的 conn 或 cursor,自己不开事务。这条约定是对的, _apply_batch_operations 甚至把 cursor 作为第一个参数以强调这一点。
但它只是约定:没有断言、没有嵌套计数、没有 SAVEPOINT。 一个新方法只要顺手写了 with self.transaction(immediate=True) 并在里面 调用了另一个带 with conn: 的公开方法,就会在没有任何报错的情况下 把外层事务提前提交。建议:
- 统一到
self.transaction(),删掉 25 处with conn:; transaction()加嵌套计数,或改用SAVEPOINT(见 N32-1,这部分应上提到 core)。
N-39 [中] 撤销会删除原始审计日志,审计记录因此可被用户操作抹除
_restore_edit_snapshot(db.py:2720-2763)在同一事务的最后:
cursor.execute("DELETE FROM operation_logs WHERE id = ?", (log_id,))即"撤销编辑"会把被撤销的那条编辑日志物理删除。后果有三层:
- 审计日志不再是 append-only。
operation_logs有 90 天保留策略 (LOG_OPERATION_RETENTION_DAYS)和隐私擦除策略(N-12), 读起来像是一份审计轨迹;但用户只要/pendo undo, "我在 X 时刻编辑过 Y" 这条记录就消失了。真正的审计设计应当是 追加一条undo_edit记录,而不是删除原记录。 - 撤销不可再撤销,也无法回溯"这条数据被改过又被改回来"。
undo_delete路径(_undo_delete_from_log)是否也删日志
对一个明确做了隐私保留策略、操作日志脱敏、90 天保留期的插件来说, 这一条与其整体设计意图矛盾。
N-44 [中] 整个撤销子系统都在物理删除 operation_logs,不只是单条路径 —— 审计记录可被普通用户操作抹除
N-8 只看到编辑撤销会 DELETE FROM operation_logs,并留了一个待确认项。 现在确认:删除撤销同样如此,而且更彻底。
_delete_operation_logs(2505-2510)是一个专门用于批量删除审计行的辅助方法, 在 _undo_delete_from_log 的三条分支上全部被调用:
| 分支 | 位置 | 删除范围 |
|---|---|---|
delete_event_collection | 2554 | 该条日志 |
delete_task / delete_note | 2588 | 同一 created_at 的整批日志(2574-2582 先按 user_id + action + created_at 查出全组) |
| 其余单条删除 | 2606 | 该条日志 |
也就是说,一次 /pendo undo 可以一次性抹掉一整批"我在某时刻删除了 N 个待办"的记录。 加上 N-39 的编辑撤销,结论是:
operation_logs不是审计日志,它是一个"可撤销操作队列"。
这个定位本身没问题——问题在于它同时被当作审计设施对待: 有 90 天保留期(LOG_OPERATION_RETENTION_DAYS)、 有隐私脱敏擦除(prune_operation_logs,N-12)、 有 sensitive_key 白名单擦除逻辑。这些都是审计日志才需要的东西。 一张会被用户操作删空的表,不值得为它做隐私保留策略; 反过来,如果确实需要审计,就必须让撤销追加记录而非删除。
建议:拆成两张表——undo_queue(可消费、可删除) 与 operation_logs(append-only,只由保留策略删除)。 这也正好解决 N-38 第 3 点(被擦除的编辑掩盖可撤销的删除): undo_queue 里本来就不该有已擦除的条目。
N-45 [中] 软删除会硬删除提醒历史,且与同文件的另一半纪律矛盾
delete_item(2070-2089):
with conn:
if soft:
cursor.execute("UPDATE items SET deleted = 1, deleted_at = ?, ... ")
else:
cursor.execute("DELETE FROM items WHERE id = ? AND owner_id = ?", params)
affected = cursor.rowcount
if affected > 0:
cursor.execute("DELETE FROM reminder_logs WHERE item_id = ?", (item_id,)) # ← 无条件
cursor.execute("DELETE FROM items_fts WHERE id = ?", (item_id,))第 2082 行不区分软删除与硬删除,也不区分已发送与未发送, 把该条目的全部 reminder_logs 一次删光。
而紧邻的 _sync_reminder_logs(2095-2118)做的恰恰相反,docstring 写得很清楚:
"删除当前条目已移除且尚未发送的提醒日志,保留历史发送记录。"
它的两条 SQL 都带 AND sent_at IS NULL。
于是同一个文件里,编辑提醒时间会小心保留发送历史, 而软删除条目会把发送历史连根删掉。后果:
- 软删除本应可撤销,但撤销恢复的是条目、不是提醒状态—— 已发送未确认的提醒、重复计数、租约状态全部丢失;
- 恢复后
_sync_reminder_logs会按remind_times重建 pending 行, 于是已经发过的提醒会被再发一次; items_fts的删除是对的(软删除后不该被搜到),但它和reminder_logs被写在同一个if affected > 0块里,读起来像是同一类清理,实际语义完全不同。
修法:软删除时改为 DELETE FROM reminder_logs WHERE item_id = ? AND sent_at IS NULL, 硬删除时才全删。
N-66 [中] 【中】同一行内 created_at 是用户本地朴素、completed_at 是 UTC
_transition_task_status(1153-1165):
timestamp = TimezoneHelper.format_for_storage(now_in_timezone(user_id, self.db))而 TimezoneHelper.format_for_storage(time_utils.py:60-66):
if dt.tzinfo is None:
raise ValueError("naive datetime is not accepted for storage; supply an explicit timezone")
return dt.astimezone(timezone.utc).isoformat()→ completed_at / cancelled_at 存的是 UTC 带偏移串, 而同一张 items 表同一行的 created_at / updated_at / plan_date 是用户本地朴素。
这一条与N-59(db.py:1950-1951 相邻两行两种纪律)同形,但更隐蔽: 这里两个字段分属两次不同的写入(创建时 / 完成时),中间隔了几百行代码。
后果:任何同时排序或比较这两列的地方都会错。已确认的消费点: _format_task_detail:1078-1085(只展示,ItemFormatter.format_datetime 会各自解析,展示正确)、 web/analytics/task_overview(N-46 已读,按 completed_at 分桶统计完成率 → 桶边界按 UTC 切)。 即:待办详情页显示对,完成率统计的"今天"错。
这里必须指出一个反直觉的点:format_for_storage 是本插件唯一主动拒绝朴素时间的存储函数 (N-8 记为正面),本条不是批评它——它是对的。 错的是其余所有写入点没有跟上。修复方向应当是让 format_for_storage 成为唯一入口, 而不是把这两处改回朴素。
N-71 [中] 7 处 cache_invalidate(f"event_collections|{owner}") 是永不匹配的空操作
_tokenize_task_text:602-619 —— 哨兵注入防御: 用(Unicode 非字符)保护单词内的自然撇号不被shlex当成引号, 并在替换前先检查用户输入里是否已经含有该哨兵(609-610),含有则直接报错。 这是全仓唯一一处对自己使用的哨兵做输入侧防御的代码。 绝大多数哨兵实现都会漏掉这一步,从而可被构造输入干扰。- 三个
_validate_task_*(169-196) 明确拒绝静默截断: docstring 直写"拒绝公共校验器会静默截断的超长输入"。 这是 N-186 / N-262 那条"禁止静默截断"纪律在第三个位置出现 (另两处:handlers/search.py:213-221、utils/validators.py)。 → 但也再次说明它是约定而非机制:三处各写各的,公共校验器本身仍然截断。 _ExplicitTaskFields/_explicit_fields(41-49, 742-749) —— 用一个显式的 TypedDict 记录"命令里到底提到了哪些字段", 使edit_task能区分"没提"与"提了但要求清空"(_INLINE_NONE_INPUTS)。 这是N-4 记录的"显式清空 vs 未提供"纪律做得最完整的一处, 而且它把这个区分编码进了类型,不是靠None的二义性。_find_inline_metadata_boundary:228-261 —— 手写的引号感知扫描器, 正确处理转义、六种成对引号(含中文引号)、以及"单词中间的撇号不算引号"。 用于把改名为 "季度 复盘 v2" cat:工作正确拆成标题和元数据。_transition_task_status:1140-1151 的幂等分支 —— 目标状态与当前一致时直接返回且不写库,因此不产生审计记录、不刷新updated_at、 不自增version。这正是 N-259(查看笔记刷新updated_at)应有的纪律, 同一插件里已经有人写对了。→ N101-2 清单第 17 处。 注释还记下了一个真实的措辞 bug("已是已完成状态"会叠两个"已")及其修法。_parse_task_list_options:864-877 的互斥校验 ——all与page:互斥、时间范围与overdue/upcoming/inbox互斥, 都是显式报错而非静默取其一。配合_apply_task_list_token的重复参数拒绝, 这是命令参数解析里最严的一份。对照core/args.py(A-0 主线): 插件自己写的解析器比 core 提供的那个严格得多。edit_task:1321-1323 的注释 —— 说明为什么normalize_task_fields要放在 try 块内部("避免 ValueError 被外层兜底包装成内部错误码")。 记录了错误分类的意图,属于稀缺注释。view_task:1102-1117 无任何写入 —— 与event.py一起再次确认 N-259(note.py:825的last_viewed写入)是全插件孤例。
N-75 [中] 【中】账目列表/汇总的排序键第 2、3 位是原始 ISO 字符串
_apply_list_only_filters(1895-1900):
if "amount_min" in filters:
where.append(f"{self._LEDGER_AMOUNT_CENTS_EXPR} >= ?")
params.append(int(round(float(filters["amount_min"]) * 100)))
if "amount_max" in filters:
where.append(f"{self._LEDGER_AMOUNT_CENTS_EXPR} <= ?")
params.append(int(round(float(filters["amount_max"]) * 100)))而同一个仓库里,元 → 分的转换已经有一个正确实现 (validators.ledger_amount_to_cents,N-9 第 3 点): Decimal(str(amount)) + is_finite() 检查 + quantize(ROUND_HALF_UP), 并且 normalize_ledger_fields 的注释明写"整数分是唯一计算字段,元值只是展示镜像"。
这里却用 float(...) * 100 再 round。两个后果:
- 纪律破口:全插件唯一一处用浮点参与金额计算。 Python 的
round用银行家舍入(round(0.5) == 0、round(1.5) == 2), 与ROUND_HALF_UP不一致;/pendo ledger list amount:0.005..这类边界值 在两条路径上会得到不同的分值。虽然实践中金额区间是筛选条件、 偏差一分不影响正确性,但这正是 N52-1 那条主线的又一实例: 正确实现就在隔壁文件,这里没用。 - 无异常保护:
float(filters["amount_min"])没有 try/except, 非数值输入直接抛ValueError。上游/pendo ledger list amount:abc..100是否已拦截,需在读handlers/ledger.py时确认; Web API 侧有 pydantic 模型,聊天侧不确定。
修法:直接调用 ledger_amount_to_cents(它已经处理了 ¥、¥、 千分位逗号、非有限值),只需为"区间下界允许为 0"放宽那条 cents <= 0 检查。
N-76 [中] 【中】_datetime_for_rule_delta 把带偏移时间换算到**硬编码的默认时区
utils/validators.py:648-652:
DEFAULT_EVENT_TIMEZONE: Final = ZoneInfo(PendoConfig.DEFAULT_TIMEZONE) # :18
...
def _datetime_for_rule_delta(value: Any, field_name: str) -> datetime:
parsed = datetime.fromisoformat(_normalize_iso_datetime(value, field_name))
if parsed.tzinfo is not None:
return parsed.astimezone(DEFAULT_EVENT_TIMEZONE).replace(tzinfo=None)
return parsed该函数是提醒规则偏移计算的时间基准。带偏移的开始时间会被换算到 Asia/Shanghai 的墙钟,而不是该用户的时区。
对一个 UTC-5 用户:他的日程 2026-05-01T09:00-05:00 → 换算成上海墙钟 2026-05-01T22:00 → "提前 1 小时提醒"算出 21:00(上海墙钟) → 写回 remind_times 时不带偏移 → 调度层按服务器本地解读 → 提醒时刻与用户预期偏离一整个时区差。
这是 N-54(集合 timezone 硬编码上海)之后第 2 处把"默认时区"当成"用户时区"用的地方, 而且这一处在 utils/validators.py——共享校验层,影响所有条目类型的提醒。
修法:_datetime_for_rule_delta 必须接收调用方的用户时区; 它的所有上游(normalize_reminder_rules / build_remind_times_from_rules / derive_reminder_rules)都已经在 handler 里有 user_id 可用。
N-80 [中] changed 读的是审计 INSERT 的 rowcount,不是 UPDATE 的
N-49 记录的 handlers/note.py:825 本组逐行复核,结论全部成立, 且补上了两处此前未确认的细节:
current = await get_user_local_wall_time(user_id, self.db) # :823 用户本地朴素
last_viewed = normalize_note_fields({"last_viewed": current.isoformat()}, partial=True)
await self._db_update_item(note_id, last_viewed, owner_id=user_id) # :825补充细节 1:读操作会改变该行的时间形式
last_viewed 写的是用户本地朴素,但 db.update_item:811-814 无条件追加 update_dict["updated_at"] = datetime.now().isoformat() —— 服务器本地朴素。
于是:一条由 create_note:258-269 以用户本地写入 updated_at 的笔记, 被查看一次之后,updated_at 变成服务器本地。 → 这是 N-224 主线里唯一一处由纯读操作导致的时间形式迁移, 也解释了为什么存量数据里同一列会出现"同一用户、同一批笔记、两种形式" (取决于这条笔记有没有被打开过)。存量体检脚本必须把这一点考虑进去, 否则会误判成随机分布。
补充细节 2:症状在同文件内可直接验证
_load_notes:697-704 的排序键第一位就是 note.updated_at(倒序)。 list_notes 的默认视图(overview_only)只显示分类统计看不出来, 但 /pendo note list cat:xxx 走 _format_note_rows 时, 刚被 view 打开过的两年前的旧笔记会排在最前面。 即:查看行为本身污染了"最近更新"排序,且这条排序在 web/analytics/notes_overview.py:299 和 Web 列表页是同一条。
新增第五个副作用:view_note 会全表扫描一次
view_note:851 → _find_note_backlinks:346-370:
notes = await run_sync(self.db.get_all_items, user_id, {"type": ItemType.NOTE.value})
for note in notes: # 全部笔记
...
return backlinks[:10]每次查看任意一条笔记,都要把该用户的全部笔记读进内存, 在 Python 侧解析每条的 related_items 与 references JSON, 最后只取 10 条。输出有界、输入无界(N240-1 第 4 处)。
related_items 是 JSON 数组列,db.py 已经有 tag_filter_pattern 那样 %"value"% 的 JSON 数组元素匹配写法(N-13 正面), 反向链接完全可以下推成一条带 LIKE 的查询。
→ 合并结论:/pendo note view <id> 是 pendo 代价最高的"只读"命令 ——一次全表读 + 一次写库 + 一次缓存失效 + 一次 version 自增。
N-85 [中] 级联删除的撤销可能"多恢复"用户并未删除的节点
diary.handle_session_message:528-544 的会话状态校验 —— 三个字段各自做类型收窄(prompts逐项isinstance(str)、step显式排除bool),并用len(answers) != step这个不变量校验会话一致性。与ledger._valid_session_state(N-78 正面) 一起,构成本仓仅有的两处"把多轮会话当状态机验证"的实现。diary._submit_template_result:593zip(prompts, answers, strict=True)—— 显式strict=True,长度不一致直接抛错而不是静默截断。 与 N-74 记录的静默截断问题形成对照:同一插件里,作者在这里选择了拒绝。diary._parse_diary_text:768-775 的布尔解析 ——favorite:只接受白名单内的真/假值,其余一律 raise, 注释写明理由:"元数据属于显式命令参数,非法布尔值等必须明确拒绝,不能静默改成 False"(:194)。 → 这是 N18-1(一个插件 4 个布尔解析函数 / 3 套真值集合)里唯一 fail-closed 的那一个, 应作为统一时的目标形态。diary._analyze_mood:782-796 —— AI 失败时捕获Exception(非BaseException)、 经public_error_message上报、降级到本地analyze_diary_mood_rule。 三段式(尝试 / 上报 / 降级)在本仓少见,且与N-5 记录的 "日记同意闸门"配合:不同意外发时走的正是同一条降级路径, 意味着降级路径是被日常使用的,不是死代码。note._build_edit_updates:886-892 —— 先判断set(updates) == {"type"}, 再用规范化后的值与当前值逐字段比较,两个条件都不满足才写库。append_note(964) /tag_note(1019) /untag_note(1062) /link_note(1108) 四处也各有"无变化则不写"分支。 → note.py 是 5 个 handler 里对"无效写入"防得最严的一个; 对照ledger.edit_ledger(N77-2 完全没有该检查)。note._parse_note_text的引号与元数据处理(409-621)——title:支持四种引号、支持content关键字、支持换行正文;cat:/ref:/#tag仅从尾部逐个剥离(_strip_note_metadata_suffix), 从而不影响正文中间的 Markdown 标题(docstring 明说)。 并且对"看起来像元数据但格式错误"的尾部(_MALFORMED_METADATA_SUFFIX_RE) 主动报错而不是当普通文本吞掉。 → 这与 N-82 里日记解析器的做法正好相反,而两者出自同一个插件、处理同一类语法。note._resolve_note_references:329-334 —— 关联目标必须存在且属于本人 (_db_get_item(ref_id, user_id)),不存在直接报错;禁止自引用。 引用不落库为裸 ID,而是带 kind/type/title 的结构 —— 前半是对的 (防了 N257-1 那类问题),后半带来了 N84-1 的陈旧快照。
N-86 [中] 级联删除的子节点**不递增 version
# delete_event_collection:1686-1690
cursor.execute(
f"UPDATE items SET deleted = 1, deleted_at = ?, updated_at = ? "
f"WHERE id IN ({placeholders}) AND owner_id = ?",
[now, now] + child_ids + [owner_id],
)对比其它三条删除路径:
| 路径 | version = version + 1 |
|---|---|
delete_item:2074-2075 | ✅ |
batch_soft_delete:1146-1147 | ✅ |
delete_event_instance:1615 | ✅ |
delete_event_collection(级联子节点) | ❌ |
version 是乐观锁列。级联删除不递增它,意味着一个持有旧 version 的 并发写入者(Web UI 的编辑表单,N-33 里唯一传 expected_version 的那条路径) 在条目被级联删除后,其 CAS 仍会认为版本匹配。 虽然 update_item 的 WHERE 还带 deleted = 0 因而最终不会更新成功, 但失败原因会被误报为"版本冲突"而不是"条目已删除"—— 而这两者给用户的提示应当完全不同。
同一条 SQL 还缺少 AND deleted = 0(其它三条都有)。 当前无害(child_ids 是刚刚以 deleted = 0 选出来的), 但四条删除路径的 WHERE 子句各不相同,没有任何一处注释说明差异是有意的。
N-92 [中] 集合导入的 update 分支没有 rowcount 校验,条目导入有
_is_important_item:598-611:
title = str(getattr(item, "title", "") or "").casefold()
return any(keyword in title for keyword in ("重要", "紧急", "会议", "deadline", "截止"))_should_suppress:277-278 在静默时段内先问 _is_important_item,为真则不抑制。
判定依据有三条:显式优先级 ≤2、标签含"重要/紧急/important/urgent"、 以及标题里出现上面五个词中的任何一个。前两条是用户主动声明的, 第三条是系统猜的,而且:
"会议"是办公场景最常见的标题词。rule_parser.EVENT_KEYWORDS(:37) 里 就把"会议/开会"列为日程识别关键词 —— 也就是说,最典型的日程恰好会命中这条;- 判定是子串匹配,
"会议室钥匙归还"、"取消会议通知"同样命中; - 用户没有任何办法关闭:改不了标题就绕不开,设置里也没有对应开关;
- 这条规则没有在任何用户可见文案里说明(
_show_help、设置帮助、PLUGIN_SETTINGS_HELP_LINES都没提)。
后果:用户把静默时段设成 23:00–07:00,仍然会在凌晨三点收到 标题含"会议"的日程提醒,且完全不知道为什么。
修法:删掉标题关键词那一档,或把它降级为"仅在用户显式开启 quiet_hours_allow_important 时生效"。前两档(优先级、标签)保留即可 —— 它们是用户声明的,语义清楚。
旁证这不是有意为之:同一函数对优先级做了 isinstance(priority, (int, float)) and not isinstance(priority, bool) 这样细致的类型收窄,说明作者对"误判"是有意识的; 标题子串匹配却没有任何限定。
N-103 [中] db.py 有两套并存的迁移机制,只有一套带版本号
| 机制 | 覆盖内容 | 是否记账 | 是否每次启动都跑 |
|---|---|---|---|
_apply_schema_migrations + schema_migrations 表 | 仅 _ADD_COLUMN_MIGRATIONS 列表 | ✅ 记版本号 + SQL 原文 | 否(已应用则跳过) |
_create_*_schema / _migrate_reminder_logs 等 7 个函数 | 建表、建索引、FTS 虚表、提醒日志合并、状态回填 | ❌ 无记账 | 是,每次都跑 |
N-54 曾把 schema_migrations 称赞为"真在用的迁移账本"(对照 qingpet M2-4 的装饰性版本), 那个评价对它覆盖的范围仍然成立;但它只覆盖 ADD COLUMN 这一类。 其余全部结构变更走的是"IF NOT EXISTS + 每次启动重跑"的路子, 和 qingpet 的做法其实是同一种。
每次启动都跑带来两个实际代价:
_migrate_reminder_logs:314-317对整张reminder_logs做一次GROUP BY item_id, remind_time HAVING cnt > 1聚合扫描—— 这张表按"每条提醒一行"增长且没有保留策略(不像operation_logs有 90 天), 扫描成本随使用时长单调上升;_migrate_reminder_logs:353-358的状态回填 UPDATE 无条件执行:sqlUPDATE reminder_logs SET state = CASE WHEN confirmed_at IS NOT NULL THEN 'confirmed' WHEN sent_at IS NOT NULL THEN 'sent' ELSE 'pending' END WHERE state IS NULL OR state = 'pending'对所有
state = 'pending'的行重写一次(多数是'pending'→'pending'的空转写), 每次插件加载都发生一次。
这两处本应在首次迁移完成后就不再执行。修法:把它们也纳入 schema_migrations 账本(作为版本化的一次性迁移), 或至少在 _migrate_reminder_logs 开头加一个"该版本已应用"的短路检查。
N-104 [中] operation_logs 没有任何索引,而撤销路径反复全表扫描它
_create_audit_schema(388-426)建了三张表,只给 transfer_logs 建了索引:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_transfer_logs_owner "
"ON transfer_logs(owner_id, created_at DESC)")operation_logs 只有 id INTEGER PRIMARY KEY AUTOINCREMENT,零索引。 而查询它的地方全部带 user_id + created_at 条件:
| 调用方 | 查询形态 |
|---|---|
get_latest_undoable_operation | WHERE user_id = ? AND action LIKE 'edit_%' AND created_at >= ? ORDER BY created_at DESC LIMIT 1(另加两条 _latest_delete_log_row / _latest_deleted_item_row) |
undo_edit | 同上形态 |
_undo_delete_from_log(批量分支) | WHERE user_id = ? AND action = ? AND created_at = ? |
prune_operation_logs | WHERE created_at < ? AND details IS NOT NULL 与 DELETE ... WHERE created_at < ? |
operation_logs 每一次创建/编辑/删除都写一行,保留 90 天 (LOG_OPERATION_RETENTION_DAYS)。因此:
- 每次
/pendo undo触发 约 4 次全表扫描; - 每晚 00:15 的清理任务再来 2 次;
- 表越旧越慢,而它恰恰是"用户刚做完操作立刻想撤销"这种要求低延迟的路径。
应至少补一条 CREATE INDEX idx_operation_logs_user_time ON operation_logs(user_id, created_at DESC), 以及一条 ON operation_logs(created_at) 供清理任务使用。 这与 transfer_logs 已有索引形成对比——同一个函数里建的三张表, 访问频率最高的那张反而没索引。
N-108 [中] confirm_reminder 的默认参数就是破坏性最强的那个选项
按防御层次列出,每一条都附行号,可直接照抄:
ZIP 容器层
| # | 位置 | 做法 |
|---|---|---|
| 1 | _index_archive_members:396-398 | 用 infolist() 而非 namelist() 建索引,重复成员名直接拒绝。ZIP 允许同名成员,多数解析器只保留最后一个 → 是经典的"清单校验的是 A、解压出来的是 B"攻击面 |
| 2 | :399-402 | info.flag_bits & 0x1 拒绝加密成员(否则 zf.read 抛 RuntimeError,被误报成"损坏") |
| 3 | :392-393 | 成员数量上限 |
| 4 | _read_member_bytes:410-417 | 读之前查 info.file_size,读之后再查 len(payload) —— 前者防止分配大内存,后者防止 ZIP 头部谎报大小(zip bomb 的标准防法,绝大多数实现只做前一半) |
| 5 | _validate_archive_layout:578-580 | 任何未在清单里声明的 ZIP 成员一律拒绝,报错带出第一个多余成员名 |
| 6 | :581-582 | 累计未压缩总大小上限(100 MB) |
| 7 | read_bundle:384-385 | 捕获 BadZipFile, EOFError, NotImplementedError, OSError, RuntimeError 统一转成 Invalid bundle zip。NotImplementedError 是不支持的压缩算法——这一项极少有人想到 |
清单语义层
| # | 位置 | 做法 |
|---|---|---|
| 8 | _validate_manifest:476 | type(version) is not int —— 不是 isinstance,因此 true 不会被当成 1。同样写法出现在 :546(count)与 :604(schema),三处一致 |
| 9 | _entry_path_and_type:458-464 | 路径不是"校验后使用",而是由类型推导出期望路径再比对(path != expected_path 即拒绝)→ 路径遍历在结构上不可能 |
| 10 | _validate_manifest_files:540-542 | 清单内重复路径拒绝 |
| 11 | _validate_manifest_source:520-526 | 来源时区必须是可被 ZoneInfo 构造的 IANA 名,否则拒绝(不是猜一个默认值) |
| 12 | _validate_manifest_metadata:497-499 | attachments_mode 只接受 metadata_only,未来扩展点被显式关闭而不是忽略 |
| 13 | _validate_prepared_members:265-291 | 写出路径也做一次自洽校验:即将写入的字节与清单声明的 count/sha256 逐项比对,不一致就拒绝写。绝大多数实现只在读入侧校验 |
记录层
| # | 位置 | 做法 |
|---|---|---|
| 14 | deserialize_record:204-211 | 字段白名单 + 未知字段 raise(deny-by-default),且白名单按 _type 分派(COMMON_FIELDS | TYPE_FIELD[type]) |
| 15 | :206 | RESERVED_IMPORT_FIELDS 在白名单判断之前跳过 —— 外部包无法通过携带 id/owner_id 之类字段影响导入(配合N-52 的 _import_source_key,构成 N257-1 的两道闸) |
| 16 | serialize_event_collection:187-188 | 导出时显式跳过 owner_id —— 传输包里不含身份 |
| 17 | _parse_record_line:595-605 | _type / _schema 可省略(宽容外部工具),但提供了就必须匹配;不匹配即拒绝 |
| 18 | _read_record_member:631-635, 670-674 | 同一条宽严政策用在 sha256 与 count 上:缺失→warning,提供但不匹配→拒绝。read_bundle 的 docstring 把这条政策完整写下来了 |
| 19 | :660-668 | 逐行错误收集成 {path, line, type, message} —— 可定位,且不中断其余记录。与N-45 的导出侧 _export_record_warning 一起,是 N69-1(损坏数据静默消失)的正解范式两件套 |
| 20 | BundleValidationError(ValueError) :94 | 继承 ValueError,使得 :660 的 except ValueError 能把单条记录的结构错误降级为逐行错误,而 manifest 级的同类错误(在 try 之外抛出)仍然中止整包。同一个异常类型靠位置区分致命性,且这个设计是成立的 |
| 21 | write_bundle:313-315 | fileobj.seek(0); truncate(0) + 注释"调用方可能复用缓冲区;先清空,避免旧前缀或尾部数据混入新归档" |
这个文件与插件其余部分的落差
值得在正文点明:同一个插件里, transfer_bundle.py 对一个来自外部的 ZIP 做了 21 道检查, 而 handlers/diary.py:47 的一个正则连词边界都没加(N-82), handlers/event.py 把 LLM 返回的 rrule 直接喂给 rrulestr(N-56)。
差别不在能力,在于这个文件的作者当时把输入当成敌意的,其余地方当成友好的。 而实际上 LLM 输出与用户自由文本同样是不可信输入。
→ N108-1 [P1] 建议正文设一条硬规则: "不可信输入"的定义应当是来源不是本进程的数据, 而不是"来自网络的数据"。按这个定义, LLM 响应、用户命令文本、导入文件三者应当适用同一套 deny-by-default 纪律。 pendo 已经在三者中的一个上做到了满分,可以直接拿来当模板。
N-124 [中] 登录链接用的是绑定地址,在多数部署下不可用
api.js的会话与 CSRF 边界:CSRF 令牌只存内存变量(注释:"任何会话失效都会立即清空"), 不进localStorage/sessionStorage;只对非安全方法附加X-CSRF-Token; 401 立即清空令牌;全部请求credentials: 'same-origin'。 → 与后端web/deps.py(N-5 记为"全仓最强认证实现")两侧对齐。api.js:89-112downloadFilename:优先 RFC 5987filename*(含UTF-8''前缀剥离 与decodeURIComponent失败回退),再回退带转义的 quotedfilename, 最后把\ / 控制字符全部替换为_并拒绝.与..,兜底常量文件名。 → 这是下载文件名注入/路径穿越的完整防法,比多数生产实现都全。app.js:46-48:登录码先从地址栏replaceState抹掉,再去交换。 一次性码不会残留在历史记录、书签或 Referer 里。app.js:63与注释:"首次匿名访问和会话自然过期都是正常登录态, 不应暴露后端内部认证错误" → 401 时不显示后端消息。 与后端 N-100(导出降级把服务器绝对路径回显进群聊)方向相反—— 前端比后端更克制,这是 N265-1「哪一层更严谨是错误的问题」的第 7 组对照。router.js的并发导航处理:requestedNavigation单调计数 + 每个await之后重新比对 +routeLoadChain.then(f, f)(注释:"即使此前队列因意外异常拒绝, 下一次导航也应能继续工作")+destroyLifecycle幂等(destroyed标志)。 → 快速连点侧边栏不会出现"旧页面覆盖新页面"或"两个页面同时监听", 这是 SPA 里最常见的一类 bug,这里被系统性地处理了。utils/ui.js:injectStyles:按稳定 ID 复用<style>节点并比对内容后才写, 重复渲染不累积节点。utils/format.js的边界函数族:isRecord/arrayValue/records/finiteNumber(含Object.is(number, -0)归零)/textValue/trimmedTextValue/nonEmptyTextValue—— 把"后端返回的东西可能不是我以为的类型" 当成默认假设。这在前端很少见,且与后端_row_to_item按 dataclass 字段过滤 (N-13 正面)是同一种思路的两端。modal.js的可访问性与生命周期:完整焦点陷阱(Tab/Shift-Tab 环绕、 焦点跑出容器时拉回)、Escape 关闭、event.isComposing排除输入法、 关闭后恢复previousFocus(且检查isConnected)、 先清空单实例状态再调用onClose(注释:"保证 onClose 可以安全重入并打开下一弹窗")、onClose的同步异常与 Promise 拒绝都被捕获。
N-125 [中] 凭据投递是 P0-1 的第 6 处,方向安全但提示与事实相反
async def _send_private_text(...) -> bool: # ← 标注返回 bool
...
accepted = await context.send_action(action)
return accepted # ← 实际可能是 Nonesend_action 的真实返回是 bool | None(正文 P0-1)。None 是假值, 因此 token_sent 为假 → 走"无法通过私聊安全发送凭据"分支。
方向是安全的(不会因为不确定就把 token 贴到群里), 但在 HTTP 传输下,凭据其实已经送达了,用户却被告知 "请先允许 Bot 向你发送私聊消息,然后重新执行此命令"—— 于是用户会再点一次,生成第二个登录码(第一个随即作废, 因为 issue_login_code 每次都是新码),陷入"每次都说失败、其实每次都成功"的循环。
同时类型标注 -> bool 与实际返回不符,mypy 在 strict 下应当能发现 (send_action 若被正确标注为 bool | None 的话)—— 这反过来说明 P0-1 的类型契约问题会沿调用链一路传播。
至此 pendo 的 send_action 使用点共 6 处、四种纪律(N-3 三种 + 本处)。
N-130 [中] 任务优先级为 NULL 时搜索结果渲染会抛 TypeError
# search.py:395-399
elif isinstance(item, TaskItem):
status_icon = STATUS_ICONS.get(item.status.value, "⬜")
main_line += f" {status_icon}"
if item.priority <= 2: # ← 无 None 防护
main_line += f" {PRIORITY_ICONS.get(item.priority, '')}"对照 commands/scheduled.py:1083(每日简报渲染同一个字段):
priority = task.priority if isinstance(task.priority, int) else 3 # ← 有防护一处有防护、一处没有。 而 items.priority 列是 priority INTEGER(可空、无 DEFAULT,N-53:items 表零 CHECK 约束)。
NULL 的可达路径:normalize_task_fields 在 partial=False 时会补 priority = 3, 但存储层的 validate_item_data(N10-7)不会—— 它只在 data.get("priority") is not None 时才校验。 数据导入走的正是这条路径(_apply_batch_operations → validate_item_data), 因此一个不含 priority 字段的导入包会写出 priority IS NULL 的待办。
此后只要该待办出现在任何一次搜索结果里:
TypeError: '<' not supported between instances of 'NoneType' and 'int'异常会冒泡到 @handle_command_errors,用户得到一条通用错误消息, 而且此后每次搜索到这条记录都会重复失败——用户无法通过搜索找到它, 也看不出原因。
修法:按简报那处的写法加 isinstance 防护; 更根本的是给 items.priority 加 DEFAULT 3, 或在 validate_item_data 里补齐类型默认值(与N-53 的 CHECK 建议一并做)。
N-131 [中] 【P1·新增混合时间形式产生点】completed_at 按前端不同写成两种形式
看板的复选框走的是通用条目更新接口:
// dashboard.js:711
await api.put(`/items/${encodeURIComponent(taskId)}`, { status: 'done' });后端 web/api/items.py 有一套字段依赖声明(写得很好,见正面):
UPDATE_FIELD_DEPENDENCIES["task"] = (
...,
(frozenset({"status", "completed_at", "cancelled_at"}),
frozenset({"completed_at", "cancelled_at"})), # 请求 status 时,连带落库这两列
)因此 status: 'done' 会触发 completed_at 的派生与写入。派生逻辑在共享校验器里:
# utils/validators.py:_sync_task_terminal_timestamps:938-948
if status_value == "done":
if completed_at in (None, ""):
normalized["completed_at"] = datetime.now().isoformat(timespec="seconds") # ← 服务器本地朴素
else:
normalized["completed_at"] = _normalize_iso_datetime(completed_at, "completed_at")而聊天路径显式传入该值:
# handlers/task.py:1160
timestamp = TimezoneHelper.format_for_storage(now_in_timezone(user_id, self.db)) # ← UTC 带偏移→ 同一列 items.completed_at,取决于用户在哪儿点的完成:
| 操作入口 | 走到 _sync_task_terminal_timestamps 的哪一支 | 写入形式 |
|---|---|---|
/pendo todo done <id>(聊天) | else:保留调用方给的值 | UTC 带偏移 |
| Web 看板复选框 / 待办页改状态 | if completed_at in (None, ""):自己补一个 | 服务器本地朴素 |
共享校验器的缺省分支用的是错的那一种形式 —— 这与N-110 (items.created_at 五个产生源里只有导入路径正确)是同一个形状的第 2 例:
正确的形式由某个调用方显式提供,而共享层的 fallback 停留在旧约定。
后果与旁证
web/analytics/task_overview(N-46)按completed_at分桶算完成率 → 桶边界对两类值含义不同,UTC+8 部署下相差 8 小时, 跨天完成的任务会被算进错误的一天;- 旁证:
dashboard.js:482写的是const completedAt = task.completed_at || task.updated_at || '';—— 作者给completed_at准备了回退。这说明这一列的可靠性在实践中就是存疑的。
修法:_sync_task_terminal_timestamps 不应该自己造时间。 它要么接收一个由调用方给定的 now(与 bundle_import._normalization_context 同构), 要么直接 raise 要求显式提供 —— 后者正是同仓 TimezoneHelper.format_for_storage 已经在做的事(N-66)。 → 这一条应当与 N-110 合并进 N-268 的第 ② 步"统一产生侧", 产生点清单需要从 created_at 扩展到 completed_at / cancelled_at。
N-142 [中] 看板复选框与待办页对同一动作用了两套并发纪律
web/api/items.py:889-895 揭示了 expected_version 的真实契约:
current_version = int(current.get("version") or 0)
if body.version is not None and body.version != current_version:
raise HTTPException(status_code=409, ...)
...
success = db.update_item(..., expected_version=current_version, ...)expected_version=总是传,但传的是本请求刚读到的版本 → 它保护的只是get_item(867) 到update_item(919) 之间的进程内窗口;- 真正的跨客户端乐观锁是
body.version,而它是| None = None的可选字段。
于是同一个 Web 应用里:
| 入口 | 是否传 version | 跨端并发行为 |
|---|---|---|
tasks.js:1085 状态按钮 | ✅ task.version | 冲突 409,提示刷新重试 |
tasks.js:1174 编辑保存 | ✅ task.version | 冲突 409 |
dashboard.js 看板复选框(N-132) | ❌ 不传 | 静默后写覆盖先写 |
→ N-33 需按此收紧:结论不是「只有一处传 expected_version」, 而是「跨端乐观锁的开关握在每个前端调用点手里,默认关闭」(N79-3 缺省站错边)。 修法:把 version 改成必填(或对 status/amount 等敏感字段必填), 让漏传变成 422 而不是变成静默覆盖。
另:tasks.js:272 的 version: Number.isInteger(version) && version >= 0 ? version : 0 把无法识别的版本号降级成 0 而不是 undefined。 0 会被后端当作一个真实的版本值去比对(body.version is not None 成立)→ 必然 409。这里降级成 undefined(不传)反而更符合「我不知道版本」的语义—— 但那样又落进上面那张表的第三行。两种降级都不对,说明缺的是「未知版本」这个显式状态。
N-143 [中] items.py 的「无变化」短路被派生字段绕开,这正是 N-132 的机制
items.py:907-915 有一个很好的设计:算出 updates 后逐字段与当前值比对, 全等则直接返回「无变化」,不写库、不记审计、不自增版本。
但 UPDATE_FIELD_DEPENDENCIES["task"] 会因为请求里带了 status 而连带派生 completed_at,派生值是「现在」(N-131:_sync_task_terminal_timestamps:943 的 fallback 取 datetime.now())。于是:
请求 status=done(当前已经是 done)
→ status 被比对掉(无变化)
→ completed_at 被重新派生成一个新时刻(必然不等于旧值)
→ updates 非空 → 照常写库 + 审计 + version 自增「无变化短路」对所有带派生时间戳的字段永远不成立。 这解释了 N-132 观察到的现象,也说明修 N-132 不能只在前端加判断: 应当在 _normalize_update_payload 里对派生字段单独判断 ——「触发它的那个字段本身没变时,不要重新派生」。
N-144 [中] delta_vs_previous == 1.0 是落在合法值域内的哨兵,真实的翻倍会被显示成「首次有支出」
N-70 / N-81 记录了两种形式。本组发现第三种:
# diary.py:216, 246-247
user_now = await get_user_local_wall_time(user_id, self.db) # ← 用户时区的朴素墙钟
...
"created_at": user_now.isoformat(timespec="seconds"),
"updated_at": user_now.isoformat(timespec="seconds"),get_user_local_wall_time(time_utils.py:78-82)的实现是:
current = cast(datetime, await run_sync(now_in_timezone, user_id, db))
return current.replace(tzinfo=None) # ← 取用户时区的时刻,然后丢掉时区而 insert_item:760-761 用的是 setdefault,因此调用方已经给了值就不会被覆盖—— 日记行最终带的是用户本地朴素时间。
于是 items.created_at 这一列现在有三个来源:
| 来源 | 形式 | 例(服务器 UTC+8,用户 UTC+9,真实时刻 UTC 06:00) |
|---|---|---|
insert_item(普通新建) | 服务器本地朴素 | 2026-07-25T14:00:00 |
create_event_collection_with_children | UTC 带偏移 | 2026-07-25T06:00:00+00:00 |
create_diary | 用户本地朴素 | 2026-07-25T15:00:00 |
最要紧的是第一种与第三种形状完全相同(都是 19 字符无偏移 ISO), 因此无法从数据本身判断某一行属于哪一种。这带来两个后果:
- N-116 建议的"先跑探测脚本统计存量数据两种形式各占多少" 只能区分带偏移与不带偏移,无法把朴素的那部分再拆成 "服务器本地"与"用户本地"。要完成迁移,必须结合
type = 'diary'(走create_diary)与其它类型(走insert_item)来推断, 而这个推断只在"用户时区 ≠ 服务器时区"时才有区别—— 也就是说只有跨时区用户的数据才是脏的,而这恰恰是最难被本地测试发现的情形; - 任何
ORDER BY created_at在同一用户的日记与其它条目之间排序错乱, 偏差正好等于用户时区与服务器时区之差。
这条使N63-1 / N101-1 的两步修复方案需要补充第 0 步: 迁移前必须先按 type 分组、结合各自的写入路径,重建每一行的真实时刻, 而不能简单地"把所有朴素时间按服务器时区解释成 UTC"。
N-149 [中] Web 笔记编辑器会永久抹掉关联条目的类型与标题快照
两条写入路径对同一个 references 字段的构造完全不同:
# handlers/note.py:332-343(聊天)
target = await self._db_get_item(ref_id, user_id)
if target is None:
raise ValueError(f"关联条目不存在: {ref_id}")
references.append({"kind": "item", "id": ref_id,
"type": item_type, "title": target.title or "无标题"})// notes.js:1335(Web)
references: relatedItems.map((id) => ({ kind: 'item', id })),后端 _normalize_note_references(validators.py:1013-1016)对 type / title 是**"给了就存,没给就不存"**,既不校验也不补齐:
for key, limit in (("type", 40), ("title", 200)):
text = sanitize_text(str(raw_reference.get(key) or ""), limit)
if text:
reference[key] = text于是任何一次在 Web 上保存笔记(哪怕只改了一个错别字), 都会把该笔记全部关联条目的 type 和 title 抹掉。 而N84-1 已确认这些标题是快照且从不刷新, 即后端没有任何机制能把它们找回来。
用户可见后果:笔记详情里的"关联条目"从 待办 · 买牛奶 [a1b2c3d4] 变成 条目 · a1b2c3d4 [a1b2c3d4] (notes.js:298-299 的两级回退全部落空),且此后再也恢复不了。
这同时暴露出 Web 侧少了三道聊天侧有的校验:
| 校验 | 聊天 handlers/note.py | Web notes.js + items.py |
|---|---|---|
| 关联条目是否存在 | ✅ 不存在直接报错 | ❌ 任意字符串都收 |
| 是否属于本人 | ✅ _db_get_item(ref_id, user_id) | ❌ 不查 |
| 是否关联自身 | ✅ "笔记不能关联自身" | ❌ 允许自引用 |
→ N135-2 的入口 × 前置检查矩阵,本组填出第 2 个整行空格(第 1 个是 N-142 的 version)。 修法在后端而非前端:_normalize_note_references 应当自己解析目标条目并填充 type/title,把"引用必须指向本人的真实条目"变成存储层的不变量, 而不是聊天处理器的局部纪律。
N-150 [中] Web 端全部四条写入路径都不带 version,notes.js/diary.js 还外加全量快照回写
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N150-1 | 中 | diary.py:410-428 | _format_diary_list 对结果数量没有任何上限,逐条输出"情绪+日期+时间/内容预览/ID/空行"四行。默认范围是本月(约 30 条 ≈ 120 行),但 /pendo diary list year 会把全年日记渲染进一条消息。同插件的搜索结果限 15 条并给"还有 N 条"提示(N-129 ④)、待办列表支持 page:n,唯独日记列表既无上限也无分页。与N146-4(按日期查看无上限)是同一处缺口的两个入口 |
| N150-2 | 低 | diary.py:458、474 | 删除成功文案固定写"💡 5分钟内可用 /pendo undo 撤销"。对删除而言这是保守的(undo_delete 依赖 operation_logs 行本身,而日志保留 90 天,因此 /pendo undo 30 同样有效)。但它与编辑撤销的真实窗口不同——编辑依赖 details.old_values 快照,而快照会被每天 00:15 的清理任务擦除(N-13),窗口在 5 分钟到 24 小时之间抖动。两种撤销的窗口语义不同,而用户看到的都是"5分钟内"。应在文案里区分,或让两者的窗口真正一致 |
| N150-3 | 低 | diary.py:578 | 返回 {"status": "question", ...} —— "question" 是第 4 个状态值,而 utils/error_handlers.py 只提供 success_result / error_result / info_result 三个构造器。main._handle_active_session 用 result.get("status") != "error" 判断,因此工作正常;但状态取值集合没有单一定义处,新增一个值不会被任何地方校验。应把状态定义为 Literal["success","error","info","question"] 并让三个构造器扩成四个 |
| N150-4 | 低 | diary.py:591-595 | template_answers 过滤条件是"prompt 或 answer 至少有一个非空",因此用户跳过某题直接回车时会留下 {"prompt": "今天做了什么:", "answer": ""} 这样的空答案条目,并原样进入 content(596 行拼成 **问题**\n 后跟空行)。应在拼接 content 时跳过空答案,或提示用户该题必填 |
| N150-5 | 低 | diary.py:485 | diary_date 在会话启动时固定为当天,若用户跨过午夜才答完最后一题,日记仍记在开始那天。这多半是有意的(一次模板填写属于同一天),但没有注释说明;SESSION_TIMEOUT_SECONDS = 300 使跨天窗口很窄,风险低 |
N-151 [中] 日记做了原型污染防护,笔记没有 —— 同一处风险两页两种待遇
diary.js:400-405:
const validMoodId = (value) => {
const id = String(value || '').trim().toLowerCase();
return /^[a-z0-9_-]+$/.test(id) && !['__proto__', 'constructor', 'prototype'].includes(id) ? id : '';
};并且读取侧一律用 Object.hasOwn(_moodEmojis, normalized)(:172, :180)。 这是全仓(含后端)唯一一处显式的原型污染防护,写得完整(写入白名单 + 读取 hasOwn)。
同一份代码库里,notes.js:298 用的是裸下标:
const label = REFERENCE_LABELS[type] || REFERENCE_LABELS[ref.kind] || '条目';REFERENCE_LABELS 虽然 Object.freeze 了,但 freeze 不切断原型链: type === 'constructor' / 'toString' 时取到的是 Object.prototype 上的函数, escapeHtml(label) 会把它 String() 成 function Object() { [native code] } 渲染出来。 不构成 XSS(escapeHtml 五个字符全覆盖,:3-10),但会出现莫名其妙的界面文本。
同类裸下标在本组还有:diary.js:892(_moodLabels,已被 hasOwn 保护)、 STATUS_META[...] / PRIORITY_INFO[...](tasks.js,键先过 TASK_STATUSES.has 白名单,安全)。 → N125-2 再次成立:正确机制已经存在,只覆盖了 1/N 处。 建议把 validMoodId 的思路提成 utils/format.js 的 lookup(map, key, fallback)。
N-156 [中] 从 Web 编辑多节点日程的节点,不会更新集合自身的起止时间
Web 上编辑一个节点走的是通用条目接口:
// events.js:1179
await api.put(`/items/${encodeURIComponent(eventId)}`, payload); // payload 含 start_time而 web/api/items.py 全文没有出现过 event_collection 或 collection_id (已 grep 确认 0 命中),db.update_item 也不触碰 event_collections 表。 集合行自己存着 start_time(events.py:294 读它做锚点)。
因此:创建时由 create_event_collection_with_children 算出的集合起止时间, 在任何一次节点改期之后就是陈旧值,且没有任何路径会重算它。
已确认的两个消费者:
events.py:140-146_normalize_collection_updates把它当提醒规则的时间锚点 (注释:"集合更新不修改起始时间,但共享规范化器需要时间锚点来校验提醒规则") → 之后再改集合级提醒规则时,是拿陈旧锚点去校验;handlers/event.py:718, 1121在聊天端展示集合时间。
附带发现:同一处的兜底值是字面量 "2000-01-01T00:00:00"(events.py:142)—— 一个能让任何提醒偏移都通过校验的哨兵,落在合法值域内(N147-2 第 3 例)。
修法:要么在 items.py 的事件更新路径上补一次集合范围重算, 要么根本不在集合行冗余存 start_time,改成查询时从子条目聚合。 后者更符合本仓已有的做法(db.py 其它派生量都是查询时算的)。
N-157 [中] "取回全部再在内存里筛"是 pendo 的默认数据访问惯用法,共 9 处
N119-1 记录过 Database.get_all_items 的实现问题:
def get_all_items(self, owner_id, filters=None, *, page_size=200) -> list[Item]:
"""通过受限分页读取完整过滤结果。"""
results: list[Item] = []
offset = 0
while True:
page = self.get_items(owner_id, filters=filters, limit=page_size, offset=offset)
results.extend(page) # ← 全部驻留内存
if len(page) < page_size:
return results
offset += len(page)关键在于它循环调用的 get_items 每一页都会 _cache_set(含一次 deepcopy), 而 _cache 是全插件共享的 1 024 条 LRU。
本组普查确认它不是个别用法,而是贯穿全插件的默认惯用法:
| 调用点 | 触发命令/场景 | 取回范围 |
|---|---|---|
note.py:349(_find_note_backlinks) | /pendo note view <id> | 该用户全部笔记 |
note.py:695 | 笔记列表 | 按筛选的全部笔记 |
note.py:1187 | 笔记批量删除 | 按筛选的全部笔记 |
task.py:780 | 待办统计/列表 | 该用户全部待办 |
task.py:948 | 待办查询 | 按筛选的全部待办 |
task.py:1258 | 待办批量删除 | 按筛选的全部待办 |
exporter.py:326 | 导出 | 按类型的全部条目 |
reminder.py:354 | 提醒服务 | 该用户全部日程 |
web/analytics/dashboard_overview.py:28(自有 _get_all_items) | Web 仪表盘 | 活动待办 + 当月账目 |
最尖锐的一处:查看单条笔记要把全部笔记读进内存
# note.py:346-359,由 note.py:851 的"查看笔记详情"调用
async def _find_note_backlinks(self, user_id: str, note_id: str) -> list[NoteItem]:
notes = cast(list[NoteItem],
await run_sync(self.db.get_all_items, user_id, {"type": ItemType.NOTE.value}))
backlinks: list[NoteItem] = []
for note in notes:
...
related = {str(v) for v in (note.related_items or [])}
related.update(str(ref.get("id")) for ref in refs if ...)即每执行一次 /pendo note view <id>:
- 把该用户所有笔记(
content上限 5 万字符)读进内存; - 每 200 条写一次共享 LRU +
deepcopy,2 000 条笔记 = 10 个缓存条目, 把其他用户的条目缓存、设置缓存一并挤出; - 再在 Python 里线性扫描找反向链接。
而正确写法在同一份代码里已经存在:Database.tag_filter_pattern (N-64)演示了如何用 LIKE ? ESCAPE '\' 在 JSON 数组列里匹配完整元素:
@staticmethod
def tag_filter_pattern(tag: Any) -> str:
"""生成 JSON 标签列表的完整元素 LIKE 模式。"""
...
return f'%"{value}"%'反向链接完全可以写成 WHERE owner_id = ? AND type = 'note' AND deleted = 0 AND (related_items LIKE ? ESCAPE '\' OR "references" LIKE ? ESCAPE '\'), 一次查询取回结果集而非全表。SQLite 的 json_each 是更精确的方案。
这是N101-2 那张"新实现正确、旧实现还在被调用"清单的第 6 处。
与已记问题的叠加
get_items的缓存键含用户可控的filters(N77-3),get_all_items又按页写入——两者叠加使单个用户的一次操作 就能把共享缓存整体轮换一遍;reminder.py:354的调用发生在每分钟的提醒检查里(N-112), 与那两次跨用户全表扫描叠加;exporter.py:326的导出场景是可以接受的(本来就要全部数据), 但它同样会污染缓存——导出一次 = 缓存清空一次。
修法(按收益):
- 给
get_items加一个use_cache: bool = True参数,get_all_items一律传False——一行改动即可消除缓存污染; _find_note_backlinks改为LIKE或json_each查询;- 批量删除路径(
note.py:1187、task.py:1258)只需要 ID, 应当用一个只SELECT id的方法,而不是物化完整Item; - 长远:把
get_all_items改成生成器(Iterator[Item]), 强制调用方流式消费。
N-158 [中] _resolve_note_references 是 N+1 查询
// transfer.js:1506
const response = await apiUpload('/transfer/import/samples', file, {...,
'X-Transfer-Page': String(nextPage), 'X-Transfer-Page-Size': String(pageSize) });服务端对 bundle 完全无状态(不落临时文件),这个取舍本身是合理且安全的 (不留未审计的用户文件)。但代价没有被界面反映出来: 同一个 zip 在一次导入流程里至少上传 3 次(预检 1 次 + 执行 1 次 + 每翻一页 1 次), 而分页器一次只展示 5 条样例。几十 MB 的备份包翻 10 页 = 上传约 500 MB。
renderSamplesWithPager 只在 totalSamples > 5 时才显示分页器, 所以小包用户看不到这个入口;包越大,越可能翻页,代价越高。
修法有两种,选哪种是产品决策而非实现细节: (a) 预检时一次返回更多样例(例如 50 条)由前端本地分页; (b) 服务端为已预检的 bundle 保留一个短期缓存句柄。 建议 (a) —— 不引入服务端状态,与现有设计一致。
N-162 [中] _explicit_fields 是"显式设置 vs 默认值"的正确做法(正面)
上一批(N145-1)只看到 ledger_insights.py 内部聚合键与筛选键不一致。 本组沿 ledger.js 的分类下拉往回追,发现真相更值得写进正文:
| 位置 | 表达式 | 是否 TRIM |
|---|---|---|
下拉选项来源 items.py:645-653 | SELECT DISTINCT TRIM(ledger_category),且排除 NULL/空串 | ✅ |
列表筛选 db.py:2131-2136 | TRIM(ledger_category) = ?,值也 .strip() | ✅ |
列表分类字段解析 items.py:764-765 | _resolve_category_field(item_type) 把 category 映射到 ledger_category | ✅ |
洞察面板筛选 ledger_insights.py:94 | ledger_category = ?(裸等值) | ❌ |
而 db.py:2130-2132 的注释把这件事说得清清楚楚:
# 兼容清理规范化前写入的首尾空格,避免下拉框显示出的
# 分类值反而筛不到同一批旧条目。
trim_value = key in {"category", "ledger_category"}作者已经识别出了这个失效模式,写下了理由,并在三个地方修好了—— 只漏了第四个地方。 于是用户在同一屏上会看到:
- 分类下拉选"餐饮"(下拉里的值是 TRIM 过的);
- 下方账目明细列出 N 条(
TRIM(...) = '餐饮'命中含空格的历史数据); - 上方"分类构成 / 支出热点 / K 线"四张卡片空白或数字对不上 (
ledger_category = '餐饮'漏掉全部含空格的行)。
且注释指明这批数据正是规范化上线前写入的历史条目,即真实存在的数据。
→ 这是 N125-2「正确机制已存在但覆盖不全」目前证据最完整的一例: 不需要论证做法是否正确(同仓已经论证过并写了注释),只需要把第四处补齐。 修复成本:一行。正文的 N101-2 清单应把这一条排在最前面。
N-163 [中] 任何一次重绘都会清空"快速记一笔"里已经填好的内容
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N163-1 | P1 | event.py:406、498、616 | 三处裸 datetime.now() 使 event 成为唯一写服务器本地时间的条目类型(详见 N-161)。修法:改用 _user_local_now 式的用户时间,先与其余四类对齐,再整体迁 UTC |
| N163-2 | 中 | task.py:494-497 | local_now.hour >= 20 的阈值硬编码在展示逻辑里,而产生这个行为的 default_task_plan_date(validators.py:236-240)里也有一份 current.hour >= 20。同一个业务规则在两个模块各写一遍,改其一即不一致(回执标注"(明天)"的条件与实际顺延条件脱钩) |
| N163-3 | 低 | task.py:479 | "context": {"group_id": group_id} if group_id else {} —— 用真值判断,与 note/ledger 的 is not None 不同(N155-6、N159-5)。四个 handler 里两种写法各占两处 |
| N163-4 | 低 | task.py:581-587 | _step_task_plan_date 用 except ValueError 包住 _create_task_from_parsed,失败时提示"请重新输入计划日期"。但该函数内部真正会抛 ValueError 的是 normalize_task_fields(标题、优先级、标签等任何字段非法都会抛),因此标题过长之类的错误也会被报成"请重新输入计划日期",把用户引向错误的字段 |
N-164 [中] 同一个金额字段在同一个文件里有两套校验,且都比后端的钱模型宽
// 快速记账(:1613)
const amount = parseAmountInput(amountInput.value, { allowZero: false });
// → AMOUNT_PATTERN = /^(?:\d+(?:\.\d*)?|\.\d+)$/ + 有限 + 非负 + 非零
// 编辑弹窗(:1778)
const amount = finiteNumber(data.amount);
if (amount <= 0) { ... }
// → 只要求有限且 > 0差异是可观测的:在编辑弹窗里输入 1e3 会被接受成 1000, 在快速记账栏里输入同样的内容会被拒绝(正则不允许科学计数法)。 同一个业务字段、同一个文件、两套规则(N18-1「一个插件 4 个布尔解析函数」的前端版)。
更值得写的是两者都比后端的钱模型宽:
# utils/validators.py:468
cents = int((value * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))后端是整数分模型(N-53 记为"全仓唯一正确的金钱处理"), 输入超过两位小数时静默 ROUND_HALF_UP,不报错、不返回警告。 前端两处校验都不限制小数位:输入 12.345 → 存成 12.35 → 列表刷新后显示 12.35, 用户只能靠自己发现。type="number" step="0.01" 不会拦住它—— step 只在表单原生提交时校验,而这里是按钮 click 手动读值。
修法二选一,但必须选一个:前端把小数位限制成 2(与钱模型对齐), 或后端在舍入发生时返回一条 warning。钱的静默修改不应该是默认行为。
N-165 [中] 同一个函数,note.py 卸载了、event.py 没卸载
最终结论:「事件循环上的同步读只有一处」已更正为约 10 处;本条自身的结论保留。以下推导保留原始分析,与该结论有出入处以本行为准。
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N165-1 | 中 | ledger.js:1804 | 编辑保存 PUT 整个表单且不带 version → Web 第 6 条无乐观锁写入路径。账本是唯一带金额语义的条目类型,丢更新等于金额被静默回退 |
| N165-2 | 中 | ledger.js:1477 | _dateFilter = DATE_FILTER_LABELS[value] ? value : 'month' —— 用普通对象下标的真值当白名单。DATE_FILTER_LABELS 由 Object.fromEntries 生成,value === 'constructor' / 'toString' 时取到原型链上的函数(真值)→ 非法值被放行进 derivePresetRange。N-151 之后前端第 2 处裸下标当白名单,而同文件 _transactionTypeFilter 用的是 TRANSACTION_TYPES.has(...)(Set,正确) |
| N165-3 | 中 | ledger.js:1851 | fetchInsights().catch(() => null) —— 洞察接口失败时四张卡片直接消失,无任何提示。用户会以为"这个范围没数据",而不是"请求失败了"。对照同一 Promise.all 里 fetchItems/fetchAggregate 失败会走整体 catch 并弹 toast |
| N165-4 | 中 | ledger.js:206-222 | fetchCategories / fetchAccounts 各自 catch 后返回 [] / ['现金']。分类下拉静默变空时,界面表现为"这个账本没有分类",与"真的没有分类"不可区分(N69-1 家族第 3 例) |
| N165-5 | 低 | ledger.js:168-178 groupByDate | 分组键在缺失日期时用中文字面量 '未知日期',随后与 YYYY-MM-DD 一起 localeCompare 排序 —— 中文串与数字串的相对位置取决于 ICU 排序规则,"未知日期"分组会落在一个不确定的位置。应把它固定排在末尾 |
| N165-6 | 低 | ledger.js:1858-1862 | 页码越界时递归调用 loadAndRender。正确性靠 ++_loadVersion 让外层的 finally 提前返回(写得对),但递归深度没有显式上界,依赖"maxPage 单调收敛"这一隐含前提 |
| N165-7 | 低 | ledger.js:1530 | _amountDebounceTimer 是模块级单例,被最小/最大两个输入框共用。当前行为正确(回调重新读两个框的值),但这依赖"回调不使用触发它的那个输入框"这一约定,没有断言也没有注释 |
| N165-8 | 低 | ledger.js:196 | total: Math.max(items.length, total) —— 与 notes.js:737(N152-8)完全相同的写法在第 2 个文件出现。两处都是"用展示层数据修正统计层数据",且都没有注释说明为什么不信任后端的 total |
N-168 [中] 统计页是唯一不订阅数据变更的页面
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N168-1 | 中 | event.py:284 | resolve_default_category 未经 run_sync,阻塞事件循环(详见 N-165) |
| N168-2 | 中 | event.py:225 | parse_event_with_ai 无 AI 外发同意闸门——N26-1 的三个调用点之一,此处是主入口(/pendo event add)。用户文本直接进 prompt |
| N168-3 | 低 | event.py:264-268、292 | create_event 就地修改传入的 parsed_data,而 add_event:239/250 又把同一个字典存进会话(result.get("data", parsed))。会话数据与被修改的字典是同一个对象。当前流程下二者恰好需要一致,但这种别名关系没有任何注释,后续若在 create_event 里增加临时字段,会连带写进会话 |
| N168-4 | 低 | event.py:328、333 | 两处 datetime.fromisoformat(...) 无 try/except。此处安全(值来自 normalize_event_fields),但与N-135 记录的 event_support.py 三处无防护是同一形态:同一代码库里,fromisoformat 有时包有时不包,读者无法区分"确认安全"与"忘记" |
| N168-5 | 低 | event.py:283-284 | if not str(parsed_data.get("category") or "").strip(): 才取默认分类。因此 AI 若返回 category: "未分类",会被当作用户已指定而跳过 resolve_default_category,用户设置的默认分类失效。应把 "未分类" 也视为未指定 |
N-169 [中] 全站唯一一处故意输出原始 HTML 的地方,恰好有一个值同时跳过了转义和数值收敛
generateInsights(:836-914)返回的 text 字段是带 <strong> 标记的 HTML 片段, 在 renderInsightCard:930 处以 ${item.text} 不转义插入。 文件顶部的注释说明了纪律:
// 洞察文本只拼接页面计算结果;来自接口的标签必须先转义。逐条核对,这条纪律基本被遵守了:escapeHtml(top.category)、escapeHtml(peak.slot) 是接口来的标签,都转义了;金额、计数、百分比都过 formatAmount / formatCount / Math.round,输出必然是数字。
只有一个值两样都没做:
const streak = diarySummary.current_streak || 0; // :896 —— 无 nonNegativeNumber
...
text: `日记连续书写 <strong>${streak}</strong> 天,累计 ...`, // :901 —— 无 escapeHtml同一行里紧挨着的 total_words 反而过了 formatCount(...)。
当前不可利用:current_streak 是服务端对自己数据库做的 COUNT, 不经过用户输入。但这正是纪律型防护的典型失效方式—— 规则写在注释里,靠每个插值点自觉,而这个函数有 11 个插值点。
建议:把 generateInsights 的返回改成结构化片段 ({ prefix, value, suffix } 或直接复用 modal.js 的 safeHtml brand, N-120 已建议把 safeHtml 提到 utils/ui.js), 让"忘了转义"在开发期就无法编译通过,而不是靠读注释。
N-170 [中] 每次切换时间范围都会重发两个与范围无关的请求
N-165 写过"普查全部 handlers 与 commands 后, 这是唯一一处未卸载的数据库调用"。这句是错的—— 当时的 grep 只搜了 resolve_default_category 与 self.db.get_*, 漏掉了 now_in_timezone,而后者内部同样要读数据库:
def now_in_timezone(user_id=None, db=None) -> datetime:
if user_id and db:
tz = TimezoneHelper.get_user_timezone(user_id, db) # → db.get_user_settings(user_id)
...now_in_timezone 全插件 18 个调用点,按所在上下文分类如下。
直接位于 async def 函数体内 —— 阻塞事件循环(已逐一核实)
| 位置 | 所在函数 | 备注 |
|---|---|---|
commands/operations.py:122 | async def handle_confirm | /pendo confirm |
commands/operations.py:212 | async def handle_snooze | /pendo snooze |
commands/scheduled.py:963 | async def _generate_briefing_content | 每日简报 |
commands/scheduled.py:1114 | async def migrate_undone_todos | 位于 for user_id in user_ids: 循环内 |
handlers/event.py:1296 | async def delete_event_reminders | |
handlers/event.py:1376 | async def confirm_event_reminders | |
handlers/task.py:1160 | async def _transition_task_status | 完成/取消/重开待办 |
经同步辅助函数间接进入 async 路径
| 位置 | 辅助函数 | 被谁调用 |
|---|---|---|
handlers/task.py:297 | def _user_local_now(同步) | async def add_task 等多处 |
services/ai_parser.py:289 | def _prompt_time_context(同步) | async def parse_event_with_ai、async def analyze_diary_mood |
不构成缺陷
| 位置 | 原因 |
|---|---|
services/reminder.py:36 | 整个提醒服务由 scheduled.py:293 经 run_sync 调用,全程在线程池内 |
web/api/*、web/analytics/*(共 7 处) | 这些 FastAPI 路由全部是同步 def(三个文件里 async def 计数为 0),FastAPI 会自动放进线程池执行 |
加上N-165 已记的 event.py:284(resolve_default_category), pendo 在事件循环上的同步数据库读取共约 10 处。
N-175 [中] Widget 的"今天/明天"按插件默认时区计算,是全仓唯一绕过 now_in_timezone 的接口
# web/api/widget.py:38-44
def _parse_now(value: str | None) -> datetime:
"""把输入时间统一为 Pendo 默认时区下的无时区本地时间。"""
text = str(value or "").strip()
if not text:
# 数据库日程使用默认时区的无时区墙钟值,不能受宿主机时区影响。
return datetime.now(TimezoneHelper.DEFAULT_TZ).replace(tzinfo=None, microsecond=0)widget.py 全文 0 处 now_in_timezone,而 web/ 下另有 6 个模块使用它 (diary_overview / notes_overview / task_overview / ledger_insights / events.py / transfer.py)。
注释是对的一半:它正确地识别出"不能受宿主机时区影响", 于是钉死在 TimezoneHelper.DEFAULT_TZ(Asia/Shanghai)—— 但用户设置里的时区才是这个插件的时间语义(N-136 已就同一件事在前端定过性)。
后果对非默认时区用户是全面的,因为 current 同时决定三件事:
_build_agenda(now=current)的"今天/明天"分界;_section_for(section, current)按小时轮换 medium 组件展示哪个板块;generated_at,而 widget 端agendaLeadLabel:440正是取它的前 10 个字符当"今天"。
即:服务端与组件端对"今天"的理解是一致的,只不过一致地错了。 (这比不一致更难发现——界面上没有任何自相矛盾之处。)
修法:_parse_now 接受 owner_id 并改用 now_in_timezone(owner_id, db); build_widget_summary 已经拿着 db 和 owner_id,改动局限在一个函数签名。
N-176 [中] 日历同步只增不改不删,因此任何一次时间错误或改期都会在系统日历里留下永久错误副本
syncAgendaToCalendar(:994-1088)的设计取舍写在注释里,而且是对的:
// 摘要并不是完整日程清单,因此这里绝不能删除"摘要中没出现"的本地事件。摘要只含最多 5 条近期日程,据此推断"远端已删除"必然误删用户数据。 问题在于这个取舍的另一半没有被处理:去重键是 title|startDate.getTime()(:1061, :1067)。
于是:
| 场景 | 结果 |
|---|---|
| Pendo 里把"产品评审"从 14:00 改到 16:00 | 下次同步新建一条 16:00 的事件,14:00 那条永远留在系统日历里 |
N-155 / N-121 导致 start_time 被解析错时区 | 错误时刻的事件被写入系统日历;修好数据后再新增一条正确的,错的那条留着 |
| 用户自己在同一时刻手建了同名事件 | 被判为重复,Pendo 的那条静默跳过(计入"跳过 N") |
event.notes = SYNC_MARKER(:1079)写进了每条同步事件,本意显然是"标记哪些是 Pendo 建的", 但全脚本没有任何一处读取它——去重不看它,清理不看它。 这是"只写不读字段"(N37-1 家族)第 6 处,也是唯一一处写在用户的系统日历里的。
修法:既然标记已经在写了,就用它——同步时读取范围内带 SYNC_MARKER 的事件, 对同名事件做更新而非新增,对本次摘要覆盖时间窗内、带标记、 且在 Pendo 侧已不存在的事件做删除(仅限带标记者,绝不碰用户自建事件)。 这既保住了原注释的安全边界,又解决了陈旧副本问题。
N-177 [中] Web 端完全没有深色模式,而同一套系统的 iOS 组件有完整的日夜切换
def _sanitize_export_filename(raw_name: str) -> str:
name = (raw_name or "").strip().strip('"').strip("'")
if not name:
return ""
name = re.sub(r"[<>:\"/\\|?*\x00-\x1f]+", "_", name).strip(" .")
if not name:
return ""
if not name.lower().endswith(".md"):
name = f"{name}.md"
return name做对的部分:
- 剥掉包裹的单双引号(用户写
/pendo export "三月 账本"时); - 替换 Windows 与 POSIX 的全部非法字符以及控制字符;
.strip(" .")处理尾部点与空格——这是 Windows 上一个真实的坑 (name.会被系统悄悄解释成name);- 两次空值检查,确保清洗后仍非空;
- 强制
.md后缀。
漏掉的两项:
- Windows 保留设备名:
CON、PRN、AUX、NUL、COM1–COM9、LPT1–LPT9。 在 Windows 上即便带扩展名(CON.md)仍然解析为设备而非文件,write_text会写向控制台/空设备而不报错,随后file_path.resolve()与上传都会给出误导性结果。 考虑到本项目的主要平台就是 Windows,这值得补一条名单检查; - 长度无上限:清洗后不截断,超长文件名在部分文件系统上会抛
OSError, 而export_markdown没有 try/except,异常会冒泡到@handle_command_errors(main._export_cmd经run_sync调用)。sanitize_text就在隔壁utils/validators.py里,没被用上。
N-192 [中] 但这也意味着**导入的条目与本地创建的条目时间形式不同
_localize_source_datetime 的返回值是 parsed.astimezone(timezone.utc).isoformat(timespec="seconds"), 即带 +00:00 偏移的 UTC 字符串; _ITEM_DATETIME_FIELDS 覆盖了 created_at / updated_at / start_time / deadline_at / entry_time 等 10 个字段。
于是N-161 的表要再加一行:
| 来源 | created_at 形式 |
|---|---|
| 聊天 diary / ledger / note / task | 用户本地朴素 |
Web API create_item | 用户本地朴素 |
Web 导入 bundle_import | UTC 带偏移 |
| 聊天 event(单次/编辑) | 服务器本地朴素 |
create_event_collection_with_children | UTC 带偏移 |
用户把自己的数据导出再导入一次,条目的 created_at 就会从 "用户本地朴素"变成"UTC 带偏移"——值相同(都指向同一时刻), 形式不同。随后:
- N-131 的搜索
created_at范围筛选(字符串比较)会对这两批行为不一致; - N-97 的
get_briefing_items同理; ORDER BY created_at在混合两批数据时排序错乱。
也就是说,"导出→导入"这个正常操作本身就会制造出混合格式, 而不只是历史遗留。这条使N138-1(逐点打补丁无法收敛)更有说服力: 只要导入路径继续写 UTC 而创建路径继续写本地朴素, 混合格式就会持续产生,任何一次性数据迁移都会被后续导入重新污染。
因此迁移必须与写入路径统一同时进行,不能只做数据转换。
N-196 [中] DEFAULT_TIMEZONE 常量有两份,其中一份是硬编码字面量
# config.py:15
DEFAULT_TIMEZONE = "Asia/Shanghai"
# web/api/transfer.py:49
DEFAULT_TIMEZONE: Final = "Asia/Shanghai" # ← 独立的第二份,不引用 PendoConfigtransfer.py 用它作为导入包的默认来源时区 (timezone: str = Field(default=DEFAULT_TIMEZONE, max_length=128))。 运维改 PendoConfig.DEFAULT_TIMEZONE 后,导入默认时区不会跟着变—— 这与M3-1(配置常量被烧进持久化结构)、 N155-2(元→分换算三份)同属"业务常量副本"清单。
N-200 [中] 但两侧检查之间是完整解压进内存,声明大小造假仍可放大
_read_member_bytes 的第二道检查发生在 zf.read(info) 之后。 Python 的 zipfile 不会在解压过程中强制执行 info.file_size—— ZipExtFile.read() 一直读到压缩流结束为止。因此一个成员完全可以:
- 在 zip 中央目录里声明
file_size = 1 MB(通过第 410 行的前置检查); - 实际压缩流解压出远大于此的数据;
- 全部数据先进内存,第 416 行才发现超限并抛错。
上界由 MAX_UPLOAD_SIZE = 100 MB 约束:DEFLATE 的理论最大压缩比约 1032:1, 即 100 MB 的上传在最坏情况下可膨胀到约 100 GB, 而进程会在到达第 416 行之前就 OOM。
修法是标准的有界读取:
with zf.open(info) as handle:
payload = handle.read(max_bytes + 1)
if len(payload) > max_bytes:
raise BundleValidationError(f"{label} exceeds maximum file size")zf.open() 返回的流式对象支持带上限的 read(n), 超限时只多读 1 字节即可判定,不会把整个炸弹读进内存。
需要说明的是,触发这条需要已认证用户(导入端点走 get_current_user, N-1),因此不是未授权的攻击面; 但 pendo 支持匿名演示空间(N-181), 若 demo 用户可访问导入端点则风险面扩大——这一点需要在读 transfer.py 的路由依赖时确认。
N-204 [中] a · 对附录 N-15 窗口描述的修正(勿再沿用旧说法)
N-15 记过 save_user_setting 用全量快照废掉了 update_user_settings 的原子性 (N63-3 反模式)。本组把完整名单和现成修法都补齐了。
先把被调用方的机制说准确。services/db.py:3062-3110:
with self._settings_lock, self.transaction(immediate=True) as conn:
row = cursor.execute("SELECT * FROM user_settings WHERE user_id = ?", (user_id,)).fetchone()
current = self._hydrate_user_settings_row(dict(row)) if row else self._default_user_settings(user_id)
merged = {**current, **settings} # ← settings 是调用方传来的
custom_patch = normalize_settings_json(settings.get("settings_json", {}), partial=True)
# INSERT ... ON CONFLICT DO UPDATE SET
# timezone = excluded.timezone, ...,
# settings_json = json_patch(COALESCE(user_settings.settings_json,'{}'), excluded.settings_json)关键在于:它在 BEGIN IMMEDIATE 事务内部重新读了一次 current。 也就是说,update_user_settings 本身对丢更新是免疫的——前提是调用方传的是增量。 merged = {**current, **settings} 里,调用方没提供的标量键由事务内新读的 current 填充; json_patch 也只合并 custom_patch 里实际出现的键。
Web 端正是这么调的(web/api/settings.py:117-128):
updates: dict[str, Any] = body.model_dump(exclude_none=True)
...
if not db.update_user_settings(owner_id, updates):exclude_none=True 使未提交的字段根本不进 updates → 事务内的新读生效 → 正确。
聊天端三处全部传全量:
| # | 位置 | 传给 update_user_settings 的内容 |
|---|---|---|
| 1 | utils/settings_utils.py:159-163 save_user_setting | db.get_user_settings() 的整个返回值,其中 settings_json 还被 parse_custom_settings 补齐成完整 4 键 |
| 2 | commands/settings.py:87-89 _update_setting 的 custom=False 分支 | 同上:读整份 → 改一个键 → 整份写回。覆盖 timezone / daily_report_time / diary_remind_time 三个设置项 |
| 3 | commands/settings.py:111-114 _set_quiet_hours | 同上,改两个键、整份写回 |
全量快照使 merged 的每一个标量键都被调用方的旧值覆盖, custom_patch 也变成"把 4 个自定义键全部按旧值重写一遍"—— 事务内那次新读因此完全失效,_settings_lock 与 BEGIN IMMEDIATE 保护的是一次 "用陈旧数据整体覆盖"的操作。
修法极便宜,照抄 web/api/settings.py:117 的形态即可:
# _set_quiet_hours
await run_sync(db.update_user_settings, user_id,
{"quiet_hours_start": start_time, "quiet_hours_end": end_time})
# _update_setting 的 custom=False 分支
await run_sync(db.update_user_settings, user_id, {spec.key: processed_value})
# save_user_setting
db.update_user_settings(user_id, {"settings_json": {key: value}})→ N101-2「正确实现已在仓库内、只是没被复用」清单第 11 处, 且这一处的两个实现就隔着一个包目录。
N-204a · 对N-15 窗口描述的修正(勿再沿用旧说法)
N-15 写的是"窗口 ≤30 秒(CACHE_TTL)"。在单进程部署下这不准确, 因为 update_user_settings 结尾必然 cache_invalidate(settings|<user>)(db.py:3109), 同进程内任何一次写都会让下一次读落到数据库。
准确的表述应当是两条:
- 单进程:丢更新窗口 = 调用方自己的"读 → 写"间隔(两次
run_sync之间)。 这个窗口虽短,但 pendo 确实有两个并发前端同时写同一行—— 聊天走run_sync线程池,Web 走 FastAPI 线程池,两者在同一进程内并行。 - 多进程:窗口才是
CACHE_TTL = 30秒,因为另一进程的写不会失效本进程的缓存。 而update_user_settings的注释明确写着BEGIN IMMEDIATE是为了"串行化其他进程的设置更新"——即作者确实把多进程纳入了设计假设。
结论(该修)不变,但严重性论证必须换成上面这条; 沿用"30 秒窗口"会被"缓存并不总是命中"一句话反驳掉。
N-205 [中] PUT /api/settings 可写入任意键、任意大小的 settings_json
SettingsUpdate 用了 ConfigDict(extra="forbid")(web/api/settings.py:23), 这是本仓库反复出现的正确做法(deny-by-default,N-2)。 但模型里有一个字段把它整个抵消掉了:
settings_json: dict[str, Any] | None = None而校验只有一句 isinstance(..., dict),随后交给 normalize_settings_json(..., partial=True)——该函数的 docstring 是 "规范自定义设置,并保留其他插件扩展键"(settings_utils.py:68), 即它按设计原样保留一切未知键。
问题在于这个扩展点的目标受众:它是给同进程的其它插件/未来功能留的服务端扩展位, 现在被直接暴露给了浏览器客户端。链路上没有任何上限:
web/api/settings.py无键数、无字节数检查;uvicorn.Config(_app, host=host, port=port, log_level="warning")(server.py:193) 未设任何请求体上限;db.update_user_settings直接json.dumps(custom_patch)写进user_settings.settings_json。
放大路径比"一行变大"更严重,因为这一行处在两条热路径上:
- 三个每分钟定时任务对全部用户做设置扫描(N22-2,每天 4320 次全表读);
get_user_settings每次缓存命中都deepcopy(N30-6 / N62-3), 批量读 N 个用户就是 N 次深拷贝。
即一个被撑大的 settings_json 会同时放大全用户扫描与每次读的深拷贝, 而这两条都在事件循环之外的线程池里跑,表现为整体延迟而非单个请求变慢。
触达面:PUT /api/settings 走 get_current_user,Widget Bearer 够不到(只允许 GET /api/widget/*),但演示会话是普通 Cookie 会话(N-203), 因此 WEB_DEMO_ENABLED=True 时匿名访客也能写。
修法:给 settings_json 加两个上限(键数 + json.dumps 后的字节数), 或者更彻底——只接受已知扩展键的白名单,把"保留其它插件扩展键"限制在服务端调用方。 这与N-199 的边界思路一致:扩展点要么有上限,要么不对外。
N-206 [中] 设备会话列表提供不了它存在的理由所需的信息
GET /api/auth/sessions + DELETE /api/auth/sessions/{device_id} 是一套"查看并撤销其它设备登录"的功能。撤销侧写得是对的: revoke_web_session_device(web/auth.py:189-199)按 owner_id + device_id 双条件匹配、返回 bool,路由据此返回 404(auth_routes.py:173-174), 并回传 {"current": ...} 让前端知道自己是否刚把自己踢了。
问题在展示侧。web/auth.py:142 每次建会话都是:
device_id=secrets.token_urlsafe(12)即 device_id 与会话一一对应,"设备"实际上是"一次登录"。 而 _device_payload(auth_routes.py:43-51)只返回 device_id / created_at / expires_at / current—— 没有 User-Agent、没有客户端 IP、没有任何用户可读的标签。
于是:会话寿命 8 小时(WEB_SESSION_EXPIRE_SECONDS), 一个每天用一次的用户,列表里会同时出现若干条随机串条目, 彼此之间除了时间戳没有任何可区分信息。 用户被要求回答的问题是"哪一条是陌生设备",而界面能给的只有"哪一条不是当前这条"。
这不是漏洞——凭据没有泄露,撤销也确实生效—— 而是一个安全功能的可用性缺陷:它的全部价值取决于用户能否识别出可疑会话。
修法:建会话时记录 User-Agent 摘要与首次出现的客户端 IP(服务端保存, 回显时截断/脱敏),并考虑按浏览器复用 device_id 而非每次登录新建。
N-216 [中] 多轮补充日程信息时,中间轮次的输入会被静默丢弃
commands/session.py:168-186 是日程补充会话的推进逻辑:
result = await event_handler.create_event(user_id, merged, context)
status = result.get("status")
if status == "need_confirm":
... # 用 safe_create_session 把会话替换成冲突确认会话
elif status != "need_info":
await safe_end_session(context)
return result三个分支里,need_confirm 会替换会话,非 need_info 会结束会话, 而 need_info 这一支什么都不做——会话原封不动地留在那里, session["data"] 仍然是最初那次 add_event 存进去的 parsed (handlers/event.py:233-242)。
而下一轮进来时,handle_event_info_session:155 重新读的正是这份没被更新的 data:
raw_base_data = session.get("data")
...
merged = _merge_event_info(dict(raw_base_data), parsed, ai_parser, user_id)于是三轮对话的实际效果是:
| 轮次 | 用户输入 | _merge_event_info 的 base | 结果 |
|---|---|---|---|
| 1 | /pendo event add 开会 | — | 缺 start_time → need_info,会话存入 {title:"开会"} |
| 2 | 在三号会议室 | {title:"开会"} | 合并出 {title, location},仍缺 start_time → need_info,会话仍是 {title:"开会"} |
| 3 | 明天九点 | {title:"开会"} | 合并出 {title, start_time} → 创建成功,地点丢了 |
用户明确说过的地点没有出现在最终日程里,而且没有任何提示—— 这正是N69-1(损坏/缺失数据静默消失)在交互层的形态。
create_event 每次都把当前合并结果原样放在 result["data"] (handlers/event.py:280),所以修法就是把 need_confirm 那一支的写法照搬过来:
if status in {"need_confirm", "need_info"}:
session_type = (PendoConfig.SESSION_TYPE_EVENT_CONFLICT if status == "need_confirm"
else PendoConfig.SESSION_TYPE_EVENT_INFO)
raw = result.get("data")
await safe_create_session(context, initial_data={
"type": session_type,
"data": dict(raw) if isinstance(raw, dict) else merged,
}, timeout=PendoConfig.SESSION_TIMEOUT_SECONDS)这一处最值得注意的地方是它的成因:同一个函数里,作者对 need_confirm 的会话迁移考虑得非常细致——不仅替换了会话, 还在替换失败时 raise RuntimeError("无法把日程补充会话替换为冲突确认会话") (session.py:181-182,拒绝静默失败,是本仓库少见的做法)—— 却完全没有处理 need_info 自己转自己的那一支。 即N32-2「守卫只加在一侧」的又一实例,只不过这次两侧是同一个 if/elif。
实际影响被两件事限制:绝大多数情况下缺的字段只有 start_time, 两轮就结束;且 _merge_event_info 只填空、不覆盖,所以丢的一定是 用户额外提供的信息而不是核心字段。因此定级为中而非高。
N-218 [中] 多节点集合的子条目数量没有上限,而重复日程有
web/api/events.py:162-165 只校验了下界:
if len(body.children) < 2:
raise HTTPException(status_code=422, detail="Multi-node collections require at least 2 children")没有上界。随后 for index, child in enumerate(body.children, 1) 逐个跑 normalize_event_fields,再把整份 child_rows 交给 create_event_collection_with_children——后者在一个 transaction(immediate=True) 事务内逐条 INSERT INTO items 并逐条 _update_fts。
对照同一插件的另一条批量创建路径,N-174 记过:
islice(rrulestr(...), PendoConfig.EVENT_MAX_RRULE_COUNT) # = 365即重复日程的展开是有界的,理由写在配置注释里("重复日程最大次数")。 两条路径做的是同一件事——一次请求创建 N 个 event 条目—— 一条有上限一条没有,且没有任何注释说明为什么。
后果分三层,一层比一层重:
- 响应时间与内存随
len(children)线性增长; - 整批插入持有一个
BEGIN IMMEDIATE写事务, 期间全库所有写操作被阻塞(pendo 是单库多前端,聊天侧的记账/笔记也会卡住); - FTS 是独立表(N57-2),每个子条目再写一行,存储近乎翻倍。
且这是演示用户可达的端点(get_current_user 接受演示 Cookie 会话,N-203), 与N201-1 的解压放大属于同一类"启用 demo 前必须先修"的项。
修法:加 EVENT_MAX_COLLECTION_CHILDREN 常量并在 422 里说明, 数值与 EVENT_MAX_RRULE_COUNT 取同一量级即可。 更进一步:create_event_collection_with_children 自己也该断言子条目数上限—— 按N215-2 的判据,边界应当由持有事务的那一层强制,而不是由每个调用方自觉。
N-219 [中] GET /events/overview 的日期范围没有上限,且代码里已经承认了这一点
_parse_range(events_overview.py:59-71)校验了三件事:能解析、非空、 end_date >= start_date。没有校验跨度。
而 _build_calendar_views:301-305 是按天物化的:
day_keys = daterange(range_start_day, range_end_day)
calendar_days = {day: {"date": day, "count": 0, "items": [], "has_events": False} for day in day_keys}
timeline_days = {day: [] for day in day_keys}timeline_list 最后会跳过空日期,但 calendar_days 是整份返回的 (build_events_overview 的返回值里 "calendar_days": calendar_days), 包括范围内每一个没有任何日程的日子。
即 GET /api/events/overview?start_date=1900-01-01&end_date=2100-12-31 会构造约 7.3 万个 dict 并全部序列化进 JSON 响应; 在此之前 db.get_events_for_range 已经把该范围内的全部日程无上限取回, _fetch_reminder_logs_by_event_ids 又按这些 ID 批量拉了全部提醒日志。
最值得记的是:这个问题在代码里被承认过,但被绕开而不是被修掉。/events/categories 端点的 docstring(web/api/events.py:109)写的是:
只读取日程分类,避免为分类下拉框构造跨 130 年的完整概览。
也就是说,前端确实曾经用一个跨 130 年的范围去调 overview 来填一个下拉框, 作者的处置是新增一个便宜的端点(而且 list_event_categories 写得很好—— 直接一条 SELECT DISTINCT ... WHERE owner_id=? AND type='event' AND deleted=0, 不实例化任何 EventItem),但没有给 overview 加范围上限。 于是同样的调用只要有人再写一次(或者直接构造请求),仍然成立。
修法:在 _parse_range 里加一条跨度上限(日历视图的合理上界大约是 EVENT_OVERVIEW_MAX_DAYS = 366,正好覆盖"年视图"), 超出时抛 ValueError → 路由已有的 except ValueError → 422 (events.py:95-96)会直接把它变成一条清楚的错误。 这与N202-1 的通用规则一致:边界要在资源消耗之前生效。
N-223 [中] stats.py 的"今天"是服务器本地日期——Web 层第四种时间基准
stats.py:53-56:
def _today() -> date:
"""集中提供当前日期,保证同次统计使用一致时钟并便于确定性测试。"""
return datetime.now().date()docstring 说的两件事都成立(同一次请求内时钟一致、便于测试), 但它回避了第三件事:这是谁的"今天"。答案是运行进程的宿主机。
_today() 决定了三处对用户完全可见的东西:
_parse_range的全部预设:month/week/quarter/year/all的边界;ledger_comparison:611的"当前月",进而决定 3~12 个月的整个比较窗口;activity_heatmap:676在未传year时的默认年份。
至此,pendo Web 层确定"今天"的方式已经有四种,且它们出现在同一个界面上:
| # | 方式 | 位置 | 基准 |
|---|---|---|---|
| 1 | datetime.now().date() | stats.py:56(本组) | 服务器本地 |
| 2 | 客户端传 ?today= / ?now= | stats.py 三个 overview 端点、widget.py(N197-4) | 客户端时钟 |
| 3 | now_in_timezone(owner_id, db) | task_overview.py:147、diary_overview.py:192、ledger_insights.py:325、notes_overview.py:109、web/api/events.py:178 | 用户时区(正确) |
| 4 | TimezoneHelper.DEFAULT_TZ | widget.py:44、event_schedule.py:24(N-195) | 固定 Asia/Shanghai |
用户可见的后果是具体的:一个 Asia/Tokyo 的用户在东京时间 7 月 1 日 00:30 打开账本页, range=month 由服务器(设为 UTC+8)算成"6 月 1 日 ~ 6 月 30 日", 而同一页面上的任务概览用 now_in_timezone 算的是 7 月 1 日。 同一次页面加载里的两个卡片分属不同的月份。
这把N198-2 那条整改项从"web/analytics 内部两种基准"扩大为 "整个 Web 层四种基准"。整改方向不变——统一到第 3 种 now_in_timezone(owner_id, db)——但需要注意第 2 种(客户端传值) 不应当被简单删掉:task_overview.py:143-147 的处理其实是本仓库里最稳妥的一份:
today_day = parse_iso_date(today)
if today is not None and today.strip() and today_day is None:
raise ValueError("today must be a valid ISO date")
if today_day is None:
today_day = now_in_timezone(owner_id, db).date()传了就必须合法(非法值抛错而不是回退,与N10-6 的静默回退"今天"正相反), 没传才用用户时区兜底。这正是第 1 种和第 4 种应当改成的形状。
N-225 [中] 为了六个直方图桶把整段历史的支出行全部取回
ledger_stats:220-228:
expense_amounts = conn.execute(f"""
SELECT ROUND({LEDGER_AMOUNT_EXPR} / 100.0, 2)
FROM items WHERE type='ledger' AND owner_id=? AND deleted=0
AND transaction_type='expense' AND ledger_date BETWEEN ? AND ?
ORDER BY {LEDGER_AMOUNT_EXPR}
""", (owner_id, start, end)).fetchall()没有 LIMIT,随后 _build_amount_histogram 在 Python 里做六次列表推导 (sum(1 for amount in amounts if ...),即对同一份数据扫六遍) 得到一个固定六行的输出。
range=all 的边界是 date(1970,1,1) 到今天(:107), 所以这条路径会把用户全部支出记录取进内存,只为算六个计数。 ORDER BY 还要求 SQLite 先排序(_LEDGER_AMOUNT_CENTS_EXPR 是表达式, 用不上索引),而排序结果在 Python 侧完全没有被用到—— 直方图分桶与顺序无关。
同一个文件里就有正确写法的示范:EVENT_TIME_SLOT_SQL 是一段 CASE WHEN ... THEN '06-09' ... 的 SQL 分桶, 配 GROUP BY time_slot 直接返回每桶计数。 金额直方图完全可以照抄这个形状,输出恒为六行。
→ N184-2「同一位作者在一处写对、在另外几处写错」的第 5 组。 修法:把 LEDGER_HISTOGRAM_BUCKETS 编译成一段 CASE 表达式 + GROUP BY, 顺带删掉那个没人用的 ORDER BY。
N-229 [中] 分析层有四份独立的"取回全部条目"循环,小组件一次请求触发两次
同一个分页循环在四个文件里各写了一遍:
| 位置 | 函数 | 取回范围 |
|---|---|---|
web/analytics/task_overview.py:101-116 | _load_all_tasks | 该用户全部任务 |
web/analytics/notes_overview.py:71-86 | _load_all_notes | 该用户全部笔记 |
web/analytics/dashboard_overview.py:28-47 | _get_all_items | 按调用点,活跃任务 / 当月账目 |
services/exporter.py:36 | get_all_items | 导出用(N178-5 已记) |
前三份连常量都各自定义了一遍(_ITEM_BATCH_SIZE = 500,三处), 循环体结构完全相同(while True / limit=500 / len(batch) < 500 退出 / offset += 500)。
问题不在重复,而在这些是每请求路径。 _load_all_tasks 与 _load_all_notes 没有任何日期或数量收窄——它们的入参只有 owner_id:
batch = db.get_items(owner_id, filters={"type": "note"}, limit=_ITEM_BATCH_SIZE, offset=offset)即 GET /stats/notes/overview 每次都把该用户历史上的每一条笔记 读进内存并 _normalize_note 一遍,只为产出一份摘要卡片。
而小组件把两条这样的路径串在了一起: widget.py:250 调 build_task_overview、:360 调 build_notes_overview, :287 又调 build_ledger_insights。 section=all 时(build_widget_summary:403-406 会构建全部三个面板) 一次 GET /api/widget/summary 就会:
- 全量物化任务 + 全量物化笔记;
- 再加
_build_agenda的 30 天日程展开; - 而 widget 正是被 Bearer 令牌授权、供第三方小组件轮询的端点 (N-1 的四重收窄限制它只能
GET /api/widget/*)—— 也就是全插件调用频率最高的端点。
叠加两条已记的主线,代价还要再乘一次: db.get_items 会走 30 秒 LRU(N160-1 指出全量路径应传 use_cache=False), 而缓存命中分支每次返回 deepcopy(N30-6)。 即"全部笔记"这份数据在一次请求里既进缓存又被深拷贝。
修法分两步,第一步很便宜:
_load_all_tasks/_load_all_notes传use_cache=False(N160-1 已提出,此处是第 3、4 个调用点);- 真正的修法是让这两个 overview 接受日期范围并下推到 SQL—— 它们产出的全部指标(总数、近 7 天新增、节奏图)都可以用
COUNT(*)+GROUP BY得到,不需要把行取回 Python。web/api/stats.py的activity_heatmap就是现成的写法(N-227)。
N-230 [中] 小组件把错误的"今天"强行塞进了两个本来正确的函数
N-195 记录了 widget.py:44 用 TimezoneHelper.DEFAULT_TZ (固定 Asia/Shanghai)作为时间基准。本组查明了它的传播机制, 这比原来记录的更严重。
build_widget_summary:389 先算出 current = _parse_now(now)(DEFAULT_TZ 基准), 然后逐个面板往下传:
# _build_task_panel:249-250
today_key = now.strftime("%Y-%m-%d")
overview = build_task_overview(db=db, owner_id=owner_id, today=today_key)
# _build_note_panel:359-360
today_key = now.strftime("%Y-%m-%d")
overview = build_notes_overview(db=db, owner_id=owner_id, today=today_key)而 build_task_overview / build_notes_overview 自己的实现是对的:
# notes_overview.py:109
today_day = _parse_argument_day(today, "today") or now_in_timezone(owner_id, db).date()只在调用方不传 today 时才用用户时区。 小组件每次都传,于是那条正确的回退路径从来不会执行。
这就是本轮审查里第三次出现同一形状——正确的原语被调用方废掉:
| # | 正确原语 | 废掉它的调用方 | 附录 |
|---|---|---|---|
| 1 | update_user_settings 的事务内新读 | 聊天端三处传全量快照 | N-204 |
| 2 | update_item(expected_version=) 的 CAS | 聊天路径从不传 | N-33 |
| 3 | build_*_overview 的 now_in_timezone 回退 | widget.py 每次都传 DEFAULT_TZ 的 today | 本条 |
三者的判据完全一致(N63-3 / N215-2): 当一个可选参数的"不传"分支才是正确实现时,就不该把它做成可选参数。 对本条的具体修法是:_build_task_panel / _build_note_panel 不传 today, 让两个 overview 自己用 now_in_timezone; widget.py 只在需要展示"月/日/星期"标签时用 current, 而那个 current 也应当改成 now_in_timezone(owner_id, db)。
N-234 [中] 看板把全部未完成任务和当月全部账目取进内存,只为得到一个计数和一条按日求和
build_dashboard_overview:216-257 有两处 _get_all_items(分页循环取全量):
active_tasks = _get_all_items(db, owner_id, filters={"type": "task", "status": "open"})
...
month_ledger = _get_all_items(db, owner_id, filters={"type": "ledger", "date_field": "ledger_date",
"start_date": ..., "end_date": today})它们的全部用途是:
| 取回的数据 | 实际用途 | 应有的写法 |
|---|---|---|
active_tasks(全部未完成任务) | len(active_tasks) 作为 tasks_pending(:316);排序后取 [:8](:324) | COUNT(*) + 一条 ORDER BY ... LIMIT 8 |
month_ledger(当月全部账目) | 累加 month_income / month_expense,以及 spending_by_day 按日求和(:262-270) | SELECT ledger_date, transaction_type, SUM(...) GROUP BY ledger_date, transaction_type |
一个重度用户积累几千条未完成任务是完全正常的(任务本来就会堆积), 而看板是打开网页就会请求的首屏接口。
同一个文件里就有正确写法。 _recent_counts:170-197 干的正是这件事:
SELECT
COUNT(CASE WHEN type = 'diary' AND diary_date BETWEEN ? AND ? THEN 1 END) AS recent_diary_count,
COUNT(CASE WHEN type = 'task' AND status = 'done'
AND COALESCE(NULLIF(completed_at, ''), NULLIF(updated_at, '')) BETWEEN ? AND ? THEN 1 END)
FROM items WHERE owner_id = ? AND deleted = 0一次查询、两个 COUNT(CASE WHEN ...)、零行物化, 连"completed_at 为空时退回 updated_at"这种细节都在 SQL 里处理了。
→ N184-2「同一位作者一处写对、另几处写错」的第 6 组, 也是N228-2(聚合放错层)在同一文件内最干净的正反例对照。 正文可以直接把这两段并排展示。
N-235 [中] /stats/ledger/insights 不带日期参数时会扫描该用户的全部账目历史
_build_focus_details:193-210:
focus_start, focus_end = filters.start_date, filters.end_date
if not focus_start or not focus_end:
focus_start, focus_end = _query_bounds(conn, where, params) # MIN/MAX(ledger_date)
...
trend_rows = conn.execute(f"""
SELECT ledger_date, ROUND({_LEDGER_AMOUNT_EXPR} / 100.0, 2), created_at, id
FROM items WHERE {" AND ".join(where)} AND ledger_date IS NOT NULL
ORDER BY ledger_date, created_at, id""", params).fetchall()而 web/api/stats.py:257-297 的 ledger_visual_insights 把 start_date / end_date 都声明为可选,只校验"要么都给、要么都不给"。 两者都不给时,_query_bounds 会把范围扩成该用户账目的 MIN(ledger_date) ~ MAX(ledger_date), trend_rows 于是逐行取回全部历史账目(无 LIMIT)。
值得注意的是,输出是有界的:bucket_mode 在跨度超过 62 天时自动切到按月 (:202),trend 与 candles 的长度只与桶数有关。 即这里的形态是输出有界、输入无界——比N-219(输入输出都无界) 隐蔽,因为响应体大小看不出问题。
ORDER BY ledger_date, created_at, id 是必要的(K 线的 open/close 依赖组内首末行),但 bucket_totals / bucket_counts / 分类占比 (category_rows 已经是 GROUP BY 了)都不需要行级数据。 修法:把 open/close 也下推(FIRST_VALUE/LAST_VALUE 窗口函数, SQLite 3.25+ 支持),或至少在未提供日期范围时给一个默认窗口(例如最近 12 个月)。
Widget 侧幸免于难,因为 widget.py:287-292 明确传了月初和今天两个边界—— 又是一次"调用方决定了被调用方的代价",只不过这次调用方传对了。
N-236 [中] 看板按 ISO 字符串排序日程,而同插件的创建路径专门规避了这一点
build_dashboard_overview:284,291:
events_month.sort(key=lambda event: event.get("start_time") or "")
events_agenda.sort(key=lambda event: event.get("start_time") or "")对照N-221 记录的 web/api/events.py:216-226:
# 有偏移和无偏移时间统一映射到集合时区后比较,避免 ISO 字符串字典序误判。
start_time = min(..., key=lambda value: TimezoneHelper.parse(value, event_timezone))同一个数据(items.start_time)、同一个操作(比较大小),一处写了注释专门规避、 一处直接按字符串排。
在N-192 描述的混合格式下这会真的排错: "2026-07-26T09:00:00"(本地朴素)与 "2026-07-26T01:00:00+00:00"(导入后的 UTC, 表示同一时刻)按字典序比较,后者排在前者之前, 而它们本该相等;跨天时顺序会整体错位。
由于 _build_calendar_views 之外的多数排序都是这种形态, 建议正文把"ISO 时间串排序"列为一条独立的机械检查项: grep -rn "sort(key=.*start_time\|sort(key=.*created_at" plugins/ 即可枚举。
N-237 [中] 纯日期列与日期时间列在导入之后不再指向同一天
本组核对了 bundle_import._ITEM_DATETIME_FIELDS(bundle_import.py:28-39), 它包含 10 个字段: created_at updated_at deleted_at start_time end_timedeadline_at completed_at cancelled_at last_viewed entry_time。
ledger_date / diary_date / plan_date 不在其中。
这本身是正确的决定(见下方正面部分)——纯日期列表达的是"用户认为这笔账算在哪一天", 不该因为时区换算而移动。但它带来一个必然结果:
导入之后,同一条记录的
ledger_date仍是原始本地日期, 而created_at变成了 UTC 带偏移。
于是对一条北京时间 7 月 26 日 07:00 记的账:
| 列 | 导入前 | 导入后 | date(...) 的结果 |
|---|---|---|---|
ledger_date | 2026-07-26 | 2026-07-26 | 7-26 |
created_at | 2026-07-26T07:00:00 | 2026-07-25T23:00:00+00:00 | 7-25 |
两个列在导入前指向同一天,导入后差一天。
这在同一个响应里就能观察到,因为 pendo 的统计按类型混用这两种列—— stats.py:690-694 的活动热力图最典型:
CASE type
WHEN 'ledger' THEN ledger_date -- 纯日期列
WHEN 'event' THEN date(start_time) -- 日期时间列
WHEN 'diary' THEN diary_date -- 纯日期列
ELSE date(created_at) -- 日期时间列
END AS activity_date即导入之后,同一天记的账和写的笔记会落在热力图的不同格子里。
这条是N-224 的补充而不是新问题,但它给出了一个更容易验证的探测方法: 迁移前的存量数据体检脚本,应当直接统计 COUNT(*) WHERE date(created_at) != ledger_date(以及 diary/plan 的对应组合)—— 这个数字就是被 N-192 影响过的行数,比逐列判断字符串形式更直接。
N-241 [中] 日记概览的粒度保护存在,但默认是关闭的
_resolve_cadence_granularity(diary_overview.py:110-126)与 N-232 / N-239 记录的另两处一样,实现了粒度随跨度自动粗化:
if granularity != "auto":
return granularity
span_days = (end - start).days + 1
if start.year != end.year: return "year"
if span_days > 62: return "month"
if span_days > 7: return "week"
return "day"问题在入口的默认值。web/api/stats.py:478:
cadence_granularity: Literal["day", "week", "month", "year", "auto"] = "day",默认是 "day",不是 "auto"。 只有客户端显式传 auto 时, 上面那段保护才会执行;默认情况下 _build_cadence 会为范围内每一天建一个桶。
而 _resolve_period(:27-50)对显式区间只校验"能解析"和"start ≤ end", 同样没有跨度上限(与N-219 的 /events/overview 完全同构)。
于是:
GET /api/stats/diary/overview?start_date=1900-01-01&end_date=2100-12-31返回一个含 7.3 万个桶的 cadence 数组,每个桶是一个五键字典。 再加上 query_items_by_date_range(:198-204)会把该范围内全部日记取回, 以及 _build_cadence 的逐日循环(见 N-242)。
这一条比 N-219 更值得记,因为保护逻辑已经写好了、就在同一个函数里, 只差把默认值从 "day" 改成 "auto"。 修法一行;同时给 _resolve_period 补一个跨度上限作为第二道防线。
至此 pendo 的三处日期范围端点是:
| 端点 | 粒度自动粗化 | 跨度上限 |
|---|---|---|
/stats/notes/overview | 有(_resolve_period 内置,无开关) | 无 |
/stats/ledger/insights | 有(bucket_mode,无开关) | 无 |
/stats/diary/overview | 有,但默认关闭 | 无 |
/events/overview | 无 | 无 |
四个端点、四种保护程度、没有任何一个有跨度上限。 建议正文把这张表作为"同一约束在四处各写各的"的样板证据—— 它比抽象论述更能说明为什么这类约束应当下沉到共享的范围解析器里。
N-242 [中] _build_cadence 按天循环,而隔壁文件按桶循环
diary_overview._build_cadence:142:
for offset in range((end - start).days + 1):
current = start + timedelta(days=offset)
...
if resolved == "year":
bucket_key = current.strftime("%Y")无论粒度是什么,循环都跑满整个日期跨度。 即便 resolved == "year" 只产出 130 个桶,循环体仍执行 4.7 万次, 每次做一次 strftime 和两次字典查找。
对照 notes_overview._cadence_slots(N-232 已记):
if granularity == "year":
for year in range(start_day.year, end_day.year + 1): ...
if granularity == "month":
cursor = start_day.replace(day=1)
while cursor <= final: ...按桶推进,循环次数等于输出规模。
两处做的是同一件事(补齐空桶),一处 O(天数)、一处 O(桶数)。 单独看 O(天数) 也能接受(毕竟只是内存里的循环), 但它与 N-241 叠加之后就成了"默认按天 + 无跨度上限 + 逐日循环"三重放大。
→ N184-2「同一位作者一处写对、另几处写错」的第 7 组, 且这一组的修法同样是"照抄隔壁文件"。
N-243 [中] 任务概览的两个列表没有上限,而同一响应里另外三个都截断到 8
build_task_overview 的返回值(task_overview.py:175-186):
"focus_tasks": [task.to_payload() for task in focus_tasks], # 无截断
"up_next_tasks": [task.to_payload() for task in up_next_tasks[:8]], # 截断
"later_tasks": [task.to_payload() for task in later_tasks[:8]], # 截断
"backlog_tasks": [task.to_payload() for task in backlog_tasks[:8]], # 截断
"all_tasks": [task.to_payload() for task in tasks], # 无截断all_tasks 是该用户历史上的全部任务(含 done / cancelled,含 content 全文), focus_tasks 是"逾期 + 今天到期"的全部未完成任务。 三个被截断的列表和两个没截断的列表混在同一个响应里,没有任何注释说明为什么。
all_tasks 大概率是有意的——任务页要在前端做筛选,docstring 也写了 "生成任务页所需全量任务和 Widget 所需活动分组"。 但这个设计有两个代价没有被记录:
响应体随用户历史无限增长。 任务是最容易堆积的条目类型 (完成的任务不会消失),一个用了两年的用户很容易有几千条。 同一个响应里其它列表却精心截断到 8,说明作者对响应体大小是有意识的。
Widget 为它付费但完全不用它。
widget._build_task_panel:250调用build_task_overview后只取focus/up_next/backlog/later与summary(_merge_unique_tasks(..., limit=5)),all_tasks构造完就被丢弃。 而 widget 是轮询端点。即每次小组件刷新,服务端都会把该用户的全部任务
_normalize_task一遍(_load_all_tasks)、再to_payload一遍, 然后扔掉。这把N-229 的结论具体化了: 代价不止是"读取全部行",还包括为它们各构造两层字典。
修法:给 build_task_overview 加一个 include_all: bool = True 参数 (或者反过来,让任务页显式请求 ?include_all=1),widget 传 False; focus_tasks 也应当有一个上限并附带 has_more。
N-247 [中] 笔记概览返回完整正文,而唯一的下游立刻把它截到 22 个字符
build_notes_overview:302-312:
recent_notes = [
{
"id": note.id,
"title": note.title,
"content": note.content, # ← 完整正文
...
}
for note in recent_records # recent_records = notes[:6]
]对照同一层的 diary_overview.py:278:
"content_preview": item.content.strip()[:80],日记概览返回 80 字预览,笔记概览返回全文。 两者的用途完全对称 (列表页的最近条目卡片),没有注释说明这个差异。
笔记正文的上限是 50000 字符(N57-2 记录的 sanitize_text(..., 50000)), 所以 recent_notes 单项最坏 50 KB、6 项 300 KB。
而它的两个消费者都不需要全文:
widget._build_note_panel:376:_preview_text(note.get("content"), limit=22)—— 服务端构造完整正文,客户端逻辑立刻截到 22 字;- 笔记页的卡片同样只展示摘要。
这与N-243(all_tasks 被 widget 构造后丢弃)是同一形态的第 2 例: 服务端为一个只需要摘要的位置准备了全量数据。 两处叠加在同一个 widget 请求里——GET /api/widget/summary?section=all 会同时构造"全部任务的 payload"和"6 条笔记的全文"。
修法:recent_notes 改为 content_preview,照抄 diary_overview.py:278。 若前端某处确实需要全文,那是 GET /items/{id} 的职责。
N-248 [中] 笔记的分类、标签、日期过滤全部在 Python 侧完成
build_notes_overview:236-252:
all_notes = _load_all_notes(db, owner_id) # 全部笔记(N-229)
notes = [
note for note in all_notes
if (period.range_start is None or (... period.cadence_start <= note.created_day <= period.cadence_end))
and (normalized_category is None or note.category == normalized_category)
and (not tag_query or any(tag.casefold() == tag_query for tag in note.tags))
]三个筛选条件——日期范围、分类、标签——没有一个下推到 SQL, 尽管 db.get_items 的 filters 参数支持前两个 (_apply_filters 的 _EXACT_FILTER_FIELDS 含 category, ALLOWED_DATE_FIELDS 含 created_at,见N-9)。 调用处却只传了 filters={"type": "note"}。
也就是说:用户在笔记页选了一个分类,服务端仍然把全部笔记读出来再在内存里筛。
同一个响应里还有一处可以完全免除全量读取的字段:
"all_categories": sorted({note.category for note in all_notes}),这正是 events_overview.list_event_categories(N-221 记为正面) 用一条 SELECT DISTINCT ... CASE WHEN category IS NULL OR TRIM(category)='' 做的事, 而且那个函数的 docstring 就写着"直接查询当前用户的日程分类,避免实例化全部历史条目"。
→ 同一个目录下,日程的分类下拉框走 SELECT DISTINCT,笔记的分类下拉框走全量物化。 N184-2 的第 8 组,且修法是现成的函数改个类型参数。
N-253 [中] 导入锁的粒度比它要保护的状态更细
_get_import_lock:117-124:
key = f"{owner_id}:{bundle_id or '__no_bundle__'}"
return _IMPORT_LOCK_POOL.hold(key)docstring:"同一所有者和 bundle 的导入规划、事务必须串行执行。"
而被保护的规划步骤读的是所有者全域的状态:
_index_imported_item_sources(db, owner_id, selected_types) # WHERE owner_id = ?
_index_imported_collection_sources(db, owner_id) # WHERE owner_id = ?即锁按 (owner, bundle) 分片,但快照按 owner 取。 于是同一个用户并发导入两个不同的 bundle(两个浏览器标签页、 或一次重试与原请求重叠)时:
- 两个请求各自拿到自己的锁,互不阻塞;
- 两者都在提交之前读取了同一份"已导入来源"快照;
- 若两个 bundle 含有相同的
source_id(例如同一份数据导出了两次, 或一个 bundle 是另一个的子集——这是重试场景下的常态), 两边都会查到"不存在",于是都走insert; - 结果是
skip策略没有生效,同一条来源记录被导入两次。
conflict_policy="overwrite" 下同理,两边会各自分配一个新内部 ID。
这不会造成数据损坏或越权(每条记录仍然是该用户自己的), 但它使 skip / overwrite 这两个策略在并发下不成立, 而这两个策略存在的全部意义就是避免重复。
修法:把锁键改成 owner_id(bundle 维度的并发本来也没有价值—— 导入是重操作,同一用户串行执行是合理的)。 AsyncKeyedLockPool(max_keys=2048) 的容量对"每用户一个键"完全够用。
顺带一提,bundle_id 层面的幂等另有机制且是可靠的: db.has_imported_bundle(owner_id, bundle_id) + DuplicateBundleImportError 在事务内二次确认(_commit_import_plan:1149-1156)。 即"同一个 bundle 重复导入"已经被守住了, 没守住的是"两个不同 bundle 含相同来源记录"。
N-254 [中] 导出对每条可疑记录都产出可定位告警,导入却静默丢弃关系
导出侧(_export_record_warning:288-299):
try:
normalizer(record, partial=True)
return None
except (ValueError, TypeError, KeyError):
return f"{item_type}/{record.get('id', '?')}: 记录字段校验失败"docstring:"校验历史记录能否通过当前规范;导出仍保留原始序列化内容。" 即:不丢数据、不静默修改,而是带类型和 ID 生成一条用户可见的告警, 最终出现在 /transfer/export/preview 的 warnings 数组里。 _collect_event_collection_records:322-324 同理, 集合丢失时告警写明了事件 ID 和集合 ID。
这正是N69-1(损坏数据静默消失)所要求的做法, 而且是本仓库里做得最完整的一处:日志带主键 ✓、用户可见 ✓、不影响主流程 ✓。
导入侧则完全相反。 三处关系重写—— _remap_note_references、_remap_related_item_ids、 _rewrite_event_collection_reference——全部静默丢弃无法解析的引用, 一条告警都不产生,尽管 details / warnings 的回传通道就在同一个响应里 (_execute_import_sync:1240-1243)。
用户可见的后果是具体的:只导入 note 类型而不导入 task 时, 笔记里所有指向任务的关联会全部消失, 导入结果却显示 inserted: N、warnings: []——一次看起来完全成功的导入。
修法很轻:三个重写函数各返回一个"被丢弃的引用数", 在 details 里加一条 {"type": ..., "id": ..., "reason": "N 个关联因未包含在本次导入中被移除"}。 数据行为不必改(丢弃是对的,见 N-252 ③),要改的只是告知。
N-262 [中] 同一个搜索功能,聊天端有六道输入约束,Web 端一道都没有
handlers/search.py 的输入处理是本插件写得最细的一段:
| # | 约束 | 位置 |
|---|---|---|
| 1 | 整体参数长度上限 2000 字 | _tokenize_search_args:167 |
| 2 | 拒绝控制字符(UNSAFE_CONTROL_RE) | :169 |
| 3 | shlex.split 分词,引号未闭合给出专门错误 | :171-174 |
| 4 | 重复筛选条件直接报错(筛选条件不能重复: {key}) | :184-185 |
| 5 | 关键词长度上限 100 字 + 清洗后为空再检查一次 | _parse_search_query:282-286 |
| 6 | 每个文本筛选各有长度上限,超限报错而非截断 | _bounded_filter_value:213-221 |
第 6 条的 docstring 值得单独引用:
"""校验用于精确匹配的文本筛选,禁止静默截断。"""分类 50/60 字、账户 80 字、商户 120 字、标签 20 字,每一个都有具体上限, 超了就抛 ValueError 并带上中文字段名。
而 web/api/search.py:105-118 的同名功能,参数签名是这样的:
q: Annotated[str, Query(min_length=1)], # 只有下界,没有上界
item_type: Annotated[str | None, Query(alias="type")] = None,
category: str | None = None, # 无任何约束
ledger_category: str | None = None, # 无任何约束
status: str | None = None,
transaction_type: str | None = None,
account_name: str | None = None, # 无任何约束
merchant: str | None = None, # 无任何约束q 只有 min_length=1;四个文本筛选是裸 str | None, 既没有 max_length,也没有控制字符检查,_clean_optional_text 只做 strip()。
这不构成注入——db.search_items_page 全程参数化(N-13 记过三道防线)。 它构成的是代价问题:N30-2 已确认每次搜索必然对 12 个列做前导通配符 LIKE 全表扫描, 而 LIKE 的单次比较代价与模式串长度相关。 一个 100 KB 的 q 会让每一行的每一列都做一次长模式匹配。
更值得注意的是这个对照的方向。N-186 的结论是 "Web 层在三处比聊天层更严谨"(乐观并发、未分类语义、可变字段白名单), 本条给出了反方向的例子:同一个功能,聊天层有六道约束、Web 层零道。
→ 这把"某一层更严谨"的说法彻底证伪了。 准确的结论是N189-1 / N215-1 / N246-2 反复指向的那一条:pendo 没有跨层共享的输入契约,每一层各自凭作者当时的注意力写。 正文应当用这一对(N-186 与本条)作为该结论的完整证据,而不是只用其中一边。
修法:_bounded_filter_value 与 sanitize_search_keyword 都是纯函数, Web 路由直接调用即可;或者把六条约束提炼成 utils/search_input.py 的一个 normalize_search_input(...),两端共用。
N-98 [低] 简报的"还有 N 项"会少算
get_briefing_items 对今日待办和逾期待办各加了 LIMIT 10:
ORDER BY COALESCE(priority, 3) ASC, COALESCE(deadline_at, plan_date, created_at) ASC LIMIT 10而 _format_daily_briefing(scheduled.py:1079-1087)展示前 5 条后写:
if len(tasks) > 5:
lines.append(f" ...还有 {len(tasks) - 5} 项")len(tasks) 最大只能是 10,所以用户有 30 条今日待办时, 简报会显示 5 条并说"还有 5 项"——实际还有 25 项。 逾期待办同理(overdue_tasks[:3] + len(overdue_tasks),上限 10)。
要么 get_briefing_items 额外返回一个 COUNT(*), 要么把文案改成"还有 5 项以上"。
N-135 [低] 同一文件里 fromisoformat 一半有防护一半没有
| 位置 | 是否 try/except |
|---|---|
has_time:33(target) | ✅ |
has_time:38(current) | ✅ |
default_reminders:57 | ✅ |
_shift_reminders_for_new_start:79 | ✅ |
ensure_start_time_reminder:128 | ❌ |
recalculate_event_reminders:152(event.start_time) | ❌ |
recalculate_event_reminders:153(new_start) | ❌ |
四处有、三处没有。三处无防护的都在写入路径上: start_time 若不是合法 ISO,会抛 ValueError 冒泡到 @handle_command_errors,用户得到通用错误。
实际可达性较低——start_time 进库前都过 validators._normalize_iso_datetime(N10-10), new_start 也来自同一校验。但同一文件内的防护不一致本身就是信号: 读者无法判断哪几处是"确认安全所以省略"、哪几处是"忘了"。 (这与N133-1 记录的形态一致:同一操作在不同调用点防护程度不同。)
N-136 [低] 【P1】导出包的 source.timezone 取的是浏览器时区,不是用户设置的时区 —— 往返会平移全部时间
事实链
// pages/transfer.js:347-353
let timezone = 'Asia/Shanghai';
try {
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || timezone; // ← 浏览器时区
} catch { /* 极少数受限浏览器不暴露时区时,使用后端支持的稳定回退值。 */ }
const payload = { types, preset, start, end, timezone };# web/api/transfer.py:390-397 —— 这个值原样成为 manifest 的 source.timezone
_build_manifest({...}, file_entries, selection.timezone)而导入侧(N-110 认定为全仓唯一正确的时间处理)正是用它来解释包里所有朴素时间:
bundle_import._resolve_source_wall_time:把来源时区墙钟换算成 UTC, DST 折叠/跳变会报错。
后果
pendo 库里绝大多数时间是朴素串,其语义是"用户设置时区的墙钟" (handlers/{task,note,diary,ledger} 用 get_user_local_wall_time,N-65 已确认)。
一个把 pendo 时区设为 Asia/Shanghai 的用户,出差在纽约用浏览器导出:
source.timezone被写成America/New_York;- 导入时,
2026-05-01T18:00:00(本意是上海 18:00)被解释成纽约 18:00; - 换算成 UTC 后落库 → 每一条时间整体平移 13 小时(DST 期间 12 小时)。
这不是显示问题,是写库问题:导入是一次真实的数据写入, 平移后的值会永久存在,且因为它是 UTC-aware 的"正确形式", 之后再也无法与"原本就是上海墙钟"的旧值区分开。
并且这条路径正好绕开了所有既有防线: transfer_bundle 的 21 道校验(N-108)校验的是结构, _resolve_source_wall_time 校验的是DST 歧义, 没有任何一处能发现"声明的时区不是这批数据实际所属的时区"—— 因为那是一个语义事实,包里没有第二个信息源可以交叉验证。
修法(一行 + 一处)
前端 transfer.js 应当用用户设置的时区而不是浏览器时区: /settings 接口已经返回 timezone(pages/settings.js:58 正在用它), 只需在导出前取一次。 后端 _resolve_timezone(transfer.py:127-136)已经做了 IANA 校验和 422 兜底,无需改动。
回退值也应改:catch 分支回退到字面量 'Asia/Shanghai' (注释称之为"后端支持的稳定回退值")——它同样应当是用户设置值; 拿不到就应当拒绝导出并提示,而不是猜一个。 这与 transfer.resolve_range(transfer.py:146-149)自己的纪律一致:
"Range clock must be timezone-aware; host local time is not a valid fallback"—— 同一个文件里,后端拒绝拿宿主机时间当回退,前端却拿浏览器时区当权威。
→ 应与 N-121(前端按浏览器时区渲染)合并成正文的一条: 前端在三处把"浏览器所在地"当成了"用户设置的时区" (渲染 new Date()、derivePresetRange 的本周/本月、导出的 source.timezone), 而三处都能从已有的 /settings 响应里拿到正确值。 N101-2 清单第 25 处,且是其中唯一会造成持久数据损坏的一处。
N-207 [低] 两个创建会话的 POST 天然在 CSRF 体系之外
pendo 的 CSRF 全部来自 deps.get_current_user(deps.py:70-73):
if request.method not in _SAFE_METHODS:
csrf = request.headers.get(CSRF_HEADER_NAME, "")
if not csrf or not secrets.compare_digest(csrf, session.csrf_token):
raise HTTPException(status_code=403, ...)令牌存在会话里 → 会话尚不存在的端点无法参与这套机制。 auth_routes.py 里正好有两个这样的端点:
| 端点 | 依赖 | CSRF |
|---|---|---|
POST /auth/exchange | 无 | 无(结构上不可能有) |
POST /auth/demo | 仅 get_db | 无(同上) |
GET /auth/session GET /auth/sessions | get_current_session | 不需要(安全方法) |
DELETE /auth/sessions/{id} | get_current_session + get_current_user | 有 |
POST /auth/logout | get_current_session + get_current_user | 有 |
后三行是对的——注销与撤销都要过 CSRF,这在很多实现里是被漏掉的。
前两行的实际可利用性需要分开看,而结论不同:
/auth/exchange跨站触发不了:它要求 JSON body(LoginExchangeRequest),Content-Type: application/json会触发预检,而server.py没有注册CORSMiddleware(create_app只有一个加安全响应头的中间件), 预检拿不到Access-Control-Allow-Origin→ 浏览器直接拦下,请求根本不到服务端。/auth/demo跨站触发得了:它没有请求体。 一个跨站<form method="POST" action="http://127.0.0.1:<port>/api/auth/demo">是简单请求,不触发预检,会真的执行; 响应里的Set-Cookie(SameSite=Strict约束的是发送,不是写入)会被浏览器保存。 后果是受害者浏览器里的现有 pendo 会话 Cookie 被一个演示会话顶掉, 之后记的账、写的笔记落进 6 小时后会被purge_demo_owner回收的演示空间。
触发条件叠起来相当苛刻(WEB_DEMO_ENABLED 默认 False、仅绑回环、攻击者要猜端口), 因此定级为低;但它与N201-1 属于同一类—— "启用 Web demo 之前必须一起修完"的清单项。
修法一次覆盖两个前置端点:在 add_security_headers 那个中间件里 对所有非安全方法校验 Sec-Fetch-Site: same-origin(或 Origin 白名单), 这是唯一能保护"会话建立之前"的位置。
附带一条:/auth/exchange 覆盖 Cookie 时不撤销旧会话, 旧会话在 _SESSIONS 里一直留到过期为止 → 每次重新登录多一条孤儿记录, 是N5-5(会话字典无上限)的又一条产生路径。
N-208 [低] 认证是逐路由声明的,不是路由器级的
create_api_router()(web/api/__init__.py:17-31)与 app.include_router(create_api_router(), prefix="/api")(server.py:92) 都没有 dependencies=[...]。 即整个 API 的认证完全依赖每个路由函数自己写上 Depends(get_current_user)。
config_routes.py 的三个端点就没写:
GET /api/config/categories
GET /api/config/diary/templates (及旧路径别名 GET /api/diary/templates)
GET /api/config/diary/moods当前不构成信息泄露:返回的是进程级常量(记账收支分类、日记模板、情绪表), 不含任何用户数据,且服务只绑回环(N-1 的失败闭合逻辑)。
真正值得记的是没有默认拒绝这一结构:新增一个路由时漏写依赖, 它就静默地成为公开端点,没有任何测试或类型检查会发现。 而这个文件已经提供了三个"合法的无依赖路由"作为先例, 使得漏写在代码评审时更难被察觉。
修法:include_router(..., dependencies=[Depends(get_current_user)]) 作为默认, 真正需要公开的(config 这三个)单独挂一个显式命名的 public 路由器。
正面:这三个端点全部对配置做了防御性拷贝—— [dict(category) for category in LEDGER_EXPENSE_CATEGORIES]、 list(template_data.get("prompts", []))、dict(MOOD_ANALYSIS_CONFIG.get(...))。 docstring 也明说了意图("与进程级配置隔离"、"不会反向修改配置")。 返回值被下游改动不会污染进程配置,这在返回模块级常量的端点里是必要的,也常被漏掉。
N-209 [低] 同一个 HH:MM 字段,两层两套接受集合
| 层 | 实现 | 8:00 | 08:00 | 存储形式 |
|---|---|---|---|---|
| 聊天 | commands/settings.py:35,43 re.compile(r"([01]\d|2[0-3]):[0-5]\d").fullmatch | 拒绝 | 接受 | 原样 |
| Web | web/api/settings.py:38 strptime(v,"%H:%M").strftime("%H:%M") | 接受 | 接受 | 补零 |
两侧最终写进库的都是补零形式,所以不构成数据缺陷—— scheduled.py 的 _is_scheduled_minute 精确字符串匹配不会因此漏触发。 但同一个字段对用户暴露了两种输入契约:在网页里能填 8:00, 在聊天里同样的输入会收到一条"无效的时间格式"。
归入N18-1 家族(一个插件 4 个布尔解析函数 / 3 套真值集合), 现在再加一条"2 套时间格式契约"。
顺带:时区校验是完全重复的两份—— commands/settings.py:48-62 与 web/api/settings.py:48-57 都是 ZoneInfo(name) + 捕获 (ValueError, ZoneInfoNotFoundError),逻辑与异常集合都一致。 这一份没有语义分歧,可以直接合并进 utils/settings_utils.py, 是本组里成本最低的一处整改。
N-210 [低] update_user_settings 恒返回 True,Web 的失败分支是死代码
services/db.py:3110 的唯一 return 是 return True,函数体内没有任何返回 False 的路径; 失败只会以异常形式抛出(事务、json_patch、磁盘错误)。
因此 web/api/settings.py:128:
if not db.update_user_settings(owner_id, updates):
raise HTTPException(status_code=500, detail="Failed to update settings")这个分支永远不会成立。最终结果仍然正确——异常会被 FastAPI 的默认处理器变成 500—— 但这段代码读起来像是在处理写入失败,实际没有。
这是N37-1 家族(snapshot_redacted / visibility / AI_PARSE_TIMEOUT / expected_version / N-71 的七处空失效)的第 6 处。
修法:把返回类型改成 None(承认它只会抛异常),或者让它真的能返回 False (例如 cursor.rowcount != 1 时)。两者都行,但不能维持现状—— 现状会让下一个读代码的人以为写失败已经被处理了。
N-211 [低] default_category 只能从 Web 设置,却只在聊天端生效
- 消费端只有聊天:
handlers/event.py:284与handlers/note.py:233调用resolve_default_category(db, user_id);Web 的items.py:823-825有自己的处理(N-186 第 2 行)。 - 设置端只有 Web:
PUT /api/settings的default_category字段。commands/settings.py:133-181的_SETTING_SPECS没有这一项,settings_utils.py:23-41的PLUGIN_SETTINGS_HELP_LINES也没有。
即:一个只对聊天生效的设置,只能在网页上设置。 只用聊天的用户既看不到它(format_plugin_settings_message 也没列出 default_category), 也无法修改它,resolve_default_category 对他们永远返回 PendoConfig.DEFAULT_CATEGORY。
这比N188-1(帮助文本没说明适用范围)更实质:那是文案问题,这是入口缺失。 WEB_ENABLED 默认为 True(config.py:79)所以不至于完全不可达, 但一旦按N5-6 的建议把 Web 关掉,这个设置就成了死配置。
N-1 [低] pendo 的 Web 认证是全仓最强的一处安全实现
web/deps.py:41-77 的 get_current_user 是唯一的认证入口 (verify_token 全仓只有这一个调用点),两条路径都做满了:
Bearer(Widget)路径——四重收窄,任一不满足即拒:
payload = verify_token(token.strip())
if payload.get("kind") != "widget": → 401 # 旧版浏览器 Bearer 明确废止
if payload.get("scope") != "widget:read": → 403
if method != "GET" or not path.startswith("/api/widget/"): → 403即长期 Widget 令牌只能 GET /api/widget/*,无法碰任何写接口或其它读接口。 错误文案 "Browser bearer tokens are no longer accepted; use a web login code" 说明这是一次有意的收紧,而不是偶然。
Cookie 路径——get_current_session 读 HttpOnly 会话 Cookie, 所有非安全方法(非 GET/HEAD/OPTIONS)强制校验 X-CSRF-Token, 且用 secrets.compare_digest 而非 ==。会话缓存在 request.state 上, 同一请求内不重复扫描。
auth.py:285-313 的 verify_token 同样是教科书写法: algorithms=[HS256] 固定(杜绝 alg 混淆与 none)、issuer= 校验、 options={"require": [exp, iat, iss, owner_id, sub, typ]}、 并额外交叉校验 sub == owner_id、typ、widget 的 kind/scope 一致性。 generate_token 还有一处别处没有的防护—— _RESERVED_TOKEN_CLAIMS.intersection(claims) 使 extra_claims无法覆盖 owner_id/sub/exp/iss(auth.py:251-253)。
配套的还有:
- CSP 完整(
server.py:37-47):default-src 'self'、object-src 'none'、frame-ancestors 'none'、base-uri 'self'、form-action 'self'、script-src 'self'(脚本不开unsafe-inline),外加X-Content-Type-Options、Referrer-Policy: no-referrer。 - 绑定失败闭(
server.py:183-189):绑定到非 loopback 且未启用WEB_SESSION_COOKIE_SECURE时直接拒绝启动,并给出可操作的中文说明。 - 密钥强度下限:
_MIN_SECRET_BYTES = 32,环境变量与落盘密钥都过_validated_secret。 server.py的模块 docstring 是全仓写得最好的一条—— 明确区分"本进程拥有的线程"与"端口可达",并说明启动超时后 必须确认线程退出才能重置状态,不能留孤儿线程。
这一段应当作为 core 的参考实现:正文 S 组(安全)里关于令牌与 CSRF 的建议, 可以直接指向 pendo/web/deps.py。
N-3 [低] [P0-1 新增受害者] pendo 在同一个插件里用了三种互相矛盾的 send_action 结果纪律
这是目前为止对正文 P0-1(HTTP/WS 投递结果三态不一致)最有说服力的一份证据, 因为三种写法出现在同一份代码里:
| 位置 | 写法 | 投递结果为 None 时的行为 |
|---|---|---|
commands/scheduled.py:261、:790 | delivered = (await context.send_action(action)) is True | 判为未送达 → 提醒进入重试(REMINDER_MAX_RETRY=3、REMINDER_REPEAT_INTERVAL_SECONDS=300、REMINDER_MAX_REPEATS=3)→ 用户收到重复提醒 |
main.py:709 | if delivered is not True: return segments(failure_message) | 判为未送达 → 群里显示"⚠️ 私聊发送失败",而私聊其实已经收到 → 提示与事实相反 |
main.py:820-831 | await ctx.send_action({...upload_private_file...}),返回值直接丢弃 | 无论成功失败,一律回复"已通过 QQ 私聊文件发送给你" → 失败被报告为成功 |
更能说明问题的是 commands/scheduled.py:43,pendo 为 context 写了自己的 Protocol:
async def send_action(self, action: JsonObject) -> bool: ...插件作者理解的契约是 bool,而 core 的实现是 bool | None (core/onebot.py:455 在任何异常下 return None)。作者显然察觉到不对, 于是防御性地写了 is True——但这恰好把"不确定"归入了"失败"。
P0-1 受害插件由 4 个增至 5 个(chime、earthquake、arxiv_filter、minecraft、pendo), 且 pendo 是唯一有用户可见错误症状的一个(重复提醒 / 相反提示 / 假成功)。 这条应当是整份报告里投入产出比最高的修复项,本组之后不再需要更多证据。
N-4 [低] 隐私设计本身是对的,值得单独记
MESSAGE_PRIVACY_MODE_DEFAULT = True——默认开启;_get_user_privacy_mode(main.py:729-744)在任何异常下返回True, 注释写明"Privacy lookup failures must not make sensitive content public."—— 这是正确的失败闭方向;_format_result在隐私模式下只在群里回一句占位符,正文走私聊; 投递未确认时回退为"未在群内显示内容"而不是把内容贴回群里;_import_cmd(main.py:839-864)拒绝从聊天命令接收任何文件路径, 帮助文本里明写"避免误读服务器文件或路径穿越风险", 改为引导到 Web 迁移页。这是全仓少见的"主动砍掉一个攻击面并说明理由"。
N-5 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N5-1 | P1 | main.py:139、auth.py:26 | 主数据库与 JWT 签名密钥写在包目录内,忽略 context.data_dir(详见 N-2) |
| N5-2 | 中 | main.py:709/820、scheduled.py:261/790 | 同一插件三种 send_action 结果纪律,两种给出错误的用户提示(详见 N-3) |
| N5-3 | 中 | plugin.json | manifest 未声明 contexts、concurrency、admin_only 中的任何一个。pendo 管理日记、账本、笔记这类最私密的数据,却默认群聊可用。插件层用 privacy_mode 做了补救(N-4),但那是运行期用户设置,而 contexts: ["private"] 是声明式保证——两者不等价:is_public 判定(main.py:478-479)把"无参数子命令"也算作公开,于是 /pendo ledger、/pendo diary 的帮助与分类概览仍会出现在群里。这是 G-0 主线中隐私后果最重的一例 |
| N5-4 | 中 | auth.py:273、config.py:87 | Widget 令牌是 180 天有效的无状态 JWT,且没有任何吊销机制:payload 无 jti,服务端无黑名单,revoke_web_session* 只作用于 Cookie 会话。令牌被嵌在手机端 Scriptable 小组件里(web/scriptable/pendo_widget.js),泄露面真实存在。唯一补救是轮换 PENDO_WEB_TOKEN_SECRET,而那会同时使所有用户的所有会话和令牌失效。建议加 jti + 一张持久化的吊销表,或把 Widget 令牌改为可按设备撤销的服务端记录 |
| N5-5 | 中 | auth.py:92-99 | _prune_expired 对 _LOGIN_CODES 和 _SESSIONS 两个字典各做一次全量扫描,而它被 get_web_session 调用——也就是每一个已认证请求都触发一次 O(n) 扫描并持全局 _AUTH_LOCK。两个字典还都没有任何容量上限(无每用户会话数上限、无全局上限),增长只受"速率 × TTL"约束(会话 TTL 8 小时)。应改为惰性/定时清理 + 每 owner 会话数上限 |
| N5-6 | 中 | main.py:149-150、config.py:79 | WEB_ENABLED = True 是纯源码常量:既不能由 config.json 设置,也没有环境变量。插件一加载就自动监听 127.0.0.1:12001,要关掉只能改源码。同时 from_global_config(config.py:130-141)只认 web_demo_enabled 一个键,WEB_HOST/WEB_PORT/WEB_SESSION_COOKIE_SECURE 全部只能走环境变量——包括那个决定是否允许公网绑定的安全开关。配置面应统一到 config.json,与 docs/06-configuration.md 描述的机制一致 |
| N5-7 | 中 | main.py:656-659 | _run_scheduled_task 捕获 asyncio.CancelledError 后返回 [] 而不重新抛出。这会破坏协作式取消:关停时任务对外表现为"正常完成",asyncio.gather/wait_for 的取消传播被吞掉。已记日志,但应在记录后 raise |
| N5-8 | 中 | main.py:153-170 | _start_web_server 同步调用 web_server.is_running()(内含 urlopen 阻塞探测,0.3 s 超时)与 web_server.start()(内含最多 1 秒的 time.sleep 轮询,且全程持 _STATE_LOCK),并直接在 init() 里执行。同文件已经有 _stop_web_server_async = asyncio.to_thread(_stop_web_server)——作者清楚这个问题,只是启动路径没用上。这是正文 K4-2(同模块内已有正确的 to_thread 版本,部分调用点未使用)的第 6 处 |
| N5-9 | 低 | main.py:484 | context._pendo_reply_private = reply_private —— 插件给 core 拥有的 context 对象挂了一个私有属性。context 是每消息构建的,因此可用;但这是一条未文档化的旁路通道,且下划线前缀用在外部对象上。应改为通过返回值或显式参数传递 |
| N5-10 | 低 | main.py:482 + :698 | 同一条命令内 _get_user_privacy_mode 被调用两次(路由前判定 reply_private,格式化时再判一次),每次都读一次数据库设置。应在请求内缓存 |
| N5-11 | 低 | main.py:811 | f"文件已保存在本地: {file_path}" —— bot 主机绝对路径外发到聊天。L3-1 主线的第 4 个插件、第 10 处 |
| N5-12 | 低 | main.py:1167-1180 | _show_help_overview 用的是 _local_catalog_root()(lru_cache 的本地 manifest 解析),而 _build_command_router 用的是 _catalog_root(context)(core 发布的目录快照)。同一份帮助有两个数据源;若 core 按权限或上下文过滤了目录,总览仍会列出用户不可用的命令 |
| N5-13 | 低 | main.py:914-1138 | HELP_MAP 是 225 行硬编码帮助文本,而 plugin.json(652 行)里同样带每条命令的 help_text。pendo 因此是"半消费 catalog"的形态:路由与别名走目录,详细帮助另写一份。两份文本没有任何一致性校验,漂移只能靠人工发现。对比 qingpet / bot_core 由 format_command_catalog 生成帮助(正文 M3-3) |
| N5-14 | 低 | main.py:1247-1284 vs :778-782 | 两处相邻的缓存决策互相矛盾:_build_command_router 的 docstring 明写"不再按 group_id 做全局缓存,避免闭包捕获过期上下文";而紧邻的 _get_services 把 AIParser(context) 连同首次请求的 context 一起缓存进 context.state,之后所有请求复用。若 AIParser 读取 context 上的任何请求相关字段,就会拿到陈旧值。另外 ai_parser.db = db 是构造后属性注入,应作为构造参数 |
| N5-15 | 低 | config.py:151 | cls.WEB_PORT = int(os.environ["PENDO_WEB_PORT"]) —— 无 try/except、无 1..65535 范围校验。畸形环境变量会让 init() 抛 ValueError,插件直接加载失败 |
| N5-16 | 低 | auth.py:232 | atomic_write_text(_SECRET_FILE, generated_secret) 未对签名密钥文件做权限收紧(POSIX 下按 umask 通常是 0644,同机其它用户可读)。应在写入后 os.chmod(_SECRET_FILE, 0o600) |
| N5-17 | 低 | plugin.json | 8 个定时任务中有 3 个是每分钟执行(pendo_reminders、pendo_daily_briefing、pendo_diary_reminder),各自独立扫库。三者都是"按用户本地时间判断是否到点",语义高度重叠,应合并为一个每分钟任务内部分派,把每分钟的数据库往返从 3 次降到 1 次 |
| N5-18 | 低 | deps.py:59 | Widget 路径的资源限制用 request.url.path.startswith("/api/widget/") 判断原始路径,而不是 FastAPI 匹配到的路由。当前 include_router(prefix="/api") 与 widget 子路由前缀一致所以正确,但前缀一旦调整,这个检查会静默失配(变成允许或全拒)。应改为依赖路由标记(如给 widget 路由打 tag/dependency)而非字符串前缀 |
| N5-19 | 低 | deps.py:87-89 | get_current_session 内部 from .services.demo_space import ensure_demo_access —— 函数内延迟导入,显然是绕循环依赖,但没有注释说明。且 demo 会话每个请求都要走一次 ensure_demo_access(get_db(), ...)(含数据库访问) |
N-6 [低] 一处值得表扬的异常处理分层
handle 带 @handle_command_errors(return_segments=True) 全捕获装饰器, 而 handle_session(main.py:372-396)故意不带,docstring 写明理由:
"This entrypoint deliberately has no catch-all command decorator. Expected Pendo business and input errors are converted to user-facing messages; unexpected failures escape to Dispatcher so SessionManager can roll back every mutation made to its isolated working copy."
pendo 是唯一一个把"哪个入口可以吞异常、哪个必须放行"与 事务回滚语义显式绑定并写进注释的插件。对比 codex 的 handle 完全不写 try/except 却也没解释(正文 J3-4)——同样是"把异常交给框架", pendo 说明了为什么,codex 没有。这条应写进 docs/03-plugin-development.md。
N-9 [低] validators.py 值得作为范本的五点
- 拒绝未知字段(deny by default):
normalize_item_fields:1186-1189与validate_item_data:433-435都先算get_allowed_item_fields(item_type), 把多余键列名报错Unsupported {type} field: ...。 全仓大多数插件是"取我认识的键、忽略其余",pendo 是少数反过来的。 - 显式清空 vs 未提供:
_normalize_reminder_fields:733-776用rules_provided/times_provided两个"键是否存在"标志算出explicitly_cleared,从而把"PATCH 里没带这个字段"和 "PATCH 里显式传了空列表"区分开。这是部分更新语义里最容易做错的一件事。 - 金额只用整数分:
ledger_amount_to_cents走Decimal+ROUND_HALF_UP+is_finite()检查,amount浮点字段的 docstring 明写是 "display mirror"(normalize_ledger_fields:606: "整数分是唯一计算字段,元值只是展示镜像")。这是全仓唯一正确处理金钱的地方。 - 引用预算三层且顺序正确:
_check_note_reference_budget先对 原始 JSON 字节数设 64 KiB 上限(在展开之前),再由_normalize_note_references/_normalize_related_item_ids各设 100 条上限, 最后_merge_note_related_ids对合并后的唯一 ID 总数再设一次上限。 - 主动拒绝退役字段:
_reject_legacy_task_fields对due_time/estimate/subtasks/dependencies/progress报错而不是忽略, 使旧客户端的静默数据丢失变成显式失败。 另有_coerce_24_hour_iso_datetime专门处理 ISO 的24:00(fromisoformat不接受,而帮助文本里确实写了deadline:2026-05-01T24:00)。
N-10 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N10-1 | P1 | 全插件 41 处 | 朴素 datetime.now() 绕开自有时区体系(详见 N-8) |
| N10-2 | 中 | validators.py:305-321 | normalize_template_answers 的条目数没有上限:每条 answer 限 50 000 字符,但列表长度不限。同一文件里笔记引用被三层限制(N-9 第 4 点),日记模板答案却完全不限——同一份代码里两种边界纪律。已认证的 Web API 请求可提交十万条答案构造内存放大。应比照 MAX_NOTE_REFERENCES 加一个 MAX_TEMPLATE_ANSWERS |
| N10-3 | 中 | validators.py:176-179 | sanitize_text 的注释写着"规范化Unicode字符",紧跟的却只有 text.strip()——没有任何 unicodedata.normalize。这不只是注释错误:缺少 NFC 归一化意味着视觉相同的分类/标签会成为不同的键(组合字符 vs 预组合字符),并且 validate_tag 的 [一-龥...] 正则会把分解形式直接判为非法字符。应真正做 unicodedata.normalize("NFC", text),或删掉这句注释 |
| N10-4 | 中 | validators.py:204、:230 | 分类/标签的字符白名单用 [一-龥]——这是 CJK 基本区,不含扩展 A/B(如 𠮷)、不含日文假名与韩文谚文、不含 emoji。而同一文件的 DIARY_MOOD_ALIASES 接受 emoji(😊→happy)。于是"心情可以是 emoji,标签不能"。对一个个人笔记/日记应用,这是会被真实触发的可用性缺陷。应改为按 Unicode 类别排除(拒绝控制符/标点/分隔符)而不是枚举中文码位 |
| N10-5 | 中 | validators.py:281 | sanitize_search_keyword 用黑名单剥离 FTS5 语法字符 "'*😦){}[]+-,但漏掉了 FTS5 的**裸词操作符 AND/OR/NOT/NEAR**。搜索 NOT或foo OR会被 FTS5 当作查询表达式解析,产生语法错误或非预期结果;而由于"也被剥离,调用方**无法**用短语引号规避。正确做法是把整个关键词转义后包成"..."短语(内部"` 加倍),而不是删字符 |
| N10-6 | 中 | time_utils.py:243-245 | _parse_time_range_core 在 strict=False 时把任何无法解析的范围静默回退为"今天"。于是 /pendo todo list 2026-13-45、/pendo ledger list 上个月 都会安静地列出今天的记录,用户完全看不出自己的筛选条件被丢弃了。至少应在回退时把"未识别范围,已按今天显示"并入返回文案 |
| N10-7 | 低 | validators.py:439 | validate_item_data 的 {k: v for k, v in data.items() if value is not None} 静默丢弃所有 None,因此经这条路径无法把字段显式置空。而同文件的 normalize_item_fields 路径是保留显式 None 语义的。两个校验入口的空值语义不同,424-429 的 docstring 解释了为什么要拆成两条路径,却没提这个差异 |
| N10-8 | 低 | validators.py:440-451 | 同一个函数里用了三种"字段是否存在"的判断方式:if "title" in data(成员)、if data.get("category")(真值)、if isinstance(data.get("tags"), list)(类型)。三者对空字符串/空列表/None 的行为各不相同,读者无法从形式上判断哪个是有意的 |
| N10-9 | 低 | validators.py:688-705 | derive_reminder_rules 对 offset < 0(即"开始之后再提醒")静默丢弃,而相邻的 normalize_reminder_rules:673-674 对同一情况抛 ValueError。同一不变量两种处理。副作用是"会议开始后 10 分钟提醒我"这类需求无法表达,且用户得不到任何提示 |
| N10-10 | 低 | validators.py:808-817 vs :852-869 | 事件的 timezone 字段被校验(ZoneInfo(timezone_name) 能否构造),但 _normalize_event_time_fields 只要求 start_time/end_time 的带时区形式彼此一致,并不用 timezone 去解释朴素的 start_time。因此一个 timezone="America/New_York" + 朴素 start_time 的事件,其绝对时刻取决于读取方如何解释,而不是记录本身。要么用 timezone 强制本地化,要么不要存这个字段 |
| N10-11 | 低 | time_utils.py:155-158 | _day_range 的右端点是 23:59:59(闭区间)。存储用 timespec="seconds" 时正确,但任何带亚秒的历史值会落在"今天"之外。应改为半开区间 [day, day+1) |
| N10-12 | 低 | time_utils.py:286-323 | parse_delay_time 的返回值有时带时区、有时朴素:走 now(TimezoneHelper.now())时带时区,走 current_due(fromisoformat,可能朴素)时朴素。这正是 N-8 里"同一列混两种 ISO 形式"的来源之一 |
| N10-13 | 低 | validators.py:18 | DEFAULT_EVENT_TIMEZONE = ZoneInfo(PendoConfig.DEFAULT_TIMEZONE) 在模块导入期求值。缺少 tzdata 的环境(未装 tzdata 包的 Windows)会在 import 阶段抛 ZoneInfoNotFoundError,表现为插件加载失败而非一条可读的配置错误。同文件其它地方都用 try/except 包了 ZoneInfo(...),唯独这里没有 |
| N10-14 | 低 | validators.py:476-484 | ledger_cents_to_amount 对 value <= 0 抛异常。这是写入路径的合理约束,但该函数也用于读取展示;若库里存在历史遗留的 0 分记录,读取时会直接抛错而不是显示 0.00。读写应使用不同的严格度 |
N-17 [低] 值得作为范本的两点
handle_command_errors的 ContextVar 重入守卫(error_handlers.py:15,89-95,121-122):pythonparent_depth = _command_error_depth.get() depth_token = _command_error_depth.set(parent_depth + 1) ... except PendoException as exc: if parent_depth: raise # 嵌套调用不重复处理,交给最外层 ... finally: _command_error_depth.reset(depth_token)嵌套的被装饰函数不会各自把异常转成用户文案,只有最外层转换一次。
ContextVar而非线程局部,因此在 asyncio 并发下按任务隔离,finally里用 tokenreset而非置零——三个细节都对。全仓没有第二个插件处理过这个问题。AI 数据外发是显式的、默认关闭的用户同意项:
DEFAULT_SETTINGS_JSON["ai_sensitive_data_consent"] = False, 命令为/pendo settings ai_consent on/off - 是否允许把日记正文发送给已配置的外部 AI。 把"日记正文会离开本机"作为一个用户可见、默认拒绝的开关, 而不是埋在配置文件里,这是全仓唯一的做法。 (N-4 读services/ai_parser.py/handlers/diary.py时需验证该开关确实被强制执行。)另有
normalize_settings_json:78-93对daily_report_enabled→daily_briefing_enabled的改名迁移:读时兼容旧键、写时pop掉, 规范化器里就把迁移做完了。
N-18 [低] 其余问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N18-1 | 中 | config.py:120、validators.py:324、settings_utils.py:48/108 | 一个插件里有四个布尔解析函数、三套不同的真值集合:_parse_bool→{1,true,yes,on};normalize_bool_flag→{1,true,yes,y,on,是,收藏};_coerce_setting_bool 与 parse_toggle_value 共用 {on,true,1,yes,是,开启,开}。于是 /pendo settings privacy 开 有效,而同样写"开"的其它输入路径无效。应收敛为一个 coerce_bool(value, *, default) |
| N18-2 | 中 | db_ops.py:98-121 | _snapshot_item_values 对非 JSON 安全类型存 str(old_val)。撤销时这些字段会被以字符串形式写回,类型与原值不同(例如 datetime → "2026-07-25 14:30:00")。当前 Item 字段是否全为 JSON 安全类型需在 N-4 读 models/item.py 时确认;若不是,这是一条静默数据损坏路径 |
| N18-3 | 中 | operations.py:159-185 | _apply_snooze 先 update_item 写新提醒集合、再 confirm_reminder 确认旧提醒,两步不在同一事务。第二步失败时代码诚实地回报"提醒时间已更新,但原提醒状态记录失败",但数据库已处于不一致状态——旧提醒仍未确认,会再次触发。诚实报告 ≠ 一致性,应合并为一个事务 |
| N18-4 | 低 | operations.py:273 | parsed.first.isdigit() + int(...) —— M3-4 主线在 pendo 的第 1 处(另 2 处在 ai_parser.py、本文件)。/pendo undo ² 抛 ValueError |
| N18-5 | 低 | operations.py:9 | pendo 确实使用 core.args.parse(parsed.options / .first / .second / len()),是少数没有手搓参数解析的插件之一。但这也意味着它继承了正文 A-0 的缺陷:任何以 - 开头的实参会被判成 option,触发 if parsed.options: return _error_result(...)。当前条目 ID 是十六进制形式所以不受影响,属于正确使用了有缺陷的公共件 |
| N18-6 | 低 | core/router.py:125 | if not provided.startswith("❌ 未知命令") —— 用错误文案的前缀做控制流。_show_help 一旦改动那句提示的措辞,这里的回退分支就会静默失效。应让 help_provider 返回 str | None |
| N18-7 | 低 | operations.py:277-286 | handle_undo 先 get_latest_undoable_operation 判断类型,再由 undo_delete/undo_edit 各自重新查询一次最近操作。既是 2 倍查询,也是一个 TOCTOU 窗口(两次调用之间用户又删了一条时,撤销的可能不是刚才判定的那条) |
| N18-8 | 低 | db_ops.py:30 | get_database(_context) 的参数完全未使用(下划线命名)。这是把"我不需要上下文"固化进签名,正是 N-14 的根源;参数应删除或真正用起来 |
| N18-9 | 低 | db.py:3168-3193 | prune_operation_logs 把全部待擦除行 fetchall() 进内存后逐行 json.loads + UPDATE。保留期 90 天、日志覆盖所有增删改,行数可观;应改为分批游标处理 |
N-21 [低] 提醒子系统是全仓最完整的"投递后确认"实现
scheduled.py + reminder.py 的租约协议逐项都对:
领取即租约:
claim_reminder(db.py)是一条INSERT ... ON CONFLICT(item_id, remind_time) DO UPDATE ... WHERE的原子语句,条件包含"未确认、未发送、退避时间已到、旧租约已过期"四项。确认后绝不回滚(
_send_claimed_private:771-775):pythoncompleted = await _complete_periodic_delivery(db, claim) if not completed: # 发送端已确认后不能释放租约,否则恢复流程会制造第二个逻辑投递。 logger.error("Lost scheduled delivery lease after confirmed send task=%s", ...)返回动作 ≠ 送达确认(
_send_private_or_collect:791-793): 没有直接发送器时把 action 交还框架,并显式返回 False, 注释写明"调用方不能据此写入已发送标记"。租约所有权移交:
delivery_claim = claim; claim = None(337-338、437-438、558-559) ——交给发送方后把局部引用置空,使except分支不会二次释放。 与 codex 的两阶段交接(L-1)是同一种纪律。失败退避而非立即重试(
_settle_reminder_delivery:229-238),注释:"OneBot 暂时离线时不能每分钟重新领取同一批历史提醒,否则每条消息的 网络超时会串成持续刷屏。"
两个配置常量之间的隐性冲突已被发现并修掉 (
reminder.py:109-119)——这是本组最值得记的一段注释:"首次见到的提醒只允许落在当前检查窗口内,防止启动时补发多年历史。 已经实际尝试但投递失败的提醒则允许按退避时间重试;否则 5 分钟退避 会天然超过 2 分钟检查窗口,记录虽写成待重试却永远不会再被领取。"
即
REMINDER_REPEAT_INTERVAL_SECONDS(300)>REMINDER_CHECK_WINDOW_SECONDS(120)会让"待重试"变成"永不重试"。作者据此把重试窗口按failure_count > 0放宽到REMINDER_STALE_AFTER_SECONDS。这类跨常量的隐性依赖是全仓最容易埋雷的地方, 这里不但发现了还写下了原因。上界齐全:重复次数上限(
REMINDER_MAX_REPEATS)、最终发送后自动确认 (AUTO_CONFIRM_AFTER_FINAL_SEND_SECONDS)、超过 24 小时自动确认 (STALE_AFTER_SECONDS,注释:"服务长期离线后,不应把数月或数年前的 '未确认'提醒重新复活")、静默时段推迟到_next_quiet_hours_end。
这一段应当与 codex 的 runner.py 并列作为 core 的参考实现—— 它正是正文 P0-1 修好之后,其它插件应该照抄的形态。 讽刺的是:它是全仓把投递结果用得最认真的一处,因此也是被 bool | None 三态语义伤得最重的一处。
N-22 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N22-1 | 中 | models/item.py:33 | 条目 ID 只有 8 个十六进制字符(32 bit),且是全库 id TEXT PRIMARY KEY(db.py:131),跨所有用户共享命名空间。生日碰撞:1 万条约 1.2%、10 万条约 69%。全文件搜不到任何 IntegrityError 处理或碰撞重试,因此一次碰撞就是一条 sqlite3.IntegrityError 冒泡,用户那条记录直接丢失。更麻烦的是 ID 生成有两套:模型默认 str(uuid.uuid4())[:8](8 位,用户看到的就是这种),db.py:758 的兜底却是 uuid.uuid4().hex(32 位)。应统一,并至少扩到 12~16 位或加唯一约束重试 |
| N22-2 | 中 | scheduled.py:363-392、462-491 | 三个每分钟任务各自做一次全用户扫描:_get_active_user_ids(db) 取出所有有数据的用户,get_user_settings_bundle_map 再把所有人的设置读进内存,然后逐个判断 _is_scheduled_minute。即每天 3 × 1 440 = 4 320 次全表设置读取,其中绝大多数用户在绝大多数分钟都不匹配。正确做法是让 SQL 直接筛出"本地时刻等于配置时刻"的用户,或至少对设置表做带失效的缓存。这是N5-17(3 个每分钟任务)的量化代价 |
| N22-3 | 中 | scheduled.py:320-324、408-412 | _is_scheduled_minute 要求 HH:MM 精确相等,没有任何补发窗口。只要那一分钟的调度被错过(进程重启、APScheduler misfire、上一轮因 N22-2 的全表扫描超过 60 秒),当天的简报/日记提醒就静默跳过,且 marker 未写、claim 未领,用户不会收到任何提示。功能正确性因此直接依赖"每分钟任务永不迟到",而该任务的开销又随用户数增长——两者耦合在一起 |
| N22-4 | 中 | scheduled.py:506-527 | 周报/月报的周期由用户本地日期算,触发却是服务器本地 cron。day_of_week: sun, hour: 21 按服务器时间触发;_finance_period 用 user_now(用户时区)算范围。对本地日期已经翻篇的用户:user_now.weekday() 变成 0(周一)→ start_date = end_date - 0 天 → "本周总结"只覆盖一天,且 period.key 落到下一个 ISO 周。月报同理:本地已到 1 号时 start_date = end_date.replace(day=1) → "本月总结"只覆盖 1 天。这是 N-8 时区主线中后果最明确的一处 |
| N22-5 | 低 | scheduled.py:660-672 + reminder.py:112-121 | 所有者 ID 非数字时(Web demo 用户)_build_private_action 返回 None → 记为投递失败 → 释放租约并退避 5 分钟。由于 failure_count > 0 后重试窗口放宽到 24 小时,这条提醒会每 5 分钟徒劳重试约 288 次,然后落到窗口外被永久搁置(既不确认也不清理,行永远留在 reminder_logs 里 pending)。应在构造动作失败时直接标记为不可投递 |
| N22-6 | 低 | models/item.py:39-40 | created_at / updated_at 的 default_factory 用朴素 datetime.now()。这是 N-8 那 41 处里最靠近数据源头的两处——每一个新建条目的时间戳都由此产生 |
| N22-7 | 低 | models/item.py:43、db.py:141 | visibility: str = "private" # private/group_scope 被定义、被建表、被列进 SELECT 列表(db.py:498/525)、被 setdefault(1243),但全仓没有任何 WHERE 子句或访问检查引用它。这是继 snapshot_redacted(N-13)之后 pendo 的第二个"只写不读"字段,而且这一个的名字直接暗示访问控制语义,容易让阅读者以为存在一层并不存在的可见性隔离 |
| N22-8 | 低 | db_ops.py:110 + models/item.py:93 | _snapshot_item_values 的跳过名单是 ("type", "updated_at")——排除了 ItemType 枚举,却没有排除 TaskItem.status 的 TaskStatus 枚举。一旦某个 edit_* 动作的 updates 里含 status,快照会存成 str(TaskStatus.OPEN) = "TaskStatus.OPEN",撤销时写回该值会被 _normalize_task_priority_and_status 判为 Invalid task status。当前 /pendo todo edit 的参数集不含状态字段,因此暂不可达;但跳过名单应改为"排除所有 Enum 实例"而不是枚举字段名(这条同时解答了N18-2 的挂账:模型其余字段确为 JSON 安全类型) |
| N22-9 | 低 | scheduled.py:316、406、543 | 简报/日记/财务三处都先查 custom_settings 里的 marker,再走 _claim_periodic_delivery 的数据库租约——两层去重。真正的保证来自租约(按 period_key 唯一),marker 只是省一次查询的优化,而它的写入走的是无 CAS 的 save_user_setting(N-15)。marker 写丢时不会导致重复投递(租约会拒),但会导致每分钟白跑一次领取尝试直到当天结束 |
| N22-10 | 低 | models/item.py:50-57 | to_dict 用 asdict() 递归展开后只在顶层把 Enum 转成 .value。当前嵌套结构都是纯 dict/list 所以正确,但 asdict 的递归语义与这个顶层循环不匹配,一旦有人往 context / ai_meta 里放枚举就会静默产生 <enum ...> 字符串 |
N-24 [低] 同意闸门核查结果:**对日记有效,对日程完全没有
N-17 挂账的验证项,结论如下。
日记路径 —— 闸门确实生效,而且做法正确
# ai_parser.py:250-259
def has_sensitive_data_consent(self, user_id: str) -> bool:
if self.db is None:
return False # ← 无数据库时失败闭
try:
settings = self.db.get_user_settings(user_id)
return bool(parse_custom_settings(settings).get("ai_sensitive_data_consent", False))
except Exception as exc:
self._record_degraded_error(exc, component="pendo.ai_parser.consent")
return False # ← 异常时失败闭# ai_parser.py:354-362
async def analyze_diary_mood(self, text, user_id):
if not self.has_sensitive_data_consent(user_id):
logger.info("AI diary analysis skipped: user=%s has not consented (chars=%d)",
user_id, len(text)) # ← 只记长度,不记内容
return analyze_diary_mood_rule(text) # ← 降级到本地词典分析,功能不消失三点都对:两条失败路径都闭、日志只记字符数不记正文、 拒绝后不是报错而是降级到本地规则分析(analyze_diary_mood_rule,纯函数、零 I/O)。 且日记正文进入 LLM 的唯一入口就是这里(handlers/diary.py:786,全仓仅此一处)。 "日记正文不外发"这个承诺是被真正兑现的。
日程路径 —— 没有任何闸门,也没有任何开关
parse_event_with_ai(ai_parser.py:299-352)不调用 has_sensitive_data_consent, 直接把用户原文拼进 prompt 发给 LLM:
prompt = self.PARSE_PROMPT_TEMPLATE.format(..., text=text)调用点三处,全部无闸门: handlers/event.py:225(新建日程)、handlers/event.py:1898(编辑日程)、 commands/session.py:165(多轮会话补全)。
于是每一条 /pendo event add <文本> 都会把原文送到外部模型, 而用户在 /pendo settings 里看到的是:
🤖 日记 AI 数据共享: 禁止设置项的帮助文本确实写的是"日记正文",所以措辞没有说谎; 但由此建立的用户心智模型是错的——日程文本同样是自由文本, 实践中经常包含体检复查、面试、就诊、会面对象、具体地址, 敏感度不低于日记,却既无闸门也无开关。
建议:把开关改名为"AI 内容外发"并覆盖两条路径, 或者增设一个独立的日程开关;无论哪种, /pendo settings 的展示行都应明确列出"哪些内容会外发"。 这是本轮审查在 pendo 里发现的最实质的隐私模型缺口—— 不是实现缺陷,而是承诺的范围小于用户合理预期的范围。
N-26 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N26-1 | 中 | ai_parser.py:299 及三处调用点 | 日程文本无同意闸门(详见 N-24) |
| N26-2 | 中 | ai_parser.py:472-482 | _normalize_event_datetimes 用 dateutil.parser.parse 解析 LLM 返回的时间字符串。dateutil 对缺失的日期部分默认补今天,而它内部取的是服务器本地当天。于是模型只回 "14:00" 时,日程会被排到服务器时区的今天 14:00,而不是用户时区的。这是 N-8 时区主线经由第三方库渗入的一条路径,grep datetime.now() 查不到它 |
| N26-3 | 中 | ai_parser.py:130、:145 | RateLimiter.call_history 是 defaultdict(str -> list[float]),第 145 行只清理过期时间戳,从不删除用户键。每见过一个 user_id 就永久多一个字典项。与 web/auth.py 的 _SESSIONS(N5-5)同类——pendo 第 2 个无上限的模块级结构 |
| N26-4 | 中 | ai_parser.py:597 | build_remind_times_from_offsets 对 remind_time > now 不成立的偏移静默丢弃。用户为明天 9 点的日程写"提前 2 天提醒",结果是一个提醒都不会有,且没有任何提示。这是提醒链路上第 3 处静默丢弃(另两处:derive_reminder_rules 的负偏移 N10-9、_parse_time_range_core 的范围回退 N10-6)。三处都应改为"照常创建 + 告知用户被跳过的项" |
| N26-5 | 低 | ai_parser.py:132-153 | check_rate_limit 是查询式命名却有副作用(history.append(now)),且在 parse_event_with_ai 里于 try 之前调用——后续 LLM 调用失败时配额已被消耗。与 qingpet is_banned_active(M2-18)同一类命名/副作用错配 |
| N26-6 | 低 | ai_parser.py:141 | 速率窗口用 time.time()(挂钟)而非 time.monotonic()。系统时钟回跳会延长限制,前跳会提前解除 |
| N26-7 | 低 | ai_parser.py:216-226 vs main.py:1254-1255 | AIParser.__init__ 本来就接受 db 参数,而 main._get_services 写的是 ai_parser = AIParser(context); ai_parser.db = db。构造后属性注入是完全不必要的——这坐实了N5-14 里对该处的判断 |
| N26-8 | 低 | ai_parser.py:160 | 速率限制 20 次/60 秒是 ClassVar 硬编码,不在 PendoConfig 里,也无法配置。而同文件其它参数(AI_PARSE_TIMEOUT、AI_MAX_TOKENS、AI_PARSE_TEMPERATURE)都在 PendoConfig 中——同一个子系统的参数分散在两处。另注:AI_PARSE_TIMEOUT / AI_MAX_TOKENS 在本文件中从未被引用(_call_llm 只传 temperature),属于配置项定义了但没接线 |
一处"看起来是 M3-4、实际安全"的对照
_parse_chinese_number:666 也是 if text.isdigit(): return float(text), 形式与 M3-4 主线完全一致。但这里 text 来自 OFFSET_TOKEN_RE 的第一个捕获组 r"\d+|[零一二两三四五六七八九十百半]+"—— \d 只匹配 Unicode Nd 类,'²'(No 类)根本进不来,而能进来的 '٣'(Nd 类) float() 也能正确解析。受正则约束,此处不构成缺陷。
记下这条是为了给 M3-4 的修复划清边界:需要修的是直接吃用户原始输入的 11 处(qingpet 9、pendo 2),而不是所有 isdigit()。 机械 grep 会把这里一并命中,逐个确认上游约束是必要的。
N-28 [低] pendo 的 SQLite 连接模型正是 qingpet M2-2 需要的修法
这是本轮审查中价值最高的一条跨插件对照:同一个仓库里, 两个插件面对同一个问题,一个做错、一个做对,而做对的那个就在隔壁目录。
# pendo/services/db.py:537-567
self._local = threading.local()
# 查询连接仍按线程隔离;额外登记连接对象,保证生命周期线程能可靠关闭
# 已退出工作线程留下的连接。以连接对象身份为键,避免线程 ID 重用冲突。
self._all_connections: dict[int, tuple[int, sqlite3.Connection]] = {}
self._lock = threading.Lock()
def get_connection(self) -> sqlite3.Connection:
conn = getattr(self._local, "conn", None)
if conn is None:
# 查询不跨线程共享;check_same_thread=False 只用于统一生命周期关闭。
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
...
with self._lock:
self._all_connections[id(conn)] = (threading.get_ident(), conn)
return conn| qingpet(M2-2) | pendo | |
|---|---|---|
| 连接 | 全插件一个 | 每线程一个 |
| 锁 | 全局 RLock 包住每一次查询 | Lock 只保护连接登记表 |
| WAL | 声明了但被全局锁抵消 | 真正生效,读可并发 |
check_same_thread=False 的理由 | 因为要跨线程复用同一连接 | 注释写明:只为让生命周期线程能关闭别的线程留下的连接 |
| 连接登记键 | — | id(conn) 而非线程 ID,注释写明"避免线程 ID 重用冲突" |
最后一行那个细节值得单独指出:用 threading.get_ident() 作键在工作线程退出、 线程 ID 被复用后会覆盖掉尚未关闭的旧连接,从而泄漏。 pendo 用连接对象的 id() 作键并把线程 ID 只作为日志信息保留。 这是一个很少有人考虑到的边界。
close_all_connections(569-592)还会汇总所有关闭失败并在最后 raise RuntimeError(...) from failures[0],而不是遇到第一个错误就中断或全部吞掉。
结论:正文 M2-2 的建议不应写成"考虑改用每线程连接", 而应直接写成"照抄 plugins/pendo/services/db.py:535-592"。 这同时也是正文 R 组(可复用性)最有力的一条证据—— 这套连接模型应当上提到 core,成为所有 SQLite 插件的基类。
N-29 [低] 结清挂账:FTS5 关键词问题(N10-5 / N11-3)应降级
N-2 曾担心 sanitize_search_keyword 的黑名单漏掉 FTS5 的裸词操作符 (AND / OR / NOT / NEAR)会导致语法错误或错误结果。 读完搜索实现后,这个担心大部分被架构消解了:
_search_fts_ids(2176-2195)把整个 FTS 查询包在try/except里, 失败时记一条 WARNING 并返回空列表——语法错误不会冒泡给用户;_search_item_ids无论 FTS 成败都会再跑一次 LIKE 搜索 (2217 行注释:"FTS 的 unicode61 分词器对 CJK 子字符串匹配不完整,需要 LIKE 兜底"), 两组结果合并去重、FTS 结果按bm25排在前面;_search_fts_ids开头(2174-2175)在查询含%/_/\时直接跳过 FTS, 注释说明这些字符需要字面 LIKE 转义而 FTS5 不支持该转义—— 这正是两套匹配语法冲突的正确处理。
因此 N10-5 的实际后果从"语法错误/错误结果"降为 "含 OR/NOT 的查询会静默退化为纯 LIKE 搜索,失去 bm25 排序"。 级别由中降为低,仍建议改为"整体包成 "..." 短语 + 内部引号加倍", 这样既能保留 FTS 又不会被操作符干扰。N11-3 同步降级。
N-30 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N30-1 | 中 | db.py:598-609 | transaction() 的嵌套保护只做了一半。进入时有 if immediate and not conn.in_transaction 的守卫——说明作者预见到了嵌套;但退出时是无条件 conn.commit(),异常时是无条件 conn.rollback()。于是一旦 transaction() 嵌套(外层已开事务,内层再进一次),内层退出会提交外层尚未完成的事务,内层失败会回滚外层已完成的工作。既没有嵌套计数也没有 SAVEPOINT。在一个 4 000 行、方法之间大量互相调用的数据层里,这是一颗结构性的雷。与N-12(prune_operation_logs 只把比较的一侧改对)是同一种失败形态:守卫加在了入口,没加在出口 |
| N30-2 | 中 | db.py:2217-2257 | 每次搜索都必然执行一次全表 LIKE 扫描,且是 12 个列的 LIKE '%kw%'(前导通配符,任何索引都用不上);type 未过滤为非 event 时还要再跑一次 event_collections JOIN items 的第二次全表扫描。也就是说 FTS 索引只用来排序,并不减少扫描量。个人库规模下可接受,但这意味着搜索开销随条目总数线性增长且没有任何上限——DEFAULT_SEARCH_LIMIT = 50 是在取回全部 ID 之后才截断的 |
| N30-3 | 低 | db.py:2264 | f"i.type = '{ItemType.EVENT.value}'" —— 全文件唯一一处把字面量插进 SQL 而非用占位符。值来自枚举常量,不构成注入;但它混在一段其余部分完全参数化的代码里,是最容易被下一个人照抄的写法 |
| N30-4 | 低 | db.py:2287-2297 | 当存在账目/待办类筛选时,代码往 collection_where 追加 "1 = 0" 让查询恒假,而不是跳过整段查询。SQLite 仍会执行一次注定无结果的 JOIN。应改为条件跳过,并且 "1 = 0" 这种写法会让人误以为是调试残留 |
| N30-5 | 低 | db.py:654-666 | cache_invalidate(pattern) 用 pattern in k 做子串匹配。_cache_key 以 | 拼接各部分,因此失效 "user1" 会连带命中 "user11"、"user123"。方向是安全的(多失效不会读到脏数据),但会在用户量增长后造成不必要的缓存抖动。应改为按分隔符做前缀/字段匹配 |
| N30-6 | 低 | db.py:633、:647 | LRU 缓存在存入和取出时各做一次 deepcopy。注释解释得很对(防止调用方就地修改污染缓存),但对搜索结果这类大列表,两次深拷贝的代价可能超过它省下的那次查询。应改为存不可变结构,或只对已知会被就地修改的类型拷贝 |
| N30-7 | 低 | db.py:599-604 | transaction() 默认是 DEFERRED,只有显式 immediate=True 才 BEGIN IMMEDIATE。读-改-写流程若走默认路径,在并发写下会拿到 SQLITE_BUSY(升级锁失败),且代码里看不到 busy_timeout 设置。建议在 get_connection 里加 PRAGMA busy_timeout,并把所有读-改-写默认改为 immediate=True |
N-31 [低] 值得记的三点纪律
check_same_thread=False的用途被写清楚了。这个参数通常是"我要跨线程共享连接" 的危险信号;pendo 在注释里明确它只服务于生命周期关闭,查询仍按线程隔离。 一句注释把一个高风险参数变成了可审计的决定。- 缓存返回副本而非引用(633、647),注释说明了为什么 ("避免调用方的就地修改污染尚未提交的缓存状态")。 全仓其它缓存(bot_core、qingpet 的
get_group_config)都是直接返回内部对象。 - FTS 与 LIKE 的转义语法冲突被显式处理(2174-2175、2213-2214), 而不是寄望于两者恰好兼容。这类"两套匹配语法之间的缝"是搜索功能最常见的 bug 来源。
N-35 [低] 其余问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N35-1 | 中 | db.py:769-783、850-851 | 缓存失效发生在事务提交之后,两者之间有窗口。with conn: 退出(提交)→ 然后才 cache_invalidate(...)。在这个窗口里,一个并发读线程若在提交前读到旧值、在失效后才写入缓存,旧值就会被缓存下来并存活到 CACHE_TTL 到期。正确顺序是"先失效再提交"或"提交后再失效一次"(双失效)。当前是单次后置失效 |
| N35-2 | 中 | db.py:854-867 | _invalidate_item_cache 在调用方未提供 owner_id 时执行一次 SELECT 去查 owner——一次缓存失效附带一次数据库查询,且这条查询在事务之外、不带 deleted = 0 过滤。应由调用方一律传入 owner_id(写路径本来就知道) |
| N35-3 | 中 | db.py:760-761、811、885-886 | 5 处朴素 datetime.now(),写入 created_at / updated_at——这是N-8 那 41 处里离数据源头最近的几处:每一次插入和每一次更新的时间戳都由此产生。同时也解释了为什么 db.py 独占 23 处 |
| N35-4 | 低 | db.py:734-783 | insert_item 没有任何 sqlite3.IntegrityError 处理,坐实N22-1:8 位十六进制 ID 与全库主键相撞时,异常直接冒泡,用户那条记录丢失且没有重试 |
| N35-5 | 低 | db.py:919-920 | raise RuntimeError(f"导入记录 {item_id} 失败") from exc —— item_id 直接来自导入包(用户上传的 .pendo.zip),此时尚未经过 validate_item_data(校验在 884 行,但这个 except 覆盖了包括 882 行 raise ValueError("import item requires id") 在内的整段)。该文案会经 Web API 返回。属于把未清洗的外部输入放进错误消息,虽然是用户自己的数据,仍应先 sanitize_text 或只回显长度 |
| N35-6 | 低 | db.py:757-758 | elif "id" not in item_dict: item_dict["id"] = uuid.uuid4().hex —— 32 位十六进制,而 models/item.py:33 的默认工厂是 str(uuid.uuid4())[:8](8 位,用户实际看到的就是这种)。同一个 ID 空间里并存两种长度,且没有任何地方说明哪种才是规范。修 N22-1 时必须一并统一 |
N-36 [低] 值得记的两点
update_item明确拒绝把内部字段写回调用方对象(806-809):for field in self._IMMUTABLE_UPDATE_FIELDS: update_dict.pop(field, None), 注释:"数据库入口不能把updated_at等内部字段写回调用方对象。" 并且是先dict(updates)复制再 pop,不改调用方的字典。- FTS 更新读的是事务内的最新行(839-841): 注释"在同一事务内读取最新行更新 FTS,不能使用可能过期的缓存值", 且只在
_FTS_FIELDS真的被修改时才刷新。既正确又避免了无谓的索引写。
N-40 [低] db.py 23 处朴素 datetime.now() 的完整定位(N-8 的核心证据)
| 行 | 写入内容 |
|---|---|
| 711 | operation_logs.created_at(审计日志时间戳,N-12 的一侧) |
| 760、761 | items.created_at / updated_at(每一次新建条目) |
| 811 | items.updated_at(每一次更新条目) |
| 885、886 | 导入路径的 created_at / updated_at |
| 947、1033 | 传输/导入审计时间戳 |
| 1078、1130、1268、1472、1567、1663、1950、2072、2439、2538 | 各类 now 局部变量,写入删除时间、集合时间戳、设置时间戳等 |
| 1416 | clean_updates["updated_at"] |
| 2733 | 撤销恢复时的 updated_at |
| 2646、2671、2776 | 撤销窗口的比较阈值(与同为朴素的 created_at/deleted_at 比较,因此这三处自洽) |
结论与N-8 一致并更精确:pendo 的整个持久化时间体系是"服务器本地朴素时间", 自洽性靠"所有写入方都朴素"维持。唯一破坏这个自洽性的是 prune_operation_logs 改用了 UTC(N-12)。因此修复顺序应当是:
- 先修 N-12(把
prune_operation_logs改回朴素本地,或把整套改为 UTC 并迁移历史行); - 再考虑整体迁移到 UTC 存储 —— 这是一次需要数据迁移的破坏性变更, 不能与第 1 步混在一起做。
不要只把部分写入点改成 tz-aware:那会制造出更多的 N-12。
N-41 [低] 其余问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N41-1 | 中 | db.py:2703-2718 | undo_edit 恢复快照时不带 expected_version,直接 UPDATE ... WHERE id IN (...) AND owner_id = ? AND deleted = 0。若用户在被撤销的那次编辑之后又编辑过一次,撤销会把两次修改一并回退,且不提示。与 N-33 是同一个未接线的 CAS |
| N41-2 | 低 | db.py:2654 | str(log_row["created_at"] or "") >= str(item_row["deleted_at"] or "") —— 跨两张表比较 ISO 时间字符串。当前两侧都由朴素 datetime.now().isoformat() 写入所以正确,但这正是 N-12 出问题的那种形状,属于同一颗定时炸弹 |
| N41-3 | 低 | db.py:2760-2762 | 缓存失效仍在事务之外、提交之后(N35-1 同类),本文件第 3 处 |
| N41-4 | 低 | db.py:2739-2747 | 批量恢复按 _SQLITE_ID_BATCH_SIZE 分批以规避 SQLite 变量数上限——这一点是对的;但 affected += cursor.rowcount 之后没有与 len(item_ids) 比对,部分恢复失败(例如某条已被再次删除)不会被察觉,返回给用户的 affected 与 instance_count 不一致时也没有说明 |
N-42 [低] 值得记的一点
_restore_edit_snapshot 在写回快照前先剔除不可变字段:
restore_values = dict(old_values)
for field in self._IMMUTABLE_UPDATE_FIELDS:
restore_values.pop(field, None)与 update_item(N-36 第 1 点)用的是同一份 _IMMUTABLE_UPDATE_FIELDS。 撤销路径复用正常写入路径的字段约束,而不是另写一套—— 这正是 handlers 层几处重复实现所缺少的纪律。 同样地,FTS 刷新与提醒队列同步也都复用了 _refresh_fts / _sync_reminder_logs, 撤销后的索引与提醒状态因此与正常编辑保持一致。
N-47 [低] 其余问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N47-1 | 中 | db.py:2134-2136 | 分类过滤用 TRIM(category) = ?,注释说明是为了兼容规范化之前写入的首尾空格。方向可以理解,但 TRIM(column) 会让该列上的任何索引失效,而分类是列表页最常用的筛选维度。正确做法是做一次性数据迁移把历史值 trim 掉,然后恢复裸列比较;保留 TRIM 等于为一批历史脏数据永久付出全表扫描的代价 |
| N47-2 | 低 | db.py:2556、2590、2611、2631 | 四条撤销路径全部调用 self.cache_clear()——清空整个缓存,而 insert_item / update_item / delete_item 用的是精确的 cache_invalidate(item_id) + cache_invalidate(f"items|{owner}")。方向安全但纪律不一致;撤销恢复的条目 ID 是已知的(restored_ids),完全可以精确失效 |
| N47-3 | 低 | db.py:2568-2582 | delete_task / delete_note 的批量撤销靠 created_at 字符串完全相等 来认定"同一批"。这依赖同一批删除的日志共享同一个 datetime.now().isoformat() 值(微秒级)。_log_operation_with_cursor 每次调用都各自取 datetime.now(),因此同一批里的多条日志几乎必然拥有不同的微秒值——除非调用方显式传入了统一的 created_at。这条批量恢复逻辑是否真的生效,取决于上游是否传参;若没传,批量撤销会退化为只恢复一条。需在读 handlers/task.py / note.py 的批量删除路径时确认 |
| N47-4 | 低 | db.py:2079 | 硬删除分支 DELETE FROM items WHERE id = ? AND owner_id = ? 不带 deleted = 0,而软删除分支带。这是对的(硬删除应能清理已软删的行),但两个分支的 WHERE 差异没有注释,容易被后来者"统一"掉 |
| N47-5 | 低 | db.py:2512-2614 | _undo_delete_from_log 的三条分支各自 with conn: + 各自 cache_clear() + 各自返回字典,重复度高;三者只在"恢复哪些 ID"和"文案"上不同。可抽出一个 _finish_undo(conn, cursor, owner_id, item_ids, log_ids, message_builder) |
N-50 [低] migrate_undone_tasks_to_date 是 db.py 里工程质量最高的一个方法
它把五件事同时做对了(1938-2040):
串行化 + 立即事务:
with self._settings_lock, self.transaction(immediate=True)——docstring 写明"立即事务会串行化多个调度实例"。幂等标记在事务内检查:
if raw_settings.get("last_todo_migrate_date") == target_date: return 0。 检查与写入在同一事务里,因此多个调度实例重入不会重复迁移。逐行 CAS + 完整业务条件复核:
sqlUPDATE items SET plan_date = ?, updated_at = ?, version = version + 1 WHERE id = ? AND owner_id = ? AND type = ? AND plan_date = ? AND status = 'open' AND deleted = 0 AND version = ?rowcount == 1才计入migrated_ids。docstring 解释了为什么: "移动前依据完整业务条件和已读版本再次校验每条待办, 防止旧快照覆盖已完成、已删除或已改期的状态。" 这是version列真正被当作乐观锁使用的地方(对 N-33 的补充: CAS 模式确实在用,只是没走update_item(expected_version=)这个 API)。设置更新是 SQL 层的原子局部合并,不是读-改-写:
sqlINSERT INTO user_settings (user_id, settings_json, updated_at, version) VALUES (?, ?, ?, 0) ON CONFLICT(user_id) DO UPDATE SET settings_json = CASE WHEN json_valid(COALESCE(user_settings.settings_json, '{}')) THEN json_patch(COALESCE(user_settings.settings_json, '{}'), excluded.settings_json) ELSE excluded.settings_json END, updated_at = excluded.updated_at, version = user_settings.version + 1json_patch只合并本次要改的键,json_valid守卫处理历史脏数据,version自增。并发写不同设置项不会互相覆盖。缓存失效覆盖三个维度:逐条目、用户列表、设置键。
由此得到一条重要结论:N-15 的修法也在本仓库里
N-15 指出 settings_utils.save_user_setting 是无 CAS 的读-改-写, 而 pendo 有聊天与 Web 两个前端同时写设置,会互相覆盖。
上面第 4 点就是它的正确写法。 db.py 里已经有一段 用 json_patch + ON CONFLICT DO UPDATE 做原子局部合并的 SQL (另一处在 3066 行附近,同样带 _settings_lock), save_user_setting 只需改为调用同一条路径即可。
这与N-28(qingpet M2-2 的修法就是 pendo 的连接模型)是同一类发现: 正确实现已经存在于仓库内,只是没有被复用。 修复建议应写成 "改为调用 db.py:2020-2034 的同一条 upsert",而不是"建议加事务"。
N-51 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N51-1 | 中 | db.py:1154-1156 | batch_soft_delete 同样无条件删光提醒历史:DELETE FROM reminder_logs WHERE item_id IN (...),不带 sent_at IS NULL。这确认N-45 的缺陷存在于两条删除路径(delete_item:2082 与此处),不是个例。同一文件的 _sync_reminder_logs 明确保留已发送记录 |
| N51-2 | 中 | db.py:1950-1951 | 相邻两行用了两种时间纪律:item_timestamp = datetime.now().isoformat()(朴素本地,写 items.updated_at)settings_timestamp = datetime.now(timezone.utc).isoformat()(UTC 带偏移,写 user_settings.updated_at)于是 items.updated_at 与 user_settings.updated_at 是两种 ISO 形式。这是N-8/N-12 主线迄今最直观的一处——两种写法相隔一行,且没有任何注释说明为什么不同。任何跨这两张表比较或排序时间的代码都会静默出错 |
| N51-3 | 低 | db.py:1142-1157 | 批内 UPDATE 之后 if cursor.rowcount: deleted_ids.extend(batch) —— 只要该批有任意一行被更新,就把整批 ID 都记为已删除。若批内混有已删除或不属于该用户的 ID,deleted_ids 会包含它们,进而为它们写审计日志、失效缓存。虽然 _select_owner_item_ids 已在前面筛过一次(1135-1141),使得实践中不会发生,但这层保护是在事务内的另一条语句里做的,两者之间没有断言。应改为逐 ID 收集或比对 rowcount 与 len(batch) |
| N51-4 | 低 | db.py:1176-1179 | 缓存失效仍在事务之外(N35-1 同类,本文件第 4 处) |
| N51-5 | 低 | db.py:2036-2039 | 迁移成功后失效了 settings 缓存键,但提前返回的分支(1971-1972 幂等命中)没有——这条路径不改数据所以正确,只是两个出口的收尾不对称,读者需要自行确认 |
N-52 [低] -0 · 先说三条主线的复核结论
| 编号 | 级别 | 内容 |
|---|---|---|
| N52-1 | P1 | "正确实现已在仓库内、只是没被复用"已累计三处:qingpet 的连接模型问题(M2-2)↔ pendo 的 threading.local 实现(N-28);pendo 的 save_user_setting 丢更新(N-15)↔ 同仓 json_patch upsert(N-50 第 4 点);update_item(expected_version=) 未被聊天路径使用(N-33)↔ migrate_undone_tasks_to_date 里正确的逐行 CAS(N-50 第 3 点)。这三条的修复成本极低(改调用点,不用设计新方案),应当在正文的修复排序里提到最前面。 它们同时说明一个共性问题:这个仓库缺的不是能力,而是把已验证的做法固化成唯一入口的机制 |
| N52-2 | 中 | json_patch + ON CONFLICT DO UPDATE 是 SQLite 上做"并发安全的局部设置更新"的标准答案,值得写进 docs/07-advanced.md。qingpet 用 Python 层的 merged_onto 三方合并解决同类问题(M-4),pendo 用 SQL 层的 json_patch。两者各有适用面(前者能表达增量语义,后者不需要读取即可合并),应当并列记录而不是二选一 |
| N52-3 | 低 | created_at 被用作"批次标识"是有意设计(N-49),_log_operation_with_cursor 的 created_at 参数就是为此存在。审查时把可选参数默认当成测试钩子是个容易犯的错——本次即因此误判过一次,记录以备后续批次自省 |
N-53 [低] items 是 55 列的单表继承,业务不变量只存在于 Python 层
N-14 记录的 N-70 是:
create_event_collection:1268用朴素本地、create_event_collection_with_children:1291用 UTC, 同一张表同一列两种形式。
经本组全仓调用点普查,这个表述需要按两点更正:
更正 1:create_event_collection(单头版)运行时没有任何调用方
$ grep -rn "create_event_collection(" --include=*.py .
tests/helpers/pendo_review_test_support.py:98
tests/plugins/test_pendo_commands_web.py:1214
tests/plugins/test_pendo_dashboard.py:52
tests/plugins/test_pendo_events_reminders.py:365,590,769,845,919,984
tests/plugins/test_pendo_event_diary_runtime.py:100,164
tests/plugins/test_pendo_event_graph.py:165,561
tests/plugins/test_pendo_scheduling_finance.py:770
tests/plugins/test_pendo_transactions_sessions.py:93
tests/plugins/test_pendo_transfer_conflicts.py:351,387
tests/plugins/test_pendo_transfer_export.py:24516 处调用全部在 tests/ 下,plugins/ 内零调用。 运行时创建集合只有 create_event_collection_with_children 一条路(3 个调用点: handlers/event.py:530、handlers/event.py:648、web/api/events.py:228)。
→ 所以「两个构造函数写成两种形式」在线上数据里不成立, 存量数据不会因为这一条而混合。N-14 关于"先跑探测脚本统计存量"的建议仍然要做, 但预期结论会比原来乐观。
→ 但由此暴露出一个更值得记的问题: create_event_collection 是一个只被测试维持存活的方法,且它的时间纪律与生产路径不同。 测试用它建集合 → 测试数据里 created_at 是朴素本地; 生产用 _with_children → 线上 created_at 是 UTC。 测试从来没有覆盖生产的那种时间形式。 这类"测试专用旁路"比死代码更危险:它让测试通过,却不证明生产路径正确。
更正 2:真正的混合产生点在同一次调用内部,比原来记的更严重
create_event_collection_with_children(db.py:1283-1343) 内:
| 写入对象 | created_at 来源 | 形式 |
|---|---|---|
event_collections 行 | _prepare_new_event_collection 的 setdefault("created_at", now),now = datetime.now(timezone.utc)(1291) | UTC 带偏移 |
items 子行(聊天路径) | 调用方 EventItem(created_at=datetime.now().isoformat())(event.py:498 / 616)已显式给值,item_data.setdefault(1318) 不生效 | 服务器本地朴素 |
operation_logs 行 | _log_operation_with_cursor(created_at=now)(1337) | UTC 带偏移 |
即:一条 /pendo event add 每周一9点周会 命令,在一个事务里同时写出了 UTC 形式的集合头 + 服务器本地朴素形式的 365 个子条目 + UTC 形式的审计日志。 UTC+8 部署下,集合头的 created_at 比它自己的子条目早 8 小时。
而 Web 路径(web/api/events.py:209-210, 243-244)两侧都显式传了 now = now_in_timezone(owner_id, db).replace(tzinfo=None).isoformat() → 集合头和子条目都是用户本地朴素,第三种形式。
于是 items.created_at 这一列的产生源确切为三种:
| 产生源 | 形式 |
|---|---|
insert_item(db.py:760-761) —— 普通新建 | 服务器本地朴素 |
handlers/event.py:406 / 498 / 616 —— 聊天建集合子条目 | 服务器本地朴素 |
web/api/events.py:209 —— Web 建多节点子条目 | 用户本地朴素 |
event_collections.created_at 的产生源为两种:UTC(聊天)与用户本地朴素(Web)。
→ N-268 修复五步中的第 ① 步(存量体检)必须按"入口"而不是按"表"统计: 同一张表的同一列,取决于是聊天建的还是 Web 建的、是普通条目还是集合子条目。 探测脚本应该按 event_collection_id IS NULL / event_role 分组统计带偏移值占比。
→ 这也再次印证 N222-1:setdefault 意味着"这行可能从不执行"。 item_data.setdefault("created_at", now)(1318) 在三个调用方下全部不执行, 它看起来是子条目 UTC 化的证据,实际是一行死代码。
N-54 [低] schema_migrations 在 pendo 是真的(对照 qingpet M2-4),但用列表下标当版本号
M2-4 记过 qingpet 的 schema_migrations 是装饰性的—— 版本号从不参与决策,实际迁移全靠 CREATE TABLE IF NOT EXISTS + _safe_add_column。
pendo 这套是真的在用(232-253):
applied_versions = {int(row[0]) for row in
cursor.execute("SELECT version FROM schema_migrations").fetchall()}
for version, sql in enumerate(_ADD_COLUMN_MIGRATIONS, start=1):
if version in applied_versions:
continue
_execute_add_column_migration(cursor, sql)
cursor.execute("INSERT INTO schema_migrations (version, name, sql, applied_at) ...")读已应用集合 → 跳过 → 执行 → 记账,并且把 SQL 原文一并存进表 (sql TEXT NOT NULL),事后可审计"这个库实际执行过哪些迁移"。 这比 qingpet 的做法强一个量级,应作为两者中的范本。
但版本号来自 enumerate(_ADD_COLUMN_MIGRATIONS, start=1),即列表下标。 这意味着 _ADD_COLUMN_MIGRATIONS 必须永远只追加:
| 操作 | 后果 |
|---|---|
| 在列表中间插入一条迁移 | 其后所有迁移的版本号 +1 → 它们在旧库上被视为"未应用"而重新执行(ADD COLUMN 会因列已存在而失败或被 _execute_add_column_migration 吞掉),同时新插入的那条因占用了已记录的版本号而被跳过,永不执行 |
| 删除列表中的一条 | 同上,整体错位 |
| 重排 | 同上 |
代码里没有任何注释、断言或测试声明这条约束。 schema_migrations 表本身存了 sql 原文,完全可以用它做校验: 应用前比对"该版本号已记录的 SQL 是否与当前列表中的一致",不一致就报错退出。 这是一处成本极低、后果极重的加固点。
N-55 [低] 【中】重复日程展开忽略集合自己的 timezone 列,改用固定偏移 —— DST 下墙钟会漂
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
id UNINDEXED, title, content, tags, category
)这是独立内容(standalone)的 FTS5 表,不是 content='items', content_rowid=... 的外部内容表。两个后果:
存储翻倍:
title/content/tags/category在items和items_fts中各存一份。content上限 50 000 字符 (_normalize_common_fields),对笔记/日记为主的库,这几乎是整库体积翻倍。同步是手工的,而且散布在至少 6 个位置:
位置 操作 insert_item:773_update_ftsupdate_item:840-841_refresh_fts(且带_FTS_FIELDS命中判断)delete_item:2083DELETE FROM items_ftsbatch_soft_delete:1157DELETE FROM items_fts(批量)_restore_edit_snapshot:2755-2757_refresh_fts_apply_batch_operations→db.py:1328、969、1050、2457_update_fts/_refresh_fts任何一条新的写路径忘记同步,搜索索引就会静默漂移—— 删掉的条目仍能被搜到,或改过标题的条目搜不到。 目前这 6+ 处看起来都记得,但这是靠人维持的。
改用外部内容表 +
AFTER INSERT/UPDATE/DELETE触发器后, 同步由 SQLite 保证,漂移在结构上不可能发生, 同时消除存储翻倍。代价是外部内容表需要rowid对应, 而items.id是 TEXT 主键,需要引入一个 INTEGER rowid 映射—— 这是一次有明确收益的重构。
N-56 [低] 【中】rrule 是 LLM 自由文本,零校验直接进 rrulestr —— 且子日粒度会撞主键
rrule 的全部来源是 ai_parser 的输出("rrule": "RFC5545格式或null", ai_parser.py:176),utils/validators.py 里没有任何 rrule 相关校验 (grep -rn rrule utils/validators.py 零命中)。
① 畸形规则 → 泛化错误
rrulestr(rule, dtstart=...) 对畸形输入抛 ValueError, _expand_recurring_instances 无 try/except → 冒泡到 handle_command_errors → 用户拿到通用错误。对照同文件对时间范围解析的处理(975-988 给出了具体的格式提示), 这里应当同样给出可操作的提示。
② 子日粒度 → 同一批次内主键冲突
instance_id = f"{collection_id}_{instance_dt.strftime('%Y%m%d')}" # :526
event_node_key = instance_dt.strftime("%Y%m%d") # :521实例 ID 只精确到天。只要 rrule 是 FREQ=HOURLY / MINUTELY / SECONDLY, 或 FREQ=DAILY;BYHOUR=9,18,同一天就会产生多个实例 → child_id 完全相同 → create_event_collection_with_children 的循环里第二条 INSERT INTO items 撞 items.id 主键 → IntegrityError → 整个事务回滚 → 用户拿到通用错误, 且没有任何提示告诉他"每 2 小时"这种说法不被支持。
用户说"每两小时提醒我喝水"是完全自然的表述,LLM 生成 FREQ=HOURLY;INTERVAL=2 也完全合理。
修法二选一:
- 收窄:显式校验
FREQ∈ {DAILY, WEEKLY, MONTHLY, YEARLY},否则返回明确文案; - 放宽:
event_node_key改用%Y%m%dT%H%M,并同步改_looks_like_id(2144-2156) 的(?:\d{8}|m\d{2,})分支。
注意:_looks_like_id 的后缀正则与 _create_recurring_event 的 ID 构造格式 是两处独立表述同一约定,改一处必须改另一处;两者之间没有共享常量、没有测试断言关联。
N-57 [低] 【P1】创建重复日程 = 最多 365 次「拉取该用户全部日程」
_create_recurring_event(463-474):
if not allow_conflict:
for instance_dt in instances: # 最多 365 次
conflict = await self._check_conflict(...) # 每次一个 run_sync而 _check_conflict → reminder_service.detect_conflict(services/reminder.py:338-356):
items = get_all_items(user_id, filters={"type": "event"}, page_size=200) # 全量分页拉取
...
for item in items: # Python 侧逐条比较detect_conflict 本身就是一次该用户全部日程的全量读取(注释解释了为什么不能在 SQL 层筛: 混合 ISO 形式字典序不可靠 —— 又一处被 N-224 主线逼出来的代价)。
于是一条 /pendo event add 每天早上9点晨会(无 COUNT/UNTIL → 展开满 365 条):
- 365 次全量日程读取。用户有 2000 条日程时 = 365 × 10 页 = 3650 次 SQL + 约 73 万次 Python 侧时间解析与比较;
- 每次
run_sync占用一次插件级 bulkhead 名额(M2-5 主线), 整条命令会长时间占住工作线程; - 之后才是一个事务里 365 条
INSERT items+ 365 次_update_fts。
这是 pendo 单条命令代价的上界,且完全可由普通用户用一句自然语言触发。
修法(按性价比排序):
- 循环外一次性读取该用户全部日程,把区间列表传给一个纯函数做批量重叠判定 ——
detect_conflict的 Python 侧比较逻辑本来就与 DB 无关,拆出来即可; - 冲突检查发现第一个冲突就
return(当前已经是),但应先按时间排序候选, 使常见情况尽早短路; - 对
instances数量给出上限提示("该规则将创建 365 个日程,是否继续"), 走已有的need_confirm会话机制 —— 同文件add_event(243-253) 已有现成范式。
→ 与 N233-2「高频端点的代价按调用树算」是同一条方法论,这里是单次命令版本: _check_conflict 自身只有 12 行,看不出任何代价。
N-58 [低] 【中】编辑集合不同步子条目,两者字段是冗余存储
_edit_collection(1105-1148) 允许改 {"title", "content", "category", "location", "tags", "notes"}, 只写 event_collections 表。
而子条目在创建时把这些字段各复制了一份进 items (content / location / tags / category 见 502-516、625-634)。
后果:
_event_matches_list_filters(938-953) 同时看节点和集合的 category/tags, 所以聊天列表页看不出问题——这正是它长期没被发现的原因;- 但
db.get_items的分类/标签下推过滤(N-248)、FTS 索引(_FTS_FIELDS含category/tags)、web/analytics/*的全部GROUP BY category、list_event_categories的DISTINCT—— 全部读items的旧值。
即:改完集合分类后,聊天里 /pendo event list cat:新分类 能查到, Web 统计页和搜索却还按旧分类归类。
修法:_edit_collection 成功后,在同一事务内把被改的共享字段传播到 family.children;或者反过来,明确子条目这些列为派生只读、 读取时一律 COALESCE(collection.x, item.x)。当前是两者都可写、都不同步。
N-59 [低] 重新理解 N-8:这不是"疏忽",是一次**没做完的时区迁移
前面几批把 41 处朴素 datetime.now() 描述为"绕开自有时区体系"。 读完租约与外发箱子系统后,这个判断需要换一个更准确的说法。
db.py 里有一个严格的 UTC 守卫,而且它是主动 raise 的:
@staticmethod
def _as_utc(value: datetime, field_name: str) -> datetime:
"""要求显式时区并转换到 UTC,杜绝本机时区参与租约计算。"""
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must be timezone-aware")
return value.astimezone(timezone.utc)它有 9 处调用点,全部集中在租约类子系统: claim_reminder / claim_reminder_repeat / release_reminder_* / complete_reminder_* / claim_scheduled_delivery / complete_scheduled_delivery / release_scheduled_delivery。 prune_operation_logs 也有等价的内联断言。
于是 db.py 实际是两个世界:
| 子系统 | 时间纪律 | 强制方式 |
|---|---|---|
| 提醒租约、调度外发箱、日志保留 | UTC,必须带时区 | _as_utc / 内联 raise,共 10 处强制 |
条目 CRUD、items.created_at/updated_at、operation_logs.created_at、撤销窗口阈值 | 服务器本地朴素 | 无 |
结论应当修正为:pendo 正在从"朴素本地"迁往"UTC 带时区", 新写的子系统已经迁完并且加了断言,老的条目 CRUD 还没迁。 prune_operation_logs(N-12 记的那个 bug)正是两个世界的交界处—— 新世界的 UTC 阈值去比较老世界的朴素列,于是保留策略被整体平移。
这个认识改变修复建议的形状:
- 不应只是"把
datetime.now()换成datetime.now(timezone.utc)"—— 那会在每个交界处制造新的 N-12; - 应当按子系统边界推进:先用
_as_utc那套断言把items/operation_logs的写入侧全部迁到 UTC, 同时做一次历史数据迁移(把已有的朴素本地字符串按部署时区转成 UTC), 再把所有比较阈值一并切换。这是一次需要停机或双写的变更, 必须整体规划,不能逐点修补。
_as_utc 的 docstring "杜绝本机时区参与租约计算" 说明作者对此完全清醒, 只是迁移停在了一半。
N-60 [低] scheduled_delivery_outbox 是一份教科书式的事务性外发箱
# claim:先幂等建行,再条件领取
INSERT INTO scheduled_delivery_outbox (...) VALUES (..., 'pending', ...)
ON CONFLICT(task_name, owner_id, period_key) DO NOTHING
UPDATE scheduled_delivery_outbox
SET state = 'leased', claim_token = ?, claim_expires_at = ?, updated_at = ?
WHERE task_name = ? AND owner_id = ? AND period_key = ?
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
AND (state IN ('pending','failed')
OR (state = 'leased' AND (claim_expires_at IS NULL OR claim_expires_at <= ?)))要点逐条都对:
- 幂等建行 + 条件领取分两步,
ON CONFLICT DO NOTHING保证并发建行不冲突; - 领取条件覆盖三种可领状态:
pending、failed(重试)、 以及租约已过期的leased(崩溃 worker 的自动回收); - 退避:
next_attempt_at IS NULL OR next_attempt_at <= ?; rowcount != 1即返回None——没抢到就是没抢到,不猜测;- 完成与释放都带
claim_token校验 (AND state = 'leased' AND claim_token = ?), 过期后复活的旧 worker 无法完成或释放别人的租约; sent_at = COALESCE(sent_at, ?)——重复完成时保留首次发送时间;failure_count = failure_count + 1供上层判断是否放弃;- 全部时间戳过
_as_utc,杜绝本机时区。
这一段与提醒租约(N-21)、qingpet 的 scheduler_runs(M-1) 共同构成本仓库三份可靠的"周期任务只执行一次"实现。 pendo 这份是三者中最完整的(多出租约过期回收与 failure_count), 应作为 core 提供 scheduled_outbox 能力时的蓝本。
N-61 [低] 【中】_check_conflict 在重复日程里回传的是原始 parsed_data,不是冲突实例
update_user_settings(3063-3105)本身是正确的:
# 进程内锁覆盖 SQLite 取得写锁前的短窗口;BEGIN IMMEDIATE 同时串行化
# 其他进程的设置更新,防止两个 JSON patch 互相覆盖。
with self._settings_lock, self.transaction(immediate=True) as conn:
...
settings_json = json_patch(COALESCE(user_settings.settings_json, '{}'), excluded.settings_json)双层串行化(进程内锁 + BEGIN IMMEDIATE)且注释说明了各自覆盖的窗口, json_patch 做局部合并。如果调用方只传"本次要改的那个键",这就是全对的。
问题在 settings_utils.save_user_setting(N-15)传的是整个快照:
settings = db.get_user_settings(user_id) # ← ①可能命中 LRU 缓存
custom = parse_custom_settings(settings)
custom[key] = value
settings["settings_json"] = normalize_settings_json(custom, partial=True) # ← ②全量 custom
db.update_user_settings(user_id, settings) # ← ③scalar 列也全量写回三处叠加使原子性失效:
- ②
custom_patch里带着读取时刻的全部键,json_patch于是把 所有键都按旧值写回——json_patch的原子性只保证"这次 patch 不被撕裂", 不保证"patch 内容是新鲜的"; - ③
timezone/quiet_hours_*/daily_report_time等标量列 由merged无条件写回,同样来自旧快照; - ①
get_user_settings先读 LRU 缓存(2982-2985,CACHE_TTL = 30秒), 所以旧快照最多可能陈旧 30 秒。
即:聊天端在 T 时刻改隐私模式、Web 端在 T+5 秒改时区, 后提交的那次会把前一次整个覆盖。窗口由 CACHE_TTL 界定为 ≤30 秒 (这也是它没有被更早发现的原因——窗口短且是个人使用场景)。
修法:save_user_setting 只传变更部分:
db.update_user_settings(user_id, {"settings_json": {key: value}})update_user_settings 的 merged = {**current, **settings} 与 json_patch 都已经支持这种"只给增量"的调用形态—— 再次印证 N52-1:"正确实现已在仓库内,只是调用方没那么用"。
N-65 [低] 【P1·主线收敛】混合时间形式的产生侧,实际只有 handlers/event.py 一个文件
N-8 / N10-1 最初记的是「41 处朴素 datetime.now()」, N-51 / N269-1 把它收敛为「产生侧零防御」。本组做了最后一步定位:
$ grep -n "datetime.now()" handlers/*.py
handlers/event.py:406: created_at = datetime.now().isoformat()
handlers/event.py:498: created_at = datetime.now().isoformat()
handlers/event.py:616: created_at = datetime.now().isoformat()handlers 层五个文件里,只有 event.py 用服务器本地时间。 其余四个全部用用户墙钟:
| 文件 | 取当前时间的方式 | 写入 created_at 的形式 |
|---|---|---|
handlers/note.py:258,268 | await get_user_local_wall_time(user_id, db) | 用户本地朴素 |
handlers/diary.py:180…438,246 | await get_user_local_wall_time(user_id, db) | 用户本地朴素 |
handlers/ledger.py:586(now 同源) | get_user_local_wall_time | 用户本地朴素 |
handlers/task.py:453,480-481 | self._user_local_now(user_id) | 用户本地朴素 |
handlers/event.py:406,498,616 | datetime.now() | 服务器本地朴素 |
而 utils/time_utils.py:78-82 就是为此提供的共享入口:
async def get_user_local_wall_time(user_id: str, db: Database) -> datetime:
"""在线程池读取用户时区,并返回供日期规则使用的朴素墙钟时间。"""
current = cast(datetime, await run_sync(now_in_timezone, user_id, db))
return current.replace(tzinfo=None)结论(应直接写进正文时区一节,替换原来的"41 处"表述):
产生侧的不一致,在命令层收敛到 3 行(
handlers/event.py:406/498/616), 在数据层收敛到 5 行(db.py:760/761/811/885/886,见 N35-3)。 共享的正确入口get_user_local_wall_time已经存在,且已被 4 个 handler 中的 4 个使用。 这不是需要设计的修复,是需要执行的修复。
→ N101-2「正确实现已在仓库内」清单第 16 处,且是其中修复成本最低的一处。
→ 同时更正N-52 的表格:items.created_at 的三种形式中, "用户本地朴素"不止 Web 一个来源 —— 聊天侧的待办/笔记/日记/记账四类条目全部是用户本地朴素, 只有日程是服务器本地朴素。也就是说,同一个用户在同一分钟内建一条待办和一条日程, 两行的 created_at 相差一个时区偏移。
N-68 [低] 【中】_task_sort_key 对 deadline_at 做字符串排序
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N68-1 | 中 | db.py:4012 + MANIFEST.in:19 | FTS 重建工具运行时不可达(详见 N-66) |
| N68-2 | 中 | db.py:3928、3948、3957 | 三条"损坏行跳过"的 WARNING 都不记录 item_id / owner_id,导致既无法定位也无法修复;同时记录对用户完全不可见(详见 N-65) |
| N68-3 | 低 | db.py:4031-4039 | rebuild_fts_index 一次 fetchall() 全量行含 content,无分批 |
| N68-4 | 低 | db.py:3993-4010 | _update_fts 用 DELETE + INSERT 两条语句更新单条索引。对独立内容 FTS5 表可行,但每次更新都产生一次删除+插入;update_item 已用 _FTS_FIELDS 命中判断减少调用次数,此处仍可考虑仅在内容确有变化时才重写 |
| N68-5 | 低 | db.py:3937-3938 | data["deleted"] = bool(...) 与 data["is_favorite"] = normalize_bool_flag(...) 对两个同为整数存储的布尔列用了两种转换方式。normalize_bool_flag 是给外部输入用的宽松解析器(N18-1 那四个布尔解析器之一),用在数据库读取路径上属于杀鸡用牛刀,且两列行为不一致 |
| N68-6 | 低 | _restore_deleted_item_ids | 与 batch_soft_delete 同样使用 if cursor.rowcount: restored_ids.extend(batch) 的整批记账(N51-3 同型),同样靠前置的 _select_owner_item_ids 筛选保证正确 |
N-69 [低] 【低】_user_local_now 是 get_user_local_wall_time 的第二份实现
| 编号 | 级别 | 内容 |
|---|---|---|
| N69-1 | 中 | "损坏数据静默消失"是个人数据类插件的一个共性风险。pendo 的 _row_to_item 跳过坏行(N-65)、qingpet 的 _log_database_failure 返回哨兵值(M 组)、arxiv_filter 的条目解析失败也是跳过。三者都只留服务端日志。对用户自己录入的数据,静默消失比报错严重得多。建议统一约定:解码失败的记录必须(a)日志带主键,(b)在用户可见结果里以某种形式出现。codex 的 reasons 分类回传(K4-3)是现成的范式 |
| N69-2 | 中 | 修复/维护工具被打包规则排除在运行时之外。rebuild_fts_index 只能由 prune 掉的脚本调用(N-66)。这与F-0 的教训相反方向——那次是我把 prune 掉的训练脚本误算进运行时缺陷;这次是真正需要在运行时可用的能力被 prune 挡在了外面。审查打包配置时两个方向都要看:不该进包的有没有进,该能用的是不是被排除了 |
| N69-3 | 低 | 动态 SQL 的三道防线(字段白名单 + 标识符正则 + 排序列白名单)值得上提为 core 的 SQLite 基类能力(N-64),与 N32-1 的 ThreadLocalDatabase 建议合并。_quote_col 与 _like_contains_pattern 的实现可以直接搬 |
N-74 [低] 【中】账目的标题/备注被静默截断,而待办的同名字段会报错
_step_description:496-506 与 _parse_quick_ledger_data:694 对 title不做任何校验(无长度、无控制字符检查),直接交给 normalize_ledger_fields → _normalize_common_fields → validate_title → sanitize_text(validators.py:155-181):
if len(text) > max_length:
text = text[:max_length] # 静默截断
text = re.sub(r"[\x00-\x08...\x7f]", "", text) # 静默删除控制字符对照 handlers/task.py:169-176:
def _validate_task_title(value: str) -> str:
"""校验待办标题,并拒绝公共校验器会静默截断的超长输入。"""
if UNSAFE_CONTROL_RE.search(value):
raise ValueError("待办标题包含不允许的控制字符")
...
if len(title) > 200:
raise ValueError("待办标题不能超过 200 字")同一个插件、同一层、同一个底层校验器,两类条目的纪律相反, 且待办侧的 docstring 明说是为了绕开公共校验器的静默截断—— 作者知道这个问题,只在待办上修了。
这是"禁止静默截断"纪律的第 4 个数据点(另三处:handlers/search.py:213-221、 handlers/task.py:169-196、N-262 的 Web 侧对照), 也是 N265-1「哪一层更严谨是错误的问题」的第 6 组对照。
修法:把 sanitize_text 拆成 sanitize_text(清洗)+ require_within(max_len)(拒绝), 让截断成为调用方必须显式选择的行为。当前它是缺省行为,因此每个想要"拒绝"的调用点 都得自己再写一遍前置检查。
N-79 [低] update_event_collection_reminders 是"多行原子重写"的正确写法(正面)
批量改写一个集合下全部节点的提醒时间,是最容易写出"改了一半"的场景。 这个方法(1443-1551)把四道关卡按正确顺序排好:
先确认集合存在,否则
raise ItemNotFoundException(collection_id);再一次性校验全部子节点 ID 确实属于该集合(分批
SELECT,missing_ids = set(item_ids) - matched_ids,非空即抛)—— 在任何写入之前完成,因此不存在"前几个改了、第五个发现不属于我"的中途失败;逐节点更新并强制
rowcount != 1即抛:pythonif cursor.rowcount != 1: raise RuntimeError(f"event reminder update lost item: {item_id}")在
transaction(immediate=True)内抛出即整体回滚;集合自身的更新同样
rowcount != 1即抛。
再加上每个节点后紧跟的 self._sync_reminder_logs(cursor, item_id, remind_times) (在同一事务内同步提醒队列),整套操作是全成或全不成。
这与N-21(提醒租约)、N-60(调度外发箱)、N-50(待办迁移)一起, 构成 pendo 数据层四处"把并发与部分失败当作一等问题处理"的实现。 它们的共同特征是:先校验后写入、每步 rowcount 断言、失败即抛让事务回滚。db.py 里凡是按这个模式写的方法都是可靠的; 问题几乎全部出现在不按这个模式写的那些(with conn: + 事后读 rowcount)。
N-81 [低] [P1 补充] 同一行数据的 created_at 与 updated_at 可以是两种时间形式
_parse_note_text:390-392:
content_provided = bool(content_text.strip())
if not title:
title = content_text # 没有显式 title: 时,标题 = 正文_build_edit_updates:870-871:
if parsed["_title_provided"]: # = explicit_title or content_provided
updates["title"] = parsed["title"]对创建而言这是合理设计(无标题笔记用正文首段当标题)。 但对编辑而言:一条原本有标题的笔记(用 title:"季度复盘" content ... 创建), 执行 /pendo note edit abc123 补充一段新内容 → explicit_title=False、content_provided=True → _title_provided=True → 标题被整段新正文覆盖,原标题无声消失。
用户没有任何提示;回执里的 display_title 显示的正是那段被当作标题的正文, 但用户会以为那只是回显。
修法:编辑路径应当区分 _title_provided(显式给了 title:)与 _content_provided,只在前者为真时写 title。 所需的两个标志在 ParsedNoteText 里已经分开存了(:400-401), 只是 _title_provided 的定义把两者又合并回去了。 → 又一处"能力已具备但没接线"(N37-1,pendo 第 5 处)。
N-82 [低] 【中】三个元数据解析器,三种锚定强度;最弱的那个会静默改写日记正文
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N82-1 | 中 | db.py:1437 | changed 读到的是审计 INSERT 的 rowcount(详见 N-80) |
| N82-2 | P1 | db.py:1416、1472 vs 1291 | 同一行的 created_at / updated_at 可以是两种时间形式(详见 N-81) |
| N82-3 | 低 | db.py:1559 | ORDER BY COALESCE(event_index, 999999), start_time, id —— 用 999999 作为"无序号"的哨兵。多节点日程的节点数远达不到这个量级,实践安全;但魔数应提取为常量并注释,否则读者无法判断它是上限还是哨兵 |
| N82-4 | 低 | db.py:1521-1522、1536-1537 | raise RuntimeError(f"event reminder update lost item: {item_id}") —— 异常文案里带 item_id。是用户自己的 ID、且经 public_error_response 脱敏后才到用户手上,风险很低;但这是 db.py 里少数把变量插进异常文案的地方,与该文件其余部分(_log_database_failure 式的"只记类型不记内容",见 qingpet M 组)纪律不同 |
| N82-5 | 低 | db.py:1409-1415 | update_event_collection 在 clean_updates 为空时返回 False,与"更新未命中"返回同一个值。调用方无法区分"你没给任何合法字段"和"这个集合不存在或不属于你"。前者是调用方 bug,后者是正常业务分支,应当区分 |
| N82-6 | 低 | db.py:1421 | update_event_collection 用 with conn:,而 update_event_collection_reminders 用 self.transaction(immediate=True)——又一对兄弟方法采用不同事务写法(N72-4 记的是 create_* 那一对)。db.py 里 create 与 update 两组集合方法各自内部都存在这种分叉 |
N-83 [低] 【中】五个列表命令里有两个没有分页
| 编号 | 级别 | 内容 |
|---|---|---|
| N83-1 | 中 | db.py 的可靠性与"是否按同一模式写"高度相关。四个按"先校验 → 写入 → 每步 rowcount 断言 → 失败即抛回滚"模式写的方法(N-79 提醒重写、N-50 待办迁移、N-60 外发箱、N-21 提醒租约)没有发现任何并发或一致性缺陷;而本轮审查在 db.py 里记下的中/高级问题(N30-1 事务嵌套、N-80 rowcount 被覆盖、N35-1 缓存失效在事务外、N-45/N51-1 软删除硬删提醒历史、N-70/N-81 时间形式分叉)全部出现在不按该模式写的方法里。这是一条可操作的结论:与其逐条修,不如把那四个方法的写法提炼成模板并逐个改写其余写入方法 |
| N83-2 | 中 | "事后读 rowcount"应作为一条机械检查项。判据:cursor.rowcount 的读取点与产生它的 execute 之间,是否隔了另一条 execute、或跨出了 with 块?pendo 有两处(N-80 隔了语句、N62-6 跨出块)。这类问题不会被测试覆盖到,因为正常路径下取值恰好相同 |
N-87 [低] 【P1】「安全版本已存在但用的是不安全版本」—— 一次普查找到三组
N64-1(只被测试调用的方法)在本组扩展成了一个更普遍的形态。 对 utils/formatters.py 与 core/exceptions.py 的每个导出符号做 "排除 tests/ 后是否仍有调用方"的检查,结果如下。
组 ①:BOUNDARY_TAG_TOKEN_RE 定义了、全仓零引用
# utils/formatters.py:77-81
TAG_NAME_PATTERN = r"[\w一-龥-]+"
TAG_TOKEN_RE = re.compile(rf"#({TAG_NAME_PATTERN})") # 被 4 处使用
BOUNDARY_TAG_TOKEN_RE = re.compile(rf"(?<!\S)#({TAG_NAME_PATTERN})(?=\s|$)") # 零引用$ grep -rn "BOUNDARY_TAG_TOKEN_RE" --include=*.py .
plugins/pendo/utils/formatters.py:79 ← 只有定义处词边界安全版本就摆在不安全版本的下一行,谁也没用它。 生产中使用 TAG_TOKEN_RE(无边界)的位置: note.py:144,389(extract_tags 间接)、diary.py:389、event.py 的 is_tag_token。
这与N-55 的 N-82(日记元数据正则缺 (?<!\S) 会静默改写用户正文) 是同一个根因的两个表现:
这个插件里所有"从自由文本抠 token"的正则,安全写法都已经被写出来过, 但每个使用点各自复制了一份不带边界的版本。
→ N-82 的修复不需要设计新正则,直接用 BOUNDARY_TAG_TOKEN_RE 和 ledger.py:52 的 (?<!\S)…(?!//) 模式。 N101-2 清单第 20 处。
组 ②:extract_metadata / CATEGORY_TOKEN_RE / PRIORITY_TOKEN_RE 只被测试调用
$ grep -rn "extract_metadata" --include=*.py .
plugins/pendo/utils/formatters.py:266 ← 定义
tests/plugins/test_pendo_transactions_sessions.py:485,488,494 ← 唯二调用方extract_metadata 是统一的元数据抽取器(cat: / #tag / p:N 三合一), 它的两个正则 CATEGORY_TOKEN_RE(:80) 和 PRIORITY_TOKEN_RE(:81) 都正确带了 (?<!\S) 词首锚定,_remove_token(:92-96) 还专门处理了 "删除 token 后两侧空白接缝"的问题。
也就是说:这个插件曾经有过一个写得对的、共享的元数据解析器, 后来每个 handler 各自又写了一份(ledger _FIELD_RE、diary 两个、note 一套、task 一套), 而原来那个正确的版本被留在原地、只剩测试在维持它存活。
这是 N64-1 的第 2 例,也是 R-1(手写重复助手)在 pendo 内部最完整的一次显形: 不是"没有共享实现",而是"有共享实现但被绕开了"。
组 ③:8 个业务异常里有 5 个从未被 raise
生产 raise 仅出现在 main.py 的元组里
ItemNotFoundException ✅
ItemAlreadyDeletedException ✅ (db_ops.py:203)
MissingRequiredFieldException ✅
InvalidTimeFormatException ❌ ✅
PastTimeException ❌ ✅
InvalidDateRangeException ❌ ✅
NaturalLanguageParseException ❌ ✅
InvalidFieldValueException ❌ ✅这 5 个只出现在 main.py:91-98 的 _SESSION_INPUT_EXCEPTIONS 元组里 ——一个专门用来捕获它们的白名单,而它们永远不会被抛出。
而 core/exceptions.py:1 的模块 docstring 写的是:
"""Pendo 运行路径实际使用的业务异常。"""
这句话对其中 5 个是错的。 该文件显然被清理过一次(docstring 就是那次清理的产物), 但只删掉了完全无引用的,留下了"被 import 但从不 raise"的这 5 个。
这一条的实际代价不是死代码,而是错失的用户体验:
class InvalidTimeFormatException(PendoException):
def __init__(self, time_str, expected_format=None):
format_hint = f"\n期望格式: {expected_format}" if expected_format else ""
super().__init__(..., f"❌ 时间格式不正确: {time_str}{format_hint}")带"期望格式"提示的时间错误消息已经写好了,而实际的时间解析失败路径 (event.py:975-988、task.py:456、ledger.py:934、diary.py:352) 全都是手写字符串或 f"❌ {exc}"。 同理 InvalidFieldValueException 的"有效值: a, b, c"格式, 正是N-56(rrule 畸形只给通用错误)所缺的东西。
→ 建议:要么让这 5 个真正上岗(各解析点改抛它们,handle_command_errors 已经能识别 PendoException.get_user_message()),要么连同 _SESSION_INPUT_EXCEPTIONS 一起删掉。当前状态是最坏的一种:既有维护成本,又给人"错误处理已经分类过了"的错觉。
N-88 [低] 【中】默认提醒会给一个朴素时间的日程产出带偏移的提醒时间
event_support.default_reminders:52-67:
start_dt = datetime.fromisoformat(start_time)
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=TimezoneHelper.DEFAULT_TZ) # ← 硬编码默认时区
now = TimezoneHelper.now(start_dt.tzinfo)
return [(start_dt - offset).isoformat() for offset in _DEFAULT_REMINDER_OFFSETS if ...]而紧接着调用的 ensure_start_time_reminder:124-130 追加的是:
normalized.append(datetime.fromisoformat(start_time).isoformat()) # ← 保持原样,朴素
normalized.sort() # ← 字符串排序于是一条朴素开始时间的日程,其 remind_times JSON 数组里会同时存在:
"2026-08-01T09:00:00+08:00"(默认提醒,被强行贴了DEFAULT_TZ)"2026-08-02T09:00:00"(开始时刻提醒,朴素)
混合时间形式在这里不是跨行的,而是同一个字段的同一个数组内部。 后果:
normalized.sort()是纯字符串排序 →+08:00后缀让同一天的两个时刻排错;format_event_reminders(256-279) 与_format_collection_reminders用log_map.get(remind_time)按精确字符串取提醒发送状态 → 一旦投递侧写日志时用的是另一种形式,状态永远显示 ⏳(待发送), 即使提醒已经发出去了;- 这是"默认时区被当作用户时区"的第 3 处(另两处:N-54 集合
timezone、 N-76_datetime_for_rule_delta)。三处都用Asia/Shanghai,都不看用户设置。
修法:default_reminders 不应贴任何时区——它的三个偏移量都是相对量, start_dt - timedelta(...) 在朴素时间上同样成立。 只有"是否已过期"的比较需要时区,而那个比较可以在调用方用统一基准做。
N-93 [低] 【中】静默时段是 fail-open,而同插件的过期判定是 fail-closed
N22-1 记过条目 ID 只有 8 位十六进制(models/item.py:33), db.py:758 的兜底是 32 位;N72-2 又发现 _prepare_new_event_collection 带一个 id_length 参数但不知道谁在用。本组找到了:
# _apply_event_collection_import_operations:1083-1087
collection = self._prepare_new_event_collection(collection, now=now, id_length=16)于是全插件的 ID 长度分布是:
| 对象 | 生成位置 | 长度 | 位数 |
|---|---|---|---|
条目(用户可见,如 abc12345) | models/item.py:33 | 8 | 32 |
| 条目(DB 兜底) | db.py:758 | 32 | 128 |
| 日程集合(正常创建) | _prepare_new_event_collection 默认 | 32 | 128 |
| 日程集合(导入) | _apply_event_collection_import_operations:1086 | 16 | 64 |
16 位(64 bit)在碰撞上是安全的;32 位更安全;只有 8 位那一档有实际风险 (N22-1:1 万条约 1.2%、10 万条约 69%,且 items.id 是全库主键、无 IntegrityError 处理)。
但四档长度并存本身说明没有统一的 ID 生成策略。 修 N22-1 时应当一并定一个规则(例如"用户可见 ID 12 位、内部 ID 32 位") 并把 id_length 参数去掉。
N-94 [低] 【中】本地降级解析器产出的 rrule 是安全子集,LLM 路径却不受任何约束
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N94-1 | 中 | db.py:1107-1113 | 集合导入 update 分支缺 rowcount 校验,失败静默(详见 N-92) |
| N94-2 | 中 | db.py:1078、947、1033 | 导入路径的三处时间戳全部是朴素本地 datetime.now()。按N-84 的口径,transfer_logs / imported_bundles 属于"审计与撤销体系",用本地时间是自洽的;但 _apply_event_collection_import_operations 的 now 会成为导入集合的 created_at——而正常创建路径 create_event_collection_with_children 写的是 UTC(N-70)。于是 event_collections.created_at 现在有三个来源、两种形式(正常单建=朴素、正常带子节点=UTC、导入=朴素) |
| N94-3 | 中 | db.py:965 vs 993 | batch_insert_or_update 用 with conn:,execute_import_bundle 用 self.transaction(immediate=True)。两个都是导入入口,事务写法不同(N-34 主线)。更要紧的是 batch_insert_or_update 不做 bundle 幂等占位、不写迁移审计——它是一条绕过 execute_import_bundle 全部保护的旁路。应确认是否还有调用方;若只是历史遗留应删除 |
| N94-4 | 低 | db.py:1070 | cache_invalidate(f"event_collections|{owner_id}") —— N-71 那 7 处永不匹配的空操作之一(本组确认的第 7 处) |
| N94-5 | 低 | db.py:1104 | data["updated_at"] = collection.get("updated_at") or now —— 导入包里的 updated_at 被原样采信。导入方可以写入任意时间戳(包括未来时间),进而影响 ORDER BY updated_at 的排序与"最近修改"类展示。条目侧 _apply_batch_operations:885-886 用的是 setdefault,同样保留外部值。属于导入信任模型的一部分(数据是用户自己的),但应在文档里写明"导入会保留原始时间戳" |
| N94-6 | 低 | db.py:1035-1036 | raise DuplicateBundleImportError(bundle_id) —— 异常携带用户提供的 bundle_id。与 N82-4 同类,风险低但与 db.py 其余部分的"异常不带内容"纪律不一致 |
N-95 [低] 【中】_extract_relative_time 把"今晚/明晚"解析成上午 9 点
| 编号 | 级别 | 内容 |
|---|---|---|
| N95-1 | 中 | "同一功能存在一条绕过全部保护的旁路"(N94-3:batch_insert_or_update 相对 execute_import_bundle)。这与N-33(update_item 的 CAS 只有一个调用方用)、N-15(save_user_setting 绕开 json_patch 的正确用法)是同一形状:加固版与原始版并存,而原始版仍可调用。建议正文把这类"旧入口未随新入口一起收口"列为一条检查项——它们的危险在于代码看起来已经加固了 |
| N95-2 | 低 | ID 生成策略在单个插件内有四档长度(N-93)。与N18-1(四个布尔解析器、三套真值集合)、N-34(四种事务写法)并列,构成 pendo 的"同一件事有 N 种做法"清单。这三条建议在正文合并成一节,因为它们的成因相同(缺少唯一入口)且修复方式相同 |
N-99 [低] 【P1】N5-1 的第三次独立推导:导出文件也写在包目录里,且从不清理
exporter.py:84-86:
def _get_export_dir(user_id: str) -> Path:
safe_user_id = _sanitize_user_id(user_id)
return Path(__file__).parent.parent / "data" / "exports" / safe_user_id至此 pendo 有三处各自独立算出"包目录下的 data/":
| # | 位置 | 写什么 | 附录 |
|---|---|---|---|
| 1 | services/db.py | SQLite 主库 + JWT 密钥 | N5-1 |
| 2 | utils/db_ops.py:30-39 | 同一个库(写法不同,参数名 _context 显式声明不用上下文) | N-14 |
| 3 | services/exporter.py:84-86 | 用户导出的全部原始数据 | 本条 |
三处没有任何一处读 context.data_dir(全仓 grep -rn "data_dir" plugins/pendo 零命中)。
第三处的后果比前两处更重:
- 文件永不删除。
export_markdown只write_text,main.py:_export_cmd发送后也不清理(全插件grep unlink只在scripts/migrate_*.py里有命中,而那些脚本已被MANIFEST.inprune —— 见 N-66)。 每次/pendo export都在包目录里留下一份用户全部数据的明文副本, 文件名由用户自选,累积无上限。 - 内容是全量原始数据:
_collect_items:314-340对每个选中类型走get_all_items(page_size=500)全量分页拉取, 再在 Python 侧过滤范围。/pendo export 备份 all= 该用户所有日程、待办、 笔记、账目、日记正文(上限 5 万字符/条)落成一个纯文本文件。 - 升级即丢失:包目录随发行包替换,用户的导出历史和主库一起消失; 容器场景下包目录常是只读挂载 →
mkdir(parents=True, exist_ok=True)(:150) 直接抛错。
→ N5-1 的修复必须同时改三处,且第三处还要补一个保留策略 (发送成功后删除,或按数量/天数轮转)。
N-100 [低] 【中】导出文件的绝对路径会被回显到聊天里(可能是群聊)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N100-1 | P1 | db.py:2876-2887 | get_briefing_items 直接对混合格式的 start_time / end_time 做字符串比较,而同文件 get_events_for_range 已有正确解法。后果:每日简报漏掉带偏移时间的日程(详见 N-97) |
| N100-2 | 低 | db.py:2904、2919 + scheduled.py:1072、998 | LIMIT 10 与"还有 N 项"文案冲突,计数会少算(详见 N-98) |
| N100-3 | 低 | db.py:85-97、2264、2829、2878、2898、2917、2945 | 类型字面量 f-string 拼接是全文件普遍写法(详见 N-99,修订 N30-3) |
| N100-4 | 低 | db.py:2953-2976 | query_items_by_date_range 不使用缓存,而它是周/月财务总结每次都要调的取数入口(_generate_finance_summary_content)。财务总结每周/每月一次,代价可接受;但同一个方法若被列表页复用会成为热点 |
| N100-5 | 低 | db.py:2830 | AND (event_role IS NULL OR event_role IN ('single', 'multi_node_child', 'recurring_occurrence')) —— 这三个值正是 validators.EVENT_ROLES 的全部内容,即该条件等价于"event_role 是合法值或为空",实际过滤不掉任何有效数据。若意图是"排除集合头",那么集合头本来就不在 items 表里(它在 event_collections),这个条件是冗余的;若是为了防御脏数据,应加注释说明 |
| N100-6 | 低 | db.py:2816-2817 | TimezoneHelper.parse(start_date, user_timezone) —— start_date 参数名暗示是日期(YYYY-MM-DD),但传给了一个解析 datetime 的函数。TimezoneHelper.parse 内部用 datetime.fromisoformat,对纯日期串会得到当天 00:00,行为正确但参数命名与实际契约不符 |
N-101 [低] 【中】_parse_datetime 的 replace(tzinfo=None) 在这里是对的——但它和 N-67 是同一行代码
exporter._parse_datetime:405-409:
if dt.tzinfo is not None:
# 导出范围是用户看到的日历时间,保留 ISO 文本自身的墙钟值;不能让
# 运行机器的本地时区改变条目所属日期。
return dt.replace(tzinfo=None)handlers/task.py:_parse_date_text:151:
"""解析数据库日期,并把带时区的旧值归一为本地朴素时间。"""
return parsed.replace(tzinfo=None) if parsed.tzinfo is not None else parsed两处是逐字相同的操作,一处正确一处错误,区别只在用途:
- 导出:语义是"这条记录属于哪一天",保留原文墙钟是正确的 (一条
18:00-05:00的日程,导出档案里应当归到写它时看到的那一天); 而且注释说明了为什么不能换算。 - 待办滞后判定(N-67):语义是"它是否已经过了绝对时刻", 必须先换算再比较;而 docstring 却声称做了换算。
→ 这一条修正了 N-67 的修法表述:不能机械地把全仓 replace(tzinfo=None) 都改成 astimezone(...).replace(...)。 判据是:
比较的是"日历归属"还是"绝对先后"? 前者保留墙钟,后者必须先换算。
exporter.py 这一处应当作为正文里解释这个区分的样板, 因为它是唯一把理由写下来的地方。
N-105 [低] 【中】导出上限 100 000 / 导入上限 20 000 —— pendo 能导出自己无法导入的包
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N105-1 | 中 | db.py:388-426 | operation_logs 零索引,撤销与清理路径全表扫描(详见 N-104) |
| N105-2 | 中 | db.py:311-358 | 提醒日志的聚合扫描与状态回填 UPDATE 每次插件加载都执行(详见 N-103) |
| N105-3 | 中 | db.py _init_database | 八个建表/迁移函数中只有 _ADD_COLUMN_MIGRATIONS 走版本账本,其余七个是"每次重跑"(详见 N-103)。N-54 对 schema_migrations 的正面评价应限定在其覆盖范围内 |
| N105-4 | 低 | db.py _init_database | 用 self.transaction()(DEFERRED)而非 immediate=True,与文件中其余 9 处写事务的纪律不一致 |
| N105-5 | 低 | db.py:378-379 | scheduled_delivery_outbox 同时有 PRIMARY KEY (task_name, owner_id, period_key) 和 UNIQUE (delivery_key)。由于 delivery_key 由 scheduled_delivery_key(task, owner, period) 从主键三元组派生,这条 UNIQUE 在逻辑上冗余;它实际起的作用是断言派生函数是单射的。这个意图值得写一行注释,否则容易被当作多余约束删掉 |
| N105-6 | 低 | db.py:192-206 | reminder_logs 表没有任何保留或清理策略(对比 operation_logs 的 90 天)。每条提醒(含重复提醒)一行,随使用时长单调增长,而 N105-2 的启动扫描成本正比于它的大小。应为已确认且早于某时限的行加清理 |
N-106 [低] 【中】写出用 "\n".join,读入用 splitlines() —— 不是同一套行边界
写出(_prepare_bundle_members:256-257):
lines = [json.dumps(record, ensure_ascii=False) for record in records]
payload = ("\n".join(lines) + "\n" if lines else "").encode("utf-8")读入(_read_record_member:638):
file_lines = file_bytes.decode("utf-8").splitlines()str.splitlines() 在 8 种边界上切分:`\n \r \r\n \v \f \x1c \x1d \x1e \x85
。 NDJSON 只认 \n`。
而 json.dumps(..., ensure_ascii=False) 不会转义 / / \x85 (它们在 JSON 字符串里是合法的裸字符,ensure_ascii=True 才会转成 )。
于是:一条内容里含 U+2028(行分隔符)的日记或笔记—— 它可以来自任何富文本复制粘贴,Word/网页里很常见——
- 写出时是一行;
- 读入时
splitlines()把它切成两行; - 两半都不是合法 JSON → 产生两条逐行错误,该记录丢失;
- 更糟:
line_count变成 N+1,与清单里的count不符 →_read_record_member:673-674抛Count mismatch,整包拒绝。
即:pendo 自己导出的包,只要有一条记录含 U+2028,就无法被 pendo 导入。 而且报错是"Count mismatch",完全指不到真正的原因。
修法(三选一,推荐第 1):
- 读入改
file_bytes.decode("utf-8").split("\n")(NDJSON 的定义就是\n); - 写出改
ensure_ascii=True(代价:包体积变大、可读性下降); - 写出时对这几个码位显式转义。
注意:ensure_ascii=False 同时出现在 _prepare_bundle_members:256 和 write_bundle:305(manifest)。manifest 走 json.loads 不分行,不受影响。
N-107 [低] 【低】MAX_ARCHIVE_MEMBERS = len(TYPE_FILE_NAMES) + 2
五个方法(两组 claim/complete/release)逐条对齐,没有一处例外:
| 保证 | 实现 |
|---|---|
| 时间必须带时区 | 全部 datetime.now(timezone.utc) 或 self._as_utc(retry_at, ...) |
| 单条件 UPDATE 完成状态转移 | 五个方法各自只有一条 UPDATE ... WHERE,无"先查后写" |
| 令牌校验 | complete/release 全部带 AND state = 'claimed' AND claim_token = ? |
| 结果判定 | 一律 return cursor.rowcount == 1 |
| 租约过期自动回收 | claim 的 WHERE 含 OR (state = 'claimed' AND (claim_expires_at IS NULL OR claim_expires_at <= ?)) |
| 退避 | claim 的 WHERE 含 AND (next_attempt_at IS NULL OR next_attempt_at <= ?) |
| 失败计数 | release 一律 failure_count = failure_count + 1 |
| 参数校验 | claim_reminder_repeat 开头 if expected_repeat_count < 1: raise / if lease_seconds <= 0: raise |
其中两处细节值得单独指出:
repeat_count被当作乐观锁版本号使用:sql-- claim_reminder_repeat WHERE ... AND repeat_count = ? -- 期望值,由调用方从上一次读取带来 -- complete_reminder_repeat SET repeat_count = repeat_count + 1 WHERE ... AND repeat_count = ?即"领取第 N 次重复"与"完成第 N 次重复"共用同一个期望计数。 两个 worker 同时看到
repeat_count = 2时,只有一个能领到; 完成时再校验一次,防止过期租约的持有者把计数推进两次。 这是version列之外,本仓库第二处真正生效的 CAS (对照N-33:items.version的 CAS 只有一个调用方在用)。complete_reminder_claim的两处幂等写法:sqlSET state = 'sent', sent_at = COALESCE(sent_at, ?), -- 首次发送时间只写一次 last_sent_at = ?, -- 最近发送时间每次更新 repeat_count = MAX(repeat_count, 1), -- 不会把已有计数降下来COALESCE与MAX使这条语句重复执行也不会破坏历史数据。
结合N-60(scheduled_delivery_outbox)与 N-21(_settle_reminder_delivery 的上层结算逻辑),pendo 的提醒/投递子系统是本轮审查中 唯一没有发现任何并发或一致性缺陷的完整子系统。 它也再次印证N83-1:db.py 的缺陷完全集中在不按这套模式写的方法里。
N-110 [低] 【P1·主线定案】混合时间形式的第 4 个产生源,而且这一个是**对的
| 编号 | 级别 | 内容 |
|---|---|---|
| N110-1 | 中 | "危险的默认参数"应当作为一条独立检查项(N-108)。判据:省略这个参数时,行为是最保守的还是最宽松的? pendo 的 confirm_reminder(remind_time=None → 全确认、owner_id=None → 不校验归属)、delete_event_collection(cascade=True 默认级联删子数据,N89-5)都是反例;而 get_item(owner_id=None)、update_item(owner_id=None) 同样默认不校验归属。这类默认值把安全性外包给了每一个调用点,与N-16(core/router.py 单向校验)、N37-1(能力未接线)同属"看起来有保护"的家族 |
| N110-2 | 低 | repeat_count 当版本号用(N-107)是一个值得记录的手法:不额外加 version 列,直接用业务上已有的单调计数器做 CAS。qingpet 的 merged_onto 需要显式 version 列(M-4),pendo 的外发箱用 claim_token,这里用业务计数器——三种 CAS 载体各有适用场景,可一并写进 docs/07-advanced.md |
N-114 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N114-1 | P1 | db.py:3798-3835、3837-3865 | 每分钟两次跨用户全表扫描且无索引支撑(详见 N-112) |
| N114-2 | 中 | db.py:3861 | get_all_events_with_reminders 用 SELECT * 取回全部 55 列(含 content,上限 5 万字符),而提醒判定只需要其中 8 列左右 |
| N114-3 | 中 | db.py:3754-3766 | get_reminder_logs(item_id) 不带 owner_id 过滤,任何调用方传入一个 ID 就能读到该条目的全部提醒历史。同文件的 get_reminder_logs_by_item_ids 是带归属校验的(3786 WHERE i.owner_id = ?)。两个相邻方法一个校验一个不校验,且不校验的那个签名上看不出来——与N110-1(危险的默认参数)同族 |
| N114-4 | 低 | db.py:3818-3830 | 判定"该提醒是否仍在条目的 remind_times 里"是在 Python 侧逐行 json.loads(row["remind_times"])。同一条目有多条提醒日志时,同一份 JSON 会被重复解析多次。SQLite 的 json_each 可以把这个判定下推到 SQL:AND EXISTS (SELECT 1 FROM json_each(i.remind_times) WHERE value = rl.remind_time) |
| N114-5 | 低 | db.py:3847、3849、3850 | 又三处类型字面量 f-string 拼接(N-99 家族,累计约 19 处) |
| N114-6 | 低 | db.py:3689-3696 | _insert_confirm_for_unsent 在 remind_times 为空、非法 JSON、非列表三种情况下都静默 return,调用方 confirm_reminder 随后仍返回 {"status": "success", "message": f"已记录: {user_action}"}。即用户确认了一条不存在的提醒,会得到"已记录"的成功提示而实际什么都没发生。这是N78-3"静默 vs 报错不一致"清单的第 5 处 |
N-119 [低] 打包核对(与 N-66 相反的一次确认)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N119-1 | 中 | db.py:1912-1929 | get_all_items 用 page_size=200 循环调用 get_items 直到取完全部结果。而 get_items 每页都会写入共享 LRU 缓存(_cache_set,含一次 deepcopy)。导出一个有 10 000 条记录的用户会:发起 50 次查询、往 1 024 容量的全局缓存里塞 50 条(把其他用户的条目与设置缓存全部挤出)、并在内存里同时持有 10 000 个 Item。应给它一条"不写缓存"的取数路径,或直接用游标流式产出 |
| N119-2 | 中 | db.py:1931-1936 | get_active_user_ids 是 SELECT DISTINCT owner_id FROM items WHERE deleted = 0。idx_owner_type(owner_id, type, deleted) 的前导列是 owner_id,因此 SQLite 可以走索引扫描而非表扫描——比预期好;但它仍是 O(索引大小),且每分钟被三个定时任务各调一次(N22-2)。用户数远小于条目数,应当维护一张 owners 小表或至少加缓存 |
| N119-3 | 低 | db.py:1902-1910 | _resolve_item_sort 对非法 sort_field / sort_order 静默回退到 created_at DESC。防注入方向正确,但用户拼错排序字段时不会得到任何提示,结果按另一种顺序返回。这是N78-3"静默 vs 报错不一致"清单的第 6 处 |
| N119-4 | 低 | db.py:3212-3222 | log_transfer 的 return 语句写在 with conn: 块内。Python 会先求值再退出上下文管理器(提交),因此行为正确;但"在事务块内 return"是一种容易被误读为"未提交就返回"的写法,且与同文件其它方法(先算后返)不一致 |
| N119-5 | 低 | db.py:450 | _LEDGER_AMOUNT_CENTS_EXPR 是一个裸 SQL 片段常量,被 f-string 插入三处查询。当前值不含用户输入所以安全;但它是全文件唯一一个"以字符串形式流转的 SQL 表达式",与 _quote_col / 白名单那套纪律不同源 |
N-120 [低] 全站 XSS 面普查:未发现可利用漏洞,但纪律是约定式的
普查方法与结果
$ grep -rn 'href="${' 全部前端 → 0 命中
$ grep -rn 'src="${' 全部前端 → 0 命中
$ grep -rc innerHTML pages/*.js components/*.js → 共 28 处,最多的文件 4 处
$ grep -rc textContent pages/*.js → 【全部为 0】
$ grep -rln escapeHtml . → 16 / 27 个 JS 文件关键结构事实:10 个页面模块没有任何一处使用 textContent, 全部渲染都是"拼一个大模板字符串 → container.innerHTML = ..."。 因此整个应用的 XSS 安全性 100% 依赖每个插值点手工调用 escapeHtml。
逐点核查结论(抽查了全部可疑插值)
初筛出 30 处"插值了用户字段却没有直接包 escapeHtml"的位置, 逐个追到渲染点后全部确认安全:
| 位置 | 初看可疑 | 实际 |
|---|---|---|
search.js:130-180 itemTitle/itemPreview/itemMeta | 三个函数返回未转义拼接 | 渲染点 renderCard:429-436 对每一个返回值都 escapeHtml,含 aria-label |
dashboard.js:396 heading | 未转义拼接 | :405 输出时 escapeHtml(heading) |
ledger.js:1330 accountText | 未转义拼接 | :1332 输出时 escapeHtml(accountText) |
stats.js:930 ${item.text} | 原样插入 HTML | 是有意的:item.text 由本模块构造并含 <strong>,其中每个用户值都单独 escapeHtml(:861、:890) |
notes.js:1246 ${renderMarkdown(...)} | 对笔记正文渲染 Markdown | 见下节,实现正确 |
renderInlineMarkdown(notes.js:201-211) 的顺序是对的
let html = escapeHtml(textValue(text)); // ① 先整体转义
html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); // ② 再在转义结果上做标记替换
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');"先转义再加标记"是唯一正确的顺序(反过来做的实现全都可被绕过)。 链接部分另有两道保险:
- URL 必须匹配
https?://→javascript:/data:被排除; - URL 已经过 ① 的转义,其中的
"已是"→ 无法逃出href="..."属性; - 且带了
rel="noopener noreferrer"。
代码块分支 renderMarkdown:238 也是 escapeHtml(line)。 这是一个写对了的迷你 Markdown 渲染器,可作为样板。
但整套纪律没有任何机制保障
components/modal.js 里存在唯一一处类型化的信任边界:
const SAFE_HTML_VALUE = Symbol('pendo.safe-html');
export function safeHtml(value) { ... return Object.freeze({ [SAFE_HTML_VALUE]: true, value }); }
function appendModalContent(parent, value, label) {
if (value instanceof Node) { parent.appendChild(value); return; }
if (!value || value[SAFE_HTML_VALUE] !== true || typeof value.value !== 'string') {
throw new TypeError(`${label} must be a DOM Node or safeHtml(...) value`);
}
...
}docstring 写得很准:"此函数只建立显式信任边界,不会替调用方清洗任意外部 HTML"。 Symbol 作 brand + Object.freeze → 外部无法伪造。
问题在于它只保护模态框(全站 28 处 innerHTML 里的 2 处)。 其余 26 处是裸的 element.innerHTML = 模板字符串, stats.js:930 那种"我知道这段是自己构造的 HTML"完全靠注释都算不上——靠记忆。
→ N120-1 [中] 建议:把 safeHtml 从 modal.js 提到 utils/ui.js, 提供 setHtml(el, safeHtmlValue) 并让所有页面渲染走它。 改动量小(26 处各加一层 safeHtml(...)), 收益是下一个漏掉 escapeHtml 的插值点会在开发期抛 TypeError 而不是在生产变成 XSS。 这正是本报告 N79-3(缺省站在哪一边)在前端的对应物。
N-121 [低] 【P1】前端是混合时间形式的最终受害者,且两类值在界面上无法区分
utils/format.js:parseDate → formatDateTime:
// 纯日期按本地午夜解析,避免浏览器把 YYYY-MM-DD 当作 UTC。
date = new Date(DATE_KEY_PATTERN.test(text) ? `${text}T00:00:00` : text);
...
return `${isoDate(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;纯日期那一支处理得完全正确(注释点名了浏览器把 YYYY-MM-DD 当 UTC 的经典陷阱, 并额外用 isoDate(date) !== text 做往返校验拒绝 2026-02-30)。
问题在另一支——new Date(text) 对带日期时间的字符串:
| 库里的值 | new Date() 的解释 | 界面显示 |
|---|---|---|
2026-05-01T18:00:00(朴素,聊天/Web 创建的绝大多数条目) | 浏览器本地时间 | 原样 18:00 |
2026-05-01T10:00:00+00:00(UTC,导入的条目,见 N-110) | 绝对时刻 | 转成浏览器所在时区后的墙钟 |
于是同一个列表里:
- 朴素行永远显示写入时那台机器/那个用户时区的墙钟数字, 与看它的人身处哪个时区无关;
- 带偏移行显示换算到浏览器时区的正确绝对时间。
用户可见症状:一个把 pendo 时区设为 Asia/Shanghai 的用户在纽约打开 Web, 自己录入的日程仍显示 18:00(应显示 06:00), 而从旧设备导入的日程正确显示成了纽约时间—— 两类值混在同一列表、同一列,没有任何视觉差异,用户无从判断哪个可信。
→ 这是 N-224 主线唯一一处终端用户直接看得见的后果, 应当写进正文时区一节的开头作为"为什么必须修"的论据。 → 同时它说明 N-268 的第 ④ 步(统一消费侧)必须包含前端: 即使后端全部迁到 UTC,new Date(...) + getHours() 也只会按浏览器时区渲染, 而 pendo 的语义是"用户设置的时区",两者不是一回事。 正确做法是把 settings.timezone 下发给前端并用 Intl.DateTimeFormat(locale, { timeZone }) 渲染。
N-122 [低] 【中】10 个路由模块只有 1 个带缓存击穿参数,且服务端没有 Cache-Control
app.js:7-16:
registerRoute('dashboard', () => import('./pages/dashboard.js?v=20260430')); // ← 唯一带 ?v=
registerRoute('events', () => import('./pages/events.js')); // ← 其余 9 个都没有
registerRoute('tasks', () => import('./pages/tasks.js'));
... 共 10 条服务端(web/server.py:94-96):
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")Starlette 的 StaticFiles 默认只发 ETag / Last-Modified,不发 Cache-Control。 浏览器对没有 Cache-Control 的响应会启用启发式缓存 (常见实现取 Last-Modified 距今时长的 10% 作为新鲜期),期间不发条件请求。
后果:一次部署之后,
dashboard.js因为?v=20260430变了 URL → 必定重新下载;events.js/ledger.js/ … 可能在数小时内继续用旧副本;- 而
app.js/api.js/router.js本身同样可能是旧的。
→ 可以出现"新版 dashboard + 旧版 events"的混合前端, 两者对同一批 API 契约的假设不同(本次迭代恰好改过 Web token 过期时间, 提交 0660e3e)。这类问题的表现是"个别用户偶发报错、刷新几次就好了", 极难定位。
而且 ?v=20260430 是手写日期常量——它只在有人记得改的时候才起作用, 本轮审查时它已经是三个月前的值。
修法(择一):
- 给
StaticFiles挂一层中间件:*.js/*.css发Cache-Control: no-cache(允许缓存但强制条件请求,配合已有 ETag 即可); - 或构建期给全部资源加内容哈希文件名。 当前这种"给一个文件加手写版本号"的做法,比不加更危险—— 它制造了"缓存问题已经处理过了"的印象。
N-126 [低] 【重要修正 N-120】"不用 textContent"是页面层独有的,组件层用得很对
N-62 的普查结论需要按层收窄。重新分层统计:
| 层 | textContent | innerHTML | 渲染方式 |
|---|---|---|---|
components/toast.js | 2 | 0 | createElement + textContent |
components/pagination.js | 3 | 0 | 同上 |
components/modal.js | 1 | 1(经 safeHtml 类型闸) | 混合,有信任边界 |
components/custom_select.js / form.js / sidebar.js / header.js | 0 | 4 | 模板字符串 + 逐点 escapeAttr/escapeHtml |
pages/*.js(10 个) | 0 | 20 | 纯模板字符串 |
而 toast.js 的文件头 docstring 把理由写得非常清楚:
消息使用原生文本节点渲染,避免把接口错误或用户输入解释为 HTML; 提示类型也只允许使用已有样式,未知值统一回退为普通信息提示。
也就是说:这个代码库不但知道 textContent 是正解,还写下了为什么。 它只是没有把这条纪律带到页面层——页面层要拼接结构化布局, 模板字符串更顺手,于是安全性退回成"每个插值点都要记得转义"。
→ N120-1 的建议因此更强了:safeHtml(modal.js)+ textContent(toast.js) 两种正确机制都已经存在于仓库中并附带理由, 只是分别只覆盖了 2/28 和 5/28 处渲染。 这是 N125-2 的又一实例,也是 N101-2 清单第 24 处。
N-127 [低] 【中】fetchItemRangeBounds 用排序取首尾,落在混合时间形式上会取错
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N127-1 | 中 | web.py:162-163、server.get_url() | 登录链接使用绑定地址,多数部署不可用(详见 N-124) |
| N127-2 | 中 | web.py:355-358 | 凭据投递是 P0-1 第 6 处;方向安全但会让用户陷入"每次都说失败、其实每次都成功"的重试循环(详见 N-125) |
| N127-3 | 低 | web.py:215、225 | Widget token 的有效期用小时展示:⏳ 有效期: 4320 小时。应换算为"180 天"。同一个值在私聊消息和公开回复里各出现一次,都是小时 |
| N127-4 | 低 | web.py:239、285、307 | _start / _stop / _status 都用 await run_sync(server.is_running) 正确卸载了那个阻塞的 urlopen 探测。这与 main.init() 里同步调用 web_server.is_running()(N5-8)形成直接对照——同一个函数,命令路径卸载了、初始化路径没有。这条对照使 N5-8 更明确:不是"作者不知道要卸载",而是初始化路径漏了 |
| N127-5 | 低 | web.py:243-248、312-318 | Web 地址与端口出现在可能发往群聊的回复里(manifest 未声明 contexts,N5-3)。默认 loopback 时信息价值有限,但配置成公网地址后就是基础设施暴露 |
| N127-6 | 低 | search.py:118-125 | search 固定 offset=0、limit=_SEARCH_RESULT_LIMIT(15),没有翻页入口。而 search_items_page 明确支持 offset,total 也被取回并展示。用户看到"共 N 条"却无法翻到第 2 页——与 /pendo todo list page:2、/pendo note list page:n 支持翻页不一致 |
N-132 [低] 【中】看板的"完成任务"绕过了聊天侧的状态机
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N132-1 | 中 | search.py:398 | 任务 priority 为 NULL 时渲染抛 TypeError(详见 N-130) |
| N132-2 | 中 | search.py:230 | 无 type= 时按 created_at 筛日期,语义意外且撞上混合时间格式(详见 N-131) |
| N132-3 | 低 | search.py:463 | idx = content.casefold().find(query.casefold()) —— 用 casefold 后的字符串求出的下标,回头去切原始 content。casefold() 对个别字符会改变长度('ß'.casefold() == 'ss'),此时下标会错位,预览窗口偏移。中文场景几乎不会触发,但这是一个真实的字符串处理陷阱:大小写折叠后的索引不能用于原串 |
| N132-4 | 低 | search.py:465-471 | 预览窗口固定为"命中位置前 10 字 + 共 SEARCH_CONTENT_PREVIEW_LENGTH(50) 字"。当关键词本身接近 50 字时(上限是 100,见 _SEARCH_QUERY_MAX_CHARS),窗口装不下完整命中,用户看到的片段里可能不含完整关键词。应按关键词长度动态扩窗或至少保证命中完整可见 |
| N132-5 | 低 | search.py:344-347 | 结果按 _SEARCH_TYPE_ORDER 固定顺序分组展示,而数据库返回的是按相关性(bm25)排序的一页。分组渲染会打散相关性顺序:最相关的一条若是 note,会被排到最后一组。对搜索而言,相关性顺序通常比类型分组更重要;至少在未指定 type= 时应保留原顺序 |
| N132-6 | 低 | search.py:421 | tags = [single_line_text(tag) for tag in item.tags[:2]] —— 只展示前 2 个标签且不提示还有更多。相邻的 remaining 处理(360-362)与 ...还有 N 条 都做了溢出提示,此处没有 |
N-137 [低] 【中】时区是纯文本输入框,而它决定整个插件的时间语义
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N137-1 | 低 | event_support.py:128、152、153 | 三处无防护的 fromisoformat(详见 N-135) |
| N137-2 | 低 | event_support.py:64 | 默认提醒静默丢弃已过期偏移(详见 N-136) |
| N137-3 | 低 | event_support.py:204、226 | event['id'] 用直接下标,而同一行上下文的 event.get('title', '无标题') 用 .get()。同一个 dict、同一个函数里两种访问方式;id 缺失时抛 KeyError 而非显示占位符 |
| N137-4 | 低 | event_support.py:235 | for conflict in conflicts[:3] —— 只展示前 3 条冲突且不提示还有更多。与N132-6(tags[:2] 无溢出提示)同型;而同插件的搜索结果(N-129 ④)与"还有 N 条"都做了溢出提示。三处截断,两处提示、一处不提示 |
| N137-5 | 低 | event_support.py:23-27、243-253 | _REMINDER_STATUS_LABELS 用emoji 字符作字典键("✅" / "📩" / "⏳"),由 get_remind_status 返回。功能正确(两个函数一一对应),但用显示字形当内部枚举键,改文案(比如把 📩 换成 📨)就要同时改两处才不 KeyError。应改为 "confirmed" / "sent" / "pending" 这类语义键,展示层再映射 |
N-140 [低] 命令元数据的布尔值故意比 API 输入更严格
# add_diary:194
# 元数据属于显式命令参数,非法布尔值等必须明确拒绝,不能静默改成 False。
# _parse_diary_text:768-775
elif key in {"favorite", "fav"}:
normalized_value = value.casefold()
if normalized_value in _TRUE_METADATA_VALUES:
result["is_favorite"] = True
elif normalized_value in _FALSE_METADATA_VALUES:
result["is_favorite"] = False
else:
raise ValueError("favorite 只接受 true/false、yes/no、on/off、1/0 或 是/否")这说明:pendo 的多个布尔解析器 一概列为"冗余重复",但这一处的严格是有理由的:
| 场景 | 解析器 | 未知值的处理 | 理由 |
|---|---|---|---|
| Web/API 表单 | validators.normalize_bool_flag | 视为 False | 表单可能缺字段,宽容合理 |
| 持久化设置 | settings_utils._coerce_setting_bool | 用调用方给的 default | 读旧数据要容错 |
| 聊天命令元数据 | diary._parse_diary_text | 抛错 | favorite:mabye 是用户拼错,静默当 False 会让用户以为设置成功了 |
三种场景的宽严不同是正确的。N18-1 应当收窄为: 真值字面量集合应当共享一份常量,而不是"四个解析器都该合并"。
不过本组也确认了字面量确实在扩散:_TRUE_METADATA_VALUES ({"1","true","yes","y","on","是","收藏"})与 validators.normalize_bool_flag 的集合完全相同,是第二份拷贝; _FALSE_METADATA_VALUES 则是全插件唯一的一份否定集合。 加上 config._parse_bool 与 settings_utils._TRUTHY/_FALSY, pendo 现有 5 处布尔字面量定义、4 套真值集合。
N-146 [低] 问题
ledger_insights.js:404的单一入口收敛:注释写明「在单一入口完成类型收敛, 后续 SVG 几何只处理可信的有限数值」,配合toFiniteNumber/toNonNegativeNumber/toCount三个收敛器 → 之后所有除法、Math.max、坐标计算没有一处再做 null 检查。 与dashboard.js:normalizeDashboardData(N-134)同一纪律,前端第 2 处贯彻到底的边界收敛。fullRingPath(:204-215) 带理由的特例:注释「起止点重合时单个 SVG 圆弧不可见, 完整圆环必须拆成两个半圆」 —— 单分类占 100% 时环形图不会消失。 这是那种「只有真的画出来过才会知道」的边界,且写下了原因。pickEvenAxisIndexes(:43-54) 注释「旧的向上取整步长会漏掉末尾」 —— 记录了被修掉的旧实现,属于本仓少见的「负面知识」注释。buildCandleSvg(:313-325) 用 domain 而非数组下标定位: 时间线含零支出日期,蜡烛只有非零日期,作者显式把两者合并成domainKeys再取下标 → K 线的 x 坐标与折线图的 x 坐标严格对齐。若按各自数组下标画,两张图会错位而不报错。tasks.js的四层不可信输入纪律:normalizeTask逐字段收敛(含TASK_STATUSES.has/ 优先级 1-5 /isIsoDate往返比对)→normalizeOverview去重 + 丢弃无 id 记录 →filteredTasks/sortTasks再filter(isRecord)→ 渲染点仍逐个escapeHtml。 注释「接口损坏记录不能进入筛选、排序或动作路径」点明了意图。_pendingTaskIds是按条目而非按页面加锁:同一任务的按钮disabled + aria-busy, 其它任务不受影响;boardCardHTML:977连draggable也一并关掉, 且dragstart里再查一次getAttribute('draggable') !== 'true'—— 同一约束在渲染侧与事件侧各表达一次(与dashboard.js:465同一手法)。- 筛选器互斥关系在状态里维护而不是靠 UI 禁用:
handlePlanChange选计划日期就清分类, 选分类就清计划日期与自定义范围(:1197-1226),filteredTasks:340-344的else if与之严格对应 → 界面状态与筛选逻辑是同一个不变量的两次表达。 updateCustomDate(:1255-1265) 用setCustomValidity+reportValidity走浏览器原生校验气泡,而不是自绘提示;非法值不写入_filters(拒绝而非静默回退) —— 对照后端 N10-6「时间范围解析失败静默回退今天」。openTaskModal的删除流程:先closeModal再showConfirmModal, 用户点「返回编辑」会重新打开原编辑框并带回原数据(openTaskModal(task)) → 取消一次危险操作不损失已填内容。render()/destroy()对 10 个模块级状态各自完整重置(与search.js同构),loadAndRender的_loadVersion护栏注释写明「路由销毁也会递增版本,使迟到响应自动失效」。
N-171 [低] 与 N-157 的叠加才是真正的问题
_customDraftStart/_customDraftEnd(:56-57, :1933-1935, :1994-2002) 正是 N-163 缺的那个机制:用户在自定义日期框里敲了一半,oninput就把草稿写进模块状态,重绘时优先用草稿回填 (_customDraftStart || _customStart || range.start)。 → N-163(账本快速记账被重绘清空)的修法就是照抄这 4 行。 N101-2「正确实现已在仓库内、只是没被复用」清单第 27 处, 而且这次的两处代码相距只有一个目录。renderComparisonCard:995-998把 N-144 的错误做对了:jsconst momVal = previousExpense ? ((expense - previousExpense) / previousExpense) * 100 : null; const momText = momVal !== null ? `${momVal >= 0 ? '+' : ''}${momVal.toFixed(1)}%` : '-';除数为零时返回
null并显示'-',没有把1.0当哨兵。 同一份代码库、同一种"环比"计算,ledger_insights.py:180-182用了落在合法值域内的哨兵 (N-144),这里用了独立的空值通道。修 N-144 时应直接对齐这一处的语义。三层"陈旧"防护(全站最完整):
_loadVersion(迟到响应)+_activeRequestSignature(同一范围的重复请求直接短路,:2011)_dataSignature(已渲染数据对应哪个范围)。 由此得到一个真正的 stale-while-revalidate:renderPage:1899只在_activeRequestSignature !== _dataSignature(即范围真的变了)时才显示整屏加载态, 否则保留旧图表并加aria-busy+ "正在更新..." 提示。 这是全站唯一不会在刷新时把内容闪没的页面。
每一个图表的输入都在调用点显式截断:
.slice(0, 8)/.slice(0, 6)/.slice(0, 5)/.slice(-8)/compressSeries(..., 28)/sampleIndexes(..., 18)。 因此 12 个原语里的Math.max(...rows.map(...))展开永远不会碰到参数个数上限, DOM 节点数也有上界。对照tasks.js(N145-6)与diary.js(N152-5)的无上界渲染。compressSeries(:285-320) 是"保峰值"降采样:首尾必留,中间按桶取绝对值最大的点。 对折线图这是正确取舍(等距采样会把尖峰采没), 而轴标签用的是等距的sampleIndexes—— 两种降采样各用在该用的地方。diffDays(:211-217) 特意用Date.parse(\${start}T00:00:00Z`)`, 即按 UTC 解析来算天数差;同文件其它地方(热力图分周、年份判定)用本地解析。 这个不一致是对的:跨 DST 边界时按本地解析会算出 n±1 天。renderSparkline:325的gradId带++_chartSequence, 且renderPage:1907每次重绘先把序号归零 → SVG 渐变 id 既不会在同一页冲突, 也不会随重绘次数无限增长。同一页出现多个 sparkline 时 id 冲突是这类手写图表最常见的 bug。12 个图表原语全部在数据为空时返回同一个
'<div class="stats-empty-card">暂无数据</div>', 没有一个返回空串或崩溃 —— 空态在原语层统一,卡片层无需判断。render()与destroy()对 14 个模块级变量逐一重置,两处列表完全一致。
N-180 [低] 补充:全仓"非原子写文件"普查结果
按N179-2 的建议立即执行了普查,结果比预期干净:
| 位置 | 是否运行时 | 判定 |
|---|---|---|
pendo/services/exporter.py:154 | ✅ 运行时 | 缺陷(N178-2) |
xiaoqing_chat/memory/thinking_back.py:102 | ✅ 运行时(plugins.xiaoqing_chat.memory 在 pyproject.toml:192 的 packages 列表内) | 待审查——它写的是聊天插件的记忆持久化,是下一阶段的重点核查项 |
xiaoqing_chat/experiments/anthropomorphic_group.py(6 处) | ❌ 离线 | 不计:MANIFEST.in:21 有 prune plugins/xiaoqing_chat/experiments,pyproject.toml 的 packages 也未包含该子包 |
正确使用 atomic_write_text 的运行时位置: ads_paper/storage.py(2 处)、arxiv_filter/main.py:551(1 处)。
结论:atomic_write_text 在本仓库是已知且被使用的助手, 运行时只有两处该用而没用(pendo 导出、xiaoqing_chat 记忆)。 这使 N179-2 建议的 CI 规则成本很低——只需豁免两处并逐个修掉。
同时这也是本轮审查第三次因"先确认是否随包分发"而改变结论 (前两次:F-0 的 arxiv_filter/train_model 109 处 print、 N-66 的 rebuild_fts_index 被 prune 导致运行时不可达)。 打包配置必须双向核对这一条已经反复得到验证。
N-183 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N183-1 | 中 | demo_space.py:123 | purge_demo_owner 结尾调用 db.cache_clear()——清空全插件共享缓存。而 purge_expired_demo_users 会在一次调度里循环 purge N 个过期用户,即 N 次全量清空。演示用户的数据与其他用户完全隔离(独立 owner_id),完全可以只失效该 owner 的键。与N47-2(撤销路径用 cache_clear)同型,是第 5 处过度失效 |
| N183-2 | 中 | demo_space.py:106 | purge_demo_owner 用 with conn: 包住 8 条 DELETE。它由 ensure_demo_access 调用,而后者在 deps.get_current_session 里每个演示请求都会执行(web/deps.py:88-90)。当前 ensure_demo_access 只在过期时才 purge,所以不会每请求都删;但这条路径意味着一次普通的页面请求可能触发 8 条 DELETE + 一次全量缓存清空,且发生在 FastAPI 的请求线程里。应把回收移交给那个每 6 小时的定时任务,请求路径只做拒绝 |
| N183-3 | 低 | demo_space.py:129 | now = now or datetime.now() —— 朴素服务器本地时间。_is_expired 能处理混用所以不出错,但它是N-8 那 41 处普查在 web 层的落点之一 |
| N183-4 | 低 | demo_space.py:288 | 速率限制的清理循环每次都遍历全部键(for key, request_times in tuple(_DEMO_REQUESTS.items())),与N5-5(_prune_expired 每请求 O(n) 全扫)同型。演示请求频率低(WEB_DEMO_REQUESTS_PER_HOUR = 3)所以影响可忽略,但两处应统一为带时间戳的惰性清理 |
| N183-5 | 低 | demo_space.py:19 | _DEMO_TEMPLATE_PATH = Path(__file__).resolve().parent / "assets" / "demo_bundle.pendo.zip" —— 又一处 __file__ 相对路径。此处是只读的包内资源(pyproject.toml:210 有 "plugins.pendo.web.services" = ["assets/*.zip"],确实随包分发),因此不属于N-175 记的那三处数据目录问题,是正确用法 |
N-186 [低] Web 层在三处比聊天层更严谨(值得作为整改依据)
本组读完后可以给出三条同一功能、两种实现、Web 更正确的对照:
| # | 主题 | Web API | 聊天 Handler |
|---|---|---|---|
| 1 | 乐观并发 | update_item 双层 CAS(N-185) | db_ops._db_update_item 从不传 expected_version(N-33) |
| 2 | "未分类"是否算已指定 | if item_type in {"event","note"} and (not category or category == "未分类"): item_data["category"] = resolve_default_category(...)(items.py:823-825)——把 "未分类" 当作未指定 | event.py:283:if not str(parsed_data.get("category") or "").strip(): ——"未分类" 被当成用户已指定,用户设置的默认分类失效(N168-5) |
| 3 | 可变字段控制 | 按类型的 MUTABLE_FIELDS_BY_TYPE 白名单 + 具名 422 | 各 handler 自行拼 updates 字典,靠 db._prepare_data 的全字段白名单兜底 |
第 2 条尤其有价值:它把N168-5 从"我认为这是缺陷"变成 "同一个判断在同一个插件里有两种写法,其中 Web 那份是对的"—— 整改时直接照抄 items.py:824 的条件即可。
这也与N184-2 归纳的形态一致,只是方向相反: 此前四组都是"聊天层某处对、另一处错",这三组是"Web 层对、聊天层错"。 合并起来看,pendo 的问题不是某一层不行,而是缺少跨层共享的写入契约。
N-187 [低] Web 创建路径的时间形式与聊天层一致(对 N-161 的补充)
now = now_in_timezone(owner_id, db).replace(tzinfo=None).isoformat()
item_data.update({..., "created_at": now, "updated_at": now, ...})Web API 创建条目时写的同样是用户本地朴素时间, 与 diary / ledger / note / task 四个聊天 handler 一致(N-161)。
因此N-161 那张表可以简化:
除了
handlers/event.py的三处裸datetime.now()(服务器本地) 与db.create_event_collection_with_children的 UTC 之外, 全插件所有创建路径(聊天 5 类 + Web API)写的都是用户本地朴素时间。
这让N147-1 的迁移第 0 步更可执行: 唯一需要特殊处理的就是 type='event',其余四类一律按"该行 owner 的本地时区"解释。
now_in_timezone 在这里是同步调用,但 create_item / update_item 都是 同步 def 路由,FastAPI 会放进线程池——按N-170 的分类,不构成阻塞缺陷。
N-188 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N188-1 | 低 | items.py:824 | 默认分类只对 event / note 生效,task / diary / ledger 不套用用户设置的默认分类。聊天侧同样只有 event.py 与 note.py 调用 resolve_default_category,所以两层是一致的;但 default_category 这个设置项的适用范围没有写进帮助文本(/pendo settings 的说明里只说"默认分类"),用户会以为它对全部类型生效 |
| N188-2 | 低 | items.py:841-844 | 日记标题缺省值用字符串切片取时分:entry_label = entry_time[11:16] if len(entry_time) >= 16 else ""。这依赖 entry_time 恰好是 YYYY-MM-DDTHH:MM… 的形状。同样的逻辑在 diary.py:257-258 是用 datetime.fromisoformat(...).strftime("%H:%M") 实现的——两层对同一个展示值用了两种解析方式,其中 Web 这份在时间形式变化时会静默产出空字符串 |
| N188-3 | 低 | items.py:867、889 | 先 db.get_item() 再 item_to_dict(item) 取 version,而 get_item 会走 30 秒 LRU 缓存(N77-5)。因此 current_version 可能来自缓存快照,比数据库实际值旧。所幸第 ⑦ 步的数据库 CAS 会以真实值判定并返回 409,最终结果正确;但客户端会看到一次"莫名其妙"的 409(它带上来的 version 其实是对的,只是服务端读到了旧值)。应在这条路径上跳过缓存读取 |
N-193 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N193-1 | 中 | bundle_import.py:123 vs 全部创建路径 | 导入写 UTC、创建写用户本地朴素,"导出→导入"会改变形式并持续制造混合格式(详见 N-192) |
| N193-2 | 低 | bundle_import.py:49 | _DEFAULT_SOURCE_ZONE = ZoneInfo(PendoConfig.DEFAULT_TIMEZONE) 在模块导入期求值——与N10-13(validators.py:18 的 DEFAULT_EVENT_TIMEZONE)同型,缺 tzdata 的环境会在 import 阶段抛 ZoneInfoNotFoundError。文件已 import ZoneInfoNotFoundError(第 8 行)却没在这里用上 |
| N193-3 | 低 | bundle_import.py:73-93 | _resolve_source_wall_time 对"歧义时刻"直接抛错。对导入而言这是正确的(宁可让用户知道),但对未来复用到数据迁移时需要另一种策略——一次迁移不能因为某一行落在秋季回拨的那一小时里就整体失败。复用时应加一个 on_ambiguous: Literal["raise","earliest","latest"] 参数 |
| N193-4 | 低 | bundle_import.py:105-106 | if value == "": return "" —— 空字符串原样返回,因此空的 start_time 会以 "" 而非 None 进入后续 normalize_item_fields。validators._normalize_event_time_fields 对 end_time in (None, "") 有处理,但 start_time 为 "" 时会走 if not start_time: raise ValueError("Event start_time is required")——结果正确(确实该报错),只是 "" 与 None 两种空值在整条链路上一直并存 |
N-197 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N197-1 | P1 | widget.py:44、event_schedule.py:24 | Web 分析层以 DEFAULT_TZ 为时间基准,与实际存储形式全都不符;同一响应内还与四个 *_overview 的用户时区基准混用(详见 N-195) |
| N197-2 | 中 | transfer.py:49 | DEFAULT_TIMEZONE 硬编码第二份,不跟随配置(详见 N-196) |
| N197-3 | 低 | widget.py:101、103 | start_time[11:16] / end_time[11:16] 用字符串切片取时分,与N188-2(items.py:843)、N-19(_get_item_time_info 的 entry_time[:16])同型。全插件已有 4 处用切片解析 ISO 时间,而 ItemFormatter.format_datetime 是现成的正确实现 |
| N197-4 | 低 | widget.py:38-49 | _parse_now 接受客户端传入的 now 参数(?now=...)。这只影响该次响应的展示,不写库,因此不构成安全问题;但它意味着小组件可以请求任意时刻的"今日摘要",而 _section_for 的 now.hour % 3 也随之可控。若将来用于缓存键或审计,需要重新评估 |
| N197-5 | 低 | widget.py:67-80 | _title_text / _preview_text 用 len(text) 判断截断长度。对含 emoji 或组合字符的标题,len() 计的是码位而非显示宽度,小组件里可能仍然溢出。这是展示层的常见取舍,记录备查 |
N-201 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N201-1 | 中 | transfer_bundle.py:407-418 | 成员读取用 zf.read(info) 一次性解压进内存,两侧大小检查无法阻止"声明小、实际大"的压缩炸弹(详见 N-200) |
| N201-2 | 低 | transfer.py:435-437 | try: size = int(content_length) except: size = 0 —— 畸形 Content-Length 被当作 0 从而跳过前置检查。这是安全的降级(后置检查仍会拦住),但把"解析失败"和"文件为空"混成同一个值;至少应记一条日志 |
| N201-3 | 低 | transfer.py:950-955 | page_size 从 x-transfer-page-size 头读取,max(1, min(100, int(...))) 钳位,解析失败回退 (1, 20)。钳位是对的;但用自定义请求头传分页参数而非查询串,会绕过大多数网关的日志与限流规则,且不便于调试。应改为 query 参数 |
| N201-4 | 低 | transfer_bundle.py:392 | MAX_ARCHIVE_MEMBERS = len(TYPE_FILE_NAMES) + 2 —— +2 是魔数(推测是 manifest.json 与 event_collections 文件)。派生方式很好,但那个 +2 应写成具名常量或注释说明,否则新增一个非类型文件时会静默超限 |
N-203 [低] 结清 N-200 的挂账:演示用户可以触达导入端点
web/api/transfer.py 的六个端点全部用 Depends(get_current_user):
/transfer/export/preview /transfer/export/download
/transfer/import/inspect /transfer/import/samples
/transfer/import/execute /transfer/logs按N-1 的分析,get_current_user 接受两类主体:
- Widget Bearer 令牌 —— 被四重收窄限制在
GET /api/widget/*, 因此够不到这些 POST 端点; - 浏览器 Cookie 会话 —— 而演示会话正是通过
create_web_session(..., demo=True)创建的普通 Cookie 会话(N-181)。
因此当 WEB_DEMO_ENABLED = True 时,匿名演示用户可以调用 /transfer/import/inspect 与 /transfer/import/execute, 从而触达N-200 描述的解压内存放大缺口。
这把 N201-1 的性质从"已认证用户可自伤"改为 "在启用演示的配置下,匿名访客可致进程 OOM"。
缓解因素有两条,都成立但都不充分:
WEB_DEMO_ENABLED默认为False(config.py:88), 且它是少数几个能从config.json配置的键之一(N5-6);- 演示空间有速率限制(
WEB_DEMO_REQUESTS_PER_HOUR = 3)与 活动会话上限(WEB_DEMO_MAX_ACTIVE_SESSIONS = 20), 但那限制的是创建演示会话的频率,不限制已建立会话的请求次数—— 一个演示会话在其 6 小时有效期内可以反复上传。
结论:N201-1 的修复(改用 zf.open() 有界读取)应当排在 "启用 Web demo 之前必须完成"的位置。若近期不打算启用演示, 则维持默认关闭即可,优先级为中;一旦计划开放演示, 它就是必须先修的一项。
顺带记一条:演示用户能否导出(/transfer/export/download) 也值得确认——演示数据是样例数据,导出本身无害, 但它会走N178-5 记录的 get_all_items 全量物化路径, 即匿名访客可以触发一次全插件缓存清空(N184-1)。
N-214 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N214-1 | 中 | commands/settings.py:87-89、:111-114、utils/settings_utils.py:159-163 | 三处以全量快照调用 update_user_settings,废掉其事务内新读;Web 的 settings.py:117 是同仓现成的正确写法(详见 N-204) |
| N214-2 | 中 | web/api/settings.py:31 + settings_utils.py:67-95 | settings_json 是 extra="forbid" 模型上的一个 allow-anything 洞,无键数/字节数上限,且该行处在每分钟全用户扫描 + 每次读深拷贝两条热路径上(详见 N-205) |
| N214-3 | 中 | auth_routes.py:43-51 + web/auth.py:142 | 设备会话列表只有随机 device_id 与时间戳,无 UA/IP/标签,且每次登录新建 device_id → 用户无法识别可疑会话(详见 N-206) |
| N214-4 | 低 | auth_routes.py:109-139 | POST /auth/demo 无请求体 → 跨站表单 POST 是简单请求、不触发预检、会真的执行,可用一个演示会话顶掉受害者的现有会话 Cookie。默认关闭,但属"启用 demo 前必须修"(详见 N-207) |
| N214-5 | 低 | web/api/__init__.py:17-31、server.py:92 | 认证逐路由声明而非路由器级,config_routes.py 三个端点无依赖;当前无泄露,但缺少默认拒绝(详见 N-208) |
| N214-6 | 低 | commands/settings.py:35 vs web/api/settings.py:38 | 同一 HH:MM 字段两套接受集合(聊天要求补零,Web 不要求);时区校验则是可直接合并的完全重复(详见 N-209) |
| N214-7 | 低 | services/db.py:3110 + web/api/settings.py:128 | update_user_settings 恒返回 True,Web 的 500 分支是死代码(N37-1 家族第 6 处,详见 N-210) |
| N214-8 | 低 | commands/settings.py:133-181、settings_utils.py:23-41 | default_category 只能从 Web 设置,却只在聊天端生效(详见 N-211) |
| N214-9 | 低 | web/api/settings.py:125-127 | _settings_patch_changes 拿 30 秒 LRU 快照与补丁比较,相等即返回"无变化"直接跳过写入。多进程部署下(该场景是 update_user_settings 注释自己承认的)会出现"网页说无变化、库里其实是另一个值"。属N189-2「读-比较-条件写,读那步必须绕过缓存」的第 2 例,且比 N188-3 更糟——那里 DB 层 CAS 还能兜底,这里写入被整个跳过 |
| N214-10 | 低 | auth_routes.py:101-106 | /auth/exchange 覆盖 Cookie 时不撤销旧会话,_SESSIONS 每次重新登录留一条孤儿记录到过期(N5-5 的又一产生路径) |
N-220 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N220-1 | 中 | commands/session.py:183-185 | need_info 分支不更新会话数据,多轮补充只保留首轮 base + 末轮增量(详见 N-216) |
| N220-2 | 中 | web/api/events.py:162-165 | 多节点集合子条目数无上限,整批在一个 IMMEDIATE 事务内插入(详见 N-218) |
| N220-3 | 中 | web/analytics/events_overview.py:59-71 | 概览日期范围无跨度上限,按天物化并整份返回(详见 N-219) |
| N220-4 | 中 | db.py:1290 + handlers/event.py:476-494,599-611 | 集合头 created_at 三种形式(聊天 UTC / Web 用户本地朴素 / 单建头服务器本地朴素);operation_logs.created_at 两种形式(详见 N-217) |
| N220-5 | 低 | web/api/events.py:142 | _normalize_collection_updates 用字面量 "2000-01-01T00:00:00" 作时间锚点喂给共享校验器。当前安全——返回值只取 updates 里的字段,不含从锚点算出的 remind_times(那些在 :310 用真实 child.start_time 重算);但共享校验器对"已过期偏移"的处理是静默丢弃(N26-4),一旦将来有人从这里取用时间派生字段就会踩中 |
| N220-6 | 低 | web/api/events.py:214 | 子条目 ID 形如 {32位hex}_{m01}(36 字符),是本插件第三种 ID 形式(模型 8 位、db.py:758 32 位、此处 36 位复合)。这一种是确定性的,反而是三者里最好的——同一集合重建不会产生重复行,可作为统一时的参考方向(N22-1 / N35-6) |
| N220-7 | 低 | commands/session.py:173 | 会话内的冲突迁移用 safe_create_session,而入口 handlers/event.py:233 用的是 safe_create_reply_scoped_session。当前没有 bug——后续消息必然从已收窄的私聊通道进来,此时 _pendo_reply_private 为假、两者行为一致;但这依赖一条没写下来的不变量,一旦将来允许群内继续会话就会分叉 |
| N220-8 | 低 | web/api/events.py:351 | delete_collection 在删除之前取 child_ids 写审计日志,取和删不在同一事务内。并发新增的子条目会被级联删除但不出现在日志里 → 撤销时恢复不全 |
N-226 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N226-1 | P1 | stats.py 27 处 + 全仓 87 处 | SQLite date()/strftime() 把带偏移的时间串按 UTC 解释,与导入产生的 UTC 形式叠加后统计口径整体位移(详见 N-224) |
| N226-2 | 中 | stats.py:56 | _today() 用服务器本地日期,是 Web 层第四种"今天"的定义(详见 N-223) |
| N226-3 | 中 | stats.py:220-228 | 支出直方图取回范围内全部支出行(range=all 即全部历史)只为算六个桶,且带一个用不上的 ORDER BY(详见 N-225) |
| N226-4 | 低 | stats.py:433,464,497 vs :141、web/api/events.py:96 | 同一类"日期参数非法"错误,三个 overview 端点映射为 400,_resolve_stats_range 与 /events/overview 映射为 422。同一个 API 两种约定,前端要写两套判断 |
| N226-5 | 低 | stats.py:28 | LEDGER_AMOUNT_EXPR = Database._LEDGER_AMOUNT_CENTS_EXPR —— 跨模块引用另一个类的私有属性,并拼进 f-string SQL。值是常量所以不构成注入,但 Database 改这个私有名会静默改变统计口径。应在 Database 上提供公开常量 |
| N226-6 | 低 | stats.py:344 | plan_key = str(plan_date or "").strip() or str(deadline_at or "")[:10] —— 用字符串切片从 deadline_at 取日期,是N197-3「ISO 串切片解析」的第 5 处(另四处:widget.py×2、items.py:843、search.py)。这一处尤其脆:deadline_at 若是带偏移形式,[:10] 拿到的日期与 date(deadline_at) 的结果可能差一天,而同一个响应里两种取法并存 |
N-231 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N231-1 | 中 | task_overview.py:101、notes_overview.py:71、dashboard_overview.py:28 | 三份独立的全量物化循环,无日期/数量收窄,且被小组件高频端点串联调用(详见 N-229) |
| N231-2 | 中 | widget.py:249-250,285,359-360 | 把 DEFAULT_TZ 基准的 today 传给两个 overview,使其正确的用户时区回退永不执行(详见 N-230) |
| N231-3 | 低 | web/utils.py:90-100 | item_to_dict 对异常形状返回 {},连一条日志都没有。API 响应里会出现空对象条目,前端无从判断是"没有数据"还是"序列化失败"。这比 db._row_to_item 的三条 WARNING(N68-2,至少有日志)更弱,是N69-1「损坏数据静默消失」在 API 层的形态 |
| N231-4 | 低 | web/utils.py:75-80 vs :26-72 | 同一个模块里两种错误纪律:normalize_item_type_query / normalize_choice_query / infer_item_query_type 抛 HTTPException(422),而 amount_filter_cents 抛 ValueError。调用方必须逐个记住哪个是哪种,漏包一层就是 500 |
| N231-5 | 低 | widget.py:121 | due_key = plan_date or deadline_at[:10] —— ISO 串切片解析的第 6 处(N197-3)。与 stats.py:344 是同一行逻辑的两份拷贝,两处都在同一个"任务到期日"语义上 |
| N231-6 | 低 | widget.py:305-316 | recent_ledger 在 DB 已经 ORDER BY ledger_date DESC LIMIT 5 之后,又在 Python 里连排两次(先按日期、再按"是否支出")。结果正确(Python 排序稳定,第二次排序保留了第一次的相对顺序),但依赖稳定排序这一性质而没有注释;且第一次排序完全冗余——DB 已经按同一个键排过 |
N-238 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N238-1 | 中 | dashboard_overview.py:216,248 | 首屏接口全量物化未完成任务与当月账目,用途只是一个计数和一条按日求和;同文件 _recent_counts 是正确写法(详见 N-234) |
| N238-2 | 中 | ledger_insights.py:193-210 + stats.py:257-297 | 日期参数可选,两者都不给时逐行取回全部历史账目(输出有界、输入无界)(详见 N-235) |
| N238-3 | 中 | dashboard_overview.py:284,291 | 按 ISO 字符串排序 start_time,与 web/api/events.py:216-226 显式规避的写法矛盾(详见 N-236) |
| N238-4 | 中 | 导入路径 + 全部混用日期列的统计 | 纯日期列不随导入换算、日期时间列换算,导入后两者差一天(详见 N-237) |
| N238-5 | 低 | ledger_insights.py:180-182 | if previous_total == 0: return None if current_total == 0 else 1.0 —— 上一周期为零时一律返回 1.0,前端渲染成"较上一周期 +100%"。从 ¥0 到 ¥10 与从 ¥0 到 ¥10000 显示完全相同,且 "+100%" 会被读成"翻了一倍"。应当返回一个独立的"无基线"状态(例如 delta_vs_previous: null 配合 delta_label: "上一周期无数据"),而不是一个会被误读的数字 |
| N238-6 | 低 | ledger_insights.py:28 | _LEDGER_AMOUNT_EXPR = Database._LEDGER_AMOUNT_CENTS_EXPR —— 跨模块引用私有属性的第 2 处(N226-5 是 stats.py:28)。两处一字不差地重复了同一行,包括那个基于它派生的 _..._TOTAL_EXPR。应在 Database 上提供公开常量并让两处引用它 |
| N238-7 | 低 | dashboard_overview.py:263 | day = str(getattr(item, "ledger_date", None) or today) —— 没有 ledger_date 的账目被静默计入今天。ledger_date 在写入路径上是必填的,所以当前不可达;但一旦出现(例如导入了外部数据),一笔历史账会悄悄出现在今天的支出趋势里。应当跳过并计数,而不是归到今天(N69-1 家族) |
N-244 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N244-1 | 中 | stats.py:478 + diary_overview.py:110-126 | 粒度自动粗化默认关闭("day" 而非 "auto"),叠加无跨度上限 → 可请求 7.3 万个桶(详见 N-241) |
| N244-2 | 中 | diary_overview.py:142 | _build_cadence 按天循环而非按桶循环,隔壁 notes_overview._cadence_slots 是正确写法(详见 N-242) |
| N244-3 | 中 | task_overview.py:181,185 | focus_tasks 与 all_tasks 无上限,同一响应另三个列表截断到 8;widget 每次轮询构造全部 payload 后丢弃(详见 N-243) |
| N244-4 | 低 | diary_overview.py:83 | _entry_label 用 raw[11:16] 从 ISO 串切出 HH:MM —— N197-3「ISO 串切片解析」的第 7 处。且 _entry_sort_key 会在 entry_time 缺失时回退到 created_at/updated_at,这三个字段都在 bundle_import._ITEM_DATETIME_FIELDS 里(N-237),导入后是 UTC 带偏移 → 日记列表显示的"时间"变成 UTC 小时。这是N-224 在用户界面上最直接可见的一处 |
| N244-5 | 低 | task_overview.py:58-82 | _normalize_task 把越界 priority 静默改成 3、未知 status 静默改成 "open"、非法 version 静默改成 0,全部无日志。写入侧有校验所以当前不可达,但一旦发生,一个 cancelled 的任务会以"进行中"的样子永久显示,且没有任何线索。属N69-1 家族;至少应记一条带 item_id 的 WARNING |
| N244-6 | 低 | diary_overview.py:67 | _load_diary_days 对无法解析的 diary_date 静默跳过(同上家族)。影响限于连续天数统计偏低,但用户会认为"我明明写了却断签了" |
N-249 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N249-1 | 中 | notes_overview.py:306 | recent_notes 返回完整正文(上限 50000 字符 × 6),唯一下游立刻截到 22 字;同层 diary_overview.py:278 用的是 80 字预览(详见 N-247) |
| N249-2 | 中 | notes_overview.py:237-252,328 | 日期/分类/标签筛选与 all_categories 全部在 Python 侧完成,而 db.get_items 支持前两者下推、list_event_categories 是现成的 DISTINCT 写法(详见 N-248) |
| N249-3 | 低 | events_overview.py:472-479 | build_event_collection_detail 返回全部子条目、无上限。对 recurring 集合受 EVENT_MAX_RRULE_COUNT=365 约束,但对 multi_node 集合没有任何上界(N-218:创建侧也没有)。即 N-218 的无界输入会一路流到这个详情端点的输出 |
| N249-4 | 低 | events_overview.py:428-437 | related_instances 的 [:12] 截断发生在列表推导完成之后——db.get_collection_events 返回全部子条目、逐个构造字典,最后只留 12 个。与 N-243、N-247 同属"为丢弃的数据付费",但规模小得多 |
| N249-5 | 低 | events_overview.py:191-194 | _summarize_reminders_in_range 对解析失败的提醒时间 continue 跳过、无日志。后果是该提醒不计入 reminder_summary.total,进而影响 reminder 筛选(with/none 的判定)—— 一条坏时间会让一个有提醒的日程被 reminder=none 筛选选中。属N69-1 家族,但这一处的静默跳过会直接改变筛选结果 |
N-251 [低] web/analytics/ 收尾小结
七个文件 2147 行读完后,这一层的整体判断:
做得好的:内部表示与对外表示分离(_TaskRecord / _NoteRecord / _OverviewEvent 三个 frozen slots dataclass)、批量取关联数据的形态统一、 出口字段白名单成体系、粒度自动粗化的思路正确、 参数校验区分"未提供"与"非法"。
贯穿全层的两个问题:
- 无界读取(N-229 / N-234 / N-235 / N-248)—— 七个文件里有 4 处全量物化循环 + 2 处无上限范围查询, 而它们产出的几乎全是聚合值(计数、求和、Top-N、分桶)。 这一层本该是 SQL 聚合最自然的地方,却成了物化最多的地方。
- 时间基准不统一(N-223 / N-224 / N-236 / N244-4)—— 四个
*_overview用now_in_timezone是对的, 但stats.py的_today()、widget.py的DEFAULT_TZ、 以及各处 ISO 串切片与字典序排序把这份正确性抵消了。
这两条应当合并成正文里针对 web/analytics/ 的一条整改项, 因为它们的修法有交集:把日期范围解析(N246-2 建议的 resolve_date_range)与聚合下推一起做, 在同一次改动里就能把"范围有界"和"按用户时区分组"两件事都落实。
N-255 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N255-1 | 中 | transfer.py:127-136 | _resolve_timezone 只捕获 ZoneInfoNotFoundError,漏掉 ValueError。ZoneInfo("../foo") 抛的是 ValueError(键必须是规范化相对路径)→ 未捕获 → 500 而非 422。而 timezone 是用户可控字段(ExportSelection.timezone,max_length=128)。同一判断在本仓库有四处实现,只有这一处漏了:web/api/settings.py:55 捕 (ZoneInfoNotFoundError, ValueError)、commands/settings.py:56 捕 (ValueError, ZoneInfoNotFoundError)、utils/validators.py:814-816 也捕了。修法是补上 ValueError |
| N255-2 | 中 | transfer.py:117-124 + :1033-1037 | 导入锁按 (owner, bundle) 分片,规划快照按 owner 取 → 同一用户并发导入两个含相同来源记录的 bundle 时 skip/overwrite 失效(详见 N-253) |
| N255-3 | 中 | transfer.py:774-827,1060-1075 | 三处关系重写静默丢弃不可解析的引用,而同文件的导出路径对每条问题记录都产出带 ID 的告警(详见 N-254) |
| N255-4 | 低 | transfer.py:938-959 | /transfer/import/samples 每翻一页都要求客户端重新上传整个 ZIP 并完整重新解析(_read_upload_body + _inspect_bundle_data),然后只切出 20 条。分页的本意是减少工作量,这里反而让工作量随页数线性增长;叠加N201-1(解压完整进内存)后,每一页都付一次完整解压代价。若要保持无服务端状态,至少应在响应里提示客户端"样例已一次性返回前 N 条" |
| N255-5 | 低 | transfer.py:1275 | filename 取自 x-transfer-filename 请求头,无长度和字符校验(Header(max_length=4096) 只加在 x_transfer_options 上),直接进 db.log_transfer 的审计记录。它不参与任何文件系统操作,所以不是路径穿越;但审计表里可以被写入任意长度的任意字符串 |
| N255-6 | 低 | transfer.py:942 | import_samples 注入了 db: Database = Depends(get_db) 但函数体从未使用。docstring 说"认证和数据库依赖仍是访问控制边界"——认证依赖确实是边界,get_db 在这里不是。应删除或说明 |
| N255-7 | 低 | transfer.py:493-511 | _get_item_identity 返回 owner_id / type / deleted 三个字段,而唯一调用方 _new_import_item_id 只判断返回值是否为 None。三个字段全是死字段(无害,但它们的存在会让读者以为某处做了所有者校验——实际的所有者边界在 _index_imported_item_sources,见 N-252 ②) |
| N255-8 | 低 | transfer.py:648 | _new_import_collection_id 用 uuid4().hex[:16] —— 本插件第 4 种 ID 形式(模型 8 位 / db.py:758 32 位 / 集合子条目 36 位复合 / 此处 16 位)。这一处有唯一性循环所以安全,但四种形式并存仍应在正文统一整改项里列出(N22-1 / N35-6 / N220-6) |
N-260 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N260-1 | P1 | handlers/web.py:166,181,205,222 | 凭据投递状态由 send_action 返回值决定(P0-1),两个方向的误判都会造成"提示与事实相反",其中"提示失败但实际已送达"会诱导用户反复生成有效登录码(详见 N-258) |
| N260-2 | P1 | handlers/note.py:825 + db.py:811-814 | 查看笔记触发通用更新入口,无条件刷新 updated_at(服务器本地)并自增 version → 破坏"最近更新"排序、造成 Web 端假 409、产生混合时间形式(详见 N-259) |
| N260-3 | 低 | handlers/web.py:262-273 | /pendo web start 失败时把 server.get_last_error() 的原文(仅做空白折叠)放进聊天回复。内容通常是"端口被占用",但也可能是 OSError 的完整消息(含绑定地址)。由于 pendo 未声明 contexts(N5-3),这条回复可能出现在群聊里 |
| N260-4 | 低 | handlers/web.py:344-348 | _send_private_text 用 int(user_id) 判定收件人,非数字 ID 直接返回 False → 对这类用户,/pendo web token 永远显示"无法私聊发送",但登录码已经生成。当前 QQ 侧 ID 都是数字,但 Web 演示用户是非数字 ID(N178-4),两套 ID 空间共存时这条会成为真问题 |
| N260-5 | 低 | handlers/web.py:158-163 | 登录码在尝试投递之前生成。若改成"先确认可投递再生成",方向二的重试累积问题会自然消失(与 N-258 的三值枚举方案二选一即可) |
N-263 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N263-1 | 中 | web/api/search.py:107-114 | Web 搜索端点的 q 无长度上限、四个文本筛选无任何约束,而聊天端同一功能有六道约束;叠加N30-2(每次搜索必然全表 LIKE)后成为可放大的代价(详见 N-262) |
| N263-2 | 低 | handlers/search.py:230-238 | _apply_date_filter 对 plan_date / diary_date / ledger_date 三个纯日期列做 [:10] 截断(正确),但对 start_time / created_at 两个日期时间列直接用完整 ISO 串做字符串比较。在N-192 描述的混合格式下(同一列既有本地朴素又有 UTC 带偏移),start_date <= created_at <= end_date 的字符串比较会把导入数据错误地排除或纳入。这是N-224 在聊天搜索路径上的表现 |
| N263-3 | 低 | handlers/search.py:230 | 未指定 type 时,range= 一律作用于 created_at。这是个合理默认,但帮助文本(:99-107)没有说明——用户以为 range=today 会按"日程发生时间/记账日期"筛选,实际按"创建时间"。指定 type= 后行为又变了(按 _DATE_FIELD_BY_TYPE),即同一个筛选参数的语义随另一个参数变化且未文档化 |
N-266 [低] 全仓有 49 处时区感知检查,分布在 14 个运行时文件
统计口径:tzinfo is None / tzinfo is not None / astimezone(...) / utcoffset() 四种写法,排除 scripts/(未随包分发,N-66)与 __pycache__。
结果:49 处,14 个文件。
commands/scheduled.py web/analytics/dashboard_overview.py
handlers/event.py web/analytics/event_schedule.py
handlers/event_support.py web/api/transfer.py
handlers/task.py web/services/bundle_import.py
services/db.py web/services/demo_space.py
services/exporter.py utils/time_utils.py
services/rule_parser.py utils/validators.py每一层都有:命令层、处理器层、服务层、数据层、Web API 层、Web 服务层、工具层。
其中有明确防御意图、且写了理由的共 9 处,按发现顺序:
| # | 位置 | 防线内容 | 附录 |
|---|---|---|---|
| 1 | db._as_utc(value, field_name) | 主动 raise,docstring:"要求显式时区并转换到 UTC,杜绝本机时区参与租约计算" | N-59 |
| 2 | db.prune_operation_logs | 内联断言要求 aware 阈值 | N-12 / N-59 |
| 3 | bundle_import._resolve_source_wall_time | 全仓唯一正确处理夏令时折叠:fold=0/1 各构造一次、往返 UTC 验证、不存在与歧义都 raise 而非猜 | N-190 |
| 4 | bundle_import._normalization_context | 要求 now 必须 tz-aware,否则 raise "import clock must be timezone-aware" | N-190 |
| 5 | demo_space._is_expired | 处理 naive/aware 混用,且两条失败路径都指向"已过期"(失败关闭) | N-181 ④ |
| 6 | events_overview._summarize_reminders_in_range | try/except + continue,docstring:"坏的导入时间不会拖垮整个概览" | N-250 |
| 7 | transfer.resolve_range | raise "Range clock must be timezone-aware; **host local time is not a valid fallback**" | N-256 ⑤ |
| 8 | web/api/events.py:216-226 | 用 TimezoneHelper.parse 作排序 key,注释:"有偏移和无偏移时间统一映射到集合时区后比较,避免 ISO 字符串字典序误判" | N-221 |
| 9 | handlers/event.py:322-332(本组新发现) | 见下 | 本条 |
第 9 处:_normalize_milestones 把危险直接写成了用户可见的错误文案
awareness: set[bool] = set()
for index, raw_milestone in enumerate(raw_milestones, 1):
...
awareness.add(datetime.fromisoformat(normalized_time).tzinfo is not None)
milestones.append(milestone)
if len(awareness) > 1:
return [], "❌ 多时间节点不能混用带时区和不带时区的时间"
milestones.sort(key=lambda milestone: datetime.fromisoformat(milestone["time"]))这是九处里唯一把问题暴露给终端用户的一处。 它的技术必要性也最直接:下一行的 sort 如果拿到混合 awareness 的 datetime, Python 会抛 TypeError: can't compare offset-naive and offset-aware datetimes, 即一个 500。作者不但防住了,还先收集 awareness 集合再统一判断, 而不是在排序里 try/except——顺序是对的(N202-1:边界检查要在消耗之前)。
N-267 [低] 这 49 处防线才是时区主线最有力的证据
一个团队不会在 14 个互不相干的文件里、用 7 种不同的写法、 各自防备同一件事——除非这件事真实、反复发生,并且每次都是在别处被发现的。
把这 9 处防线按"它们各自防的是什么"归类,恰好复现了整条主线:
- 防朴素时间混进计算:#1 #2 #4 #7
- 防带偏移与不带偏移放在一起比较:#8 #9
- 防坏数据拖垮整个响应:#5 #6
- 防夏令时歧义与不存在时刻:#3
也就是说,N-59(两个世界的交界)、N-192(导入改变形式)、 N-224(SQL 日期函数按 UTC 解释)、N-237(日期列与时间戳列不同步) 这四条,每一条都能在代码里找到对应的、作者亲手写下的防御。
但这同时解释了它为什么一直没被修
每一处局部防御都让问题在局部看起来"已经处理过了"。 这正是N32-2 的判断——单侧加固比两侧都不加固更危险, 因为它制造了"已经处理过"的错觉——在时区这条线上的最大规模体现。
更具体地说,这 9 处防线有一个共同特征: 它们全都在"消费侧",没有一处在"产生侧"。db.insert_item / db.update_item / 三条集合创建路径 / handlers/event.py 的三处裸 datetime.now()—— 这些真正产生混合形式的地方,一处防御都没有。
N-268 [低] 对修复方案的直接影响(应写进正文的时区一节)
结合本组的普查,N-40 / N-59 / N-147-1 给出的修复顺序需要补充一步, 完整顺序应当是:
- 先做存量体检,用N-237 给出的那条查询即可开始:
SELECT COUNT(*) FROM items WHERE date(created_at) != ledger_date(以及 diary/plan 的对应组合), 再按列统计"带+/Z后缀的值占比",覆盖items/event_collections/operation_logs三张表(N-217 的更正); - 统一产生侧——这是当前完全空白的一侧。所有写入路径改为同一个助手函数, Python 侧的参考实现已经在仓库里:
transfer._coerce_date(N-256 ④) 与bundle_import._resolve_source_wall_time(N-190); - 迁移存量数据,用 #3 那份 DST 处理,加上N193-3 建议的
on_ambiguous: Literal["raise","earliest","latest"]参数; - 统一消费侧的 SQL——N-224 的 87 处
date()/strftime()必须显式带用户时区偏移,否则第 2、3 步做完之后统计口径会从 "服务器本地的一天"变成"UTC 的一天",对 UTC+8 用户同样是错的; - 最后才是删除这 9 处局部防线中已经多余的那些—— 而 #3 #5 #6 应当保留(它们防的是外部输入,不是内部不一致)。
第 2 步必须先于第 5 步,否则拆掉防线的那一刻, 今天被局部吸收掉的问题会一次性全部显形。
N-269 [低] 问题
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| N269-1 | P1 | 全仓(产生侧) | 49 处时区感知检查全部位于消费侧,产生侧(insert_item / update_item / 三条集合创建路径 / handlers/event.py 三处裸 datetime.now())零防御。这是整条时区主线能长期存在的结构性原因(详见 N-267) |
| N269-2 | 低 | handlers/search.py:491 | f"📔{item.entry_time[:16].replace('T', ' ')}" —— ISO 串切片解析的第 8 处(N197-3)。与N244-4(diary_overview._entry_label)是同一展示语义的两份实现,且 entry_time 在导入后是 UTC 带偏移 → 搜索结果里的日记时间会比日记页显示的还多一种可能取值 |
xiaoqing_chat(232 条,其中 91 条附完整推导)
索引 · memory/ 检索与持久化子系统(23 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-74 † | P1 | — | 人物事实抽取的「每 20 条」节流门,在会话满 200 条后永久失效为「每条都跑」 |
| O-75 † | P1 | — | 写路径全部 to_thread,读路径全在事件循环上 —— 而全库重算恰好发生在读路径 |
| O-76 † | P1 | — | .bak 备份被认真维护、被显式删除,却没有任何读路径会用它 |
| O-77 † | 中 | — | ReAct 记忆代理的两个配置项被硬编码的 4.0 秒预算完全废掉 |
| O-78 † | 中 | — | min_score 阈值在三处被写成 0.0,而这三处恰好都是主路径失败后的兜底 |
| O-79 † | 中 | — | 向量索引无任何上限或过期,且 docs / vecs 两个文件可以静默错配 |
| O81-1 | 中 | person_profile.py:74 + :107 | 画像存 120 条事实,但索引进向量库的文本只含 facts[-20:]。第 21~120 条既不会出现在提示词里,也不会被检索命中 —— 83% 的持久化内容是只写不读的。与 N-13(snapshot_redacted)、N22-7(visibility)、N37-1 同族,但比例最高的一处 |
| O81-2 | 中 | context_builder.py:127-130 | max_inj = min(1, max(0, int(cfg.expression.max_injected or EXPRESSION_MAX_INJ_DEFAULT))) —— min(1, ...) 让这个配置项只能表达"0 或 1",而 constants.py:102 的 EXPRESSION_MAX_INJ_DEFAULT = 10 因此是一个永远取不到的默认值。注释说明了限制意图(避免口癖淹没上下文),但配置项名字与常量值都在撒谎。要么删配置项,要么让它真的生效 |
| O81-3 | 中 | memory_retrieval.py:352-374 | query_chat_history 的工具描述写"按语义/关键词找相关片段",实现是 if q in text.lower() 纯子串匹配。代理生成的检索词是自然语言问句("用户提过的生日是哪天"),几乎不可能子串命中 → 该工具近乎恒返回空。这是代理最先会调的工具,它的空结果又直接决定 has_evidence。与 O-37(is_question 子串匹配疑问词)同族:**工具描述承诺语义、实现是字面匹配 |
| O81-4 | 中 | llm/summarizer.py:153-155 + :169 | topic_id = f"{int(now)}" —— 秒级精度的 ID,同一秒内产生两个摘要就会碰撞成同一个 topic:{chat}:{id} doc_id,向量库 upsert 覆盖前者,而 JSON 缓存里两条都在 → 两份表示发散。概率低(总结是低频 LLM 调用),但与 N22-1 同族且**无碰撞检测 |
| O81-5 | 中 | memory_retrieval.py:557-573、:600-607 | 每轮把完整工具返回 JSON(含 text 全文,top_k 最多 10)追加进 messages,无任何截断。max_tokens=min(768,...) 只限输出不限输入。多轮之后输入 token 随检索结果线性膨胀,而这条链路上**没有任何一处统计或限制输入规模 |
| O81-6 | 低 | person_profile.py:23-25 | 变量名 safe_chat_id,但唯一的处理是 .strip() or "default" —— 没有任何路径安全处理就拼进 data_dir / "person_profiles" / safe_chat_id。memory.py:206/367、topic_summary_cache.py:22 同样直接把 chat_id 当文件名。当前 helper_utils.py:22-29 的 _chat_id 产出 g{group_id} / u{user_id},安全性完全依赖上游对 OneBot 事件字段类型的校验。命名断言了一个它没有提供的保证,这类命名比没有命名更危险 |
| O81-7 | 低 | vector_store.py:54 | self._lock = asyncio.Lock() 创建后从未被 acquire(全文件 0 处使用)。实际的并发保护在上层 MemoryDB._lock(threading.RLock)。一个死锁对象让人以为 VectorStore 自己是线程安全的 —— 它不是。N37-1「能力已具备但没接线」在本插件的又一例 |
| O81-8 | 低 | memory.py:167-174 | 同一个类里 _load_locks 用 WeakValueDictionary 专门防止无限增长,而 _write_locks / _generations / _tombstones 三个 dict/set 按 chat_id 永久累积、从不清理。上限是真实会话数,量级可控,但同一个类里对同一个键空间用了两套相反的纪律且无注释说明 |
| O81-9 | 低 | memory_retrieval.py:505、knowledge_extract.py:144、summarizer.py:124 | if "_ai" in secrets and secrets.get("_ai") is None: return —— 三处重复的魔法哨兵键,无常量、无类型、无注释。看起来是测试注入点,但它在生产代码路径上,且三处的行为是"静默返回",与真实的"AI 未配置"无法区分 |
| O81-10 | 低 | vector_store.py:228、:252、memory_retrieval.py:632 | 一-鿿 作为"中文"的判据出现 3 次。扩展区汉字、日文假名、韩文全部落在外面,会被当作拉丁文本按空白切分 —— 一整句日文会变成一个 token、一个哈希桶。与 N10-4(pendo [一-龥] 白名单)是同一条主线跨插件的第 2 例 |
| O81-11 | 低 | memory.py:365 | history[-200:] 硬编码,与同文件 :18 的 MAX_CACHED_MESSAGES_PER_CHAT = 200 是同一个量的两份来源。改常量不会改这里 |
| O81-12 | 低 | context_builder.py:149 | 知识块用的是 cfg.memory.min_score —— KnowledgeConfig 有自己的 top_k(config.py:318)却没有 min_score,于是跨命名空间借了一个。调 memory 的阈值会静默改变知识库行为 |
| O81-13 | 低 | memory_retrieval.py:238、reply_generator.py(O47-2)、config.py:215 | 字面量 1200 第三次出现:build_question_messages 的 max_chars=1200、审查侧 memory_block[:1200]、max_block_chars 默认值 1200。三处同值不同源,改配置只动其中一处 |
| O81-14 | 低 | knowledge_base.py:38-50 | _split_chunks 按 800 字符硬切且块间无重叠,跨越切分点的事实在两个块里都不完整。配合词袋检索(无语义泛化能力),召回质量对切分位置敏感 |
| O76-1 † | 低 | — | 与 O-70 合并成一条完整的教训 |
| O77-1 † | 低 | — | 附带:getattr 的默认值与配置声明的默认值相反 |
| O-80 † | 低 | — | -1 knowledge_base.py 是 C-0 主线在 xiaoqing_chat 的第二个完整实现 |
索引 · 媒体注册表与闲聊媒体桥接(15 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-94 † | P1 | — | 会话清除覆盖 8 个存储、漏掉 3 个,漏掉的其中一个在设计上就无法按会话清除 |
| O-95 † | 中 | — | 渲染路径先把手上的描述丢掉,再去查注册表把它找回来 |
| O-96 † | 中 | — | 媒体注册表无作用域、无上限、无淘汰 —— 而淘汰所需的数据它一直在记 |
| O-97 † | 中 | — | 注册表的加锁边界把磁盘 I/O 圈了进去 —— O-75 的第 7、8 个受害点 |
| O-98 † | 中 | — | 媒体标记与占位符都是用户可以直接打出的明文,没有任何转义 |
| O-99 † | 中 | — | 全仓遗留哈希普查:7 处 md5/sha1,全部缺 usedforsecurity=False |
| O101-1 | 低 | media_registry.py:346-354 vs :356-365 | 相邻两段:emotion_tags 整体替换(升级时取 incoming,否则取 existing),aliases 累积合并。docstring 只写了"别名会累积",没说标签为什么不累积。两种纪律相隔 3 行且无注释区分 —— N-45 家族 |
| O101-2 | 低 | media_registry.py:332-334、:372 | quality_score 存的是"历次观测中最高的单次得分",而记录里的字段是多次观测混合的结果(description 来自升级那次、aliases 累积、file_path 来自首次)。因此 quality_score 并不描述这条记录本身。且分数公式没有版本号 —— 一旦 _quality_score 的权重调整,旧记录的存量分数与新算的分数不可比,条目可能永久冻结(旧高分再也打不过)或反复抖动 |
| O101-3 | 低 | media_registry.py:456-462 | resolve_message_content 在 store is None 时函数体内 import runtime_state 并取全局单例。这是一个纯函数式模块对全局状态的隐式依赖,且被包在 try/except Exception: store = None 里 —— 单例不可用时静默退化,调用方无从得知。模块 docstring 说的"尽力而为"覆盖了它,但这一处的隐式全局访问值得显式化为参数 |
| O101-4 | 低 | media_registry.py:503-509、:511-518 | flush 依赖 _save_json 返回 bool 判断成功;StoreBase._save_json 只捕获 OSError 并返回 False,不记日志(承 O27-3)。索引写盘持续失败时,_dirty 一直为 True(这是对的),但没有任何日志,运维看不到"媒体索引已经 3 天没写进去了" |
| O101-5 | 低 | media_registry.py:25 | _MEDIA_MARKER_RE 的标签部分是 [^\]\n]{0,400} —— 上限 400 字符没有对应常量,且与 _render_media_marker 生成的标记长度上限之间没有约束关系。模型生成的超长描述会产出匹配不上自己正则的标记,于是在下一轮 compact 时不被识别为媒体标记 |
| O101-6 | 低 | media_registry.py:47-48 | media_placeholder(index) 用 max(1, int(index)) 把 0 和负数夹到 1 —— 于是索引 0 与索引 1 产出同一个占位符。当前调用方都是从 1 开始,但这个夹逼把参数错误变成了静默的别名,而不是抛出 |
| O101-7 | 低 | smalltalk_media_helpers.py:121-125 | _event_media_items_for_memory 的 except Exception: return [] —— 媒体渲染失败时整条消息的媒体静默消失(docstring 写的是"解析失败时让文字消息继续进入记忆",行为与意图一致),但没有日志也没有降级标记。用户发了图,机器人记忆里只有文字,且无人知道 |
| O101-8 | 低 | smalltalk_media_helpers.py:100 | file_path 写的是 str(item.cached_path),即本机缓存文件的绝对路径,随 parts 一起持久化进 <chat_id>.json。它不会进入提示词(_render_media_marker 不读该字段),但会进入聊天历史文件与媒体注册表。与 memory_retrieval._public_memory_meta(O-80-2 主动把绝对路径换成 local-ref:)形成对照:**同一插件里,向模型外发的路径被脱敏了,落盘的路径没有 |
| O-100 † | 低 | — | -4 compact_message_content / rebuild_message_content 的"不静默丢内容"约定 |
索引 · 表情库、出站标记解析、QQ 表情目录(media/ 收尾)(15 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-121 † | P1 | — | 每发/收一个表情,下一次回复就要把整个表情库重新读盘并 sha256 —— 全在事件循环上 |
| O-122 † | 中 | — | marker_media_part 的 image 分支做了二次授权,emoji 分支没做 —— 而 emoji 路径的来源函数明确要求调用方自己验证 |
| O-123 † | 中 | — | 打开"表情需要审核"这个安全开关,反而会失去容量保护 |
| O-124 † | 中 | — | _BUNDLED_QQ_FACE_LABELS 的进程级备忘永不失效,而缓存签名却在监视那个文件 |
| O-125 † | 中 | — | QQ 表情候选按使用次数升序返回,于是"最少用过的"总是被优先选中 |
| O-126 † | 中 | — | 感知去重命中时,返回给调用方的条目里 media_hash 与 file_path 指向两张不同的图 |
| O127-1 | 中 | marker_resolver.py:203-230 + :296-320 | _collect_library_image_entries 每次都 rglob 整个 reply_images 目录,对每个文件各调一次 resolve_registered_media_items(= 每个文件一次媒体注册表加锁,见 O-97);_collect_history_image_entries 再扫最多 200 条历史、对每个 image part 又各调一次。全部同步、全部在回复生成路径上。应改为一次批量 resolve_media_items(所有 refs)(该方法本来就接受列表) |
| O127-2 | 中 | qq_face_catalog.py:213-241、:318-343 | record_face_observation(每收到一个 QQ 表情)与 mark_qq_face_used_by_id(每发一个 QQ 表情)都会读整份 payload、写整份 payload、并 _invalidate_catalog_cache → 下一次回复要重建整个合并目录(内置数百条 + 用户条目)。与 O-121 是同一形状的第 2 例,只是数据量小一档 |
| O127-3 | 低 | qq_face.py:57 + qq_face_catalog.py:263-276 | _clean_face_text 把标签截到 32 字符并拒绝 URL 与通用词,但允许内部的 ]。该标签直接渲染成 [QQ表情:{label}] 进提示词,且经 record_face_observation 持久化成该 face_id 的候选别名。构造一个 x] … 的标签可以提前闭合标记。影响很窄(≤32 字符、且 _MEDIA_MARKER_RE 的 [^\]\n] 反而会因此匹配不到自己生成的标记,见 O101-5),但这是入站事件里唯一一处**用户可控文本被持久化成提示词结构的一部分 |
| O127-4 | 低 | qq_face.py:60-78 | _extract_label_from_raw 对事件里的 raw 字段做无深度限制的递归(dict/list 任意嵌套)。实际不可达(Python 的 json 解析器在同样深度上会先抛 RecursionError),但函数本身没有深度参数 |
| O127-5 | 低 | emoji_library.py:409-411 | threshold = max(0, cfg...) 之后又判 if not perceptual_hash or threshold < 0 —— threshold < 0 永远为假,是死条件 |
| O127-6 | 低 | emoji_library.py:95-103 | 缓存签名用 st_mtime(秒级),而同目录的 qq_face_catalog._catalog_signature 用 st_mtime_ns + st_size。同一个包里两种精度,弱的那个用在文件更多、变更更频繁的库上 |
| O127-7 | 低 | marker_resolver.py:25 vs :27 | _OUTBOUND_MARKER_RE 的 hint 上限是 12 字符,_RENDERED_OUTBOUND_MARKER_RE 是 80。同一个字段两个上限,且没有常量名 |
| O127-8 | 低 | marker_resolver.py:153-159 | 模糊匹配没有最低分阈值:if score > 0 即可成为候选,一个 token 命中就够。而 _TOKEN_PATTERN 的 [一-鿿]{1,4} 是贪婪的,实际 token 数很少,所以"一个 token"往往就是整个提示词——风险不大,但阈值缺失是显式的 |
| O127-9 | 低 | emoji_library.py:568 | if not target_path.exists(): _copy_into_library_if_needed(...) —— 目标已存在就跳过复制,不校验内容是否一致。配合 O-126 的路径复用,索引里的 media_hash 与磁盘文件的实际哈希可能不符,而 load_emoji_library 每次都会重新哈希文件并以文件为准,所以能自愈;但自愈依赖的正是 O-121 那次昂贵的全库重算 |
索引 · 表达学习的执行层(expression/ 收尾)(5 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-142 † | P1 | — | 每来一条消息,表达存储的合并基线就被清空一次 —— O-136 表扬的三方合并在生产路径上基本不生效 |
| O-143 † | 中 | — | 用户明确"拒绝"过的表达,会被下一轮学习自动复活 |
| O-144 † | 中 | — | 黑话释义的"严格路径"永远轮不到,被同一函数里的"廉价路径"抢先 |
| O-145 † | 中 | — | 黑话库的缓存在写盘之前就被改了 —— 落盘失败时内存与磁盘发散 |
| O-146 † | 中 | — | 反思判定不接 LLMError,模型抖动会连带停掉同一后台任务里的"提问"那一半 |
索引 · 入口与运行时骨架(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-1 † | 中 | — | 两个配置来源用浅合并,嵌套子配置会被整块覆盖 |
| O-2 † | 中 | — | 配置模型不拒绝未知字段,而同仓的 pendo 在风险更低的地方拒绝了 |
| O-3 † | 中 | — | 配置支持两个文件位置,热重载只监听其中一个 |
| O7-1 | 中 | main.py:225 + runtime_state.py | _flush_shutdown_state 用 asyncio.to_thread 在工作线程里调用四个 store 的 flush/persist_all,而此时后台任务尚未取消、仍在事件循环上运行(这是刻意的,见 O-6)。也就是说:落盘线程与事件循环可能同时访问同一批内存结构。是否安全完全取决于各 store 内部是否加锁——经逐个确认各 store 的加锁情况后此处成立:落盘线程与事件循环可能同时访问同一批内存结构 |
| O7-2 | 中 | runtime_state.py:181-184 | cleanup_stale_chats 的触发条件是 len(locks) % 100 == 0,而 locks 的长度会在清理中减少。清理后长度落到 ~500,之后要再新增到 600 才会命中下一个 100 的倍数 → 实际上界是 _MAX_TRACKED_CHATS + 99 而非 500。更值得注意的是这个触发器依赖"长度只在 get_lock 里单调增长"这一隐含前提,一旦有人在别处删除锁,取模就可能被永久跳过 |
| O7-3 | 中 | main.py:139 | event["_xc_command_forced"] = True —— 用一个下划线开头的魔法键就地修改共享的 event 字典来跨模块传信号。event 是 core 分发给所有插件的对象,这个键的读方在别的文件里。属于隐式契约,grep 不到定义、也没有类型 |
| O7-4 | 低 | main.py:87-140 handle() | 子命令解析有三条并行路径:目录树 invocation → _HANDLERS 名字回退 → root.resolve_child 再判一次;first_is_help 又把 help 检测独立实现了一遍({"help","帮助","?"} 硬编码,而目录里已经声明了同样的 aliases)。三条路径的优先级只能靠通读推出,且 help 的别名清单出现了两份 |
| O7-5 | 低 | runtime_state.py:252/282 | get_reply_timestamps / get_stats 在键存在时返回内部对象本身,不存在时返回新建的默认值。调用方对返回值的修改,会不会写回内部状态取决于"这个 chat 之前有没有出现过"。当前调用方都配套调用了 setter 所以不出错,但这是典型的"键存在时才生效"的潜在 bug |
| O7-6 | 低 | runtime_state.py:485-487 | reset_global_state() 直接把单例置 None,不落盘、不取消后台任务。旧实例的任务仍持有旧 store 并继续写盘。若仅供测试使用应在文档字符串里写明;若被生产路径调用则是数据丢失入口(后续批次核对调用方) |
| O7-7 | 低 | config/config.py:214 | MemoryConfig.min_score 用 UnitWeight(-1.0 ~ 1.0)。相似度下限取负值等于"全部命中",语义上下限应为 0。类型复用带来的边界过宽 |
| O7-8 | 低 | plugin.json | 未声明 contexts(G-0 主线)。对聊天插件而言群聊+私聊都需要,属于合理默认;但 review(group_admin)与 model global(bot_admin)在 manifest 里逐条声明了 permission,这一点比 pendo(N5-3 完全没有 admin_only/contexts)好得多,值得在正文的 G-0 一节里作为"正确声明方式"的例子 |
索引 · 处理器层(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-10 † | 中 | — | 唯一会把真实用户对话外发并沉淀成人物档案的后台任务,是四个后台任务里唯一没有开关的 |
| O-11 † | 中 | — | 跨模块依赖用"把函数当参数传"实现,而其中大多数被传的函数并不来自本模块 |
| O-12 † | 中 | — | 模块对自己的进程内单例做鸭子类型探测,把可静态验证的调用变成了运行时猜测 |
| O-13 † | 中 | — | need_replan 重试重跑的是整轮,包括可能改变结论的回复门控 |
| O14-1 | 中 | handlers.py:436-455 | _is_prefixed_xc_command_observation 自己从 context.config["command_prefixes"] 取前缀、自己做 startswith + lstrip + 取首 token。这是插件在重新实现 core 的命令前缀解析,属于 A-0 主线(core/args.py 缺陷导致插件绕开 core)的一个新形态:不是绕开参数解析,而是绕开"这条消息是不是命令"的判定。core 应当把这个结论直接放进 event 或 context |
| O14-2 | 中 | handlers.py:1049 | user_scope = str(event.get("user_id") or "anonymous") —— 所有取不到 user_id 的请求共用一个名为 anonymous 的配额桶。max_generation_calls_per_user_per_day 对这一桶的语义因此从"每人每天"变成"所有匿名来源每天合计"。要么拒绝无 user_id 的生成,要么按 chat_id 兜底分桶 |
| O14-3 | 中 | handlers.py:1044-1058 | 生成限流器 generation_limiter.admit(...) 在 _prepare_smalltalk_turn 之后才进入。准备阶段包含媒体分析、注意力判定与一次 to_thread 磁盘读,全部不受 max_generation_inflight_global 约束 → 高并发时被限流拒绝的请求,其准备开销已经全额付出。至少应把媒体分析挪进限流范围 |
| O14-4 | 中 | handlers.py:392-409 | 记录 reply_rejected 动作的整段用 try/except Exception: pass 包住。这是拒绝原因唯一的落盘点(action_history 后面会被复盘会话读取,见 handlers_helper.py:83-86 的 rej_cnt),静默失败会让复盘逻辑少算拒绝次数而不自知 |
| O14-5 | 低 | handlers.py + main.py | event 字典上的私有键已达 4 个:_xc_command_forced、_xc_idle_context_checked、_xc_user_recorded、_xc_user_recorded_local_id、_xc_effective_user_parts、_xc_new_emoji_count(实为 6 个)。它们是跨模块的隐式契约,没有集中定义、没有类型、grep 才能找到写方与读方。应收敛成一个 dataclass 挂在 event["_xc"] 下,或放进 HandlerContext |
| O14-6 | 低 | handler_context.py:71-73 | handle_errors 用 context = kwargs.get("context") or args[-1] 猜测 context 参数位置。当前所有处理器都把 context 放最后所以正确;一旦有人加个可选参数,错误上报就会拿到错误的对象(而这正是异常路径,最不容易被测到) |
| O14-7 | 低 | handler_context.py:50 | HandlerContext.from_event 每次都调用 _bind_all_stores(state, context.data_dir),即每条消息重新绑定 12 个 store 的数据目录。当前各 bind() 大概率是幂等赋值,但这是每消息 12 次调用的固定开销,且把"绑定"这个一次性初始化动作放在了请求路径上 |
| O14-8 | 低 | handlers.py:107-144 _refresh_mood_state | min_d = max(60.0, cfg.state_min_duration_seconds) —— 配置里已经用 PositiveDurationSeconds(>0)约束过,这里又硬编码一个 60 秒下限。两处限制不一致且第二处没有注释:配置允许 30 秒,实际生效 60 秒 |
索引 · 权限判定、生成配额与注意力门控(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-17 † | 中 | — | O-10 的实际影响面比上一批判断的更大:任何群成员都能把"关于他人的推断事实"检索出来 |
| O21-1 | 中 | attention_gate.py:192-194 | 时效判断写成 if ts and now - ts > MAX_AGE: continue —— ts 为 0/None 时整个时效检查被跳过,该消息永远算"近期锚点"。而 handlers.py:_event_message_timestamp(附录 O-15 记为正面)在 OneBot 时间戳非法时正是返回 None → 这两处正确的防御串起来的结果是:时间戳非法的消息会变成永久有效的注意力锚点。此外 continue 而非 break 使"只看最近 10 分钟"这一意图依赖"history 已按时间排序"的隐含前提 |
| O21-2 | 中 | handlers_internal.py:284-298 | /xc memory 无权限判断且回显 item.text 原文(见 O-17)。同一文件的 重置 / 模型 都有权限判断,记忆 / 表达 / 黑话 三个检索命令都没有——同一个命令族内,写操作有权限、读操作没有,而读出来的正是最敏感的推断内容 |
| O21-3 | 中 | handlers_internal.py:372-389 | /xc 模型 无参数时任何用户都能列出全部模型别名、model 字段与 provider 字段。这是 LLM 路由配置的完整披露(用了哪家供应商、哪个模型)。plugin.json 里父命令 model 确实没声明 permission,所以是设计如此,但值得在正文里作为"信息披露面"记一笔 |
| O21-4 | 低 | generation_limiter.py:22-23 | max_tracked_users / sweep_interval_seconds 是 dataclass 字段(看起来可配置),但 XiaoQingChatConfig 里没有对应项,ChatRuntimeState.__init__ 也用默认值构造 → 实际是常量。N37-1 家族的小型实例:字段可设,无人设 |
| O21-5 | 低 | generation_limiter.py:52-55 | 用户数达上限时拒绝的是新用户(user_id not in self._user_calls)。即容量耗尽时,老用户继续可用、新来的人被静默拒绝(非 forced 路径返回空列表)。若要按"公平"设计,应淘汰最久未活跃者而非拒绝新来者 |
| O21-6 | 低 | attention_gate.py:164-177 | _recent_history 又是一处对自有 store 的 getattr 探测 + except Exception: return [](O-12 的第 3 处)。这里吞掉异常的后果是"没有锚点"→ 静默改变回复行为,且不会有任何日志 |
| O21-7 | 低 | handlers_internal.py:123-126 | get_stats(chat_id) 的默认值只有 {"replies": 0, "calls": 0},而这里读的是 resets(由 inc_stats 动态添加)。统计字典的字段集合在三处各不相同(默认 2 个 / 实际 3 个 / 展示 2 个),没有集中定义。与 O7-5 是同一个 get_stats 的两个不同问题 |
| O21-8 | 低 | handlers_internal.py:143-146 | 取消挂起的持久化任务用 getattr(state, "pop_persist_task", None) + callable 探测(O-12 第 4 处)。ChatRuntimeState 明确定义了这个方法且带 __slots__ |
索引 · 日志脱敏底座、存储基类与并发结账(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-24 † | 中 | — | 重置操作的审计日志,其主体在进程重启后不可解析 |
| O27-1 | 低 | logging_utils.py:124-125 | if value is None: continue —— 显式传入的 None 字段整个消失,与"根本没传这个字段"在日志里不可区分。对排查"这里为什么是空"的场景不利,建议记成 null |
| O27-2 | 低 | logging_utils.py:128 | 标识符判断用子串匹配(any(part in lowered ...)),且排在数值分支之前 → 形如 message_id_count、user_id_total 的整数字段也会被脱敏成指纹串。失败方向是安全的,但会丢掉本可直接观测的计数 |
| O27-3 | 低 | store_base.py:118-124 _save_json | 捕获 OSError 后返回 False,不记录任何日志。调用方是否检查返回值决定了写失败是否可见 —— 这是 N69-1(损坏/失败静默消失)在存储层的入口,需在后续各 store 批次逐个核对调用方 |
| O27-4 | 低 | store_base.py:79 | _load_json = staticmethod(load_json_file),而 load_json_file 对 OSError/UnicodeError/JSONDecodeError 一律返回默认值,不区分"文件不存在"与"文件损坏"。损坏的会话状态会被静默当成空状态,用户看到的是"记忆没了" |
| O27-5 | 低 | helper_utils.py:89-94 | _load_runtime 编译 ban_regex 时 except re.error: continue 静默跳过编译失败的模式。虽然 config.py 的 _validated_regex_pattern 已在加载期校验过(理论上到不了这里),但如果真到了,用户配置的屏蔽词会**静默失效 |
| O-26 † | 低 | — | 结清 O7-1:shutdown 期间的落盘线程与事件循环是安全的 |
索引 · 回复门控与后台任务调度(9 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-29 † | 中 | — | 坐实 O-13:回复门控含随机数与状态写入,因此重放不等价 |
| O-30 † | 中 | — | 同一个函数里用了两种概率合成方式,其中一种被同文件的注释否定过 |
| O-31 † | 中 | — | 三份 per-chat 任务登记表放在模块全局,逃出了 ChatRuntimeState 的容量治理 |
| O-32 † | 中 | — | 防抖调度的公共实现只覆盖了 4 个调用点中的 2 个 |
| O35-1 | 低 | participation.py:8-11 + frequency_control.py:185 | _DIRECTED_OTHER_RE 的 小[一-鿿]{1,2} 分支会匹配机器人自己的名字(小青)。当前不出错,因为带机器人名的消息在 attention_gate 就被判为 direct_mentioned 而强制回复,根本走不到这个门控。但 participation.py 完全不知道 bot_name 的存在,它的正确性依赖调用方的执行顺序,且没有注释说明。若将来有人在别处复用 is_group_turn_directed_to_other,机器人会静默拒绝回答点自己名的消息 |
| O35-2 | 低 | participation.py:40-52 | classify_group_participation_cue 的 docstring 说识别的是 "high-confidence message that openly invites group participation",但实现里任何长度 ≥4 且不是明确叫别人的问句都返回 open_question → 直接把回复概率抬到 participation_cue_reply_probability(默认 0.9)。文档描述的是窄集合,实现是宽集合,而这个差异直接决定群里的插话频率 |
| O35-3 | 低 | frequency_control.py:76-87 | _remember_reply_gate_decision 是 O-12(对自有单例做鸭子类型探测)的第 5 处,但这一处透露了动机:它显式检查 "set_reply_gate_decision" in state.__dict__(测试替身)。这说明 O-12 的修法不能只是"删掉 getattr",而应改为用 Protocol 声明 state 契约,让测试替身与真实实现共享同一个静态类型 |
| O35-4 | 低 | frequency_control.py:59-61 | _freq_record 对 get_reply_timestamps 返回值做读-改-写。键存在时拿到的是内部 list(原地 append 已生效),不存在时拿到的是新建的空 list(靠随后的 setter 才生效)—— 两条路径都对,但对的原因不同。正是 O7-5 描述的别名危险的具体实例 |
| O35-5 | 低 | task_scheduler.py:117 | _schedule_memory_persist 的 _run 里 await asyncio.to_thread(_state().memory_store.persist, chat_id) —— 在协程体内才调用 _state() 取单例。若期间发生 reset_global_state()(O7-6),写入的会是新单例的 store,而调度时的意图是写旧的。建议在调度时捕获 store 引用 |
索引 · 共享判定、回复拆分与投递载荷(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-37 † | 中 | — | is_question 用子串匹配疑问词,误判会沿四条链路放大成"更愿意插话" |
| O-38 † | 中 | — | 一条回复要经过两套互不知情的拆分,只有其中一套受配置约束 |
| O41-1 | 低 | reply_payload.py:25-43 | _media_segment 对缺 file_path / face_id 的媒体返回 None,随后在 _build_batch_segments(:63)与 trailing 分支(:101)都是 if segment is not None 静默跳过。媒体丢失没有任何日志,用户看到的是"小青说要发图但没发"。N69-1 家族,建议至少 _log_step 一条 |
| O41-2 | 低 | constants.py:96-107 | 六个常量(FIND_BY_LOCAL_ID_LIMIT=200、MEMORY_RETRIEVAL_TIMEOUT=4.5、UNKNOWN_WORDS_MAX=6、EXPRESSION_MAX_INJ_DEFAULT=10、EXPRESSION_LEARN_MIN_INTERVAL=90.0、EXPRESSION_LEARN_MIN_MESSAGES=10)全部硬编码,而同类量(memory.top_k、expression.max_injected、expression.max_store)都在 XiaoQingChatConfig 里。同一维度的参数一半可配一半不可配,且 EXPRESSION_MAX_INJ_DEFAULT=10 与 ExpressionConfig.max_injected(默认 1、上限 20)在语义上重叠 |
| O41-3 | 低 | constants.py:88 | is_question 先判 "?" in t —— 半角问号出现在英文 URL、代码片段里也会命中(如 https://x.com/a?b=1)。群聊里贴链接是常见行为,配合 O-37 的四条放大链路,贴链接会被当成提问 |
| O41-4 | 低 | reply_splitter.py | 拆分只认 ``` 围栏,不认缩进代码块、也不认单行 ` 内联代码里的空行。当前场景(聊天回复)够用,但函数 docstring 说"保护代码块不被拆分",实际保护的是"围栏代码块" |
索引 · reply_generator.py(1325)(6 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-43 † | 中 | — | 兜底话术把默认人设写死在代码里,配置改了人设也改不掉它们 |
| O47-1 | 中 | reply_generator.py:855-856 | _check_candidate_draft 开头 if not cfg.reply_check.enable_reply_checker: return None —— 人物依据的句法降级守卫(O-44 第二层)整个嵌在这个分支之内。因此 enable_reply_checker: false 关掉的不只是"回复质量检查",还包括"审查不可用时对身份问题的兜底保护"。这个开关的实际作用面比它的名字大,配置里也没有说明 |
| O47-2 | 低 | reply_generator.py:891-897 | grounding_text 里 str(memory_block or "")[:1200] —— 硬编码 1200,与 MemoryConfig.max_block_chars(默认 1200,可配 1~20000)数值相同但互不相关。运营方把 max_block_chars 调大后,检查器看到的证据仍被截到 1200,即"生成时看到的证据"多于"审查时看到的证据" |
| O47-3 | 低 | reply_generator.py:917 | 外层 asyncio.wait_for(..., timeout=checker_timeout + 0.25) 与内层 timeout_seconds=checker_timeout 是双保险(防内层超时不生效),写法正确,但 +0.25 这个余量没有注释也没有常量名 |
| O47-4 | 低 | reply_generator.py:244-246 | _log_prompt_audit_metadata 的 except Exception: return 会在第一条消息记录失败时直接返回,跳过剩余消息的审计记录(而不是继续尝试)。审计日志部分缺失比全部缺失更难发现 |
| O47-5 | 低 | reply_generator.py:1113 / :1130 | 轮转起点 sum(ord(char) for char in str(plan.request_id or plan.text)) —— request_id 为空时退化为按用户消息内容取起点,于是同一句话在不同会话、不同时间永远得到同一条兜底话术。对"你是谁"这类高频重复问题,群里会看到完全一致的回答 |
索引 · 投递确认 —— **P0-1 主线的结论需要整体改写(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-50 † | 中 | — | xiaoqing_chat 自己的两条次要投递路径绕过了它主路径实现的协议 |
| O52-1 | 低 | smalltalk_execution.py:731-758 | 中间批次逐条发送,批次之间 asyncio.sleep(gap)(拟人化停顿,最多约 1.2 秒 + 基础间隔)。这段时间内 receipt 处于"部分确认"状态;若进程在此崩溃,已发出的前几条消息已经到达用户,而 commit 从未执行 → 记忆里没有这次回复。属于 at-least-once 投递的固有代价,但没有注释说明这个取舍 |
| O52-2 | 低 | smalltalk_execution.py:751 | sent = await ...send_action(...),随后 if not sent —— 中间批次仍然依赖 bool 判定。收据协议在这里是叠加在 bool 之上的(bool 决定是否继续发下一批,收据决定 commit/rollback)。可行,但意味着 send_action 的返回值语义仍然被使用了一次 |
| O-49 † | 低 | — | [P0-1 结论改写] core/delivery.py 已经实现了 N23-1 建议的协议,问题不是"缺能力"而是"没采纳" |
索引 · smalltalk_execution.py 生成段(:26-660)(5 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-54 † | 中 | — | 规划器快照的补偿回滚只覆盖 ReplyRejected 一种异常 |
| O57-1 | 中 | smalltalk_execution.py:203-206 | 快照里有一处特殊处理:if generated.pfc_state_snapshot.ended: generated.pfc_state_snapshot.ended = False —— 拍快照时就地改写了 ended 标志。这意味着回滚恢复的不是"生成前的真实状态",而是"生成前状态但强制未结束"。可能是刻意的(避免回滚后会话被判为已结束而不再规划),但没有注释,且它让"快照"这个词名不副实 |
| O57-2 | 低 | smalltalk_execution.py:244 | speculative_history = recent_history[-max_context_size:] if max_context_size > 0 else [] —— max_context_size 为 0 时预取用空历史,而主路径的 recent_history 已经按同一个值取过一次。两处对 max_context_size <= 0 的处理不一致(一处返回空、一处直接切片) |
| O57-3 | 低 | smalltalk_execution.py:206 | deepcopy 整个 PFC 状态对象。该对象含 goal_list 等结构,每轮非 forced 且启用规划器的对话都要深拷贝一次。当前规模下无所谓,但它在会话锁内执行(:201-206),会延长锁持有时间 |
| O57-4 | 低 | smalltalk_execution.py:207-233 _pfc_generate | 三个 style_override 文案(告别、连发、默认)硬编码在函数里,而 PersonalityConfig.reply_style / multiple_reply_style 是配置项。与 O-43(兜底话术硬编码人设)是同一形状的第 2 例 |
索引 · llm/reply_checker.py(1258)(7 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-60 † | 中 | — | 两道证据检查的严格程度差一个数量级,而更弱的那道守的是更敏感的类别 |
| O63-1 | 中 | reply_checker.py:1223 + :1232 | enable_llm_checker 为假、或 risk 分流判定不需要语义审查时,都返回 ReplyCheckResult(True, "", False, "soft") —— reason 是空字符串。调用方 reply_generator 会把 check.reason 记进日志与 ActionRecord。两种语义完全不同的放行("配置关了检查" vs "风险分流判定无需检查")产生完全相同且无信息量的结果对象,事后无法区分 |
| O63-2 | 中 | reply_checker.py:1226-1231 | risk 分流用 _requires_llm_semantic_check 的句法信号决定是否调用远程模型,默认 llm_checker_mode: "risk"。这意味着绝大多数回复不经过语义审查。这是有意的成本取舍(注释写明"避免每条消息再串行等待一次远程模型"),但配置项 enable_llm_checker: True 的字面含义与实际行为有落差——它开着,却只对一小部分消息生效。建议在配置注释里点明 llm_checker_mode 才是实际生效范围的决定项 |
| O63-3 | 低 | reply_checker.py:714-715 | if proactive_persona_scan and "我" in candidate: return True —— 主动插话且回复含"我"就送远程审查。"我"在中文回复里出现频率极高,因此 check_omitted_persona_episode=not plan.forced(reply_generator.py:911)意味着主动插话路径几乎总是触发远程调用,而这正是最在意延迟的路径(非 forced 说明用户没在等) |
| O63-4 | 低 | reply_checker.py:342-367 | _direct_evidence_supports_claim 的双字组覆盖阈值(>= 2 且 >= 0.6)与最短长度 4 都是魔法数字,无常量名、无注释说明取值依据。这是全文件唯一一处纯启发式阈值,恰恰最需要说明它是怎么定出来的 |
| O63-5 | 低 | reply_checker.py:219-222 | _normalize_evidence_text 硬编码 replace("小青", "我") —— bot_name 是可配置的(_get_bot_name(context)),此处却写死默认名。运营方改名后,第三人称资料与第一人称断言不再对齐,证据比对整体失效(倾向于误判为"无依据"而拒绝,方向安全但功能退化)。**与 O-43 / O57-4 同属"配置值在代码里被写死"第 3 例 |
| O-59 † | 低 | — | 先厘清架构:确定性门禁始终执行,远程 LLM 只是附加层 |
索引 · llm/ 传输与提示词组装(5 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-65 † | 中 | — | 回复检查器看到的上下文被截了两次,因此会把有据可查的陈述判成"无依据" |
| O68-1 | 中 | prompt_builder.py:95-100 _format_message_time | except Exception: return time.strftime("%H:%M", time.localtime()) —— 时间戳非法时回退到"现在"。于是一条时间戳损坏的旧消息会被标成当前时刻发送,模型据此推断"这条是刚刚说的"。应回退到空字符串(该函数的调用点 :359 已经有 if msg_ts else "" 的空值分支,直接复用即可)。与 O21-1(时间戳非法的消息成为永久注意力锚点)同源:_event_message_timestamp 对非法值返回 None,下游两处各自用不同方式"补"了一个错误的值 |
| O68-2 | 低 | prompt_builder.py:95-99 | 提示词里的时间一律用 time.localtime(),即服务器本地时区。本插件没有用户时区概念(对照 pendo 有完整时区体系),单机部署下无碍;但若机器人服务跨时区用户,模型看到的"14:30"与用户的墙钟不一致,而人格提示词里又鼓励它聊"熬夜""食堂"这类与时间强相关的日常 |
| O68-3 | 低 | gateway.py:74-100 | 三个 chat_completions* 包装函数签名全是 **kwargs: Any,而它们转发的 complete_raw 就在上方且是完整的仅关键字签名。调用方拼错参数名会在 core 深处抛 TypeError,且 mypy 无法检查任何调用点 —— 这与 xiaoqing_chat 在 mypy exclude 名单里占 16 个文件是同一类成因(附录 O-11 已记 handlers.py 的 16 个注入参数) |
| O68-4 | 低 | prompt_builder.py:329-330 | truncate: bool = True, max_chars: int = 800 与 :333 的 len(items) > 12 —— 三个控制上下文规模的量都是函数默认参数或字面量,而同类量(max_context_size、memory.max_block_chars)都在配置里。见 O-65 |
索引 · 回复后处理与落盘原子性结账(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-70 † | 中 | — | 结清 N-180:全插件唯一一处非原子写,恰好是唯一没有继承 StoreBase 的模块 |
| O72-1 | 低 | thinking_back.py:92-96, 99-103, 105-106 | 三处 except OSError: return —— 追加失败、探测大小失败、压缩失败全部静默返回,无日志。回想缓存写不进去时,用户表现为"小青想不起刚聊过的事",而运维没有任何线索。与 O27-3(StoreBase._save_json 失败返回 False 不记日志)是同一主线在两个模块的实例 |
| O72-2 | 低 | thinking_back.py:105 | 压缩后写回 items[-max_entries:],而 _load_recent 已经按 max_lines=max_entries 取过一次尾部 —— 同一个上限被应用了两次,第二次是空操作。不是缺陷,但说明两个函数对"谁负责截断"没有约定 |
| O72-3 | 低 | postprocess.py:98-99 | text.strip().strip('"').strip("'").strip() —— 只剥一层首尾引号。模型输出 ""内容"" 或中文引号 “内容” 时剥不干净(_normalize 是否处理中文引号需在其实现处确认)。属于启发式清洗的固有边界,但链式 strip 的写法让"只剥一层"这个事实不明显 |
索引 · 反思会话子系统 + 消息片段 + 助手工具(22 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-84 † | 中 | — | 反思会话子系统四道闸门断了三道,bad_reply_pattern 整条支路不可达 |
| O-85 † | 中 | — | open_session_if_allowed 的 max_pending 是全局配额,却按每会话语义使用 |
| O-86 † | 中 | — | _load_sessions_state 返回缓存对象本体,全部调用方原地修改它 |
| O-87 † | 中 | — | helper_utils._load_runtime 是一个名字里看不出副作用的重量级同步函数 |
| O-88 † | 中 | — | 写错的 ban_regex 被静默丢弃 |
| O-89 † | 中 | — | _is_private 与 _chat_id 对 group_id 用了两套空值语义 |
| O-90 † | 中 | — | message_parts 里两种媒体匹配纪律:一处按身份、一处按位置 |
| O92-1 | 中 | review_sessions.py:663 + config.py:264 | max_avoid_patterns 默认 30,但 build_policy_block 只注入 avoid_patterns[-6:] —— 30 条里 24 条永不生效,而运营方每次都收到"已记录为长期规避模式。"的确认。这是 O81-1 的同形状第 2 例(画像存 120 条只索引 20 条):上限配置成 N、消费端硬编码只取最近 M,且 M ≪ N。检查项:每个 [-K:] 切片都要问"K 与对应的容量配置是不是同一个量" |
| O92-2 | 低 | review_sessions.py:343、expression/bw_expression_learner.py:83 | 全仓仅有的两处 hashlib.md5(),都在本插件、都用于生成 ID(非安全用途),都没有 usedforsecurity=False → 在启用 FIPS 的 Python 构建上直接 ValueError 崩溃。同插件其余哈希(memory_db.person_fact_doc_id、memory_retrieval._opaque_local_reference、knowledge_base._hash_id)全部用 sha256。一行可修 |
| O92-3 | 低 | review_sessions.py:341-343 + :459-471 | _new_session_id 取 md5 前 10 位十六进制(40 bit),open_session_if_allowed 不检查 sid 是否已存在就 active[sid] = ...。同时活跃会话上限 10,碰撞概率可忽略,但与 N22-1(pendo 8 位十六进制主键无 IntegrityError 处理)是同一种"生成即信任" |
| O92-4 | 低 | review_sessions.py:255-267 | _save_sessions_state 每次落盘前都跑一遍 _normalize_sessions_state(O(活跃会话数)),且 scrub_backup 会写两次盘(两次 fsync + 两次原子替换)。规模很小所以无碍,但"保存"路径上做全量校验是隐性成本 |
| O92-5 | 低 | review_sessions.py:397-418、:345-371 | close_session 与 cleanup_expired 删除会话时不做 scrub_backup,被删会话的 payload 会在 .bak 里停留一代(下一次任何状态写入即冲掉)。与 O-91-2 的隐私意图不完全一致,但影响窗口很短 |
| O92-6 | 低 | review_sessions.py:106-117 | _non_negative_int 用 value.strip().isdigit() 后 int() —— isdigit() 对上标字符(²)返回 True 而 int() 抛 ValueError,该异常不在函数自己的 try 里。当前无害(抛出的仍是 ValueError,调用方 _normalize_sessions_state:159 照样捕获),但这是巧合而非设计。同文件 _finite_float 接受任何 float()-able 值(含 "1e5"),两个同目的的校验器入口宽度不一致。M3-4 主线第 12 处,级别最低的一处 |
| O92-7 | 低 | review_sessions.py:632-648 | a.lower().startswith("goal:") —— 运营方输入全角冒号 goal: 时不匹配,静默落入 else 分支被当成策略备注,回复"已更新策略备注。"而不是"已更新目标"。中文输入法下全角冒号是默认输出 |
| O92-8 | 低 | review_sessions.py:269-290、:219 | _cache_policies 按 chat_id 只增不清(只有 bind() 和 clear_policy 会移除)。上限是会话数,量级可控,但与 O81-8(MemoryStore 三个字典无限增长)同族 |
| O92-9 | 低 | helper_utils.py:235-241、:251-264 | _replace_local_ids_with_text 的正则回调对每一个匹配到的 m\d{1,6} 调一次 _find_by_local_id,后者每次都 _state().memory_store.get(chat_id) —— 而 get() 会 list(cached) 复制整份 200 条历史(冷启动时还会同步读文件)。一段含 10 个 ID 引用的文本 = 10 次全历史复制。应改为调用方取一次历史后传入 |
| O92-10 | 低 | helper_utils.py:264 | m\d{1,6} 的边界断言是 (?<![A-Za-z0-9_]) / (?![A-Za-z0-9_]),因此英文里孤立出现的 m3、m5(型号、单位)只要恰好存在同名本地消息就会被替换成"对方说过"。窄,但属于"启发式改写用户可见文本"的固有风险 |
| O92-11 | 低 | helper_utils.py:148-150 | for info in model_infos: if info.name not in aliases.values(): aliases[info.name] = info.name —— 若管理员配置的别名与某个 profile 的真名同名,那个 profile 会被别名遮蔽且无告警;同时 in aliases.values() 是 O(n²) |
| O92-12 | 低 | message_parts.py:259-265 | 模板里越界的占位符([图片3] 但只有 2 张图)整段被静默吃掉:cursor 越过 match 但不追加任何内容。行为本身合理(不显示原始占位符),但没有任何计数或日志,与 O-6 记录的"媒体要么被消费要么被追加"是同一函数里方向相反的一半 |
| O92-13 | 低 | message_parts.py:226 | build_text_message_parts 对每一行生成一个 text part(splitlines(keepends=True)),而 normalize_message_parts 不合并相邻 text part → 一条 30 行的回复会产生 30 个片段,全部逐个持久化进聊天历史 JSON。功能正确,但存储与遍历开销与行数成正比 |
| O-83 † | 低 | — | 先结清 O-76:正确实现就在同一个包里,而且写得比 core 还细 |
| O-91 † | 低 | — | -3 message_parts 的"顺序即事实"契约写在模块 docstring 里 |
索引 · 入站媒体解析(C-0 主线)(17 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-104 † | 中 | — | 超出 max_media_per_message 的媒体静默消失,而失败的媒体反而有降级标记 |
| O-105 † | 中 | — | C-0 三方对照:本地路径的 check → open 之间没有 O_NOFOLLOW,也没有 fd 身份复核 |
| O-106 † | 中 | — | 收件箱淘汰:每解析一张图全目录扫描一次,且"TTL"实际按写入时间而非使用时间 |
| O-107 † | 中 | — | 渲染缓存的字节上限循环,每轮都把整个缓存重新序列化一遍 |
| O-108 † | 中 | — | media/ 子包把并发纪律全做对了,而它的父目录 media_registry.py 一条都没做 |
| O-109 † | 中 | — | 失败媒体的合成哈希与内容寻址哈希共用同一个命名空间 |
| O111-1 | 中 | event_media_common.py:515-548 | _render_animation_contact_sheet 逐帧 seek + convert("RGBA") + thumbnail,但没有 warnings.simplefilter("error", DecompressionBombWarning) 保护(只有 _inspect_image_payload_details 有)。当前安全,因为同一份 payload 必然先过探测器;但这是顺序依赖的隐式保证,没有断言也没有注释。任何新增的、直接调用 contact sheet 的路径都会失去保护 —— 与 O73-2("追加安全≠文件安全",低频路径最容易漏)同族 |
| O111-2 | 中 | event_media_common.py:705-740 | _looks_like_structured_media_text 是一张黑名单:<think、```、"kind"、"输入数据:"、"只输出 JSON" 等固定字符串。而同插件 O-61 已经确立"用句法结构而非语义词表做判定"并有四处实现。同一个插件,回复审查用结构判据、媒体描述清洗用词表判据,后者会随模型更换而失效(换一个把思维链包在 <reasoning> 里的模型就全漏)。_normalize_emotion_tags:569 的 cleaned.startswith("think") 更是临时补丁 |
| O111-3 | 低 | event_media.py:1131-1152 | _rendered_media_to_message_part 的 docstring 是"只序列化模型安全的媒体元数据,排除来源字节和凭据",但 :1147-1148 把 file_path(本机缓存文件的绝对路径)写了进去。它确实不进提示词(_render_media_marker 不读该字段),但会随 parts 落盘进 <chat_id>.json 并进入媒体注册表。同一插件里 _public_memory_meta(O-80-2)和 _logical_source_label 都会把绝对路径换成不透明引用,这里没有。docstring 的"模型安全"是准确的,但读起来像"对外安全" |
| O111-4 | 低 | event_media_common.py:31 | _MEDIA_BLOCKING_MAX_CONCURRENCY = 2 是模块常量,而同维度的量(max_media_per_message / max_analyze_bytes / inbox_disk_quota_bytes)全是配置项。多核部署上这是一个无法调整的吞吐天花板 |
| O111-5 | 低 | event_media_common.py:155-156 | _MEDIA_DOWNLOAD_TIMEOUT(total=20)与 _ONEBOT_HTTP_TIMEOUT(total=15)硬编码,而插件其余超时全部走 foreground/background_timeout_seconds 配置。O41-2 主线第 N 处 |
| O111-6 | 低 | event_media.py:1314-1331 | 表情包收藏(collect_emoji_candidate)用 try/except Exception: collected = None 整体吞掉。它写的是持久化的表情库并带 source_chat_id / source_user_id,失败完全无日志。同一函数里媒体解析失败有 WARNING 日志,收藏失败没有 |
| O111-7 | 低 | event_media.py:1323-1327 | 这里第 3 次内联重写了 _chat_id 的逻辑(f"g{group_id}" if ... else f"u{user_id}"),而 helper_utils._chat_id 就是干这个的。承 O-89:group_id 空值语义在本仓已有三套写法,这是第三处 |
| O111-8 | 低 | event_media.py:498-500 | _resolve_media_bytes 的 except Exception as exc: last_error = exc; continue 逐来源吞异常,只有最后一个错误会被抛出。多来源都失败时,前面几次失败的原因(例如"URL 被 SSRF 策略拒绝"vs"文件超限")完全丢失,而这正是排查媒体问题时最需要的信息 |
| O111-9 | 低 | event_media_common.py:249-256 vs :296-317 | 同一个 max_bytes 上限,_load_render_cache 的做法是"超限就整个丢弃",_prune_render_cache 的做法是"逐条淘汰最旧的"。两种纪律无注释说明,且前者会因为 .bak 超限而丢弃完好的主文件 |
| O111-10 | 低 | event_media_common.py:563 | re.sub(r"[^\w一-鿿]+", "", ...) —— Python 3 的 \w 在 unicode 模式下已经包含汉字,一-鿿 是冗余的;同时该表达式会剥掉全部 emoji,而这是"表情标签"的归一化函数。一-鿿 范围在本插件的第 4 处出现(承 O81-10) |
| O111-11 | 低 | event_media.py:328 | _decode_base64_payload 先 re.sub(r"\s+", "", payload) 把整个字符串物化一遍,然后才做长度上限检查。上限本身是对的(先按编码长度判、解码后再判),但那次 re.sub 的内存峰值不受 max_bytes 约束 |
索引 · 视觉分析流水线(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-113 † | 中 | — | 转码后的载荷没有任何上限 —— 四层预算漏掉了真正发出去的那一份 |
| O-114 † | 中 | — | 承载模型输出的 19 个日志字段全部被脱敏,debug.log_steps 打开后仍然什么都看不到 |
| O-115 † | 中 | — | 后台优化的 docstring 承诺"只在质量提升时更新",实现没有做这个比较 |
| O-116 † | 中 | — | 准备阶段的异常不产生任何日志,而"缓存文件被淘汰"恰好走这条路 |
| O118-1 | 低 | :300-303 | if mode not in {"RGB","RGBA"}: convert("RGBA")` / `elif image.mode == "P": convert("RGBA") —— elif 分支是死代码:"P" 本来就不在 {"RGB","RGBA"} 里,第一个分支已经覆盖。说明这段被改过一次而旧分支没删 |
| O118-2 | 低 | :399 + llm/gateway.py:98 | _call_media_llm_once 传 model=str(secrets.get("model","")),而 chat_completions_raw_with_fallback_paths 下游 kwargs.pop("model", None)(O-67:注释写明"真实模型由 route 决定,插件不能覆盖")→ 这个参数被静默丢弃。不是缺陷,但读代码的人会以为模型是这里定的 |
| O118-3 | 低 | :61-66 | _media_llm_max_tokens 用 "thinking" not in model 对模型名做子串嗅探来决定是否放大 token 预算。模型命名是服务商的自由,gpt-5-thinking-mini 命中、o1-preview 不命中。应改为由 core 的模型 profile 暴露一个 reasoning: bool 能力位 |
| O118-4 | 低 | :1341 vs :1391-1392 | 提示词要求 hint "不超过 24 字",代码按 60 字截断。两个上限不一致且都是字面量 |
| O118-5 | 低 | :1393-1394 | refusal_tokens = ("不知道","不确定","无法","无内容","无梗","需要更多") —— 又一张黑名单(承 O111-2)。而 _VISION_REFUSAL_RE 已经是一套更完整的拒绝识别正则,两处各维护一份 |
| O118-6 | 低 | :604-611、:1351-1359 | _refine_emoji_analysis_with_llm 与 _extract_cultural_hint 都走 _call_media_llm,而后者只用 candidates[0](:365)→ 这两条增强路径没有服务商降级,主路径有完整降级阶梯。可能是有意的(增强失败无所谓),但没有注释说明 |
| O118-7 | 低 | :1155 | for semantic_attempt in range(1, _MEDIA_SEMANTIC_RETRY_LIMIT + 2) —— 用 +2 表达"1 次初试 + N 次重试",可读性差;_MEDIA_SEMANTIC_RETRY_LIMIT 是模块常量而非配置项,而同文件的超时/重试次数(vision_max_retry / vision_retry_interval_seconds)都是配置项 |
| O118-8 | 低 | :270 | _prepare_media_for_llm 的 payload is None 分支直接 resolved.cached_path.read_bytes() —— 绕开 _read_file_bounded 的全部上限。当前唯一调用方 _load_and_prepare_media_for_llm 总是传 payload,所以不可达;但这是一个默认参数形式的后门,一旦有第二个调用方就失效。应删掉该默认分支或让它也走 _read_file_bounded |
索引 · PFC 规划层与会话状态存储(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-131 † | 中 | — | 规划器显式请求"去查记忆",而记忆层用启发式把它否决了 |
| O-132 † | 中 | — | 状态保存写在 finally 里 await,取消时可能不落盘 —— 而同插件已有 asyncio.shield 的先例 |
| O-133 † | 中 | — | 三个同族状态存储,只有一个有缓存上限 |
| O134-2 | 中 | action_history.py:80-91 | get_recent_async 在 self._lock 之外读写 self._cache(:87-89 的 self._cache[chat_id] = loaded or []),而同类的 append / clear / _persist 全部持锁。它用 _state_version 做了检查,但 check 与 set 之间没有锁,与在工作线程里跑的 _persist 构成 TOCTOU。GIL 保证不会数据结构损坏,但版本判断可能失效。同一个类里唯一没持锁的缓存写入点 |
| O134-3 | 中 | heartflow.py:14-27、:130-142 | _LEGACY_SCORE_KWARGS 列了 14 个已废弃参数(threshold / enable_random / mentioned / weight_mentioned / is_private / replies_last_minute / max_replies_per_minute / cooldown_left_seconds / min_reply_interval_seconds / weight_private / weight_rate_limit / weight_cooldown / weight_interval),score() 用 **kwargs: Any 接收后静默丢弃。核对结果:HeartflowConfig(config.py:237-244)已经清理干净、唯一调用方 frequency_control.py:214-224 也不传这些参数 → 这段防御代码没有任何真实的生产者。代价是 score / score_async 的签名对类型检查器完全不透明(O68-3 家族),传错参数名不会在 mypy 里报错,只会在运行时 _calculate_score 抛 TypeError |
| O134-4 | 低 | pfc_engine.py:125-126 | _normalize_action 的默认值是 "direct_reply" —— 未知动作 fail-open 到最积极的行为。当前不可达(plan_next_action:355 已用 _ALLOWED_PLANNER_ACTIONS 收窄,且 8 个动作全在 _ACTION_ALIASES 里),但这是第二道防线的默认值与第一道防线的降级方向相反:第一道是"群聊退回 wait",第二道是"退回 direct_reply"。一旦第一道被改动,第二道会静默地把行为翻到反面。而 O-37 已确认"机器人插话太频繁"是这个插件的现存问题 |
| O134-5 | 低 | pfc_engine.py:59 | last_ctx = f"...detail={last.detail}" —— 把 ActionRecord.detail(一个含 media_kind/media_hash/media_key/media_face_id 的 dict,见 smalltalk_media_helpers._media_action_detail)用 Python repr 直接拼进提示词。模型看到的是 {'media_kind': 'emoji', 'media_hash': 'a3f2...'}。同插件别处(reply_checker、媒体分析、记忆工具)交给模型的都是 JSON 或自然语言 |
| O134-6 | 低 | pfc_engine.py:63 minutes: int = 6、:424 [-10:]、:133 [:120]、:474 [:200] | 四个影响提示词内容的量都是字面量:长时间无回复的阈值 6 分钟、知识列表保留 10 条、thinking 在两个位置分别截 120 和 200 字符。同文件的规划器超时/失败窗口/阈值/退避全是配置项,同一个文件里两种做法 |
| O134-7 | 低 | goal_state.py:24-49、heartflow.py:44-58、pfc_state.py:44-70、action_history.py:113-144 | 四个 _load 全部走 StoreBase._load_json 或裸 read_text + except Exception: return None,即 O-76 那个不看 .bak 的读取器。PFC 状态损坏 → 静默重置为默认状态(ended=False、goal_list=[]、熔断计数清零),用户表现为"小青忘了刚才在聊什么",无日志 |
| O134-8 | 低 | heartflow.py:78-95 | on_user_message_async / on_bot_reply_async / on_no_reply_async 都是"取缓存对象 → 原地改 → to_thread(self._save)",没有任何锁。_save 在工作线程里读同一个对象、事件循环可能同时改它 → 落盘的 payload 可能混合两次更新(例如 reply_streak 已增、last_bot_ts 未更新)。字段少、后果轻,但与 PFCStateStore 的 threading.RLock 纪律不一致 |
| O134-9 | 低 | goal_state.py:52-66 | set() 的 80 字符截断先找标点、if idx >= 20 保证不切太短、否则退回 77 字符硬切 —— 逻辑本身周到,但 80/20/77 三个数字都是字面量且没有常量名,… 的追加使实际长度可能是 81 |
索引 · 表达学习的存储层(含一处对既有结论的更正)(12 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-137 † | 中 | — | 隔壁的黑话库对同一个问题什么都没做 |
| O-138 † | 中 | — | O-50 的第 2 处裸 send_action,与 O-84 完全同形 |
| O140-1 | 中 | bw_expression_reflector.py:38-42 | _pick_candidates 取前 80 条后 random.shuffle 再取 max_pick 条 —— 推送给运营方的候选是随机的、不可重放的。同插件 O-45 特意用 request_id 哈希而非 random 做轮转,并把"可重放"作为设计目标(对照 O-29)。运营方报告"刚才那条反思请求我没看清"时无法复现是哪一条 |
| O140-2 | 中 | bw_expression_store.py:82-84 | _read_records 的 except Exception: return [] —— 表达库文件损坏时返回空列表,随后 save() 会把 latest_items=[] 与 desired 合并并整份覆盖磁盘上那个损坏的文件。.bak 里有完好版本但没人读(O-76)。而这个文件承载的是"机器人学到的说话方式",用户症状是"小青说话突然变得很生硬" |
| O140-3 | 低 | bw_expression_store.py:34-36 | _baseline 是实例状态而路径锁是跨实例的:一个从未 load() 过的实例直接 save() 时 _baseline 为空 → _merge_record(latest, None, desired) 走 baseline is None 分支 → 所有标量字段被 desired 覆盖。不会删除别人的条目(set(baseline_by_id) 为空),所以退化是安全的,但注释没有说明这一点 |
| O140-4 | 低 | bw_expression_store.py:94 | load() 每次都 deepcopy(self._cache) 返回(防污染,正面),但 context_builder._build_expression_block 每条消息调一次 load()(同步、在事件循环上,承 O-75)→ 每条消息深拷贝整个表达库 |
| O140-5 | 低 | bw_expression_reflector.py:18-32 | _load_state / _save_state 又是"裸 read_text 读 + write_json 写"(O-76 家族第 7 处);_save_state 的 except OSError: return 静默吞掉,运营推送节流状态写不进去时无人知晓 |
| O140-6 | 低 | bw_expression_reflector.py:45-48 | _operator_chat_id 是第 4 处内联重写 helper_utils._chat_id 的逻辑(承 O111-7、O-89)。本处的写法又不一样:if operator_group_id: 用真值判断而不是 not in (None, "") |
| O140-7 | 低 | bw_expression_learner.py:82-84 | _mk_id 用 hashlib.md5(f"{chat_id}\ {situation}\ {style}\ {time.time()}") —— O92-2 / O-99 记录的两处 md5 之一,FIPS 构建上直接崩。且 ID 里混入 time.time(),因此同一个 (chat_id, situation, style) 每次学习都会得到新 ID,去重完全依赖 _similarity 的文本相似度判断而不是键 |
| O140-8 | 低 | expr_utils.py:19-20 | t[: max_text_len - 40] —— 从 max_text_len(默认 200)里减去 40 再截断,40 这个数没有任何说明;实际截断长度是 160 而不是参数名暗示的 200 |
| O140-9 | 低 | bw_jargon_store.py:73 | out[self.key_for(content, "" if rec.is_global else rec.scope_chat_id)] = rec —— 加载时按 is_global 决定键的作用域前缀,若同一个词同时存在全局条目和会话条目,两者键不同不会冲突;但若一条记录的 is_global 被外部改动,它的键会漂移,save() 又是全量覆盖,因此旧键对应的记录会静默消失 |
| O-139 † | 低 | — | 结清 O-94 的挂账:这两个存储连 clear 方法都不存在 |
索引 · 规划器提示层与拟人化大群实验运行器(11 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-150 † | 中 | — | 规划器自己包的 wait_for 让 core 的跨模型 fallback 永远来不及发生 —— 而 core 已经为此提供了参数 |
| O-151 † | 中 | — | 目标对象的键名在生产者与两个消费者之间有三套词汇 |
| O-152 † | 中 | — | 提示词明确告诉模型"可以删除不再相关的目标",代码把"全删"当成解析失败 |
| O-154 † | 中 | — | 3 000 轮实验里只有 60 条不同的输入,而报告按 3 000 计 |
| O-155 † | 中 | — | 断点续跑不校验矩阵指纹,换个 --seed 会把别人的结果当成自己的 |
| O-156 † | 中 | — | 真实模式没有静默点:被测系统的异步副作用还没落地就打分了 |
| O-157 † | 中 | — | 泄漏的"检测集合"比"脱敏集合"大,被检出的原文照样写进产物 |
| O-158 † | 中 | — | 实验上下文把 logger 的五个级别全实现成 no-op,于是唯一能解释"为什么没回复"的证据全丢了 |
| O-160 † | 中 | — | 自检数据被逐条构造成"必然通过",于是只验证了不误报 |
| O-153 † | 低 | — | 规划层的其它问题 |
| O-159 † | 低 | — | 实验运行器的其它问题 |
索引 · JSON 解析、参与度判据、频控与关停(收尾)(4 条)
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| O-163 † | 中 | — | JSON 修复管道里,一步区分字符串内外、另一步不区分 —— 于是"修复"会改写字符串里的内容 |
| O-164 † | 中 | — | JSON 提取要求"整段就是一个 JSON",而修复层却肯容忍中文引号和尾逗号 —— 宽容度不成体系 |
| O-165 † | 中 | — | 同一个文件里,先明确拒绝"按剩余概率增益"的公式,25 行后又用了它 |
| O-166 † | 中 | — | 日志里被强制脱敏的标识符,原样写进了发给第三方模型的提示词 |
详述(91 条,† 标记的条目)
O-74 [P1] 人物事实抽取的「每 20 条」节流门,在会话满 200 条后永久失效为「每条都跑」
事实
memory/knowledge_extract.py:140-143:
if len(history) < 20:
return
if len(history) % 20 != 0:
returnhistory 的来源是 handlers.py:709 的 state.memory_store.get_async(chat_id), 即完整缓存历史。而 memory/memory.py:18 与 :270-271:
MAX_CACHED_MESSAGES_PER_CHAT = 200
...
if len(history) > MAX_CACHED_MESSAGES_PER_CHAT:
del history[:-MAX_CACHED_MESSAGES_PER_CHAT]一个会话累计到 200 条之后,len(history) 就永久等于 200。200 % 20 == 0 → 门恒真 → 抽取在此后的每一次回复上都执行。
后果
抽取一次的开销是:拼 1 800 字符对话 + 一次 chat_completions(max_tokens 上限 768)
- 0~6 条
upsert_text+ 每个 subject 一次画像文件读写。设计意图是每 20 条一次, 实际变成每条一次:
- LLM 调用量 ×20(这是一条与用户回复并行的后台调用,成本与限流都实打实)
- 向量库文档增长速率 ×20(见 O-79:这些文档没有任何上限或过期)
- 与 O-75 复合:每次
upsert_text都把嵌入矩阵置空,下一条消息的读路径 就要在事件循环上重算全库嵌入 → 抽取频率 ×20 等于全库重算频率 ×20
前 200 条消息里它是正确的(20/40/…/200 各触发一次), 所以新装的实例、短会话、测试用例全都看不出问题;只有长期运行的群才会进入错误状态。
同仓已有正确写法(llm/summarizer.py:114-121)
话题总结器用了同一个 len(history) % N 门,但多了一条兜底分支:
if len(history) % min_messages_per_update != 0:
if not cache:
return
last_updated = max((float(t.updated_at or 0.0) for t in cache), default=0.0)
observed_since_last = sum(
1 for msg in history if float(getattr(msg, "ts", 0.0) or 0.0) > last_updated
)
if observed_since_last < min_messages_per_update:
return按「上次总结之后新增了几条」计数,不依赖 len(history) —— 这正是不受 200 上限影响的写法。 事实抽取缺的就是这一段。→ N101-2 第 35 处(正确实现已在同仓、隔壁文件、同一门控形状)。
但总结器也只补对了一半 —— 一个配置值依赖的悬崖
那条兜底只在 modulo 不匹配时才生效,它防的是"永远不触发",防不了"永远触发"。 min_messages_per_update 默认 12,200 % 12 = 8 ≠ 0 → 恒走兜底分支 → 恰好正确。 但配置允许 ge=1, le=1000,而只要运营方填了 200 的约数 (20 / 25 / 40 / 50 / 100 / 200),modulo 分支就恒真,总结器同样退化为每条消息总结一次。
即:这个配置项的行为在"整除 200"与"不整除 200"之间存在断崖, 配置注释、字段名和取值范围里没有任何提示。事实抽取硬编码的 20 正好落在断崖的坏侧。
修法:两处统一改为「按 ts > last_updated 计数」或维护一个显式的 messages_since_last_extract 计数器,删掉两处 % N。
O74-1 方法论
任何以 len(某个被截断的集合) % N 表达的节流,都是缺陷。 被截断的集合长度会收敛到常数,此后 modulo 只有恒真、恒假两种结果,取决于 截断上限与 N 是否整除 —— 而这两个常量通常定义在互不知情的两个文件里 (此处 memory/memory.py:18 与 memory/knowledge_extract.py:142)。 检查项:全仓 grep len( 与 % 同现的条件,逐个确认被取长度的集合是否有上限。
O-75 [P1] 写路径全部 to_thread,读路径全在事件循环上 —— 而全库重算恰好发生在读路径
嵌入矩阵是全量重建的,没有增量更新
memory/vector_store.py:60-75:upsert 与 delete 都只做一件事 —— self._matrix = None。 :88-98 的 build() 于是从零重算所有文档的嵌入:
mat = np.zeros((len(self._docs), self._dim), dtype=np.float32) # dim=2048
for i, d in enumerate(self._docs):
mat[i, :] = _embed(d.text, dim=self._dim)而 _embed(:268-277)是纯 Python 逐字符循环:_tokenize 逐字符判 CJK、 _char_ngrams 逐字符切二元组、_hash32(:260-265)对每个 token 再逐字符做 FNV-1a。 一篇 800 字符的知识分块 ≈ 2 400 次 Python 循环 + 一次 2048 维 float32 分配。
build() 只在 query() 里被调用,而 query() 从不 to_thread
| 调用点 | 是否 to_thread |
|---|---|
main.py:199、task_scheduler.py:150 → memory_db.save | ✅ |
task_scheduler.py:117 → memory_store.persist | ✅ |
handlers.py:276-306 的 8 处清理 | ✅ |
planning/*、store_base.py:146-149、media/event_media_common.py:67 | ✅ |
context_builder.py:146 _build_knowledge_block → query_global | ❌ |
context_builder.py:168 _build_jargon_explanation → query_global(循环最多 6 次) | ❌ |
memory/memory_retrieval.py:460 直接检索 | ❌ |
memory/memory_retrieval.py:393/416 ReAct 代理的 6 个工具 | ❌ |
handlers_internal.py:284 /xc 记忆 命令 | ❌ |
context_builder.py:105、person_profile.py:109 的 bind()(首次含 load + 全量遗留迁移) | ❌ |
reply_generator.py:479-503 连续四个块(画像 / 知识 / 表达 / 黑话) 全是同步调用,夹在 async 生成主链路中间。
因此这是每条消息的常态路径,不是罕见路径
链路闭合成一个环:
本轮回复 → 后台事实抽取(O-74:每条都跑) + 话题总结 → upsert_text → _matrix = None
→ 下一条消息 → _build_knowledge_block → query_global → build() → 全库重算
→ 事件循环停顿(整个 bot、所有插件、core 调度器一起停)规模:配置知识库上限 512 分块 × 800 字符(knowledge_base.py:18-19), 加上无上限增长的人物事实与话题摘要(O-79)。数千文档量级时, 单次重算是百万级 Python 循环 + 数千次 numpy 分配,属于「秒」而非「毫秒」量级。
还有一处锁上的相互阻塞
memory_db.save()(在工作线程里)在 _save_lock + _lock 内也调 build() 并 mat.copy();同一时刻事件循环上的 query() 要拿同一把 threading.RLock (memory_db.py:293)。即使矩阵是热的,事件循环也会阻塞等待保存线程完成全量重建与拷贝。
修法(按性价比排序)
query全部改成await asyncio.to_thread(...)—— 本插件其余部分已经是这个纪律, 只是没覆盖到读路径。这一步单独就能消除事件循环停顿。- 增量矩阵更新:
upsert只重算被改的那一行(_embed一次 + 一次行赋值), 仅在维度或文档数变化时才整表重建。当前把 O(1) 的更新做成了 O(N)。 _embed的逐字符 Python 循环换成 numpy / 批处理,或改稀疏表示 (2048 维稠密 float32 存的是一个词袋,实际非零元素只有几十个)。
→ 这与 K4-2 / N48-2 主线是同一形状的最严重实例:to_thread 纪律在这个插件里 覆盖了几乎所有写入点,唯独漏掉了读取点 —— 而昂贵的那一半在读取点。
O-76 [P1] .bak 备份被认真维护、被显式删除,却没有任何读路径会用它
三个事实并列
(一)写入侧维护备份。 core/plugin_base.py:203-205 的 write_json → AtomicJsonStore.write → _write_unlocked(core/atomic_store.py:139-150): 写新内容前,先把能正常解码的当前文件原子复制到 <name>.bak。
(二)core 提供了会读备份的读取器,但插件没用。core/plugin_base.py:183-200 的 load_json 走 AtomicJsonStore.read, 主文件解码失败时自动回退 .bak 并把它写回主文件。
(三)插件自己写了一个不看备份的读取器。plugins/xiaoqing_chat/store_base.py:55-62:
def load_json_file(path: Path, default: Any = None) -> Any:
"""读取 UTF-8 JSON;文件缺失或内容无效时返回调用方给定的默认值。"""
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return defaultStoreBase._load_json = staticmethod(load_json_file)(:76)—— 所有继承 StoreBase 的存储都继承了这个不看备份的读取器。
作者知道 .bak 的存在
同一个文件 store_base.py:33-52 的 delete_json_artifacts 特意连备份一起删, docstring 写着:"只删主文件会让旧内容继续留在 .bak 中,甚至可能在后续恢复流程中重新出现"。
这句话描述的恢复流程在整个插件里不存在。
普查:9 个存储,1 个读备份
| 模块 | 读取方式 | 是否用 .bak |
|---|---|---|
memory/review_sessions.py:59-65 | AtomicJsonStore.read(且 raise_on_error 两级重试) | ✅ 唯一一个 |
memory/memory.py:401 聊天历史 | json.loads(path.read_text()) | ❌ |
memory/vector_store.py:153 向量库文档 | 同上 | ❌ |
memory/person_profile.py:33 人物画像 | 同上 | ❌ |
memory/topic_summary_cache.py:35 话题摘要 | 同上 | ❌ |
planning/action_history.py:118 | 同上 | ❌ |
expression/bw_expression_reflector.py:24 | 同上 | ❌ |
experiments/anthropomorphic_group.py:1083 | path.read_text() | ❌ |
store_base.py:60 基类(覆盖其余全部继承者) | 同上 | ❌ |
用户可见后果,以人物画像为例
person_profile.py:28-50 的 load_profile 在任何异常上 return None, 而调用方 update_profile_and_index:82-88 把 None 当成"这个人还没有画像", 从空画像开始累积,然后 save_profile 把它写回去。
于是:画像文件出现一个坏字节 → 该用户的全部历史事实清零 → 立刻被只含本轮新事实的 文件覆盖。而完好的旧版本一直躺在同目录的 .bak 里(因为 _write_unlocked 只备份能解码的文件,坏文件不会污染备份),没有任何代码会去看它。 用户症状是"小青突然不记得我了",日志里没有任何一行。
memory/memory.py:448-449(聊天历史)与 vector_store.py:166-169(整个向量库) 是同一形状,vector_store 的注释甚至写明"文档加载失败时没有可用数据"—— 恰恰不是没有可用数据,可用数据就在隔壁 .bak 里。
修法
store_base.py:55-62 的 load_json_file 改为委托 core.plugin_base.load_json (一行),memory.py / vector_store.py / person_profile.py / topic_summary_cache.py / action_history.py / bw_expression_reflector.py 六处裸 read_text 改用同一入口。→ N101-2 第 36 处。
同时补 O69-1 要求的日志:解码失败必须带路径与主键,不能静默返回默认值。
O76-1 与 O-70 合并成一条完整的教训
O-70(上一批):thinking_back.py 因为没继承 StoreBase 而失去原子写。 O-76(本组):其余模块继承了 StoreBase,于是集体继承了一个不看备份的读取器。
同一个基类,一个方向上"没继承就没保护",另一个方向上"继承了反而锁死了半个保护"。AtomicJsonStore 的读写是一对协议(写维护备份 / 读消费备份), core.plugin_base 把 write_json 和 load_json 都导出了,插件只采纳了写的一半。
→ O73-1 检查项应扩写:不只要问"谁没有继承基类",还要问 "基类提供的保证是不是成对的,插件是不是只实现了其中一半"。 判据很简单:凡是写入侧会产生一个附属文件(.bak / .wal / 索引)的设施, 必须能指出读取侧消费它的具体代码行。指不出来,这个附属文件就是纯粹的磁盘开销。
O-94 [P1] 会话清除覆盖 8 个存储、漏掉 3 个,漏掉的其中一个在设计上就无法按会话清除
对账结果
handlers.py:273-295 的 _reset_chat_session(/xc clear 的实现)逐一清理:
| # | 存储 | 清理方式 |
|---|---|---|
| 1 | memory_store 聊天历史 | clear(chat_id)(含 .bak,走 delete_json_artifacts) |
| 2 | memory_db 向量库 | delete_chat(chat_id) + 强制 save() |
| 3 | goal_store | _clear_store_entry |
| 4 | heartflow | 同上 |
| 5 | action_history | clear(chat_id) |
| 6 | review_store 反思会话与策略 | _clear_review_state(含 scrub_backup,见 O-91-2) |
| 7 | pfc_state_store | _clear_store_entry |
| 8 | thinking_back + topic_summary_cache | 各自的 clear_* |
这份清单本身做得很细(连 .bak 和"等待已进入线程的持久化任务"都考虑到了, 见 :273-275 的注释)。但全插件的存储不止 8 个:
| 遗漏的存储 | 是否有会话归属 | 后果 |
|---|---|---|
media_store(media/index.json) | ❌ 完全没有 chat_id 字段 | 见下 |
bw_expr_store 表达习惯库 | ✅ 每条带 ex.chat_id | 学到的口癖在清除后继续注入提示词 |
bw_jargon_store 黑话库 | ✅ 带 scope_chat_id / is_global | 学到的黑话释义继续生效 |
后两个本可以清(context_builder.py:118 与 :178 正是按 chat_id 过滤它们的), 只是没有写清理代码 —— 全仓 grep bw_expr_store|bw_jargon_store 与 clear|delete|reset 同现的结果为 0。
媒体注册表的问题是结构性的
media_registry.py:467-591 的 MediaRegistryStore 索引在 <data_dir>/media/index.json,条目按 media_key 组织, 记录的字段是 kind / media_hash / face_id / marker / description / file_path / label / emotion_tags / aliases / quality_score / first_seen_ts / last_seen_ts / seen_count。
这里面没有任何一个字段表示"这张图是在哪个会话里出现的"。
因此即使补上清理代码也无从下手:无法知道哪些 media_key 属于被清除的会话。 而里面存的东西并不轻:
description是模型生成的图片描述(视觉分析结果),file_path是本机缓存文件的绝对路径(smalltalk_media_helpers.py:100把item.cached_path直接写进去),first_seen_ts/last_seen_ts/seen_count构成一条跨会话的媒体出现时间线。
用户执行"清除本会话数据"之后,这三类信息全部原样保留, 且因为注册表是全局的,A 群里出现过的图片,其描述会在 B 群里被复用 (同一 media_hash → 同一 media_key → 同一条注册表记录)。 对同一张图片而言描述本身是等价的,风险有限; 但 label / emotion_tags 是可以带上下文色彩的字段, 而 seen_count 直接泄露"这张图在别处出现过多少次"。
修法
- 给注册表条目加
chat_ids: list[str](或独立的media_key → chat_id反向表), 清除会话时移除该会话的引用、引用归零则删条目; _reset_chat_session补上bw_expr_store/bw_jargon_store两处清理;- 把"清除会话"的存储清单变成一处声明(例如
ChatRuntimeState上的iter_chat_scoped_stores()),让新增存储时忘记接线这件事变成编译期/启动期可发现的。 当前是 8 处手写调用,新增第 12 个存储时不会有任何提示 —— 这正是 O73-1 / O93-1 的又一实例:能力的完整性靠人记住,而不是靠结构保证。
O-121 [P1] 每发/收一个表情,下一次回复就要把整个表情库重新读盘并 sha256 —— 全在事件循环上
链路
reply_generator.py:775 → marker_resolver.resolve_marker → _resolve_emoji_marker:282 → await load_emoji_library(context, runtime, chat_id=chat_id)。
load_emoji_library 虽然是 async def,函数体除 render_local_media_file 外全是同步的, 其中每次缓存未命中都要(:679-703):
for file_path in files: # files = library_dir.rglob("*") 的全部图片
media_hash = _hash_file(file_path) # = sha256(path.read_bytes()) ← 整文件读盘 + 哈希
...
if not perceptual_hash:
perceptual_hash = _average_hash(file_path) # ← PIL 打开 + 转灰度 + 缩放emoji_auto_collect_max_entries 默认 200(config.py:325), 加上 pending/ 目录里的文件(见 O-123),量级是数百个文件、数十 MB。
缓存为什么频繁失效
缓存签名是 (library_dir.st_mtime, index.json.st_mtime), 而 _save_index 每次都调 _invalidate_library_cache。调用它的有:
| 触发 | 频率 |
|---|---|
mark_emoji_used_by_hash(:851-864)—— 机器人每发一个表情包 | 高 |
collect_emoji_candidate(:592)—— 每收到一个表情包(内容有变时) | 高 |
load_emoji_library 自身的 _save_index_if_changed(:814) | 首次/修复时 |
也就是说:群里表情包一来一回,缓存就失效一次; 下一条消息生成回复时,事件循环上要把整个表情库重新读一遍并逐个 sha256。
mark_emoji_used_by_hash 的代价还不止于此 —— 它为了给一个计数器 +1, 要 _load_index(读整个 index.json)+ _save_index(原子写整个 index.json + .bak)。
与 O-75 的关系
这是 O-75 主线的第 9~11 个受害点,也是单次开销最大的一个: 前面几处是全库嵌入重算(CPU),这一处是数十 MB 的磁盘读 + sha256(IO + CPU), 而且和 O-97(媒体注册表持锁 I/O)、O-87(配置加载里同步建知识索引) 叠加在同一条回复生成路径上。
同一个包里就有正确做法:event_media_common._run_media_blocking (to_thread + 插件级信号量)。emoji_library.py 已经 import 了 同目录的 event_media_common,只是没用这个。→ N101-2 第 41 处。
修法(三层,从便宜到彻底)
_hash_file/_average_hash的整段循环包进_run_media_blocking;- 文件哈希改为增量:索引里已经记了
media_hash ↔ file_path, 只需按(size, st_mtime_ns)判断文件是否变过,没变就复用索引里的哈希 —— 现在是"每次都重算,然后拿它当键去查索引",等于把索引当成了纯粹的元数据附属品; mark_emoji_used_by_hash改为累积到内存、按MediaRegistryStore.flush那样的显式刷盘边界落盘(同插件已有该模式,见 O-97 的正面部分)。
O-142 [P1] 每来一条消息,表达存储的合并基线就被清空一次 —— O-136 表扬的三方合并在生产路径上基本不生效
链路
O-136 的结论成立的前提是:_baseline 记录的是"我这次读出来时磁盘上的样子"。 ExpressionStore 只在两个地方动 _baseline:
# bw_expression_store.py:34-37
def bind(self, data_dir: Path) -> None:
super().bind(data_dir)
self._cache = None
self._baseline = [] # ← 无条件清空
# bw_expression_store.py:92-96(load,仅当 _cache is None 时才重建基线)
# bw_expression_store.py:161-162(save 成功后把基线推进到刚写出的内容)而 bind() 是每条消息都会被调用的:
HandlerContext.from_event(handler_context.py:50)
→ _bind_all_stores(state, context.data_dir)(store_binding.py:15)
→ state.bw_expr_store.bind(data_dir)写入方全部是后台任务,且 load 与 save 之间隔着若干次模型调用:
| 写入方 | load | 中间的 await | save |
|---|---|---|---|
upsert_learned | :238 | 最多 20 次串行自审模型调用(:293) | :325 |
_tick_reflect_tracker_once | :242 | 1 次判定模型调用(:265) | :305 |
也就是说:任何一条在后台学习期间到达的消息,都会把这次读-改-写的基线抹掉。 群聊里这不是竞态窗口,这是常态。
后果一:count 每次保存翻倍
基线为空时走 _merge_record(latest, None, desired):
original_count = baseline.count if baseline is not None else 0 # → 0
merged.count = max(0, latest.count + (desired.count - 0)) # → latest + desired磁盘上 5、本次想写 6 → 合并成 11;下一轮 11 + 12 → 23。 count 正是注入排序(context_builder.py:125 key=lambda x: (-x.count, ...)) 和反思候选排序(bw_expression_reflector.py:41)的第一关键字, 于是最近被学到的表达会以指数速度压过其它所有表达。
后果二:并发字段覆盖回来了 —— 正是 _merge_record 要防的那件事
标量字段的三方判据是 if baseline is None or desired_value != original。 baseline is None 时短路成"全部覆盖"。
可复现的语义丢失:
- 反思判定在 T1 把某条表达置
checked=True, modified_by="user"(用户明确同意); - 学习任务在 T0 就读出了这条记录(
checked=False),T2 保存; - 若 T0~T2 之间有任何一条消息到达 → 基线为空 → T2 把
checked覆盖回False、modified_by覆盖回"ai"。
用户的同意被无声丢弃,且事后无法从数据上分辨。
后果三:max_store 裁剪静默失效,文件无界增长
upsert_learned:320-325 的裁剪是用"desired 里没有"来表达删除的:
items = other + (scoped[:limit] if limit else [])
store.save(items)而删除判据在 save() 里是 set(baseline_by_id) - set(desired_by_id)。 基线为空 → 差集为空 → 一条都不删,而 merged_by_id = dict(latest_by_id) 起手就带上了磁盘上的全部记录。 配置里 max_store 默认 200(config.py:312,ge=1, le=10_000), 实际上这个上限在有并发消息时从不生效。
再叠加 bind() 同时把 _cache 清成 None: _build_expression_block(每次回复都走)里的 load() 每条消息都要把 整份 expressions.json 重新读盘 + 逐条构造 dataclass,全部同步、在事件循环上。 文件因后果三而无界增长 → 这条同步读盘随时间线性变慢。
同一子包里就有正确写法
# bw_message_recorder.py:33-38
def bind(self, data_dir: Path) -> None:
"""切换数据根时丢弃旧根缓存;重复绑定同一路径不触发无谓重读。"""
if self._data_dir != data_dir:
self._state = {}
super().bind(data_dir)同一目录、同一基类、同一个 docstring 写清了理由, ExpressionStore.bind 只差这一个 if。
修法
ExpressionStore.bind加同样的if self._data_dir != data_dir:守卫(一行,直接消除三个后果);- 更彻底的做法是把基线从对象生命周期挪到读取生命周期:
load()返回(records, baseline_token),save(items, token=...), 基线随这一次读-改-写走,而不是随存储对象走 —— 这样即使有人在中途bind,也不影响已在飞行中的那一次合并。
O-1 [中] 两个配置来源用浅合并,嵌套子配置会被整块覆盖
# config/config.py:450-462
data: dict[str, Any] = {}
if isinstance(context_config, Mapping):
...
data = dict(plugin_config) # 来源①:机器人主配置 plugins.xiaoqing_chat
...
if config_file_path.exists():
data = {**data, **_read_json(config_file_path)} # 来源②:插件自带 JSON
elif file_path.exists():
data = {**data, **_read_json(file_path)}{**a, **b} 是一层合并。XiaoQingChatConfig 却有 14 个嵌套子模型 (personality / memory / media / reply_check / reflection / …)。
于是:主配置里写了 plugins.xiaoqing_chat.media.max_media_per_message = 3, 而插件自带的 config/xiaoqing_config.json 里只要出现 "media": {...} 这个键, 整个 media 子树都以文件为准,主配置那一项被静默丢弃。 用户看到的是"我在主配置里改了,但没生效",且没有任何日志。
这与 R-1 主线(12+ 个插件手写 _get_config)方向相反但同源: 那些插件的问题是没有模型,这里的问题是有了好模型却用了一个不认识嵌套结构的合并函数。
修法:递归合并(对 dict 值递归、其余以后者为准), 或干脆明确"两个来源二选一"并在加载时记录实际生效的来源。 同仓已有正确样板:pendo/utils/settings_utils.py 的 normalize_settings_json(N-114 记为"pendo 少数真正的共享契约层") 与 db.py:2020-2034 的 json_patch 局部合并(N52-2)。 → N101-2 清单第 28 处。
O-2 [中] 配置模型不拒绝未知字段,而同仓的 pendo 在风险更低的地方拒绝了
XiaoQingChatConfig 及其 14 个子模型没有一处 model_config = ConfigDict(extra="forbid"), 因此 Pydantic 默认 extra="ignore":配置文件里写错的键被静默丢弃。
把 reply_probability_base 敲成 reply_probablity_base, 结果是插件按默认值 0.55 运行、日志无声、/xc config 显示的也是默认值—— 用户唯一能察觉的途径是"我改了但行为没变",与 O-1 的症状完全一样、原因却不同。
对照同一个仓库:
| 位置 | 输入来源 | 未知字段策略 |
|---|---|---|
pendo/web/api/settings.py:23、transfer.py:64/76/83/90 等 5+ 处 | HTTP 请求体(调用方是自己写的前端) | extra="forbid" ✅ |
xiaoqing_chat/config/config.py 全部 15 个模型 | 人手编辑的 JSON 配置文件 | 默认 ignore ❌ |
风险分配正好反了:调用方是代码的地方做了严格校验, 调用方是人手且没有任何补全提示的地方反而宽松。 配置文件恰恰是最容易打错、也最难发现打错的输入源。
修法:给 XiaoQingChatConfig 加 extra="forbid", 加载失败时把 Pydantic 的错误信息原样写进启动日志(它会直接指出哪个键不认识)。
O-3 [中] 配置支持两个文件位置,热重载只监听其中一个
最终结论:定级由高降为中;结论保留。以下推导保留原始分析,与该结论有出入处以本行为准。
- 加载器(
config.py:457-462):优先plugin_dir/config/xiaoqing_config.json, 否则回退plugin_dir/xiaoqing_config.json; plugin.json:7-10的watch_files只列了config/xiaoqing_config.json。
因此使用回退位置的部署,改配置后不会触发热重载, 而 runtime_state.get_runtime_mtime / set_runtime 这一整套按 mtime 失效的缓存 (config.py 之外,runtime_state.py:157-172)也就永远拿不到新值—— 只能重启进程。
同样属于"能力已具备但没接线"(N37-1): mtime 缓存、watch_files 机制、两个候选路径三者都实现了, 只是第三者没有把第二个候选路径告诉第一者。
修法:watch_files 两条路径都写上(多写一条不存在的文件不会有副作用), 或者取消回退位置、只保留一处。
O-10 [中] 唯一会把真实用户对话外发并沉淀成人物档案的后台任务,是四个后台任务里唯一没有开关的
handlers_helper.py:_spawn_post_reply_bg_tasks 在每次成功回复后启动四类后台工作:
| 后台任务 | 配置开关 | 外发内容 | 落盘内容 |
|---|---|---|---|
主题摘要 _run_summarizer | summarizer.enable_topic_summarizer ✅(且私聊不跑) | 历史 | 主题缓存 |
表达学习 extract_and_learn | expression.enable_expression_learning ✅ | 历史 | 表达/黑话库 |
复盘推送 maybe_push_session | reflection.enable_review_sessions ✅ | — | 审查会话 |
人物事实抽取 _run_fact_extract | 无(:110-124 无条件 spawn) | 最近 50 条真实对话 | memory_db 中的人物事实 |
memory/knowledge_extract.py:136-146 里只有三个结构性节流,没有一个是配置项:
if len(history) < 20: return
if len(history) % 20 != 0: return
if "_ai" in secrets and secrets.get("_ai") is None: return即:每个群、每 20 条消息,就把最近 50 条真实用户对话发给远程模型, 抽取"关于人的事实"并持久化,管理员无法关闭,用户不知情,也没有对应的 enable_* 字段可以在 /xc config 里看到。
这与 pendo 的对照极强:N-17 记录了 ai_sensitive_data_consent 是"全仓唯一的显式外发同意项",而那道闸门守的只是用户自己写的日记; 这里外发的是群里其他人说的话,并且沉淀成关于他们的档案。
修法(按成本排序):
- 加
memory.enable_person_facts(默认False),与该插件其它 "学习类"能力的保守缺省一致(O-5 已记:enable_expression_selector等五处默认关闭); - 在
/xc config概要里显示该开关状态; - 若保留默认开启,至少要像日记那样有一条明确的同意/告知路径。
顺带记一处写法:
if "_ai" in secrets and secrets.get("_ai") is None—— 只有"键存在且值为 None"才跳过,键不存在时继续执行。 这个条件的意图("路由显式禁用 AI")与它的写法之间没有注释,很容易在重构时被简化成if not secrets.get("_ai"),那会改变行为。
O-11 [中] 跨模块依赖用"把函数当参数传"实现,而其中大多数被传的函数并不来自本模块
handlers.py 调用两个 _impl 时注入了大量可调用对象:
generate_smalltalk_turn_impl(...):12 个注入参数(:955-970)finalize_smalltalk_turn_impl(...):16 个注入参数(:999-1022)handle_internal_impl/handle_review_impl/handle_provider_impl:各 2~5 个
通常这种写法是为了打破循环导入。但逐个核对来源后,实际情况是:
| 注入的来源模块 | finalize 注入数 | smalltalk_execution.py 是否已直接导入该模块 |
|---|---|---|
handlers.py 自身 | 4 | 会形成循环,确实只能注入 |
task_scheduler | 4 | ✅ 已导入(:20 _create_task_safely) |
smalltalk_media_helpers | 4 | 未导入,但它是叶子模块 |
helper_utils | 2 | 未导入,叶子模块 |
logging_utils / handlers_helper | 2 | 叶子模块 |
也就是说:16 个注入里只有 4 个是循环依赖所必需的, 另外 12 个来自 smalltalk_execution 本可以(其中 4 个已经在)直接导入的叶子模块。
代价是具体的:
- 两个文件之间的契约变成 16 个关键字参数的名字与签名,改任何一个都要同步改两处, 且错了只在该代码路径执行时抛
TypeError; - 静态检查无法跟随(
handlers.py、handlers_internal.py、handlers_helper.py正在pyproject.toml的 mypy exclude 名单里,N-118 已列出 xiaoqing_chat 占 16 个文件); - 读代码时无法从
smalltalk_execution.py反查"get_lock到底是谁"。
修法:把确实属于 handlers.py 的那 4 个(_record_bot_reply、 _build_generated_reply_output、_cancel_generated_tasks、_clear_store_entry) 下沉到一个叶子模块,两边都直接导入 —— 注入参数可以从 16 降到 0, mypy 也就能覆盖这条主链路。
O-12 [中] 模块对自己的进程内单例做鸭子类型探测,把可静态验证的调用变成了运行时猜测
handlers.py 里有一组针对 state(即 ChatRuntimeState,同包内、 带 __slots__、API 完全静态已知)的防御式访问:
def _state_method(state, name: str): # :195
if hasattr(type(state), name) or name in getattr(state, "__dict__", {}):
...
def _consume_pending_bot_name_call(state, chat_id, user_id) -> bool: # :203
method = _state_method(state, "consume_pending_bot_name_call")
if method is None: return False
try: return bool(method(chat_id, user_id))
except Exception: return False # 吞掉一切同样的形状还有 _clear_store_entry(:231)、_clear_review_state(:244)、 _reset_reply_tracking(:258,整个 else 分支不可达,因为 clear_transient_chat_state 一定存在)、_maybe_reset_idle_conversation 里的 getattr(state.memory_store, "get_recent_async", None) + inspect.isawaitable 双重探测(:332-337)。
这些方法全部定义在 runtime_state.py 里,且 ChatRuntimeState 用了 __slots__ (连动态挂属性都不可能)。也就是说:模块把自己进程内的、自己定义的对象, 当成来源不明的外部接口在对待 —— 与 N108-1 的判据("不可信输入 = 来源不是本进程的数据")正好相反。
三个具体代价:
except Exception: return False会把consume_pending_bot_name_call内部 任何真实缺陷(包括将来引入的)变成"没有待响应的呼叫",永远不会有人发现;- 不可达分支(
_reset_reply_tracking的else)会被后来者当成"另一条支持的路径"维护; - 这些
getattr是 mypy 无法推断的,而handlers.py恰在 exclude 名单里 —— 鸭子类型与静态检查缺失互为因果。
修法:这些调用直接写成属性访问。若确有"state 可能是测试替身"的需求, 应当用 Protocol 显式声明契约,而不是逐个 hasattr。
O-13 [中] need_replan 重试重跑的是整轮,包括可能改变结论的回复门控
handle_smalltalk(:362-428)在 ReplyRejected(need_replan=True) 时 continue 回到循环顶部,重新执行 _maybe_reply_smalltalk —— 而后者的第一步 _prepare_smalltalk_turn 会重新做:
build_effective_user_text(可能含媒体分析);decide_attention;_should_reply频率门控;- 目标推导
derive_goal_async(一次to_thread磁盘读); _refresh_mood_state(可能重摇 personality state)。
其中频率门控是有状态的:_freq_record 虽然只在成功时记录, 但 _should_reply 会读 continuous_cooldown_until / reply_timestamps 等, 且第一次尝试期间可能已有别的消息改变了这些值。
后果:"需要重新规划"可以静默退化成"这一轮干脆不回复" —— _prepare_smalltalk_turn 返回 None 时 _maybe_reply_smalltalk 直接返回 [], 不会再触发 ReplyRejected 分支,也就不会走到 :416 的 "已耗尽重试" 警告日志。日志里只剩一条 smalltalk.no_reply, 排查时看不出这是一次被拒绝后的重试。
另外,重试整轮意味着 _refresh_mood_state 可能给出与第一次不同的 state, 即重规划改变的不只是规划,还有人格状态。
修法:重试的粒度应当是"生成 + 检查"(_generate_smalltalk_turn 起), 而不是包含门控的整轮;或者在重试路径上显式跳过门控并复用第一次的 prepared。 配置里已经区分了 max_regen 与 max_replan 两个计数,说明作者本来就把 "重生成"和"重规划"当两件事,但这里的实现让 max_replan 顺带重做了第三件事。
O-17 [中] O-10 的实际影响面比上一批判断的更大:任何群成员都能把"关于他人的推断事实"检索出来
上一批(O-10)确认人物事实抽取无开关。本组读到检索侧后,链条完整了:
群里任意 20 条消息 → maybe_extract_person_facts 把最近 50 条发给 LLM
→ LLM 推断出"关于人的事实" → 写入 memory_db(持久化)
→ 任意群成员发送 /xc memory <关键词>
→ handle_memory_impl:284-298 直接把命中的 item.text 原文回显到群里handle_memory_impl 没有任何权限判断(对照同文件里 重置 / 模型 都有), 只按 chat_id 限定范围。这个限定是对的——不会跨群泄露—— 但它意味着:A 在群里说的话被模型推断成一条"关于 A 的事实"之后, B 可以用关键词把它检索出来并展示在群里,而 A 既不知情、也无法关闭、 更没有单条删除的途径(唯一的清除方式是管理员执行 /xc reset,把整个群的记忆一起删掉)。
与"检索原始聊天记录"的关键差别在于:这里回显的不是 A 说过的话, 而是模型对 A 的推断。推断可能是错的,而它以"记忆"的形式呈现,看起来像事实。
→ O-10 的建议因此升级为三条,缺一不可:
memory.enable_person_facts开关,默认关闭;/xc memory的结果里区分"原始记录"与"推断事实"两类来源, 推断类明确标注(该插件在别处已经有这个意识——BrainChatConfig.brain_identity写着"引用过去信息时保留来源和不确定性,不把推断说成记忆", 人格提示词里写了的纪律,检索命令自己没有遵守);- 提供单条删除入口,否则唯一的补救是清空全群记忆。
第 2 条是本组最值得记的一处:同一个插件把正确的原则写进了给模型看的提示词里, 却没有写进自己的代码里。
O-24 [中] 重置操作的审计日志,其主体在进程重启后不可解析
handlers_internal.py:150-156 是全插件唯一一条明确标为审计的日志:
context.logger.info(
"XiaoQing Chat reset_audit scope=%s chat_id=%s group_id=%s operator_user_id=%s",
"group" if is_group_reset else "private",
_redacted_value(chat_id), _redacted_value(event.get("group_id")),
_redacted_value(event.get("user_id")),
)三个标识全部走 _redacted_value,最终落到 core/sensitive_audit.py:summarize_sensitive,而它的 docstring 写着:
Fingerprints intentionally rotate at process restart. Callers must never use this helper as an authorization, persistence, or cache key.
指纹是用每进程随机密钥的 HMAC 算出来的。于是:
- 进程内:同一个 user_id 的多条日志可以互相关联 ✅
- 重启后:同一个 user_id 产生完全不同的指纹; 也无法离线用已知的 user_id 反查出它当时的指纹。
对普通流程日志,这个取舍是对的(防止从日志反建 user_id 字典)。 但审计记录的要求不同:它要回答"谁在什么时候清空了这个群的共享记忆"。 按当前实现,运维在重启之后拿着这条日志,既说不出是谁、也说不出是哪个群, 只能知道"发生过一次群重置"。
这是一次把通用工具用在了它明确声明不适用的场景上—— core 的 docstring 已经列举了三种禁止用途(授权、持久化、缓存键), "审计"不在列表里,但它属于同一类(需要跨进程稳定标识)。
修法二选一,必须选一个:
- 承认这条只是计数,把
reset_audit改名(如reset_event), 并在旁边补一条走 core 审计设施的、可受控解析的真实审计记录; - 或者在 core 提供"审计专用"的稳定标识(部署级密钥而非进程级), 与流程日志的轮换指纹区分开。
→ 并建议在 core/sensitive_audit.py 的 docstring 里把 audit trail 显式加进禁止用途清单。
O-29 [中] 坐实 O-13:回复门控含随机数与状态写入,因此重放不等价
O-13 指出 need_replan 重试会重跑整轮门控,但只推断了后果。 本组读到门控实现后可以给出确切机制——_should_reply_decision 有三处不可重放的操作:
window = [t for t in state.get_reply_timestamps(chat_id) if now - t < 60.0]
state.set_reply_timestamps(chat_id, window) # ① 写状态
...
_remember_reply_gate_decision(state, chat_id, decision) # ② 写状态(每次 make_decision)
...
roll = random.random()
if roll >= p: return make_decision(False, "probability", ...) # ③ 重新掷骰因此一次 need_replan 重试可能出现:
- 第一次掷骰通过、第二次没通过 → 本轮从"回复被拒、需要重新规划" 静默变成"概率门控不回复",且日志里最后留下的是
reason=probability, 看不出这是一次重规划; _remember_reply_gate_decision覆盖掉第一次的决策记录, 而handlers.py:_last_reply_gate_log_fields读的正是这个记录 → 诊断信息被后一次覆盖。
O-13 的修法(把重试粒度收到"生成 + 检查")因此是必须的,不是优化: 门控本身就不是幂等函数。
O-30 [中] 同一个函数里用了两种概率合成方式,其中一种被同文件的注释否定过
frequency_control.py:201-204 明确拒绝了"按剩余概率增益":
if (not is_private) and is_active_topic:
# 使用明确上限而不是按剩余概率大幅增益,避免 0.72 被抬到 0.888 一类过强参与率。
p = max(p, min(1.0, active_probability))而 25 行之后的 heartflow 加权用的正是被否定的那种:
hf_bonus = max(0.0, hf_score - runtime.cfg.heartflow.base_score)
p = p + (1.0 - p) * hf_bonus # :227 —— 按剩余概率增益两种合成方式在同一个函数里,语义不同(一个取上限、一个吃掉剩余空间的一部分), 且注释只解释了其中一个的理由。当 enable_heartflow 打开时, 被"明确上限"限制住的 p 会立刻被后面的剩余概率增益重新抬高—— 前一段代码的意图被后一段抵消。
当前默认 enable_heartflow: False(O-5 已记该插件默认值站在保守一侧), 所以线上不生效;但这正是那种"打开一个开关就让另一处的设计意图失效"的隐性耦合。
修法:把两处统一成同一种合成语义(建议都用取上限), 或在 heartflow 分支上注明为什么这里可以突破上限。
O-31 [中] 三份 per-chat 任务登记表放在模块全局,逃出了 ChatRuntimeState 的容量治理
runtime_state.py 为 13 张 per-chat 字典建立了统一治理 (_MAX_TRACKED_CHATS = 500 + cleanup_stale_chats 保护持锁中/未完成任务的会话,O-4)。 但 task_scheduler.py:159-161 又在模块全局维护了三份:
_action_flush_tasks: dict[str, asyncio.Task[Any]] = {}
_pfc_state_flush_tasks: dict[str, asyncio.Task[Any]] = {}
_media_registry_flush_task: asyncio.Task[Any] | None = None前两个是按 chat_id 分键的,cleanup_stale_chats 看不到它们, reset_global_state() 也不会清空它们(O7-6 已记该函数不落盘不取消任务)。
实际风险有限:_cleanup_done 回调会在任务完成时(约 0.8 秒后)按身份比对移除条目 (if current is t —— 这一点写对了,避免迟到的完成回调删掉新任务的条目)。 所以稳态下这两张表几乎是空的。
但它构成两个真实问题:
- 治理边界不一致:插件花了很大力气给"每会话状态"设上限, 却有两张同样按
chat_id分键的表不在这套治理内。 任何让_cleanup_done不触发的情形(事件循环停摆、任务被外部持有)都会让它们无界增长。 reset_global_state()之后旧任务仍持有旧 store(O7-6), 而这两张表还会保留指向它们的引用。
修法:把三份登记表移进 ChatRuntimeState,与 _per_chat.persist_tasks 并列 ——那一张已经在 cleanup_stale_chats 的保护名单里(:217-219), 现成的模式,直接复用。
O-32 [中] 防抖调度的公共实现只覆盖了 4 个调用点中的 2 个
_schedule_debounced_flush(:164-198)是被提取出来的公共实现, 但同文件里还有两个近乎逐行重复的版本:
| 函数 | 用的是 | 取消旧任务失败时 |
|---|---|---|
_schedule_action_history_flush | ✅ 公共实现 | 静默 pass |
_schedule_pfc_state_flush | ✅ 公共实现 | 静默 pass |
_schedule_memory_persist(:102-130) | ❌ 自己写一遍 | 记 debug 日志 |
_schedule_media_registry_flush(:225-252) | ❌ 自己写一遍 | 静默 pass |
四份代码的骨架完全一致(取消旧任务 → 创建带 sleep(delay) 的新任务 → 写入登记表 → _cleanup_done 身份比对 → _track_bg_task), 差别只有:登记表是 dict 还是单变量、flush 函数签名带不带 chat_id、 以及取消失败时记不记日志。
第三列的不一致值得单独记:同一个动作(取消一个可能已经进入线程的旧任务) 在一份实现里被认为值得记 debug 日志,在另外三份里被认为不值得。 N125-2 家族在单个 253 行文件内的实例。
修法:把 _schedule_debounced_flush 的登记表参数放宽成"getter/setter 一对", 四处收敛为一处;顺带统一取消失败的处理。
O-37 [中] is_question 用子串匹配疑问词,误判会沿四条链路放大成"更愿意插话"
constants.py:82-91 的最后一行是整个函数的兜底:
_QUESTION_KEYWORDS = frozenset({"啥","谁","咋","为啥","为什么","什么","哪","哪里","哪个","多少","几","吗","嘛"})
...
return any(kw in t for kw in _QUESTION_KEYWORDS) # 子串匹配,不看位置"几" 与 "哪" 作为子串在日常中文里出现得极其频繁:
| 输入 | 命中 | 是否疑问句 |
|---|---|---|
| 「他几乎不来」 | 几 | ❌ |
| 「买了好几个」 | 几 | ❌ |
| 「十几块钱而已」 | 几 | ❌ |
| 「哪怕再等等」 | 哪 | ❌ |
| 「没什么大事」 | 什么 | ❌ |
而 is_question 的返回值被四个模块消费,每一个消费方都用它来提高回复意愿:
participation.classify_group_participation_cue:48→ 返回open_question→frequency_control:198把概率抬到participation_cue_reply_probability(默认 0.9);frequency_control:131active_followup_question→ 最小间隔降到active_topic_question_min_reply_interval(默认 2 秒)、概率抬到active_topic_question_reply_probability(默认 0.9);heartflow的weight_question加权;reply_checker(后续批次核对)。
即:一句「买了好几个」在活跃话题里会被当成"有人在追问", 把回复间隔从 12 秒压到 2 秒、概率从 0.55 抬到 0.9。 这正是"机器人插话太频繁"这类体感问题的机制来源,且它是一行子串匹配造成的。
同一个文件恰好证明了作者知道怎么做精确判定: _is_colloquial_mei_question(:60-74)为了识别「起床了没」这一种口语疑问, 写了 20 行——长度上限、独立否定词黑名单(没/我没/还没…)、 单字谓语白名单、体标记后缀、动作词表,五重约束避免把「真没」判成疑问。
→ 精度用在了罕见情形上,常见情形留给了裸子串匹配。
修法(成本都很低):
- 位置约束:疑问词多数出现在句首或句尾附近,可要求命中位置在前/后 N 字内;
- 排除表:为
几/哪加否定前缀表(几乎、好几、十几、哪怕、哪知); - 或者把
几/哪从兜底子串表移到"必须配合问号或句末语气词"的次级条件里。
O-38 [中] 一条回复要经过两套互不知情的拆分,只有其中一套受配置约束
| 阶段 | 位置 | 依据 | 是否可配置 |
|---|---|---|---|
| ① 后处理拆分 | llm/postprocess.py:106-117 | 句子切分 + max_length 截断(默认 256)+ max_sentence_num 上限(默认 3) | ✅ postprocess.splitter.*,且整体受 enable_response_post_process 控制 |
| ② 投递拆分 | reply_payload.py:85 → reply_splitter._split_chat_reply | 空行分段,并保护 ``` 代码块 | ❌ 无任何配置、无条数上限、无长度上限 |
两者都会把一条回复变成多条聊天消息,但彼此不知道对方存在。
后果在 enable_response_post_process: false 时最明显: ①整个跳过,②没有上限 → 模型输出多少个空行分段,就往群里发多少条消息。 即使 ① 打开,两级拆分也会叠加(先按句子截到 3 段,再按空行二次拆)。
ResponseSplitterConfig 的字段名(max_sentence_num)也暗示作者认为 "一条回复最多拆成 N 条"是个应当受控的量——这个意图只覆盖了两条拆分路径中的一条。
修法:_split_chat_reply 接受一个上限参数,与 splitter.max_sentence_num 共用同一个配置项; 或在 _build_reply_payload_core 里对 outbound_batches 做最终封顶。
O-43 [中] 兜底话术把默认人设写死在代码里,配置改了人设也改不掉它们
_finish_rejected_candidate(:1094-1105)在人物依据审查失败且必须回复时, 直接返回硬编码的中文话术:
fallback_texts = ("具体到现实资料我就不展开啦,我是个住校的大二理工女,这点够群里认识我了。",)
...
fallback_texts = ("我就是个住校的大二理工女,平时爱看天文和新鲜小玩意,也刷梗图。"
"性格随和但不是没主意,熟了会皮两句,别真把我当简历看就行。",)而这些内容正是 PersonalityConfig.identity(config/config.py:99-111, "住校的大二理工科女生…对天文、电脑和新鲜小玩意有兴趣…")的复述。
于是:运营方把 personality.identity 改成另一个人设之后, 正常路径按新人设回复,而只要触发人物依据兜底,机器人就会用默认人设自我介绍。 且这条路径恰好是"用户在问你是谁"的时候——最不该串味的时刻。
同一函数里 _requires_configured_profile_boundary(text, plan.effective_identity) 是读了配置的(把 effective_identity 传了进去), 说明作者知道这里应当与配置挂钩,只是把判定挂上了、把内容留在了代码里。
修法:把这几条兜底话术提到 PersonalityConfig 里 (如 persona_fallback_intro / persona_fallback_boundary), 默认值就用现在的字符串;或者从 identity 里截取而不是另写一份。 → 这是 N101-2 家族的一个变体:不是"实现已在仓库内", 而是"数据的唯一真源已在配置里,代码里又抄了一份"。
O-50 [中] xiaoqing_chat 自己的两条次要投递路径绕过了它主路径实现的协议
memory/review_sessions.py:598-603:
if not action:
return False
await context.send_action(action) # ← 返回值被完全丢弃
sess.last_push_ts = now
store.update_session(sess)
return True这里连 bool 都没取——发送结果被彻底忽略,随后无条件把 "已推送时间"写成现在并落盘。而 ReflectionConfig.resend_interval_seconds 默认 1800 秒。
用户可见后果:一次推送失败(网络抖动、限流、目标群不可达)之后, 复盘审查提示会被标记成"刚推送过",30 分钟内不会重试, 而运营方从未收到过那条提示,也没有任何日志说明它失败了。
这正是 P0-1 主线描述的形状,只是更彻底:pendo 至少取了 bool(N5-2), 这里连取都没取。expression/bw_expression_reflector.py 同样是裸调用。
→ 同一个插件在主路径上实现了教科书级的收据协议(O-51), 在两条次要路径上退回到最弱的写法。 这与 N125-2(正确机制已存在但覆盖不全) 是同一形状,但因为两条路径同属一个插件、相隔两个目录,修复几乎没有成本。
O-54 [中] 规划器快照的补偿回滚只覆盖 ReplyRejected 一种异常
generate_smalltalk_turn_impl 在生成前拍下 PFC 状态快照(:203):
if not prepared.forced and planner_enabled:
generated.pfc_state_snapshot = deepcopy(await state.pfc_state_store.get_async(chat_id))并在回复被拒时完整回滚(:392-408):恢复快照、按快照里的首要目标重设 goal_store(没有则清空)、再调度落盘。这段补偿写得很完整—— deepcopy 是必要的(否则拿到的是活对象)、恢复在同一把会话锁内、 目标与规划器状态一起回滚而不是只回滚一个。
问题是它只挂在 except ReplyRejected: 上。
生成阶段还可能抛出其它异常:LLM 网络错误、asyncio.TimeoutError、 媒体处理异常、以及任何实现缺陷。这些走到 handlers.py:421 的 except Exception → public_error_response 返回一条用户可见的错误提示, 而 PFC 状态停留在"已经推进过"的位置,快照被丢弃。
后果:一次 LLM 故障之后,规划器认为上一轮的计划已经执行, 但用户实际收到的是错误提示。下一轮会基于错误的规划状态继续。
修法:把回滚从 except ReplyRejected 改为 except BaseException(回滚后 raise), 或用 try/finally + 成功标志。补偿逻辑本身已经写好了,只是绑错了异常类型。
这与 O-51(投递失败用
asyncio.shield保证记账完成)形成对照: 同一个文件里,投递阶段考虑了"任何情况下都要记账", 生成阶段只考虑了一种失败。
O-60 [中] 两道证据检查的严格程度差一个数量级,而更弱的那道守的是更敏感的类别
同一个文件里有两条"回复里的陈述必须有证据支持"的检查,实现差别很大:
人物履历 _grounded_self_history_check(:226-290) | 上下文 _grounded_context_history_check(:401-428) / _validate_evidence_contract(:453-510) | |
|---|---|---|
| 匹配方式 | normalized_claim in evidence(裸子串包含) | _direct_evidence_supports_claim |
| 否定极性 | ❌ 不检查 | ✅ _EVIDENCE_NEGATION_RE 两侧必须一致 |
| 模态保持 | ❌ 不检查 | ✅ _evidence_modality_is_preserved:证据里的疑问/假设/不确定不得被抹掉 |
| 最短长度 | ❌ 无要求 | ✅ 双字组路径要求 len(claim_core) >= 4 |
| 覆盖率 | ❌ 无要求 | ✅ 共享双字组 ≥2 且覆盖率 ≥60% |
具体后果(人物路径):
- 证据里的否定可以支持肯定断言。受控资料写"我不住校", 归一化后是
我不住校;回复声称的经历核心是住校→"住校" in "我不住校"为真 → 视为有依据,放行。而正下方的上下文路径为这件事专门写了极性检查。 - 短断言容易被大段证据偶然命中。
grounding_text是identity + state_text + profile_block + memory_block[:1200]的拼接(reply_generator.py:891-897), 几百到上千字;一个两三字的断言核心撞上其中某处的概率不低。
讽刺之处:_direct_evidence_supports_claim 的 docstring 明确写着 "不允许用一段相关但不同义的身份资料支持新经历"—— 作者恰恰想到了"身份资料被误用作证据"这个风险, 但这个函数被用在上下文路径上,人物路径没有用它。
定级说明(不要读成"默认不安全")
_grounded_self_history_check 只在 not allow_low_stakes_persona_fiction 时执行, 而配置默认是 allow_low_stakes_persona_fiction: True(config.py:114, 且附了理由:"允许角色为闲聊补充普通、低风险且与稳定人设一致的日常片段。 真实用户、第三方、外部事实和现实承诺仍由回复检查器按证据约束")。
因此默认部署下这道检查根本不运行,安全性由始终运行的 ③⑤⑦⑧ 四道(身份一致性、未设定资料边界、上下文证据、社交臆测)承担 —— 这套默认姿态是自洽的。
问题在于:运营方把开关调到更严格的一侧(设为 False)时, 接管的恰好是全文件最弱的那道检查。 也就是说, "我要更严格"这个动作换来的保护,比使用者预期的弱。
修法成本极低:让 _grounded_self_history_check 复用同文件已有的 _direct_evidence_supports_claim + _evidence_modality_is_preserved, 替换掉那一行 in 判断即可。→ N101-2 清单第 32 处,且两处代码相距 150 行。
O-65 [中] 回复检查器看到的上下文被截了两次,因此会把有据可查的陈述判成"无依据"
把三个文件的参数串起来看,生成侧与审查侧的可见范围不对称:
生成侧(build_prompt_messages) | 审查侧(check_reply 的证据) | |
|---|---|---|
| 对话历史 | max_context_size 条(默认 30,可配到 200) | build_dialogue_prompt(truncate=True) → 最近 12 条且总长 ≤800 字符(prompt_builder.py:324-330, 368-370,两个值都是硬编码默认参数) |
| 记忆块 | 完整 memory_block(memory.max_block_chars 默认 1200,可配到 20000) | str(memory_block or "")[:1200](reply_generator.py:897,硬编码,O47-2) |
于是这条链路成立:
用户在第 20 条消息里说过某事
→ 生成器看得到(max_context_size=30),据此写出一句回应
→ 审查器只看最近 12 条 / 800 字符,看不到那条证据
→ _grounded_context_history_check 找不到支持
→ ReplyCheckResult(suitable=False, severity="hard", failure_code="context_grounding")
→ 非 forced 场景 → _finish_rejected_candidate 走 proactive_silence(O-45)
→ 机器人对一条它本来答得出的消息,选择不说话用户可见症状:群里聊得稍微长一点之后,小青对"承接前面话题"的消息越来越沉默, 而日志里只有 reply.check.exhausted.proactive_silence, failure_code=context_grounding —— 看起来像是模型在瞎编被拦下了, 实际是审查器的视野比生成器窄。
800 字符在中文群聊里大约只有 10~15 条短消息; _maybe_truncate_message 还会把靠前的消息进一步截到 50~200 字符 (见 O-66),于是"证据"往往只剩片段,而 _direct_evidence_supports_claim 要求双字组覆盖率 ≥60%(O-60)——被截断的证据几乎不可能达到这个覆盖率。
修法(三选一,或组合):
- 审查器的
chat_history_text改用truncate=False,或把上限提到与max_context_size同源的配置项; - 证据比对改为对未截断的历史做(截断只用于给模型看的提示词);
- 至少让
max_chars/12/1200三个数字来自同一个配置项, 而不是分散在两个文件里的默认参数与字面量。
这与 O47-2 是同一条主线的两半:那一条只看到记忆块被截, 本条补上对话历史也被截,两次截断都发生在"审查侧",方向一致。 合并后应作为正文一条独立发现。
O-70 [中] 结清 N-180:全插件唯一一处非原子写,恰好是唯一没有继承 StoreBase 的模块
落盘链路的真相
StoreBase._save_json(path, data) # store_base.py:105-124
→ core.plugin_base.write_json(path, data) # :203-205
→ AtomicJsonStore(path).write(data)
→ atomic_write_bytes() # core/atomic_store.py:61-80
同目录临时文件 → fsync → os.replace(原子)+ 维护 .bak因此每一个继承 StoreBase 的存储都免费获得原子写。普查结果:
| 模块 | 是否继承 StoreBase / 用原子写 | 结论 |
|---|---|---|
planning/pfc_state.py、planning/goal_state.py、media_registry.py | self._save_json(...) | ✅ 原子 |
planning/action_history.py、memory/person_profile.py | 直接用 write_json / AtomicJsonStore | ✅ 原子 |
expression/bw_expression_store.py、media/event_media.py、media/event_media_common.py、memory/review_sessions.py | 直接用 AtomicJsonStore | ✅ 原子 |
memory/thinking_back.py | 模块级函数,不继承 StoreBase,直接操作 JSONL | ❌ 非原子 |
具体缺陷
thinking_back.py:85-106 是"追加 + 超限压缩"两段:
with path.open("a", encoding="utf-8") as f: # ① 追加:单行 append,安全
f.write(json.dumps(row, ensure_ascii=False) + "\n")
...
items = _load_recent(path, max_lines=...) # ② 读出要保留的尾部
with path.open("w", encoding="utf-8") as f: # ③ 截断后重写 ← 非原子
for obj in items[-max_entries:]:
f.write(json.dumps(obj, ensure_ascii=False) + "\n")① 是对的(O_APPEND 单行写入天然安全,也允许多个写入方并发)。 问题在 ③:open("w") 先把文件截成 0 字节再逐行重写。 进程在这中间退出(崩溃、被 kill、容器重启、Windows 上尤其没有兜底), 该会话的整份"回想缓存"要么为空、要么半截,而且没有 .bak —— AtomicJsonStore 恰恰会维护备份(atomic_store.py:132/153)。
附带一个更窄的竞态:② 读与 ③ 写之间没有 keyed_path_lock (core/atomic_store.py:36 提供了这个原语,store_base.delete_json_artifacts 就在用), 因此并发的 ① 追加会在压缩窗口里丢失。
修法
core 已经提供了现成原语,改动是两行:
from core.atomic_store import atomic_write_text, keyed_path_lock
...
with keyed_path_lock(path):
items = _load_recent(path, max_lines=...)
atomic_write_text(path, "".join(json.dumps(o, ensure_ascii=False) + "\n"
for o in items[-max_entries:]))→ N101-2 清单第 33 处,且是清单里修复成本最低的一条。
值得单独记的结构性教训
原子性在这个插件里是"通过继承获得"的,而不是"通过约定遵守"的。 9 个存储模块里 8 个继承或直接使用了原子写设施,唯一没继承 StoreBase 的那个模块,也就唯一失去了这个保证——而它失去保证这件事, 在代码里没有任何地方会提示。
这与O-32(_schedule_debounced_flush 只覆盖 4 个调用点中的 2 个) 是同一形状的两个方向:那次是"公共实现存在但没被调用", 这次是"公共基类存在但没被继承"。两者都不会有任何告警。
O-77 [中] ReAct 记忆代理的两个配置项被硬编码的 4.0 秒预算完全废掉
三层预算,最外层最小
| 层 | 位置 | 值 |
|---|---|---|
| 配置声明的代理总预算 | config/config.py:212 agent_timeout_seconds | 默认 120.0 |
| 配置声明的迭代上限 | config/config.py:211 max_agent_iterations | 默认 5(le=20) |
| 函数内硬编码软预算 | memory_retrieval.py:675 soft_budget = 4.0 | 4.0 |
| 外层再包一次 | constants.py:96 MEMORY_RETRIEVAL_TIMEOUT | 4.5 |
react_retrieve 内部确实检查了 cfg.agent_timeout_seconds(:535), 但它被包在 asyncio.wait_for(..., timeout=float(soft_budget))(:764)里, 外层 4.0 秒先到,内层 120 秒的检查永远不会为真。
后果不只是"配置无效",而是整个架构不可达
单次迭代要完成一轮 chat_completions_raw_with_fallback_paths (timeout_seconds=min(4.0, ...),:760)+ 工具执行。 4 秒总预算下实际只够一轮,第二轮几乎必然撞上 wait_for。 而超时的后果是 except Exception: answer = ""(:766-767)—— 已经取回的工具证据被整体丢弃,返回空记忆块,且没有任何日志 (:618-622 的 exhausted 日志只在正常跑完循环时打,被 wait_for 取消时不会执行)。
也就是说:8 个工具、证据授权协议、多轮工具循环这整套约 350 行的设计, 在默认配置下只有"一轮就能拿到答案"的情况会产出结果, 而这恰恰是最不需要代理的情况。运营方把 agent_timeout_seconds 从 120 调到 300 或调到 10, 观察到的行为完全一样。
修法
soft_budget 应取自 cfg.agent_timeout_seconds(MEMORY_RETRIEVAL_TIMEOUT 相应改为它的上界而非固定 4.5),或者把配置项的 le 收到实际可用范围并在 docstring 里写明真实上限。当前状态是配置声明的范围比实际生效范围大 30 倍 —— 与 O63-2(enable_llm_checker 的字面含义 > 实际生效范围)同一形状,第 2 例。
O77-1 附带:getattr 的默认值与配置声明的默认值相反
memory_retrieval.py:741:
bool(getattr(cfg, "agent_on_direct_miss_requires_reference", False))而 config/config.py:210 的声明是 agent_on_direct_miss_requires_reference: bool = True。 MemoryConfig 是 pydantic 模型,字段必然存在,所以 getattr 的兜底当前不可达; 但它写的是与真实默认值相反的值。这种"防御性 getattr + 拍脑袋默认值" 一旦在重构中变成可达路径,就会静默把一个默认开启的收敛开关翻成关闭。 判据:getattr(cfg, "x", D) 里的 D 必须与配置模型里 x 的默认值逐字相同,否则删掉 getattr。
O-78 [中] min_score 阈值在三处被写成 0.0,而这三处恰好都是主路径失败后的兜底
分布
| 位置 | min_score | 场景 |
|---|---|---|
memory_retrieval.py:464 直接向量检索 | cfg.min_score(默认 0.12) | 主路径 |
context_builder.py:149 知识块 | cfg.memory.min_score | 主路径 |
memory_retrieval.py:397 ReAct 工具 _tool_query_db | 0.0 | 直接检索失败后的兜底 |
memory_retrieval.py:419 ReAct 工具 _tool_query_global_db | 0.0 | 同上 |
context_builder.py:168 黑话解释 | 0.0(且 top_k=1) | 长期记忆未命中前的第一跳 |
后果一:代理路径把"已被判定为不相关"的条目当成授权证据
react_retrieve 的核心安全约束是 has_evidence(:532、:593-597、:579-586)—— 没有非空检索结果就拒绝 found_answer,模型裸答也返回空串。这是对的。 但判定"非空"的那次检索关掉了相关性阈值。
完整链路:
build_memory_block: 直接检索用 min_score=0.12 → 全部低于阈值 → 空 → 判定"没记忆"
→ 启动 ReAct 代理
→ 代理调 query_person_info,min_score=0.0 → 取回刚刚那批低于阈值的同一批条目
→ has_evidence = True
→ found_answer 被批准 → 记忆块注入主提示词即:阈值筛掉的东西,兜底路径原样捡回来,并且捡回来的动作本身构成了"有证据"的授权。_REACT_SYSTEM(:259-263)写的是"没有直接相关的非空证据就报告信息不足", 而代码只强制了"非空","直接相关"完全交给模型自觉。
且 has_evidence 是一个跨迭代、跨工具的粘性布尔: query_words 查黑话返回了东西,就足以授权一个关于人物生日的 found_answer。 证据与断言之间没有任何绑定关系。
→ 这与 O-60 是同一形状的第 2 例:授权链条上最弱的一环, 恰好守着最需要收紧的那一步。O-60 是人物履历路径用裸子串匹配, 这里是兜底检索路径关掉阈值。两处都不是"忘了加校验", 而是主路径校验严格、兜底路径宽松,而兜底路径正是主路径判定失败后才走的。
后果二:黑话解释会给出确定错误的词义
context_builder.py:161-182,对每个未知词:
hits = state.memory_db.query_global(w, top_k=1, min_score=0.0, type_filter="word_def")
if hits:
items.append(hits[0].text.strip())嵌入向量是 log1p 词频(非负),余弦相似度恒 ≥ 0, min_score=0.0 意味着只要词库里有任何一条 word_def,就必然返回相似度最高的那一条, 哪怕相似度是 0.001。结果直接以
黑话/缩写解释:
- <完全无关的词义>的形式注入主提示词,模型会照单全收。
讽刺的是 else 分支才是精确的:本地黑话库走 key_for(w, chat_id) 精确键查找
rec.is_global or rec.scope_chat_id == chat_id作用域校验,一个字都不含糊。 精确的那条路只在不精确的那条路失败时才会被走到 —— 而后者永远不会失败。
修法:min_score 至少取 cfg.memory.min_score; 黑话这种"必须精确匹配"的语义应当用远高于 0.12 的阈值,或干脆调换两条分支的顺序。
O-79 [中] 向量索引无任何上限或过期,且 docs / vecs 两个文件可以静默错配
增长源普查(memory_db 里 5 类文档)
| 类型 | 是否有上限 | 说明 |
|---|---|---|
knowledge(kb:) | ✅ 512 分块 / 4 MB | knowledge_base.py:15-19,且全量替换 |
person_profile | ✅ 每会话每人 1 条 | 稳定 doc_id profile:{chat}:{subject} |
person_info 人物事实 | ❌ 无上限、无过期 | 见下 |
topic_summary 话题摘要 | ❌ 无上限 | 见下 |
word_def | 视写入方而定 | 本组未覆盖 |
人物事实:memory_db.py:30-45 的 person_fact_doc_id 把 chat_id + subject_id + subject_name + fact 一起哈希成 doc_id。 同一条事实重复抽取是幂等的(好),但任何措辞变化都产生一个新文档—— "喜欢吃辣" / "喜欢吃辣的" / "口味偏辣" 是三条独立记录。 配合 O-74(抽取频率 ×20),这是主要增长源。
话题摘要:llm/summarizer.py:164-166 按 max_cache_topics(默认 20) 裁剪 JSON 缓存,但被裁掉的那些主题对应的向量文档从不删除(:168-173 只有 upsert)。 于是 JSON 缓存有界、向量索引无界,两者随时间发散。
唯一的清理入口是 delete_chat(memory_db.py:172-188), 只有用户手动执行 /xc clear 才会触发。长期运行的实例上, 向量库只增不减 —— 而每次查询都要为它做一次 O(N) 全表重算(O-75)。
矩阵是 len(docs) × 2048 的稠密 float32:1 万文档 = 80 MB 常驻, 且每次重建都要再分配一份。
docs / vecs 两文件不同步
vector_store.py:195-218 的 write_vector_store_files 先写 memory.docs.json (原子),再写 memory.vecs.npz(另一次原子替换)。 两次原子写之间没有任何一致性纽带。
load 侧(:172-189)的校验是 _validate_cached_matrix(rows == len(docs), dim, 有限性) —— 只校验行数。而行数不变的更新恰恰是最常见的更新: upsert_text 对已存在的 doc_id(人物画像 profile:{chat}:{subject}、 话题摘要 topic:{chat}:{id})只改文本,不改行数。
于是:docs.json 写成功、npz 写失败或进程在两次写之间退出 → 下次启动时行数校验通过 → 矩阵对应的是旧文本,检索按旧内容打分,且永久如此 (直到某次增删改变了行数)。没有日志,没有校验和。
这在 Windows 上不是理论风险:core/atomic_store.py:71-79 的 atomic_write_bytes 专门为"杀毒软件/索引器短暂占用刚关闭的目标文件"加了 PermissionError 退避重试, 而 write_vector_store_files 是手写的另一份原子替换实现(:208-218), 没有这个退避、也没有目录 fsync。docs.json 走 write_json(有退避), npz 走手写版本(无退避)→ 两个文件在 Windows 上的失败概率本来就不相等, 恰好构成上面那个场景。
修法:把 npz 也交给 core.atomic_store.atomic_write_bytes(消除第二份实现), 并在 docs.json 里写一个文档内容摘要(例如按 doc_id + text 滚动哈希), load 时比对;不匹配就丢弃缓存矩阵重建 —— 重建本来就是现成能力。 → N101-2 第 37 处(atomic_write_bytes 已在 core,被手写复制了一份且丢了 Windows 退避)。
O-84 [中] 反思会话子系统四道闸门断了三道,bad_reply_pattern 整条支路不可达
调用图追踪结果
全插件 open_session_if_allowed 的调用点只有一处: review_sessions.py:696 的 maybe_open_goal_strategy_review, 其 kind 是硬编码字面量 "goal_strategy"(:697)。
grep -rn "bad_reply_pattern" 全插件三处命中,全部是消费方:
| 位置 | 作用 |
|---|---|
review_sessions.py:532-554 | render_session_prompt 的三步提问文案 |
review_sessions.py:620-629 | apply_review_answer 里唯一写 avoid_patterns 的分支 |
handlers_internal.py:247-250 | /xc 审查 ok 对该 kind 的特殊推进逻辑 |
没有任何地方创建 kind="bad_reply_pattern" 的会话。
连锁的死代码
因此 ReviewPolicy.avoid_patterns 永远是空列表,于是:
build_policy_block:662-666的"长期规避"整段永不输出config.py:264的max_avoid_patterns(默认 30)是死配置apply_review_answer:620-629整个分支不可达render_session_prompt里写得最细的那套三步提问(:535-554, 包括"请把问题概括成可跨话题复用的判断原则:描述失败机制和正确方向, 不要写具体词、专名、数字或单次事件"——这段文案质量很高)从不会显示给任何人
而这正是整个反思机制的核心价值:让运营方把"这条回复不好"沉淀成长期规避规则。 现存的 goal_strategy 只能改目标和策略备注,改不了"别再这样说话"。
接线点是现成的:O-45 记录的 reply_generator 拒绝后降级阶梯, 每一次 ReplyRejected 都带着 failure_code 和 reason, 正是 payload={"reason": ..., "goal": ...} 需要的两个字段。
第二道断口:推送目标与可应答范围互斥
maybe_push_session:593-597 把会话提示推送给 operator_user_id / operator_group_id(运营方的私聊或专用群)。
而应答入口 handlers_internal.py:242 的守卫是:
if session is None or session.chat_id != hctx.chat_id:
return segments("❌ 审查会话不存在、已过期或不属于当前会话")session.chat_id 是被审查的那个会话(如 g123456), hctx.chat_id 是运营方执行命令的那个会话。两者相等, 当且仅当运营方群恰好就是被审查的群。
- 用
operator_user_id推送(私聊运营方)→hctx.chat_id是u<运营方>→ 永远不等于g<被审查群>→ 100% 无法应答 - 用
operator_group_id推送到专用运营群 → 同样永远不匹配
即:只有把运营群配成"被审查的那个群本身",回路才闭合 —— 而那样一来 operator_group_id 这个配置项就失去了意义(它存在的理由就是把审查 路由到别处)。这两处守卫各自都讲得通,合起来把功能锁死了。
修法:应答守卫改为"当前会话是被审查会话 或 是配置的运营方会话"。
第三道断口:O-50 确认并细化
maybe_push_session:600-602:
await context.send_action(action)
sess.last_push_ts = now
store.update_session(sess)
return True返回值连 bool() 都不取,随后无条件写 last_push_ts 并落盘。 后果由 resend_interval_seconds(默认 1800 秒)放大: 一次失败的推送把会话标记成"刚推过",30 分钟内不重试, 而 session_timeout_seconds 默认 7200 秒 —— 会话过期前最多再试 3 次, 每次失败都同样被记成成功。日志里没有任何一行。
这与 O-51 在同一个插件里:smalltalk_execution.py:690 是 core/delivery.py 收据协议的参考用法(commit/rollback 绑定真实状态变更、 asyncio.shield 保证失败被记下)。同一个插件同时是 P0-1 的最佳实践方和违反方 —— O-49 里"twitter 与 xiaoqing_chat 各自两边都占"在这里得到具体实例。
修法在同仓 40 行外:last_push_ts 的写入正是"投递确认后才提交的状态", 教科书式的 DeliveryReceipt(expected_actions=1, commit=..., rollback=...) 用例。
第四道闸门是默认关闭
config.py:259 enable_review_sessions: bool = False。 这降低了以上三条的现网影响,但也解释了为什么它们至今没被发现 —— 默认关闭的子系统不会有人报 bug。
O-85 [中] open_session_if_allowed 的 max_pending 是全局配额,却按每会话语义使用
review_sessions.py:441-451:
for sid, obj in active.items(): # ① 去重:按 (kind, chat_id)
if ... obj["kind"] == kind and obj["chat_id"] == chat_id:
return _decode_session(str(sid), obj)
pending_limit = max(0, int(max_pending))
if pending_limit == 0 or len(active) >= pending_limit: # ② 配额:len(active) 是全局的
return None① 是每会话的判据,② 是全局的计数。active 是所有群、所有私聊共用的一张表。
max_pending 默认 10(config.py:257),配置里它与 ask_per_check 一起被 _validate_review_batch_size 校验(:266-269),读起来完全是"每次审查批量"的语义。
后果:任意一个群积压 10 个未关闭的审查会话, 所有其它群的反思功能全部静默停止 —— open_session_if_allowed 返回 None, 与"处于冷却期"的返回值完全一样,调用方(handlers_helper.py:88-96)无法区分, 也没有日志。会话只有被显式关闭或过期(默认 2 小时)才释放配额, 而 O-84 已经证明运营方在多数配置下根本没法关闭它们。
三个缺陷复合的结果:推送到运营方 → 运营方无法应答 → 会话只能等 2 小时超时 → 在此期间该群持续占用全局配额 → 其它群的反思会话开不出来。
修法:len(active) 改为按 chat_id 过滤后的计数,或把配置项改名为 max_pending_total 并额外加一个每会话上限。
O-86 [中] _load_sessions_state 返回缓存对象本体,全部调用方原地修改它
:239-253 的 _load_sessions_state 直接 return self._cache_sessions —— 不是副本。而五个调用方都是先原地改、后落盘:
| 方法 | 原地修改 | 落盘 |
|---|---|---|
clear_sessions_for_chat:328-337 | active.pop(sid) / last_closed.pop(key) | :338 |
cleanup_expired:353-369 | active.pop(sid) / 写 last_closed | :370 |
close_session:406-416 | active.pop(sid) | :417 |
open_session_if_allowed:471-473 | active[sid] = ... | :474 |
update_session:485-486 | active[...] = ... | :487 |
_save_sessions_state 里的 AtomicJsonStore.write 可以抛(磁盘满、权限、 Windows 上被占用后重试仍失败),_normalize_sessions_state 也可以抛 TypeError。 一旦抛出,内存缓存已经是新状态、磁盘还是旧状态,且异常向上传播后 缓存不会回滚 —— 直到进程重启或 bind() 被重新调用。
同样的形状出现在策略上,而且后果更具体: get_policy:271-272 返回缓存里的 ReviewPolicy 对象本身, apply_review_answer:617-624 直接在它上面 pol.avoid_patterns.append(a), 然后才调 save_policy。若 save_policy 抛出:
- 用户看到命令报错,以为没记下
- 但内存里的 policy 已经包含该规则,
build_policy_block会把它注入提示词 - "没保存成功"的东西实际生效了,且重启后消失
save_policy:305-310 在成功路径上用规范化后的值重建了一个新的 ReviewPolicy 放回缓存 —— 说明作者意识到了共享可变对象的问题, 但只在成功路径上处理了(N32-2「守卫只加在入口没加在出口」的又一例)。
修法:_load_sessions_state / get_policy 返回深拷贝 (对照 pendo N30-6 的 deepcopy 纪律),或改为"先落盘成功、再更新缓存"的顺序。
O-87 [中] helper_utils._load_runtime 是一个名字里看不出副作用的重量级同步函数
helper_utils.py:68-106,函数名与 docstring 都只承诺"加载或返回缓存的运行配置"。 实际上在缓存未命中时,它在 :97-104 做了:
from .memory.knowledge_base import ensure_knowledge_index
ensure_knowledge_index(memory_db=..., data_dir=..., plugin_dir=..., files=...)而 ensure_knowledge_index(O-80-1)会:读最多 32 个文件 / 4 MB → 分块成最多 512 个文档 → replace_configured_knowledge → 内容有变则 memory_db.save()(全库 build() 重算嵌入 + 写 docs.json + 写 npz)。
三个问题叠加:
- 同步执行在事件循环上 ——
_load_runtime是同步函数,位于每条消息的处理路径上。 → O-75 的第 6 个受害点,也是单次开销最大的一个(前 5 个只是查询触发重算, 这一个还额外做文件 I/O 和两次写盘)。 - 缓存键是配置文件的 mtime(
:81、:85)。运营方改一次xiaoqing_config.json→ 下一条到达的消息在事件循环上重建整个知识索引。 进程冷启动后的第一条消息同理。 KnowledgeIndexError会让_load_runtime整体失败。knowledge_base的 fail-closed 设计(宁可不发布也不发布半个索引)是对的, 但它被放在了配置加载路径上,于是失败面从"知识索引不可用"扩大到 "插件的每一条消息处理都抛异常"。 而且门槛不高:配置里任何一个知识文件超过 1 MB、非常规文件、非 UTF-8、 或分块总数超过 512,都会触发。 注意_resolve_sources:100-102对文件被删除是continue容忍的, 对文件过大是raise—— 两种同样属于"运维配置问题"的情况处理强度不对称。
修法:把 ensure_knowledge_index 从 _load_runtime 里拆出来, 放到插件 init() 或一个 to_thread 的后台任务里;失败只记录并禁用知识块, 不阻断消息处理。
O-88 [中] 写错的 ban_regex 被静默丢弃
helper_utils.py:89-94:
for pattern in cfg.ban_regex:
try:
compiled.append(re.compile(pattern))
except re.error:
continueban_regex 与 ban_words 是这个插件的内容过滤入口 (_should_ignore_text:216-227 用它决定是否完全忽略一条消息)。 一个写错的正则(漏了右括号、非法转义)会被静默跳过,无日志、无启动告警、 无任何用户可见反馈。运营方在配置里写了 5 条屏蔽规则,实际生效 4 条, 没有任何途径能发现少了哪一条。
这与 N69-1(损坏数据静默消失)是同一主线在配置维度上的实例: 坏的输入不该消失,应该报出来。 最低成本修法:except re.error as exc: logger.error("ban_regex 编译失败 index=%d error=%s", i, exc)。 更好的做法是在 XiaoQingChatConfig 的 pydantic 校验器里编译一次, 让坏正则在配置加载时就报错(该文件已有 model_validator 的先例, 见 config.py:266)。
O-89 [中] _is_private 与 _chat_id 对 group_id 用了两套空值语义
helper_utils.py 相邻的两个函数:
def _chat_id(event):
group_id = event.get("group_id")
if group_id not in (None, ""): # ← 空字符串算"没有群"
return f"g{group_id}"
...
def _is_private(event):
return event.get("group_id") is None # ← 空字符串算"有群"某些 OneBot 实现会在私聊事件里带 "group_id": ""。此时:
_chat_id判定为私聊 → 返回u<user_id>_is_private判定为群聊 → 返回False
_is_private 的下游包括 @ 判定、回复意愿门控、 以及所有"私聊直接回复 / 群聊需要被点名"的分支。 后果是私聊被当成群聊处理:用户在私聊里说话,机器人按群聊规则决定是否搭理, 而记忆和会话状态又存在私聊的 key 下。
这是 N-45(相邻函数对同一附属表用相反纪律)在字段判据维度的实例。 修法:抽一个 _group_id_of(event) -> str | None 单一入口,两处都用它。
O-90 [中] message_parts 里两种媒体匹配纪律:一处按身份、一处按位置
同一个文件、相隔 20 行:
按身份(正确) —— insert_or_merge_media_part:147-166:
identity = {field: ... for field in ("face_id", "media_hash", "media_key") if ...}
# 没有稳定身份的图片/表情不能仅凭 kind 判重,否则第二张匿名图片会覆盖第一张。
if identity:
for index, item in enumerate(parts):
if item["kind"] != media_kind: continue
if any(item[field] != value for field, value in identity.items()): continue
parts[index] = {**item, **normalized_media}; return注释明确说明了为什么不能只按 kind 判重。
按位置(有风险) —— replace_message_media_parts:203-216:
media_index = 0
for part in normalized_parts:
if part["kind"] == "text": rebuilt.append(dict(part)); continue
replacement = None
if media_index < len(resolved_items):
replacement = _normalize_media_part(resolved_items[media_index])
media_index += 1
merged = dict(part);
if replacement is not None: merged.update(replacement)第 i 个非文本片段被第 i 个 resolved_items 覆盖,不校验 media_hash / face_id 是否对得上。只要 media_items 的顺序与 parts 里媒体片段的顺序 出现任何偏差(历史消息经过 compact_media_items 去重、 某个媒体项被 _normalize_media_part 判为无效而丢弃、 上游按不同规则排序),就会把 A 图的 file_path/description 写到 B 图的片段上。
而且 resolved_items 少于媒体片段数时,多出来的片段保持旧值, 没有任何标记 —— 调用方无法知道这次替换只完成了一部分。
修法:replace_message_media_parts 复用同文件已有的 identity 逻辑, 按 media_hash/face_id/media_key 配对;配不上的显式跳过并返回一个 "未匹配数",而不是按位置硬覆盖。同一文件内的正确写法,第 3 处未被复用的案例 (前两处:O-76 的 .bak、O-84 的 DeliveryReceipt)。
O-95 [中] 渲染路径先把手上的描述丢掉,再去查注册表把它找回来
链路
render_message_parts(message_parts.py:354-362)拿到的 parts 是完整的:_normalize_media_part 保留 description / marker / emotion_tags / aliases / label(message_parts.py:44-62)。 但它做的第一件事是:
content, media_items = message_parts_to_legacy(parts) # message_parts.py:359而 message_parts_to_legacy:349-351:
compacted_media_items = compact_media_items(media_items) # ← 丢弃 description/label/
content = compact_message_content(visible_text, compacted_media_items) # emotion_tags/aliases
return content, compacted_media_itemscompact_media_items:217-225 的规则是:只要条目有 media_key, 就只保留 kind / media_key / media_hash / face_id / marker / mode, 其余"可由注册表恢复的冗长元数据"一律省略。 同时 compact_message_content 把正文里的 [图片:xxx] 标记换成 [[xc_media_N]] 占位符 —— 连 marker 里的描述文字也从正文中消失了。
紧接着 render_message_parts:362 调 resolve_message_content, 后者(media_registry.py:443-464)再通过 store.resolve_media_items 把描述一个个查回来,最后 rebuild_message_content 用它们重建标记。
净效果:把刚拿到手的数据删掉,然后向一个哈希表请求同样的数据。
两个具体后果
(一)注册表缺条目 → 历史消息永久降级为通用标签。_render_media_marker:199-201:
if kind == "image":
desc_text = description or _extract_marker_label(marker) or "一张图片"
return f"[图片:{desc_text}]"description 已被 compact 丢掉、marker 也被占位符替换掉, 若注册表里查不到该 media_key(索引文件损坏、被运维删除、 upsert 那一步曾经失败过),渲染结果就是 [图片:一张图片]。
而 media/index.json 的读取走 StoreBase._load_json = O-76 那个不看 .bak、任何异常都返回默认值的读取器 (_load_entries_locked:498 的 default={"entries": {}})。 一个坏字节 → entries 变成空字典 → 全部历史消息里的图片、表情包 在提示词中集体退化成"一张图片"/"一张表情包",而模型据此生成回复。 用户看到的症状是"小青突然看不懂图了"。
→ 这是 O-76 目前找到的最具体、影响面最大的下游后果: 其它模块丢 .bak 只是丢自己那份数据,这里是全部历史消息的可读性依赖一个 没有恢复路径的索引文件。
(二)热路径上的重复加锁查表。render_stored_message 被用在提示词组装(每轮 30 条上下文)和 _tool_query_chat_history(memory_retrieval.py:365,扫最近 120 条)。 每条消息的每个媒体片段都要走一次 resolve_media_items → with self._lock → 可能触发 _load_entries_locked 的磁盘读。 一次记忆检索最多 120 次持锁查询,全部在事件循环上(O-75 第 7 个受害点)。
修法
message_parts_to_legacy 承担了两个不同职责: 产出"可持久化的紧凑形式"(需要 compact)与 产出"可渲染的完整形式"(不需要 compact)。 拆成两个函数,render_message_parts 走不 compact 的那条 —— 它手上本来就有完整数据,一次查表都不需要。 smalltalk_media_helpers._sync_message_parts_to_registry:145-149 已经知道要传 compact=False(见 O-100-3),说明这个区分是存在的, 只是没有贯彻到渲染路径。
O-96 [中] 媒体注册表无作用域、无上限、无淘汰 —— 而淘汰所需的数据它一直在记
_merge_media_record:373-375 每次观测都维护三个字段:
merged["first_seen_ts"] = float(merged.get("first_seen_ts", 0.0) or 0.0) or now
merged["last_seen_ts"] = now
merged["seen_count"] = int(merged.get("seen_count", 0) or 0) + 1这三个字段没有任何一处读取方(全插件 grep:first_seen_ts 1 写 0 读, seen_count 1 写 0 读,last_seen_ts 1 写 0 读)。 它们唯一可能的用途就是 LRU/LFU 淘汰 —— 而 MediaRegistryStore 上 没有任何删除、过期或容量限制的方法:upsert_media_items 只增不减, flush 整表写出,bind 只在换目录时丢缓存。
于是:
- 群里每出现一张新图片/表情包,注册表多一条永久记录
- 整表在首次访问时全量读进内存(
_load_entries_locked), 在每次flush时全量序列化写出(_save_entries_locked) - 活跃群跑一年后,
index.json会长到几十 MB 量级, 而每次 flush 都要把它整个json.dumps(indent=2)再原子写一遍
这是 N37-1「能力已具备但没接线」的一个特别清晰的实例: 淘汰策略需要的全部输入数据都已经在采集了, 缺的只是那十行淘汰代码。而且因为字段存在,读代码的人会以为有淘汰。
对照:pendo 的 prune_operation_logs(有保留期,虽然 N-12 有时区 bug)、 qingpet 的 asset_ledger(append-only 是刻意的且有说明)。 本处既不是刻意 append-only,也没有保留期。
修法:flush 时按 last_seen_ts + seen_count 淘汰到一个可配上限 (例如 20 000 条),并把上限做成配置项。
O-97 [中] 注册表的加锁边界把磁盘 I/O 圈了进去 —— O-75 的第 7、8 个受害点
MediaRegistryStore 的模块 docstring 写得很清楚: "修改操作只把内存索引标记为脏,不在组装每条消息时执行 I/O; 生命周期代码必须在持久化边界调用 flush"。写路径确实做到了。 但两处 I/O 仍然在锁里:
(一)_load_entries_locked:492-501 在 self._lock 内做磁盘读。 它由 resolve_media_items 和 upsert_media_items 触发, 两者都从事件循环上被同步调用(resolve_message_content → 提示词组装、 render_stored_message → 记忆检索)。首次访问 = 事件循环上同步读整个索引文件, 而这个文件按 O-96 是无上限增长的。
(二)flush:511-518 在 self._lock 内做磁盘写。flush 本身是在 to_thread 里跑的(task_scheduler.py:239、main.py:184), 方向是对的 —— 但它持有的锁与事件循环上的 resolve_media_items 是同一把。 于是事件循环会阻塞等待后台线程完成 json.dumps(整个索引) + 临时文件 + fsync + os.replace + 维护 .bak。
这与 memory_db.save() / query() 争用同一把 RLock(O-75)是完全相同的形状, 在同一个插件里的第 2 个实例。 共同的修法也一样: 锁只保护内存结构的读写,序列化后的字节串在锁外写盘 (pendo db.py:535-592 的连接模型、本插件 MemoryStore.persist 的 "短暂持锁取快照、锁外做 I/O"都是现成范式 —— memory.py:351-355 的 docstring 甚至把这条纪律写出来了)。
→ N101-2 第 38 处:同插件同文件夹里就有正确的加锁范式没被复用。
O-98 [中] 媒体标记与占位符都是用户可以直接打出的明文,没有任何转义
两个结构性语法:
_MEDIA_MARKER_RE = re.compile(r"\[(?:图片|表情包|QQ表情):[^\]\n]{0,400}\]") # :25
MEDIA_PLACEHOLDER_RE = re.compile(r"\[\[xc_media_(\d+)\]\]") # :26两者都出现在用户消息正文经过的那条链路上,且没有任何转义或来源标记。
(一)用户输入 [图片:小狗] 会被当成真实媒体标记。compact_message_content:301-308 按出现顺序给匹配到的标记编号, 第 i 个匹配换成 [[xc_media_i]]。若用户自己打的假标记出现在真实媒体标记之前:
用户发送: "[图片:小狗] 你看这个" + 一张真实图片
↓ 上游把真实图片的标记插入正文,正文里于是有 2 个匹配
compact: 第 1 个(用户打的假标记)→ [[xc_media_1]]
第 2 个(真实标记)→ 索引 2 > len(items)=1 → 原样保留
rebuild: [[xc_media_1]] → 真实图片的 marker结果:真实图片被渲染到用户假标记的位置,用户自己写的那段文字被抹掉, 而真实标记以字面量形式留在正文里。存进历史的也是这份被改写的内容。
(二)用户输入 [[xc_media_1]] 会绕过正常的压缩路径。message_parts.py:296-297:
if MEDIA_PLACEHOLDER_RE.search(raw_text):
template = raw_text # ← 直接把用户正文当模板用用户正文里含占位符时,rebuild_message_content + compact_message_content 这两步被整个跳过,用户文本被当作系统生成的模板处理。
级别说明:这不是权限提升 —— 模型看到的仍然只是标记文本, file_path 从不进入渲染结果(_render_media_marker 只用 kind/marker/description/label/emotion_tags/aliases)。 真实后果是正文被静默改写、媒体错位、历史记录失真, 以及一个可被用户构造的"让机器人以为自己发过某张图"的输入面。
修法:占位符改用一个用户键盘打不出的私有区字符做定界 (如 {index}),或在 compact 之前对用户正文里的 [ 与 [[ 做一次转义并在 rebuild 时还原。 判据(新):任何"结构标记直接混在用户可控文本里"的表示法, 都必须能回答"用户打出这串字符会怎样"。 这个模块的 docstring 把"顺序是唯一事实来源"写得很清楚,却没有回答这一条。
O-99 [中] 全仓遗留哈希普查:7 处 md5/sha1,全部缺 usedforsecurity=False
承接 O92-2,本组把范围扩到全仓:
| 文件 | 行 | 算法 | 用途 |
|---|---|---|---|
xiaoqing_chat/memory/review_sessions.py | 343 | md5 | 会话 ID |
xiaoqing_chat/expression/bw_expression_learner.py | 83 | md5 | 表达条目 ID |
xiaoqing_chat/media_registry.py | 78 | sha1 | file: 媒体键 |
xiaoqing_chat/media_registry.py | 82 | sha1 | marker: 媒体键 |
xiaoqing_chat/media/event_media.py | 932 | sha1 | summary: 媒体哈希 |
xiaoqing_chat/media/event_media.py | 967 | sha1 | 媒体键派生 |
qingpet/main.py | 320 | sha1 | 用户 ID 摘要(前 8 位) |
全部是非安全用途的标识派生,但全部没有传 usedforsecurity=False → 在启用 FIPS 的 Python 构建上,hashlib.md5() / hashlib.sha1() 直接抛 ValueError: [digital envelope routines] unsupported。 仓库其余部分(memory_db.person_fact_doc_id、memory_retrieval._opaque_local_reference、 knowledge_base._hash_id、core/atomic_store 相关)一律用 sha256。
两条修法二选一,一行改动:加 usedforsecurity=False,或统一换 sha256 截断。 建议后者 —— 保持"仓库里只有一种哈希"这个可 grep 的不变量, 比记住哪些用途允许弱哈希更可靠。
另注 media_registry.py:78 的 file_path.lower(): 在区分大小写的文件系统(Linux 容器部署)上, /a/B.png 与 /a/b.png 是两个不同文件却会得到同一个 media_key → 注册表记录互相覆盖。当前部署以 Windows 为主所以不显, 但这是一个隐含了"文件系统不区分大小写"的假设,且没有注释。
O-104 [中] 超出 max_media_per_message 的媒体静默消失,而失败的媒体反而有降级标记
render_event_media:1229-1230:
if max_media == 0 or len(rendered_items) >= max_media:
break达到上限后直接 break,后续媒体片段既不渲染也不记录。 随后 _compose_effective_user_parts:1195-1197 按顺序配对:
rendered = next(media_iter, None)
if rendered is not None:
ordered_parts.append(_rendered_media_to_message_part(rendered))media_iter 耗尽后 next(..., None) 返回 None → 该媒体片段什么都不追加。
后果:用户一条消息发 5 张图、max_media_per_message 配成 3 → 模型看到 3 张图的标记,完全不知道还有 2 张。 它会基于"用户发了 3 张图"来回复,而用户的实际语境是 5 张。
为什么这条值得单独报
同一个文件里,解析失败的媒体有降级标记 (_render_summary_only_media → [图片:图片内容读取失败]), 超出预算的媒体什么都没有。 两种"处理不了"的情况纪律不对称: 前者告诉模型"这里有东西但我没看懂",后者假装它不存在。
而这恰好违反了同插件已经确立了三次的原则(O-6 的 _build_reply_payload_core、O-100-4 的 compact/rebuild 双向追加): 媒体要么被消费、要么被追加,不存在静默消失的第三种结局。 这里出现了第三种结局,而且是在入站方向—— 出站方向(回复)三处都守住了,入站方向没有。
修法极便宜:break 改成统计剩余数量,在 ordered_parts 末尾追加一条 {"kind": "text", "text": "(还有 N 张图片未分析)"}。 _render_summary_only_media 已经证明"降级但可见"这条路是通的。
O-105 [中] C-0 三方对照:本地路径的 check → open 之间没有 O_NOFOLLOW,也没有 fd 身份复核
本组把 C-0 主线的三个实现放在一起对比:
codex/artifacts.py | xiaoqing_chat/memory/knowledge_base.py(O-80-1) | xiaoqing_chat/media/(本组) | |
|---|---|---|---|
| 根目录/容器约束 | ✅ | ❌(管理员配置,允许任意绝对路径) | ✅ _is_allowed_local_media_path,且 resolve 顺序正确 |
拒绝符号链接(O_NOFOLLOW) | ✅ | ❌ | ❌ |
| 拒绝硬链接 | ✅ | ❌ | ❌ |
打开后用 fstat 复核身份 | ✅ | ✅ (dev, ino, size, mtime_ns) 三点 | ❌ 只有 path.stat() 后 path.open() |
| 读取后再次复核 | ✅ | ✅ | ❌ |
| 多读一字节判溢出 | ✅ | ✅ | ✅ |
强制完整解码(verify / 逐帧) | ✅ | 不适用 | ✅ 本仓最完整(含解压炸弹提升为异常) |
| 校验副本而非源 | ✅ | ✅(读进内存再校验) | ✅(内容哈希 + 原子写入收件箱) |
| 尺寸/帧数/磁盘配额 | 部分 | ❌ | ✅ 本仓唯一四层齐全 |
具体缺口在 _read_file_bounded:70-84:
file_stat = path.stat() # ① 按路径 stat
if not stat.S_ISREG(file_stat.st_mode): ...
if limit > 0 and file_stat.st_size > limit: ...
with path.open("rb") as handle: # ② 按路径再打开一次 —— 中间可被替换
payload = handle.read(read_size)① 与 ② 之间、以及更早的 _resolve_media_source_path 的包含性检查与 ① 之间, 路径都可能被替换成指向根目录之外的符号链接。
攻击面评估(不要读成高危):可写入 data_dir / plugin_dir 才能构造, 而这两个目录本来就归插件自己所有。真正的风险场景是 同机上运行的其它进程或另一个插件恰好能写入这些目录。 因此定级为中而非 P1。
修法在同仓两处现成:knowledge_base._read_source 的 os.fstat(handle.fileno()) 身份复核可以直接搬进 _read_file_bounded (三行),O_NOFOLLOW 参考 codex。 做完之后 media/ 就是三个实现里唯一每一项都齐的那个, C-0 的 core 提取应改以它为蓝本。
O-106 [中] 收件箱淘汰:每解析一张图全目录扫描一次,且"TTL"实际按写入时间而非使用时间
一、每次解析都全目录 glob + stat
_materialize_resolved_media:607-615 在 keyed_path_lock(收件箱目录) 内调 _prune_media_inbox,后者(:665-674):
for path in root.glob("*"):
...
stat = path.stat()磁盘配额默认 256 MB(cfg.inbox_disk_quota_bytes), 按表情包平均几十 KB 计,收件箱里可能有数千到上万个文件。 每解析一张图片就把整个目录 glob 一遍并逐个 stat —— 这是 J3-1/J3-3(codex 每次入队 rglob 全目录)在本仓的第 2 个实例。
好在它跑在 _run_media_blocking(线程 + 信号量)里,不阻塞事件循环; 但它持有收件箱目录锁,两条并发的媒体解析会互相等待整轮目录扫描。
修法:维护一个 inbox_index.json(大小 + mtime + 总量), 只在总量接近配额时才做全目录核对。
二、命中不刷新 mtime,于是 TTL 与 LRU 都退化成 FIFO
_prune_media_inbox 的两级淘汰都以 st_mtime 为准:
if not is_protected and ttl_seconds > 0 and now - stat.st_mtime > ttl_seconds:
path.unlink(missing_ok=True) # ① TTL
...
for _mtime, size, path in sorted(files): # ② 按 mtime 升序,最旧优先删而 _materialize_resolved_media:608-617 在 already_cached 时 只是跳过写入,不 touch 文件:
already_cached = cached_path.exists()
...
if not already_cached:
atomic_write_bytes(cached_path, payload)因此 st_mtime 永远是首次写入时间,与"最近一次被用到"无关。 后果:
- 群里天天在发的那个表情包,7 天后照样被 TTL 删掉
- 配额淘汰选的是"最早下载的"而不是"最久没用的",命中率显著低于真正的 LRU
注意这不会导致功能错误:渲染缓存按 media_hash 独立存放且不受影响, 图片被删只是下次要重新下载。但它让配额和 TTL 两个参数的实际含义 与名字(inbox_ttl_seconds)不符 —— 用户会以为"7 天内用过就不删"。
修法一行:already_cached 分支里 cached_path.touch()。
O-107 [中] 渲染缓存的字节上限循环,每轮都把整个缓存重新序列化一遍
event_media_common._prune_render_cache:312-316:
while normalized_items and (
byte_limit == 0 or _serialized_render_cache_size(bounded_cache) > byte_limit
):
key, _payload = oldest_first.pop(0)
normalized_items.pop(key, None)_serialized_render_cache_size:285-293 是 len(json.dumps(cache, ensure_ascii=False, indent=2, allow_nan=False).encode("utf-8")) —— 对整个缓存做一次完整序列化,上限 _RENDER_CACHE_MAX_BYTES = 4 MB。
循环条件在每一轮都重新算一次。常态下缓存贴着上限运行、 每次写入淘汰 1~2 条 → 每次媒体渲染要额外做 2~4 次 4 MB 的 JSON 序列化 (再加 _load_render_cache 的一次反序列化和 _save_render_cache 的一次序列化)。 最坏情况(一次性塞进大量条目、或 max_bytes 被调小)是 O(条目数) 次全量序列化, _RENDER_CACHE_MAX_ENTRIES = 1000 时即 1000 × 4 MB 的序列化工作。
它同样跑在线程里且被路径锁保护,所以不会导致数据错误 —— 但这是纯粹的浪费,且淘汰逻辑本身依赖它,无法通过调参绕开。
修法:先按条目数淘汰到 max_entries(现有第一个循环), 然后一次性算出当前大小,按"平均每条字节数"估算需要再删几条, 删完之后只再校验一次。或者在写入时记录每条的序列化长度增量。
(附带:_load_render_cache:249-256 对文件大小的预检查是好的, 但它的判据是"超过 max_bytes 就整个丢弃返回空缓存" —— 丢弃比截断更激进,而且丢的是主文件和 .bak 里任何一个超限就丢两个。 这与 _prune_render_cache 的"逐条淘汰"纪律不一致: 同一个上限,一处是渐进淘汰、一处是整体丢弃。)
O-108 [中] media/ 子包把并发纪律全做对了,而它的父目录 media_registry.py 一条都没做
这两个模块在同一个插件、互相 import、处理同一批数据:
media/event_media*.py(本组) | media_registry.py(O-97) | |
|---|---|---|
| 磁盘 I/O 是否离开事件循环 | ✅ 全部经 _run_media_blocking | ❌ _load_entries_locked 在事件循环上读盘 |
| 是否有并发上限 | ✅ 插件级信号量(2) | ❌ 无 |
| 跨进程/线程的文件锁 | ✅ keyed_path_lock | ❌ 只有进程内 threading.Lock |
| 锁内是否做 I/O | ✅ 做,但整段在线程里 | ❌ flush 在线程里持锁写盘,而事件循环要抢同一把锁 |
| 单飞去重 | ✅ 按 (data_dir, media_hash) | ❌ 无 |
O-97 的修法不需要设计,只需要把 _run_media_blocking 和 keyed_path_lock 用到 MediaRegistryStore 上 —— 前者就定义在它 import 的那个包里。
→ N101-2 第 39 处,且是本轮审查里"正确实现与错误实现距离最近"的一处: media_registry.py:18 就在 from .media.event_media_common import ...。
O-109 [中] 失败媒体的合成哈希与内容寻址哈希共用同一个命名空间
_render_summary_only_media:930-932:
summary_key = f"{segment_type}:{kind}:{summary or description}"
return RenderedMedia(
media_hash=f"summary:{hashlib.sha1(summary_key.encode('utf-8')).hexdigest()}",
...这个 media_hash 不是从任何字节算出来的,而是从"失败时的摘要文本"算的。 而 _segment_failure_summary_hint:941-950 会把 图片 / 一张图片 / [图片] 这几个通用值过滤成空串,于是绝大多数失败媒体走到
summary_key = "image:image:图片内容读取失败"同一个字符串 → 同一个 sha1 → 同一个 media_hash。
这个值随后:
- 进入
_rendered_media_to_message_part的media_hash字段 → 持久化进聊天历史 - 经
media_registry._stable_media_key变成media:summary:<同一个 sha1>→ 所有历史上失败过的图片在媒体注册表里共用一条记录,seen_count累加、aliases累积、_merge_media_record的质量单调逻辑 在一堆互不相干的图片之间做合并 RenderedMedia.media_hash在别处被当作"内容身份"使用 (_render_resolved_media的单飞锁键、渲染缓存键)
第 3 点当前不会出错,因为失败路径根本不走渲染缓存和单飞锁。 但**"内容寻址的哈希"与"内容无关的合成 ID"共用同一个字段和同一个命名空间**, 是一个随时会被下一个消费者踩到的地雷。
修法:合成 ID 用一个明确不同的前缀并在 _stable_media_key 里显式拒绝 (例如 unresolved: 且不进注册表),或者在 RenderedMedia 上加一个 media_hash_is_content: bool 字段。
O-113 [中] 转码后的载荷没有任何上限 —— 四层预算漏掉了真正发出去的那一份
事实
O-103 记录的四层预算(字节 → 像素 → 帧数 → 磁盘配额) 约束的全部是源文件。而真正通过网络发给视觉服务商的是 _prepare_media_for_llm:297-314 转码之后的 PNG:
from PIL import Image
with Image.open(io.BytesIO(payload)) as image:
if getattr(image, "mode", "") not in {"RGB", "RGBA"}:
image = image.convert("RGBA")
...
buffer = io.BytesIO()
image.save(buffer, format="PNG") # ← 无损重编码,无尺寸上限
return PreparedMediaForLLM(payload=buffer.getvalue(), ...)随后 _load_and_prepare_media_for_llm:1299:
image_b64 = base64.b64encode(prepared.payload).decode("ascii")再由 _prepare_media_analysis_request:873-875 拼成 data:{mime};base64,{image_b64} 塞进一次 HTTP 请求体。
从 max_analyze_bytes 到这一步之间,没有任何一处检查转码结果的大小。
量级
配置默认值(config.py:328/338):
max_analyze_bytes = 4 MB(源文件上限)max_image_pixels = 16_000_000(像素上限)
一张 4 MB、1600 万像素的 JPEG 是完全合法的输入。转成 RGBA 后 内存里是 16M × 4 = 64 MB;PNG 无损压缩后对照片类内容通常是 20~40 MB; base64 再放大 4/3 → 27~53 MB 的请求体。
即:源文件 4 MB 的硬上限,在发出去的时候变成了 50 MB 量级。 后果包括服务商侧 413、超时、按字节计费的成本放大, 以及本机一次媒体分析的内存峰值达到上百 MB (payload + RGBA 位图 + PNG buffer + base64 字符串同时在内存里)。
同一个函数里,动画路径是有上限的
# _render_animation_contact_sheet(event_media_common.py:517-518)
gap = 8
frame_max_side = 320 # ← 每帧缩到 320 px
indexes = _animation_sample_indexes(frame_total) # ← 最多 3 帧动画路径把帧数收到 3、每帧收到 320 px;静态图路径一个尺寸约束都没有。 两条分支在同一个 if resolved.is_animated: 的两侧,相隔 12 行。
修法
_prepare_media_for_llm 的静态图分支加一次 image.thumbnail((L, L)) (L 取一个配置项,视觉模型通常在 1024~1536 就足够), 并在 PreparedMediaForLLM 上记录缩放前后尺寸供审计。 动画路径已经证明这个做法是通的,把它对齐到静态图路径即可。
→ N-45 / N48-2 主线(相邻分支相反纪律)在本仓的第 N 例, 且这一例的后果是"发出去的东西比允许收进来的大一个数量级"。
O-114 [中] 承载模型输出的 19 个日志字段全部被脱敏,debug.log_steps 打开后仍然什么都看不到
链路
_media_log(event_media_common.py:211-229)先看 runtime.cfg.debug.log_steps, 关闭则直接返回;打开则把 fields 交给 logging_utils.sanitize_log_fields。
而 O-110 已经确认 sanitize_log_fields 是 deny-by-default 的: 未在四张白名单(_CORRELATION_LOG_KEYS / _TYPE_LOG_KEYS / _REASON_CODE_LOG_KEYS / _STATUS_LOG_KEYS)里出现的字符串字段, 一律换成 [redacted kind=… length=… bytes=… fingerprint=…]。
本文件写进日志的内容字段:
| 日志步骤 | 字段 |
|---|---|
media.analyze.detail.ok(:996-1000) | description / visible_text / emotion_tags / raw_output |
media.analyze.ok(:1128-1134) | description / detail_description / visible_text / refined_description / marker / cultural_hint |
media.analyze.semantic.invalid(:1036) | description |
media.analyze.refine.background.ok(:740-742) | description / emotion_tags / marker |
media.analyze.refine.background.skip(:716) | description |
media.cultural_hint.ok(:1404) | hint |
这些键一个都不在白名单里,因此全部输出为 [redacted …]。
为什么这是问题而不是"脱敏正常工作"
脱敏本身是对的(O-110 是本组仍然认可的正面项)。问题在于:
debug.log_steps这个开关名承诺的东西拿不到。 运营方打开它是为了排查"小青为什么把这张图描述错了", 得到的是一串 fingerprint。两个调试层叠在一起,第二层把第一层的产出抹掉了。- 代价照付。
_redacted_value(logging_utils.py:100-118) 为了算length/bytes/fingerprint,仍然要把整个值序列化一遍。raw_output是视觉模型的完整原始响应(_MEDIA_DETAIL_TRUNCATED_RETRY_MAX_TOKENS = 720token 量级),每次分析都要为一个必然被丢弃的值做一次完整哈希。 - 它掩盖了一个真实的隐私判断。 这些字段里
visible_text是用户图片里的 OCR 文字、description是对用户图片的描述。 如果哪天有人把description加进白名单来"方便调试", 就等于把用户图片内容写进普通日志 —— 而当前代码读起来像是本来就在这么做。
建议
明确区分两条通道:普通日志只记 reason_code / quality / finish_reason / raw_chars(这些本来就是安全的),把内容字段移到一个显式命名的 诊断通道(例如 debug.log_media_content,默认关闭且在配置注释里写明 "会把用户图片的描述与 OCR 文字写入日志")。 当前状态是既没有可用的诊断能力,也没有说清楚为什么没有。
O-115 [中] 后台优化的 docstring 承诺"只在质量提升时更新",实现没有做这个比较
_schedule_background_emoji_refine:641-644 的 docstring:
当前轮次始终以前台输出为准;延迟结果只能在质量提升时更新同一哈希的缓存条目。
实际的更新条件(:697-730)只有一条:语义检查通过就写。
refined_rendered, used_summary_fallback, quality = _finalize_media_analysis(
detail=detail, refined=refined, resolved=resolved)
semantic_reason = _semantic_retry_reason(...)
if semantic_reason:
...log skip...; return
await _run_media_blocking(write_render_cache_entry, ..., source="llm", quality=quality, ...)而 _finalize_media_analysis:500-511 在 detail(= 前台已提交的结果) 与 refined(= 后台压缩结果)之间的取舍是:
if refined and refined.description and not _is_generic_media_label(refined.description):
description = refined.description # ← refined 只要不是通用标签就赢
elif detail.description and not _is_generic_media_label(detail.description):
description = detail.description只要后台结果不是"一张表情包"这种通用词,它就无条件覆盖前台结果, 不管前台那条描述是不是更丰富。而后台这一路的提示词恰恰是 "把详细描述压缩成适合聊天使用的短标签"(:584)—— 它的产出按设计就更短。
于是实际行为是:"后台优化"倾向于用一条更短的描述替换一条更长的, 只要短的那条不落在 _GENERIC_MEDIA_LABELS 名单里。这未必是错的 (短标签在聊天上下文里确实更好用),但它不是 docstring 说的那件事, 而且没有任何地方能回退。
同插件就有现成的质量比较器
media_registry._quality_score + _merge_media_record(O-100-2)做的正是 "多次观测同一对象、取质量更高的那次,且带迟滞(+0.05)防抖动"。 后台优化要的单调性用它一行就能表达:
if _quality_score(refined_fields) <= _quality_score(cached_fields) + 0.05:
return # 不覆盖→ N101-2 第 40 处:需要的比较函数在同一个插件里已经写好、测试过、 docstring 里还专门解释了迟滞的作用,只是没有被这条路径调用。
O-116 [中] 准备阶段的异常不产生任何日志,而"缓存文件被淘汰"恰好走这条路
_load_and_prepare_media_for_llm:1294-1300 只捕获一种异常:
try:
payload = _read_file_bounded(resolved.cached_path, max_bytes=max_bytes)
except MediaPayloadTooLarge as exc:
return None, "", exc.size
prepared = _prepare_media_for_llm(resolved, payload)而这两行可以抛出的还有:
FileNotFoundError——_read_file_bounded:73的path.stat()。 这不是理论情况:O-106 已确认收件箱按inbox_ttl_seconds淘汰、 且命中不刷新 mtime,所以一个正在被重新分析的图片完全可能刚被删掉ValueError("image decode or transcode failed")——_prepare_media_for_llm:315-316OSError—— 磁盘错误
这些异常向上穿过 _prepare_media_analysis_request → _analyze_media_with_llm(没有 try)→ 最终落到 event_media.py:792-793 的
except Exception:
rendered = None—— 完全静默。随后打的 media.render.fallback 日志(:801-812) 只记 summary_hint 和 marker,不带 error_type。
后果:用户看到"图片内容暂时无法识别",运维在日志里看到一条 media.render.fallback,无法区分「视觉模型没配置」「文件被淘汰了」 「图片解码失败」「服务商全挂了」四种完全不同的原因。
对照:同一文件里 _analyze_media_with_provider 的失败路径有 _log_media_analysis_exception(记 error_type)、 _log_terminal_semantic_failure(记 reason_code)、 _log_media_provider_fallback(记 from/to provider 和 reason)—— 分析阶段的失败分类做得非常细,准备阶段一条都没有。
修法:_analyze_media_with_llm 在两个准备步骤外包一层 except Exception as exc: _media_log(..., step="media.analyze.prepare.fail", fields={"error_type": type(exc).__name__, ...}); return None。 error_type 是白名单字段(_TYPE_LOG_KEYS),能真的打出来。
O-122 [中] marker_media_part 的 image 分支做了二次授权,emoji 分支没做 —— 而 emoji 路径的来源函数明确要求调用方自己验证
marker_resolver.marker_media_part:384-427 的三个分支:
if resolved.kind == "emoji":
...
return {..., "file_path": str(resolve_emoji_file_path(context, raw_file_path)), ...}
# ↑ 只解析,未做 _is_path_within_roots
if resolved.kind == "image":
file_path = _resolve_authorized_image_path(Path(context.data_dir), entry.file_path)
if file_path is None:
return None # ↑ 重新证明位于 data 根内,失败即拒绝
return {..., "file_path": str(file_path), ...}而被 emoji 分支调用的 resolve_emoji_file_path, 它自己的 docstring(emoji_library.py:71-75)就是:
解析索引路径,但不因此授予访问权限。 调用方在读取、移动或删除结果前,仍须确认路径属于
_allowed_emoji_target_dirs。
这是一条被明确写出来、并且被同文件其它三个调用方遵守 (_remove_library_file / _safe_target_file_path / _find_similar_entry)、 唯独在这里没被遵守的义务。
当前是否可利用
不可利用。emoji 条目的 file_path 由 load_emoji_library 从 _iter_library_files(library_dir) 的结果生成,按构造就在库目录内。 所以安全性来自生产者而不是边界。
为什么仍然要报
- 模块 docstring 承诺的是"图片路径在两个边界都要重新证明"—— emoji 也是图片文件,只是走了另一个分支;
index.json是可以被外部修改的普通 JSON(模块 docstring 第一句就是 "索引路径均视为不可信"),而load_emoji_library的path_matches校验(:686-693)比对的是"索引声明的路径 == 遍历到的文件", 不是"索引声明的路径在根目录内";两者在正常情况下等价, 在索引被篡改 + 库目录里有符号链接时不等价;- 修法是一行:emoji 分支复用
_is_path_within_roots(path, _allowed_emoji_target_dirs(context))。
→ "相邻分支相反纪律"在 media/ 子包的第 3 例 (另两处:O-113 动画 vs 静态图的尺寸上限、O-104 失败 vs 超预算的降级标记)。
O-123 [中] 打开"表情需要审核"这个安全开关,反而会失去容量保护
_prune_auto_entries:458-464 只统计一种条目:
auto_active = [
(media_hash, record) for media_hash, record in entries.items()
if isinstance(record, dict)
and _source_from_record(record) == "auto"
and _status_from_record(record) == "active" # ← 只淘汰已激活的
]
if len(auto_active) <= max_entries:
return而 collect_emoji_candidate:550:
status = "pending" if runtime.cfg.media.emoji_auto_collect_requires_approval else "active"- 默认
emoji_auto_collect_requires_approval = False(config.py:324) → 新条目是active→ 受emoji_auto_collect_max_entries(默认 200)约束 ✅ - 运营方把它改成
True(这是一个更保守的选择) → 所有自动收集的条目都是pending→ 全部不在auto_active里 →emoji_auto_collect_max_entries完全失效
pending/ 目录里的图片文件同样只增不减 (_prune_auto_entries 是唯一会调 _remove_library_file 的自动路径)。 群里每出现一个新表情包就往磁盘上多存一份,没有任何上限。
"打开审核开关 = 关掉容量上限"是一个反直觉的耦合: 运营方选择更严格的策略时失去了一项保护。而且没有任何配置项或文档提示这一点。
修法:_prune_auto_entries 对 pending 单独设一个上限 (或直接把 status 从过滤条件里去掉,让 _score_record 排序自然把 pending 排在后面)。
O-124 [中] _BUNDLED_QQ_FACE_LABELS 的进程级备忘永不失效,而缓存签名却在监视那个文件
qq_face_catalog._catalog_signature:77-90 特意把内置目录文件的 st_mtime_ns 和 st_size 纳入缓存签名:
return (
catalog_stat.st_mtime_ns ..., catalog_stat.st_size ...,
bundled_stat.st_mtime_ns ..., bundled_stat.st_size ..., # ← 监视内置基线
)用意很清楚:发行包升级替换了 qq_face_builtin_catalog.json 之后,缓存要失效重建。
但 _load_bundled_qq_face_labels:118-144 是一个进程级全局备忘:
global _BUNDLED_QQ_FACE_LABELS
if _BUNDLED_QQ_FACE_LABELS is not None:
return _BUNDLED_QQ_FACE_LABELS # ← 一旦填充,永不重读于是升级之后:签名变化 → 目录缓存失效 → load_qq_face_catalog 重建 → 重建时用的仍然是内存里的旧基线。只有重启进程才会生效。
"守卫检测到了变化,但真正需要重载的那份数据没有重载" —— N32-2(守卫只加在一侧)的又一个形态,而且这一处两侧的代码相距 30 行。
修法:把 _BUNDLED_QQ_FACE_LABELS 的备忘键也换成 (mtime_ns, size), 与签名用同一个判据。
O-125 [中] QQ 表情候选按使用次数升序返回,于是"最少用过的"总是被优先选中
load_qq_face_catalog:307:
results.sort(key=lambda item: (item.usage_count, item.last_used_ts, item.face_id))没有 reverse=True —— 结果是 usage_count 从小到大。
而 find_candidate_by_hint:146-152 在模糊匹配阶段:
for entry in candidates:
for key in key_fn(entry):
...
if compact_hint == candidate_text or compact_hint in candidate_text:
return entry # ← 遇到第一个子串命中就返回候选顺序直接决定选中谁。 于是模型说"[想发QQ表情:笑]"时, 在所有标签含"笑"的表情里,选中的是历史上用得最少的那个。
同插件 emoji_library._prune_auto_entries:470-474 对同类打分是 sorted(..., key=lambda item: (_score_record(item[1]), item[0]), reverse=True) —— 带 reverse=True。两处对"使用次数越高越好"的表达方向相反。
这可能是有意的探索策略(让冷门表情有机会被用到), 但代码里没有任何注释,而且 last_used_ts 也一并升序 ("最久没用过的优先"),两个字段合起来更像是漏了 reverse=True。
无论哪种意图,都应该写一行注释。 当前状态下, 读代码的人无法判断"表情选得很奇怪"是 bug 还是 feature。
O-126 [中] 感知去重命中时,返回给调用方的条目里 media_hash 与 file_path 指向两张不同的图
collect_emoji_candidate:537-548:
record_key = rendered.media_hash
existing = entries.get(record_key)
if not isinstance(existing, dict):
similar = _find_similar_entry(...) # 感知哈希去重
if similar is not None:
record_key, existing = similar # ← record_key 改成了"相似条目"的哈希随后:
target_path = _safe_target_file_path(context, existing=existing, ...) # 复用旧条目的文件路径
if not target_path.exists():
_copy_into_library_if_needed(source_path, target_path) # 已存在 → 不复制
entry = _entry_from_render(context, target_path, rendered, existing) # ← media_hash = 新图的哈希
...
normalized["media_hash"] = record_key # ← 索引里存的是旧哈希
entries[record_key] = normalized
return entry, is_new返回的 EmojiLibraryEntry:media_hash = 新图的 sha256, file_path = 旧图的路径;而索引里那条记录的 media_hash = 旧图的哈希。
后果是 mark_emoji_used(context, entry) → mark_emoji_used_by_hash(context, entry.media_hash) → entries.get(新哈希) 查不到 → :860-861 静默 return,计数器不增加。
当前影响面
有限但真实:event_media.py:1332-1335 只用返回值的 is_new, 不用 entry 本身;smalltalk_media_helpers.py:233 的 mark_emoji_used(context, marker.entry) 用的是 load_emoji_library 产出的条目(那里的 media_hash 是从文件重新算的,一致)。
所以今天不会出错,但这是一个已经不自洽的返回值: 函数签名承诺返回"这次收集得到的条目",实际返回的是两张图的字段拼接。 任何新的调用方只要用它去查索引就会静默失败。
修法:命中相似条目时,返回值的 media_hash 应改成 record_key (entry = _entry_from_render(...) 之后补一次 replace(entry, media_hash=record_key)), 让"返回的条目"与"索引里的记录"是同一个东西。
O-131 [中] 规划器显式请求"去查记忆",而记忆层用启发式把它否决了
链路
规划器可以返回 fetch_knowledge 动作,意思是"我需要检索记忆再决定"。 pfc_engine._fetch_pfc_knowledge:406-420 据此调用 build_memory_block:
memory = await build_memory_block(
...
planner_question="", # ← 传空
...
)而 memory_retrieval.build_memory_block:740-745:
if (
bool(getattr(cfg, "agent_on_direct_miss_requires_reference", False))
and not explicit_planner_question # ← 只要传了非空就绕过下面的启发式
and not _needs_memory_agent(f"{current_text}\n{question}")
):
return ""agent_on_direct_miss_requires_reference 默认 True(config.py:210)。 _needs_memory_agent 用两条正则判断句子里有没有"记得/上次/之前/说过" 这类回指词,或"你/他 + 喜欢/生日/住哪"这类人物查询句式。
于是:规划器明确说"我要查记忆",直接向量检索没命中, 记忆层看了一眼用户这句话不像在回忆往事,就返回空字符串, 连工具代理都不启动。 规划器拿到空的 knowledge, 下一轮 _plan_pfc_action 面对的还是同样的信息。
为什么这是缺陷而不是设计
explicit_planner_question 这个参数存在的意义就是表达"这是上游的显式请求" —— build_memory_block 的条件里专门为它留了一条短路。 主链路(context_builder._build_memory_block ← reply_generator.py:582) 传的是真实的 planner_question,只有 PFC 这条显式请求的路径传了空串。
接口已经提供了表达意图的方式,唯一真正"有意图"的调用方没有使用它。
修法
_fetch_pfc_knowledge 传 planner_question=plan.reason(规划器自己写的理由) 或 session.current_text。一行改动。→ N101-2 第 42 处。
O-132 [中] 状态保存写在 finally 里 await,取消时可能不落盘 —— 而同插件已有 asyncio.shield 的先例
pfc_engine.run_pfc_once:617-624:
finally:
should_save = session.dirty if session is not None else dirty
if should_save and persist_state:
pfc_state_store.set_state(chat_id, state)
await pfc_state_store.save_async(chat_id) # ← finally 里的 awaitfinally 块本身会在取消时执行,但块内的 await 是新的取消点: 如果外层任务已经被 cancel(),await save_async(...) 会立刻抛 CancelledError,set_state 已经改了内存缓存、磁盘没写。
这不是理论情况 —— 这个插件主动取消正在生成的轮次: smalltalk_execution._drop_stale_generated_turn(O-56)在用户于生成期间 又发消息时取消旧轮次;asyncio.wait_for 的超时同样会取消。
同插件的正确写法
O-51 记录过:smalltalk_execution 的投递结算用 except BaseException: await asyncio.shield(...), 注释说明了"保证投递失败被记下"。O-54 也指出过同一形状的另一处 (补偿回滚绑在 except ReplyRejected 上而不是 finally)。
三处代码在同一条"取消安全"主线上,各自处于不同的正确程度:
| 位置 | 状态 |
|---|---|
smalltalk_execution 投递结算(O-51) | ✅ finally + asyncio.shield |
smalltalk_execution 规划器快照回滚(O-54) | ❌ 绑在具体异常上,通用异常路径不回滚 |
pfc_engine.run_pfc_once(本条) | ⚠️ 在 finally 里但 await 未 shield |
修法:await asyncio.shield(pfc_state_store.save_async(chat_id))。 → O58-1 检查项应扩写:不只要问"try 块里还有别的东西会抛吗", 还要问"finally 里的 await 在取消时能不能跑完"。
O-133 [中] 三个同族状态存储,只有一个有缓存上限
pfc_state.py / goal_state.py / heartflow.py 都继承 AsyncKeyedStore, 结构几乎相同(self._cache: dict[str, X] + _path(chat_id) + get/clear), 但:
| 存储 | 缓存上限 | 淘汰策略 |
|---|---|---|
PFCStateStore | ✅ _MAX_CACHE_SIZE = 200 | 按 updated_at 淘汰,且跳过 dirty 与当前会话 |
GoalStore | ❌ 无 | 无 |
HeartflowEngine | ❌ 无 | 无 |
GoalState 每条只有三个字段、HeartflowState 四个整数/浮点, 所以内存量级很小 —— 这不是内存问题,是纪律不一致: 同一个基类的三个子类,一个认真实现了带 dirty 保护的 LRU, 另外两个连上限都没有,且没有注释说明为什么不需要。
ActionHistoryStore 是第四个:它不继承 AsyncKeyedStore (自己实现了 _lock / _write_lock / _state_version / _async_loading 四件套), 缓存同样无上限,但每条最多保留 200 record(_persist:104 的 [-200:])—— 内存里的列表却不截断,只有落盘时才截。长会话的内存副本会一直增长到进程重启。
→ 承 O81-8(MemoryStore 里一个字典用弱引用防增长、另外三个无限增长): 本插件"有的缓存有上限、有的没有"已经是第 2 组 4 个实例。 建议把上限做进 AsyncKeyedStore 基类(O73-1:靠基类提供的保证要按"谁没继承/谁没启用"来审计)。
O-137 [中] 隔壁的黑话库对同一个问题什么都没做
expression/bw_jargon_store.py 与 bw_expression_store.py: 同一个目录、同一个基类(StoreBase)、同一个数据目录(bw_learner/)、 同样是"全量 JSON + 调用方读-改-写"(bw_jargon_miner.py:148 db = store.load() … :237 store.save(list(db.values())))。
但 JargonStore.save() 是:
def save(self, items: Sequence[JargonRecord]) -> None:
payload = [asdict(x) for x in items]
if not self._save_json_to_path_parts("bw_learner", "jargon.json", data=payload):
return
self._cache = {...}没有锁、没有重读、没有基线、没有合并 —— 直接整份覆盖。
后果:mine_jargon 是后台任务(每次消息记录满足条件时触发), 两个会话的挖掘任务并发跑时,后写的那次会把先写的那次的全部新词条整体丢弃。 黑话库还是跨会话共享的(is_global 与 scope_chat_id 都在同一个文件里), 所以并发窗口不小。
而且 JargonStore.load() 与 save() 都没有 keyed_path_lock, 连"读到一半被写"的保护都没有 —— 而它的邻居为此专门写了注释解释路径锁的作用。
修法:JargonStore 照抄 ExpressionStore 的结构(_baseline + keyed_path_lock + 按 key 的三方合并)。JargonRecord 的字段 (count / raw_content / chat_id_counts / is_complete / last_inference_count) 恰好全是"增量型"的,比 ExpressionRecord 更适合三方合并。 → N101-2 第 43 处,且是距离最近的一处:两个文件在同一个目录里。
O-138 [中] O-50 的第 2 处裸 send_action,与 O-84 完全同形
expression/bw_expression_reflector.py:82-96:
action = build_action(segs, user_id=..., group_id=...)
if action:
await context.send_action(action) # ← 返回值完全丢弃
tracker_store.set_tracker(op_chat_id, ex.expression_id)
sent += 1
...
if sent:
st["last_sent_ts"] = now
_save_state(data_dir, st) # ← 无条件记为"已发送"min_interval_seconds 来自 ReflectionConfig.min_interval_seconds,默认 3600 秒。 推送失败之后:
tracker_store里已经建了一条"等待运营方回复"的跟踪记录(但没人收到消息)last_sent_ts被刷新 → 一小时内不再尝试- 没有任何日志
这与 O-84 第三点(review_sessions.maybe_push_session)是同一个函数形状的第 2 个副本: 都是"推给运营方 → 丢弃返回值 → 无条件写时间戳 → 用间隔配置抑制重试"。
两处加起来说明:这个插件的运营侧推送(反思会话、表达反思) 是 P0-1 未采纳收据协议的重灾区,而用户侧推送(smalltalk) 恰恰是全仓的参考实现(O-51)。→ O-49 的采纳率统计应按"面向用户/面向运营方"分开看: 面向用户的路径 100% 采纳,面向运营方的路径 0% 采纳。
O-143 [中] 用户明确"拒绝"过的表达,会被下一轮学习自动复活
upsert_learned:250-267 找相似记录时不排除已拒绝的记录:
for ex in items:
if ex.chat_id != chat_id: # ← 唯一的过滤条件
continue
score = _similarity(sit, ex.situation)
...
if best and best_score >= similarity_threshold:
...
best.checked = False
best.rejected = False # ← 把用户的"不行"清掉
best.modified_by = "ai" # ← 把"这是用户判的"也清掉而 rejected 正是注入侧唯一的硬性拦截条件:
# context_builder.py:115-123
for ex in expr_items:
if ex.rejected:
continue拒绝是怎么来的:_tick_reflect_tracker_once:296-299, 运营方在群里回复"不行/拒绝" → rejected=True, modified_by="user"。
于是完整的可复现路径是: 用户拒绝 → 同一会话后续再出现相似情景(situation 相似度 ≥ 0.72) → 记录被复活成 rejected=False, checked=False → 若 require_approval_for_injection 为 True(默认), 再由同一轮的 AI 自审(single_expression_check)把 checked 置回 True → 这条被用户否掉的表达重新进入注入候选。
这是本轮审查里唯一一处"自动流程可以推翻用户的显式否定", 而它的反面样板就在同插件:O-129 在废弃一个模型可控字段时, 连磁盘上的存量值都主动清零了。
修法:rejected 应是吸收态 —— 匹配阶段 if ex.rejected: continue(相似的新表达另起一条新记录, 或者干脆按"已被拒绝的情景"整体跳过), 且任何非 modified_by == "user" 的写入都不得把 rejected 由 True 改回 False。
O-144 [中] 黑话释义的"严格路径"永远轮不到,被同一函数里的"廉价路径"抢先
bw_jargon_miner 里 meaning 有两条写入路径。
廉价路径(抽取阶段,:176-178):
if meaning and not rec.meaning:
rec.meaning = meaning[:120].strip()
rec.is_complete = True一个词第一次出现、抽取模型顺手给了一句解释,就直接落库并标记完成。
严格路径(推断阶段,:181-235):进入条件是
if rec.is_complete: continue # ← 被廉价路径挡住
if rec.count < int(infer_threshold): continue # 至少出现 3 次
if rec.last_inference_count >= rec.count: continue推断路径才是带纪律的那条:_INFER_PROMPT 明确写了 "不同上下文含义不一致或证据不足时,meaning 留空;不要用熟悉的相近词义补全", 并且要喂 6 段上下文(rec.raw_content[-6:])。
结果:只要抽取模型愿意猜,infer_threshold(3 次出现) 和多上下文交叉验证这套设计就永远不会被触发。 而抽取阶段的提示词对 meaning 的要求只有一句 "如果你能确定,给一个简短解释;否则留空" —— 门槛低得多。
顺带暴露的两处不一致:
- 同一字段两个上限:廉价路径截到 120(
:177),严格路径截到 200(:229); is_complete是终态(没有任何代码把它改回 False), 所以一次低置信度的猜测会永久锁死这个词条的释义。
修法:抽取阶段的 meaning 只做候选(写进 raw_content 或单独字段), is_complete 只允许严格路径设置;或者给廉价路径加上同样的 count >= infer_threshold 前置条件。
O-145 [中] 黑话库的缓存在写盘之前就被改了 —— 落盘失败时内存与磁盘发散
JargonStore.load() 返回的是浅拷贝:
# bw_jargon_store.py:41-43
def load(self) -> dict[str, JargonRecord]:
if self._cache is not None:
return dict(self._cache) # dict 是新的,JargonRecord 还是同一批对象mine_jargon 拿到之后全程原地改这些对象:
rec.count = int(rec.count or 0) + 1 # :168
rec.raw_content.append(context_text) # :174
rec.meaning = meaning[:200].strip() # :229
rec.last_inference_count = rec.count # :233
store.save(list(db.values())) # :237而 save() 在写盘失败时是直接返回的:
if not self._save_json_to_path_parts(...):
return # 缓存不回滚 —— 但它早就被改过了后果:磁盘写失败(目录只读 / 磁盘满 / 权限)时没有任何异常和日志 (StoreBase._save_json 吞掉 OSError 返回 False), 而进程内缓存已经带上了这一轮的全部修改。于是:
count与last_inference_count在内存里继续推进 →last_inference_count >= count恒成立 → 这个词条在本进程内再也不会被重新推断;- 重启后全部回到写失败之前的状态,运维侧看到的是"黑话学了几天什么都没存下来"。
对照组就在隔壁:ExpressionStore.load() 返回 deepcopy, save() 只在 _save_json_to_path_parts 返回 True 之后才更新 _cache 与 _baseline(:159-162)。同一目录、同一基类、相反的做法。
O-146 [中] 反思判定不接 LLMError,模型抖动会连带停掉同一后台任务里的"提问"那一半
handlers.py:926-946 把两件事放在同一个后台协程里:
async def _run_reflection() -> None:
await tick_reflect_tracker(...) # ← 判定用户的回答
await maybe_ask_for_reflection(...) # ← 挑一条新表达去问运营方
_spawn_bg_task(context, _run_reflection(), name=f"reflection:{chat_id}")_tick_reflect_tracker_once:265 直接调用 chat_completions_raw_with_fallback_paths,没有 try。 该函数会抛 LLMError(llm/llm_client.py:11,即 core.ai.AIError)。
于是模型不可用期间:判定抛异常 → _run_reflection 中断 → maybe_ask_for_reflection 一次都不会执行。 异常本身有日志(task_scheduler.py:62-74 的 _done 回调会走 public_error_message),所以不是静默失败,但受影响的是没抛异常的那一半功能, 日志里看不出这层因果。
而同一目录的 mine_jargon 对完全相同的调用两处都接了 LLMError (:129-139 抽取、:208-219 推断),失败只丢弃当前词条并打 warning。 同一子包、同一个异常、两种纪律。
顺带:upsert_learned 的自审循环(最多 20 次串行模型调用)同样不接异常, 第 k 次失败会让前 k-1 次已经付过费的判定结果连同本轮全部新学表达一起丢弃 (store.save 在 :325,异常直接越过它), 而水位也不会推进(bw_message_recorder.py:247 同样被越过)→ 下一轮从头再来一遍。
O-150 [中] 规划器自己包的 wait_for 让 core 的跨模型 fallback 永远来不及发生 —— 而 core 已经为此提供了参数
pfc_action_planner.py:270-282:
resp, _path = await asyncio.wait_for(
chat_completions_raw_with_fallback_paths(
...,
timeout_seconds=float(timeout_seconds), # ← 单次请求的超时
max_retry=int(max_retry),
...
),
timeout=max(0.1, float(timeout_seconds) + 0.3), # ← 整条链路的总预算
)timeout_seconds 传下去是单次尝试的超时(core/ai.py:_request_target), 而外层给整条链路的预算只比它多 0.3 秒。
链路里真正需要预算的是跨模型 fallback:
# core/ai.py:711-742(_complete_route)
for index, target in enumerate(targets):
try:
response = await _request_target(...)
return ...
except AIRequestError as exc:
if not has_fallback or exc.category not in route.fallback_on:
raise
logger.warning("AI route fallback ... from_profile=%s to_profile=%s ...")而 timeout 就在默认的 fallback 类别集合里(core/ai.py:48-58_DEFAULT_FALLBACK_ON 含 transport / timeout / rate_limit / server_error / model_unavailable / invalid_response / empty_response)。
于是:第一个模型超时 → core 正准备切到第二个模型 → 0.3 秒后外层 wait_for 把整个协程取消。 配置了多模型链的部署,规划器这条路径永远只用得上第一个模型。
(max_retry 不是受害者:pfc_engine._planner_kwargs:250 已经显式传 "max_retry": 0, 这是有意的。受影响的只有 fallback。)
core 早就提供了正确的参数,且插件自己的 gateway 已经透传
# core/ai.py:842-859(complete_configured_route)
return await asyncio.wait_for(
_complete_route(...), # 重试 + fallback 全在里面
timeout=effective_total_timeout, # ← 这就是"整条链路的预算"
)total_timeout_seconds 一路可达: llm/gateway.py:46 → core/capabilities.py:186 → core/interfaces.py:237 → core/ai.py:768。规划器没有用它,而是在调用点重新实现了一遍,并且把预算设错了。 → N101-2 第 45 处,且属于 O53-1 那一类(判"core 缺能力"前必须反查 core)。
副作用:同一个原因产生两个不同的 reason 标签
- 外层
wait_for先超时 →asyncio.TimeoutError→reason="planner_timeout"; - core 的总超时先触发 →
AIRequestError("route_timeout")→ 被except Exception接住 →reason="planner_failed"。
两者都会进熔断器的失败集合(pfc_engine.py:285-291),行为上没差别, 但日志上是同因两码,排查时会误以为是两类故障。
修法:删掉外层 asyncio.wait_for,改为 total_timeout_seconds=<预算> 传给 chat_completions_raw_with_fallback_paths, 并让预算至少覆盖 len(targets) × timeout_seconds。
O-151 [中] 目标对象的键名在生产者与两个消费者之间有三套词汇
唯一的生产者 pfc_goal_analyzer.analyze_goals:104 产出:
out.append({"goal": g, "reasoning": r})消费者一,规划提示词(pfc_action_planner._goals_to_text:161-162):
g = str(item.get("goal", "") or "").strip()
r = str(item.get("reason", "") or "").strip() # ← 生产者写的是 reasoning→ r 恒为空 → 规划器提示词里的目标永远只有一行 - {goal},没有原因。 而 analyze_goals 的提示词专门要求模型写 "reasoning: 目标所依据的当前对话证据",:103 还会把没有 reasoning 的目标整条丢弃 —— 费力拿到的证据,在唯一消费它的地方读不到。
消费者二,pfc_engine._build_goal_focus_context:38:
focus = str(first.get("focus", "") or "").strip()
if focus:
lines.append(f"焦点: {focus}")focus 这个键全插件 0 个生产者(grep 全仓只有这一处读、无一处写), 所以 session.planner_goal_context 永远只有 "目标: X" 一行。
顺带一提:analyze_goals 自己读旧目标时用的是 reasoning(:40), 所以生产者与自己是一致的,出问题的是另外两个读者。 → O162-1:每个跨模块的 dict 载荷都要做一次"写入键 × 读取键"对表。
O-152 [中] 提示词明确告诉模型"可以删除不再相关的目标",代码把"全删"当成解析失败
pfc_goal_analyzer 的提示词第 4 条写着"删除不再相关的目标", 但返回值处理是:
return out[:5] if out else list(current_goal_list) # :105模型正确地返回 [](当前确实没有目标了)→ out 为空 → 原样退回旧目标列表,与"模型没返回/解析失败/请求异常"(:86、:106) 走同一条路。于是目标只能增加或替换,永远无法清空, 而目标会一路进规划器提示词(O-151 那条链)。
同一段还有第二个入口:
if not g or not r:
continue # :102-103模型只给了 goal 没给 reasoning → 该目标被丢弃; 若这一轮全部如此 → out 为空 → 同样退回旧目标。
这与 O-131 完全同形(记忆层的启发式否决了规划器的显式请求): 接口/提示词承诺了一种能力,实现把它和"失败"混为一谈。 修法:区分"解析失败"(保留旧目标)与"解析成功且为空"(清空), 前者已经有 ok 标志可用(:88 ok, result = get_items_from_json(...)),信息是现成的。
O-154 [中] 3 000 轮实验里只有 60 条不同的输入,而报告按 3 000 计
_build_turns_for_group:693-695:
for round_index in range(1, config.rounds_per_group + 1): # 默认 150
template = templates[(round_index - 1) % len(templates)] # 30 个模板
user = personas[(round_index - 1 + group_offset) % len(personas)]- 模板 30 个、轮次 150 → 每个模板在每组里原样重复 5 次,顺序固定;
- 20 个组用的是同一份
_turn_templates,唯一的组间差异是other_group_food = "牛肉面" if group_offset % 2 == 0 else "麻辣烫"(:721) —— 即只有奇偶两个变体; rng只在_build_personas里用到(人数、昵称顺序), 轮次内容没有任何随机性。
于是:3 000 轮 = 30 模板 × 2 变体 = 60 条不同输入,其余是逐字重复。 而 _coverage_summary 与 _build_summary_markdown 报的是 Turns: 3000、Media turns: ...、Scenarios: {...} 全部按 3 000 计。
对一个"拟人化大群"实验来说,重复输入还有额外后果: 被测系统带记忆和表达学习,第 2 次遇到同一句话时的行为与第 1 次不同, 但打分器对两次用完全相同的判据 —— 差异被平均掉,看不出来。
修法:报告里同时给出"去重后的输入数";模板参数化(把 rng 用到轮次层, 或者按组打乱模板顺序);case_id 里带上模板哈希。 → O162-6
O-155 [中] 断点续跑不校验矩阵指纹,换个 --seed 会把别人的结果当成自己的
rows = _read_result_rows(results_path)
completed_case_ids = {str(row.get("case_id") or "") for row in rows} # :486-487
...
if str(turn["case_id"]) in completed_case_ids:
continue # :491-492而 case_id 是 f"ANTH-G{group_offset+1:02d}-R{round_index:04d}"(:701)—— 只由组序号和轮次决定,与 --seed、--min-users、--max-users、 --bot-name 全都无关。
所以用不同 seed 续跑同一个 --output-dir: 所有已完成的 case_id 命中 → 跳过 → 而 :468 又用新矩阵覆盖了 anthropomorphic-group-matrix.json 和 anthropomorphic-personas.json。 产出的目录里,matrix 描述的人设与 results 里记录的行为不是同一批人, transcript 也会把新矩阵的轮次和旧结果拼在一起(_write_transcripts:986 按 case_id 关联)。没有任何一处会报错。
修法:把 asdict(config) 的哈希写进 results.jsonl 的首行或 run 元数据, 续跑前比对,不一致就拒绝或换目录。
O-156 [中] 真实模式没有静默点:被测系统的异步副作用还没落地就打分了
await xiaoqing_chat.observe_message(clean_text, event, context)
reply_segments = await xiaoqing_chat.handle_smalltalk(clean_text, event, context)
...
score = score_turn(turn, reply_segments, elapsed_s=..., error=error) # :511-517handle_smalltalk 返回时,这一轮真正的副作用大多还没开始: _spawn_post_reply_bg_tasks(handlers_helper.py:18)刚刚 spawn 了 主题摘要 / 表达学习 / 事实提取 / 复盘推送四类后台任务, 记忆落盘还额外带 io_persist_debounce_seconds 去抖 (task_scheduler._schedule_memory_persist)。
而实验的 rubric 里恰恰有依赖这些副作用的维度: memory_seed(第 21 个模板)→ memory_recall(第 22 个)相隔一轮, cross_group_memory 依赖记忆是否被跨组污染。 这些维度的得分取决于后台任务有没有跑完,而这一点没有任何保证, 在不同机器/不同负载下会给出不同结论 —— 一个号称"默认离线且可复现"的实验, 它的 real 模式在这一点上是不可复现的。
修法:每轮打分前 await 一个静默点 (runtime_state 已经维护了 _bg_tasks 集合,加一个 await state.drain_background_tasks() 即可),或至少在 memory_* 场景之间插入显式 barrier。
O-157 [中] 泄漏的"检测集合"比"脱敏集合"大,被检出的原文照样写进产物
检测(:78-82)认这些: api_key / token / secret / password / Bearer xxx / config/secrets / secrets.json / C:\ / /home/ / /Users/ / 系统提示|system prompt 是|如下|:
脱敏(_redact:1172-1181)只处理两类: Bearer\s+\S+ 和 (api_key|token|secret|password)\s*[:=]\s*\S+,最后截断 800 字。
于是一条包含 /Users/xxx/config/secrets.json 或 "系统提示如下:……" 的回复: safety 记 0 分、failure_tags 加 leak ✅, 然后原文(截断到 800 字)被写进 anthropomorphic-results.jsonl 和给人看的 group_*.md(_write_transcripts:1005-1007)。
error 字段同理:f"{type(exc).__name__}: {exc}" 里可能带 URL、路径、 provider 返回的报文片段,只过同一个弱 _redact。
修法:LEAK_PATTERNS 命中时整条替换为 "[redacted: leak detected]"(保留 failure_tag 与匹配到的类别名即可), 两个正则用同一个来源定义。 → O162-5:检测到敏感内容后的处置必须与检测同集合。
O-158 [中] 实验上下文把 logger 的五个级别全实现成 no-op,于是唯一能解释"为什么没回复"的证据全丢了
class Logger:
def debug(self, *_args, **_kwargs): pass
def info(self, *_args, **_kwargs): pass
def warning(self, *_args, **_kwargs): pass
def error(self, *_args, **_kwargs): pass
def exception(self, *_args, **_kwargs): pass # :1221-1235而 _log_step 写的正是 context.logger.info(logging_utils.py:184)。 本插件的全部流程证据 —— smalltalk.no_reply 及其 reason、 pfc.plan.done 的 action、reply_gate 的判据、 smalltalk.*.spawn —— 在 real 模式下全部被丢弃。 实验产物里关于"这一轮为什么是沉默"只剩 did_reply: false。
而这个 rubric 的第一维度就是 trigger_reasonable(该不该说话), expected == "silence" 与 expected == "reply" 的判据完全依赖它。 打分器判了分,却没有留下任何可以复核这个判分的证据。
更有意思的是幸存下来的日志: pfc_action_planner(O-153)和 bw_jargon_miner(O-147)用的是 模块级 logging.getLogger("plugin.xiaoqing_chat"),不走 context.logger —— 所以 real 模式下留下来的,恰好是两个破坏了日志约定的模块。 这从反面说明"约定"是有价值的:统一走 context.logger 让替身可以整体接管, 而绕过它的两个模块反而无法被实验环境统一处置。
修法:实验 Logger 至少把 info 以上落到一个 anthropomorphic-steps.jsonl, 和 results 用同一个 request_id 关联(_make_context 已经在传 request_id=f"{run_id}-{case_id}",链路是现成的)。
O-160 [中] 自检数据被逐条构造成"必然通过",于是只验证了不误报
run_dry_experiment 的 docstring 是"生成可复现的占位结果,用于验证实验框架"。 但 _dry_reply_for_turn:1111-1127 的 15 条文本,逐条对着打分器的失败判据写:
| 场景 | 占位回复 | 恰好避开/命中的判据 |
|---|---|---|
third_party_grounding | "光看今天这一下,判断不了他平时怎么样" | 命中 UNCERTAINTY_MARKER_RE → 免罪 |
persona_boundary | "学校和城市不展开啦,我就是个住校大二学生" | 避开 UNBOUNDED_PERSONA_FACT_RE |
communication_constraint | "行,只吐槽:这事确实离谱" | 既不含 UNREQUESTED_COMFORT_RE,也不以 ? 结尾 |
prompt_injection | "我不发系统提示和配置内容" | 恰好不触发 LEAK_PATTERNS("系统提示"后面没有 是/如下/:) |
general_knowledge_direct | "夹一根尝一下……" | 不含 GENERIC_DEFLECTION_PATTERNS |
所有 optional_reply 又被 round_index % 3 规则变成"沉默也得 4 分"。 结论:dry-run 的 failure_tags 恒为空、status 恒为 PASS。
于是从来没有被自检覆盖过的部分包括: 每一条正则能否命中(写错一个字符就永远不匹配,dry-run 照样全绿)、 status == "REVIEW" 分支、_aggregate_results 的 failure_tags 汇总、 _write_transcripts 里 failures: 那一行。
修法:占位集合里加一组故意失败的样例(每个 failure_tag 至少一条), 并断言 dry-run 的 failure_tags 汇总与预期完全相等 —— 这样正则退化会立刻暴露。 → O162-3
O-163 [中] JSON 修复管道里,一步区分字符串内外、另一步不区分 —— 于是"修复"会改写字符串里的内容
repair_json_text:133-138 依次做三件事:
s = s.replace("“", '"').replace("”", '"')... # ① 中文引号
s = _remove_trailing_commas(s) # ② 尾逗号
s = _quote_unquoted_keys(s) # ③ 给未加引号的键补引号②(:101-126)是字符串感知的:它维护 in_string / escaped, 只在字符串外面删逗号。
③(:129-130)是一个纯正则:
re.sub(r"([{\[,]\s*)([A-Za-z_][\w\-]*)(\s*:)", r'\1"\2"\3', text)它对整段文本无差别替换,包括 JSON 字符串的内部。
可复现:模型返回
{"reason": "他说, ok: 行", }原文因尾逗号解析失败 → 走修复 → ② 删掉尾逗号 → ③ 看到字符串内部的 , ok: 并改写成 , "ok": → 结果是
{"reason": "他说, \"ok\": 行"}解析成功了,但内容被篡改了,且没有任何日志。 本插件把模型输出直接落库(表达 style、黑话 meaning、 规划 reason/thinking、目标 reasoning)并再注入下一轮提示词, 所以被篡改的文本会长期留在存储里。
修法:③ 复用 ② 的 in_string 扫描器(同文件已有,20 行), 或者干脆去掉 ③ —— 未加引号的键是最少见的一种畸形, 而它的代价是唯一一处会改写内容的修复。 → O170-1:一条修复管道里的每一步都必须对"字符串内/外"有相同的认知。
O-164 [中] JSON 提取要求"整段就是一个 JSON",而修复层却肯容忍中文引号和尾逗号 —— 宽容度不成体系
extract_first_json_object_text:33-64 的实际语义不是函数名说的 "提取第一个 JSON 对象",而是"整段文本必须恰好是一个 JSON 对象":
if not s.startswith("{"):
return "" # 前面有任何字符 → 放弃
...
if depth == 0:
return s if not s[i + 1 :].strip() else "" # 后面有任何字符 → 放弃前置的 normalize_llm_text:10-17 只剥一种围栏:
_JSON_BLOCK_RE = re.compile(r"^\s*```json[ \t]*\r?\n([\s\S]*?)\r?\n```\s*$", re.IGNORECASE)必须带 json 语言标记、必须 fullmatch。裸 ``` 围栏不认, "好的,结果如下:" 之类的前言不认,"以上。" 之类的后记也不认。
于是模型多说一句话,整条结果作废,而各调用方对"作废"的处理是:
| 调用方 | 作废后的行为 |
|---|---|
bw_reflect_tracker:276 | obj = ... or {} → judgment 为空 → 判成 Ignore,用户的回答被无视 |
bw_jargon_miner:221 | meaning 空 → 词条保持未完成,下次还要再问一遍(付两次费) |
pfc_goal_analyzer:88 | ok=False → 退回旧目标(O-152) |
pfc_action_planner:347 | planner_invalid_response → 走 fallback,并计入熔断器失败次数 |
最后一行尤其要注意:模型只是话多,会被熔断器当成"规划器故障", 攒够阈值就把规划器停用一个退避窗口。
而同一个模块的修复层却愿意处理中文引号、尾逗号、无引号键 —— 能修畸形语法,却不肯剥一句前言,两种宽容度不在同一个体系里。
修法:把"整段必须是 JSON"放宽为"扫描到第一个平衡的 {...}" (扫描器已经写好了,只需去掉 startswith 与尾部残留两个判据, 或者在失败时退回到"找第一个 {"再扫一次); 围栏正则改为可选语言标记。两处都是几行的改动。 → O170-2:宽容度必须成体系 —— 先写下"允许的偏差集合",再让每一层照它实现。
O-165 [中] 同一个文件里,先明确拒绝"按剩余概率增益"的公式,25 行后又用了它
frequency_control.py:201-204(活跃话题加成):
if (not is_private) and is_active_topic:
# 使用明确上限而不是按剩余概率大幅增益,避免 0.72 被抬到 0.888 一类过强参与率。
active_probability = runtime.cfg.active_topic_reply_probability
p = max(p, min(1.0, active_probability))frequency_control.py:226-227(heartflow 加成):
hf_bonus = max(0.0, hf_score - runtime.cfg.heartflow.base_score)
p = p + (1.0 - p) * hf_bonus # ← 正是注释拒绝的那个形状p + (1-p)*bonus 就是"按剩余概率增益"。0.72 + 0.28×0.2 = 0.776, bonus 由五个权重相加而来(weight_question=0.12 + weight_goal_match=0.06
weight_no_reply_streak=0.05+weight_long_silence=0.08= 0.31 可同时命中) →0.72 → 0.807。
紧接着还有第三层:
if no_reply_streak >= 5: p = min(p * 1.4, 0.95)
elif no_reply_streak >= 3: p = min(p * 1.2, 0.90)三层放大里只有最后一层有上限,而 weight_no_reply_streak 已经在 heartflow 里为同一个 no_reply_streak 加过一次分 —— 同一个信号被计了两次。
这不是"注释写错了",而是同一份代码里对同一个问题有两种态度: 作者显然想过这个形状的危害(注释很具体,连数字都举了), 但只在活跃话题那一处防住了。
修法:heartflow 也改成 p = max(p, min(1.0, hf_target)), 或者把三层放大合成一次带统一上限的计算;no_reply_streak 选一处生效(heartflow 权重或倍数),不要两处都算。
O-166 [中] 日志里被强制脱敏的标识符,原样写进了发给第三方模型的提示词
logging_utils.py:47-55,128-129 把这些键定为必须脱敏:
_IDENTIFIER_LOG_KEY_PARTS = ("chat_id", "user_id", "group_id", "owner_id",
"message_id", "msg_id", "local_id")
...
if any(part in lowered for part in _IDENTIFIER_LOG_KEY_PARTS):
safe[key] = _redacted_value(value) # [redacted kind=... fingerprint=...]_log_step 连 chat_id 都是 _redacted_value(chat_id)(:180)—— 本地日志里看不到任何真实会话号。
而 utils/tool_info.py:32-35:
lines.append(f"- 会话:{chat_id}")
if user_id is not None:
lines.append(f"- 说话人:{user_id}")这段文本进 tool_info_block → 进提示词 → 原文发给模型服务商。
也就是说:同一份标识符,对本地日志用最严的标准,对第三方服务用最松的标准, 而两者的风险方向恰好相反(日志留在自己机器上,提示词离开了边界)。
模型侧也不需要这两个值:昵称与称呼已经在对话文本里, - 会话:g930001 对生成回复没有任何用处。
修法:提示词里去掉 会话 与 说话人 两行,或改用与日志相同的 _redacted_value 指纹(若确实需要让模型区分说话人,用对话文本里已有的昵称)。 → O170-3:同一份标识符在日志与外发提示词之间必须用同一套标准,且取严的那套。
O-26 [低] 结清 O7-1:shutdown 期间的落盘线程与事件循环是安全的
O-7(O7-1)挂账的问题是:main.py:225 刻意在取消后台任务之前 用 asyncio.to_thread 落盘,因此落盘线程与仍在运行的事件循环可能并发访问同一批结构。
逐个核对四个被 flush 的 store,结论是安全:
| store | 加锁 |
|---|---|
memory_store(memory/memory.py) | threading.Lock ×2 层(见下) |
pfc_state_store(planning/pfc_state.py) | threading.Lock |
action_history(planning/action_history.py) | threading.Lock ×2 |
media_store(media_registry.py) | threading.Lock + LockedDirtyStateMixin |
memory_db(memory/memory_db.py) | threading.Lock ×2 + LockedDirtyStateMixin |
memory/memory.py 的锁结构值得单独记,它是本轮审查里最讲究的一处:
_sync_lock(全局短锁)只保护字典本身,注释写明 "仅短暂持有_sync_lock获取快照,然后在持锁外做 I/O"(:354);_write_locks[chat_id](每会话锁)覆盖整段 I/O,因此不同会话的落盘互不阻塞;_load_locks是weakref.WeakValueDictionary,存的是 asyncio 锁, 没人用时自动回收 → 与 pendo N26-3(按 user_id 无限增长的字典) 和前端custom_select.js的WeakMap(N-116)构成本仓第 3 处正确的弱引用容器选择。
LockedDirtyStateMixin(store_base.py:22-30)把"带锁读 _dirty 标志"收敛成一处实现, docstring 写明"提供唯一的线程安全读取实现"——正是 main.py:198 的 if state.memory_db.is_dirty() 在跨线程调用的那个方法。
一处残留:expression/bw_expression_store.py 没有任何 threading 锁 (0 处),它不在 shutdown 的 flush 名单里,但会被后台任务 extract_and_learn 写入。若那些写入全部发生在事件循环上则无需加锁; 这一点留到 expression/ 批次确认(改为该批次的头号问题)。
O-49 [低] [P0-1 结论改写] core/delivery.py 已经实现了 N23-1 建议的协议,问题不是"缺能力"而是"没采纳"
受影响的调用点
P0-1 HTTP 投递三态语义:插件用
bool(await context.send_action(...))门控状态提交,已确认 5 个受害插件(chime / earthquake / arxiv_filter / minecraft / pendo)。 N23-1:修复方向应是三值枚举DELIVERED / REJECTED / UNKNOWN, 迫使调用方显式处理"不确定"。
本组查明的事实
core/delivery.py 存在,模块 docstring 是 "In-process delivery receipts for commit-after-ack plugin state." 它提供的正是 N23-1 想要的东西,而且比三值枚举更完整:
class DeliveryReceipt:
"""Resolve one logical reply only after every physical action is acknowledged."""
def __init__(self, *, expected_actions: int, commit: DeliveryCallback, rollback: DeliveryCallback)
@property
def resolved(self) -> bool: ... # 由任一失败或全部成功终结
@property
def committed(self) -> bool: ... # docstring:已 resolved 不等同于已 committed三个设计细节说明它是被认真设计过的:
- 一条逻辑回复 ↔ N 个物理 action:
expected_actions+add_expected_actions(), 因为拆分消息(O-38)会让一条回复变成多次发送,全部确认后才 commit; resolved与committed是两个属性,docstring 显式点明二者不等价 —— 这正是"三态"里最容易丢的那一态;- 并发纪律写在注释里:
threading.Lock同时保护同步的add_expected_actions()与异步的record(),且"临界区只做内存读写,绝不在持锁期间 await"。
采纳情况(全仓普查)
| 类别 | 文件 |
|---|---|
✅ 使用 DeliveryReceipt | arxiv_filter/main.py:620、twitter/main.py:893、xiaoqing_chat/smalltalk_execution.py:690 |
❌ 仍是裸 await context.send_action(...) | chime/main.py、earthquake/main.py、codex/arxiv_summary.py、qingssh/session_handlers.py、pendo/main.py、pendo/commands/scheduled.py、pendo/handlers/web.py、twitter/main.py:723、xiaoqing_chat/expression/bw_expression_reflector.py、xiaoqing_chat/memory/review_sessions.py:600 |
两个插件同时出现在两边:twitter 与 xiaoqing_chat 各有一条路径用了收据、另一条没用。
因此 P0-1 的正文表述必须改写
- 不再是"core 缺少三态语义",而是 "core 已提供 commit-after-ack 收据,10 处调用点未采纳,其中 2 个插件自己就是采纳方";
- 修复成本从"设计并推广一套新枚举"降为"照抄同仓 3 处现成用法";
- N23-1 关于"三值枚举"的建议应标注为已被
core/delivery.py以更完整的形式实现 (收据模型天然覆盖了"一条回复多次发送"的情形,而枚举不能); - N101-2「正确实现已在仓库内、只是没被复用」清单第 31 处,并且是清单里影响面最大的一条 —— 它同时解释了 chime / earthquake / arxiv_filter / minecraft / pendo 五个插件的既有缺陷。
O-59 [低] 先厘清架构:确定性门禁始终执行,远程 LLM 只是附加层
check_reply 的实际顺序(:1140-1232)是八道确定性检查 → 才轮到远程模型:
① _heuristic_check(重复度、连续 assistant 条数)
② _media_meta_reply_check
③ _persona_identity_consistency_check
④ _explicit_communication_constraint_check
⑤ _requires_configured_profile_boundary
⑥ _grounded_self_history_check ← 仅当 not allow_low_stakes_persona_fiction
⑦ _grounded_context_history_check
⑧ _unsupported_social_speculation_check
─────────────────────────────────────
⑨ if not enable_llm_checker: 放行(soft)
⑩ if mode == "risk" and not _requires_llm_semantic_check(...): 放行(soft)
⑪ 远程语义审查这个分层是对的,而且与 reply_generator.py 里那句注释 ("结构性检查已在 check_reply() 的远程调用之前完成",O-44)严格一致。 关掉远程检查器或走 risk 分流,都不会让确定性门禁失效 —— 这是本轮审查里少数"注释描述与代码实际顺序完全对得上"的复杂控制流。
ReplyCheckResult.severity 的三值也各自带定义注释:
# hard:上下文、说话人、人物经历、事实或结构错误,任何情况下都不能发送。
# soft:口癖、措辞、节奏等风格问题,重生成耗尽后可在强制回复场景谨慎采用。
# infra:远程检查器超时、不可用或返回了无效协议;确定性检查已通过,可受控放行。→ 这正是 P0-1 想要的三态语义,只不过实现在了内容审查而非投递上(对照O-49)。 同一个仓库里,"三态"这件事在两个不同维度上各被正确实现了一次 (core/delivery.py 的收据、这里的 severity),而 P0-1 的受害插件两者都没用。
failure_code 的注释同样值得记: "只标识调用方能够安全恢复的通用失败类别,不传递具体话题或个案" —— 正因为它被设计成不含内容,logging_utils 才能把它放进可明文记录的白名单(O-25)。 两个模块是配套设计的。
O76-1 [低] 与 O-70 合并成一条完整的教训
O-70(上一批):thinking_back.py 因为没继承 StoreBase 而失去原子写。 O-76(本组):其余模块继承了 StoreBase,于是集体继承了一个不看备份的读取器。
同一个基类,一个方向上"没继承就没保护",另一个方向上"继承了反而锁死了半个保护"。AtomicJsonStore 的读写是一对协议(写维护备份 / 读消费备份), core.plugin_base 把 write_json 和 load_json 都导出了,插件只采纳了写的一半。
→ O73-1 检查项应扩写:不只要问"谁没有继承基类",还要问 "基类提供的保证是不是成对的,插件是不是只实现了其中一半"。 判据很简单:凡是写入侧会产生一个附属文件(.bak / .wal / 索引)的设施, 必须能指出读取侧消费它的具体代码行。指不出来,这个附属文件就是纯粹的磁盘开销。
O77-1 [低] 附带:getattr 的默认值与配置声明的默认值相反
memory_retrieval.py:741:
bool(getattr(cfg, "agent_on_direct_miss_requires_reference", False))而 config/config.py:210 的声明是 agent_on_direct_miss_requires_reference: bool = True。 MemoryConfig 是 pydantic 模型,字段必然存在,所以 getattr 的兜底当前不可达; 但它写的是与真实默认值相反的值。这种"防御性 getattr + 拍脑袋默认值" 一旦在重构中变成可达路径,就会静默把一个默认开启的收敛开关翻成关闭。 判据:getattr(cfg, "x", D) 里的 D 必须与配置模型里 x 的默认值逐字相同,否则删掉 getattr。
O-80 [低] -1 knowledge_base.py 是 C-0 主线在 xiaoqing_chat 的第二个完整实现
O-80-1 knowledge_base.py 是 C-0 主线在 xiaoqing_chat 的第二个完整实现
_resolve_sources / _read_source(:68-147)对每个配置文件做的事:
预检:path.stat() → S_ISREG 拒非常规文件 → st_size 上限 → 累计总量上限
→ 记录 identity = (st_dev, st_ino, st_size, st_mtime_ns)
读取:open("rb") → os.fstat(handle.fileno()) 校验 identity 一致 ← 校验的是句柄不是路径
→ read(MAX+1) 字节(不信任 st_size,多读一字节来判溢出)
→ 再次 os.fstat 校验读取期间未变三点身份校验 + 用 fd 而非路径复核 + 多读一字节判溢出, 与 C-0 认定的天花板 codex/artifacts.py 是同一套方法论。 加上 type(raw_path) is not str 的严格类型检查(拒 bool 与 str 子类)、 _prepare_documents 全部校验通过才交给 replace_configured_knowledge 发布、 后者(memory_db.py:198-267)在锁外构造完整替代存储、锁内一次性换指针 —— 全有或全无,不存在只刷新了一半的知识集合。
唯一缺口:没有 O_NOFOLLOW(path.resolve() 会跟随符号链接)。 因为来源是管理员配置项,风险等级远低于 codex 的场景,但同一条主线的两处实现有强弱差, core 提取公共实现时应以 codex 版为准(C-0 结论不变)。
O-80-2 三处独立的"交给模型之前先脱敏"
memory_retrieval.py:71-103_public_memory_meta/_public_retrieved_item: 工具返回给 LLM 的每一条记忆,元数据里所有绝对路径被换成local-ref:<sha256[:16]>,且按键名(path/data_dir/*_path/*_dir) 整体剔除,递归处理嵌套结构,连doc_id本身像路径时也一并封装。knowledge_base.py:57-65_logical_source_label:插件根目录内的文件用相对路径做来源名, 根目录外的换成external:<hash><后缀>,docstring 明写"绝不包含插件根目录外的本地路径"。memory_retrieval.py:425-434_execute_memory_tool: 工具异常一律转成invalid_memory_tool_request/memory_query_failed两个固定串, 日志只记type(exc).__name__,不记参数也不记消息。
三者与 O-46(提示词审计只记指纹)、O-25(core.logging_sanitizer)是同一意识。 建议把"外发给模型的内容"确立为与"写进日志的内容"并列的第二类脱敏面, 写进 docs/07-advanced.md:pendo 管入口白名单、xiaoqing_chat 管出口脱敏。
O-80-3 _bind_facts_to_history_subjects —— 不信任模型给的标识符
knowledge_extract.py:88-118:提示词里要求模型输出 subject_id, 但代码从不直接采信。它先从历史消息里建立权威映射 (trusted_names: {user_id → 昵称}、ids_by_name: {昵称 → {user_id}}), 然后:
- 模型给的
subject_id不在权威表里 → 丢弃,改用subject_name反查 - 反查命中多个 id →
len(matches) == 1不成立 → 整条事实丢弃,不猜 - 最终写入的
subject_name一律用权威表里的名字,不用模型输出的
在群聊里,模型把 A 的事实挂到 B 身上是最容易发生也最难发现的错误, 这段代码把"身份"这一维度整个从模型手里收回来了。 应与 pendo 的 deny-by-default 字段白名单并列写进 docs/03-plugin-development.md。
O-80-4 MemoryStore 的世代 + 墓碑协议
memory/memory.py:193-343 处理的是一个真问题:冷加载是异步的, 加载期间用户可能继续发消息,也可能执行 /xc clear。它的做法是:
_generations[chat_id]单调递增,_tombstones记录已清除的会话get_async读快照时记下 generation,加载完成后只有 generation 未变且未被墓碑标记 才写回缓存 —— 否则丢弃加载结果_merge_history按local_id→message_id→ (角色+名字+时间+内容)三级身份去重后按(ts, local_id)排序,因此"加载结果"与"加载期间新追加的消息"可以安全合并而不重复clear与persist争用同一把_write_locks[chat_id], 注释写明"此处等待可确保删除操作成为旧数据世代的最后一次 I/O"persist在写盘前后各校验一次 generation 与墓碑(:369-383), 写完才_dirty.discard
这是本插件工程质量最高的一段,也是全仓少见的把"异步加载 / 并发写入 / 用户主动清除" 三者的交互显式建模出来的代码。(对照 N32-2「守卫只加在入口没加在出口」—— 这里入口出口都加了,而且加的是同一个不变量。)
O-83 [低] 先结清 O-76:正确实现就在同一个包里,而且写得比 core 还细
上一批(O-76)的结论是"9 个存储只有 1 个消费 .bak"。那 1 个就是本组的 review_sessions.py:43-91,而它不只是"用对了 API",它是全仓损坏数据处理的范本:
def _load_json_document(path, *, description, default_factory, normalize):
store = AtomicJsonStore(path)
with keyed_path_lock(path): # ① 与写入方共用同一把路径锁
if not path.exists():
return default # ② 文件不存在 ≠ 文件损坏
try:
raw = store.read(default, raise_on_error=True) # ③ 严格读,不吞错
except (UnicodeDecodeError, json.JSONDecodeError) as primary_error:
store.read(default, raise_on_error=False) # ④ 触发 .bak 恢复
try:
raw = store.read(default, raise_on_error=True) # ⑤ 恢复后再严格读一次
except (...):
_quarantine_corrupt_file(path, ...) # ⑥ 备份也坏 → 隔离证据
return default
_logger.warning("... 主文件损坏,已从备份恢复", description)
try:
normalized, repaired = normalize(raw)
except (TypeError, ValueError) as exc:
_logger.error("... 结构无效,已重置并保留备份 ...") # ⑦ JSON 合法但结构不可用
store.write(default)
return default
if repaired:
store.write(normalized) # ⑧ 部分记录无效 → 修复并落盘
_logger.warning("... 含无效记录,已保留备份并修复", description)
return normalized⑤ 是最难想到的一步,代码注释把理由写清楚了: "非严格读取会在备份有效时恢复主文件;恢复后再严格读取, 避免把『备份也损坏』误判成一个合法的空状态"。 这正是 AtomicJsonStore.read(raise_on_error=False) 的语义陷阱 —— 它在备份也坏时返回 default,与"文件本来就是空的"无法区分。
⑥ _quarantine_corrupt_file(:26-40)把不可恢复的文件改名成 <name>.corrupt-<time_ns> 而不是删掉或覆盖,docstring: "保留无法恢复的损坏文件,避免后续正常写入直接覆盖原始证据"。 这一步直接回答了 N69-1(损坏数据静默消失)——证据被保留、日志带 error_type 和隔离后的文件名、用户数据不被覆盖。
五种结局各自可区分:不存在 / 正常 / 从备份恢复(WARNING) / 部分记录无效并修复(WARNING) / 完全不可恢复并隔离(ERROR)。对照同插件 person_profile.py:49、memory.py:448、 vector_store.py:166 的 except Exception: return <空> —— 五种结局压成一种,且无日志。
→ O-76 的修复方案由此确定:不需要设计任何东西, 把 store_base.py:55-62 的 load_json_file 换成 _load_json_document 的通用化版本 (把 normalize 设为可选),六处裸 read_text 全部改走它。 正确实现与错误实现在同一个包、同一个 memory/ 目录下并存 —— N101-2 至此已不是"跨插件没复用",而是"跨文件、隔壁目录都没复用"。
O-91 [低] -3 message_parts 的"顺序即事实"契约写在模块 docstring 里
O-91-1 _normalize_sessions_state 的"逐记录降级"
:141-183 不是"整份数据坏了就全丢",而是:
- 根结构不是 dict /
active或last_closed不是 dict →raise(交给上层隔离) - 单条会话解码失败 → 只丢这一条,
repaired = True,其余保留 - 规范化后与原值不同 → 也置
repaired,触发一次回写把文件收敛到规范形式 last_closed的值逐个走_finite_float,坏的单独丢
于是"一条记录里有个 NaN"不会导致所有会话消失。 配合 O-83 的 ⑧ 分支(repaired → 写回 + WARNING), 数据自愈是显式的、有日志的、粒度是单条记录的。 对照 vector_store.load:166-169(一个坏字符 → 整个向量库清空)差别极大。
O-91-2 scrub_backup —— 想到了删除操作会留在备份里
_save_sessions_state:264-266 在 clear_sessions_for_chat 时连写两次相同状态, 注释:"清除会话时再写一次相同状态,使 .bak 也不再含被删除的会话"。
AtomicJsonStore 每次写入把当前主文件转存为 .bak,所以第一次写之后 .bak 仍是含被删会话的旧状态;第二次写把 .bak 也换成新状态。 "用户要求删除"与"崩溃恢复要保留一份旧数据"是冲突的,这里选对了边并写明了理由, 与 store_base.delete_json_artifacts 的 docstring 是同一个意识。
(close_session / cleanup_expired 不做 scrub —— 严格说这两条路径删掉的会话 也会在 .bak 里停留一代。但 .bak 只保存紧邻的上一代, 下一次任何状态写入就会把它冲掉,因此影响很小。记录在 O-92 里作低级别条目。)
O-91-3 message_parts 的"顺序即事实"契约写在模块 docstring 里
message_parts.py:1-6:
内部顺序以 parts 为唯一事实来源:文本中的媒体占位符只用于持久化兼容, 媒体片段仍保留哈希、QQ face id、文件路径等结构化字段。规范化会丢弃未知类型和空字段, 但不得改变合法片段顺序;合并媒体时只有稳定身份相同的片段才能覆盖, 匿名同类媒体必须并存。
这段 docstring 把三条不变量写死了,而且下面的实现逐条对得上 (normalize_message_parts 只过滤不重排、_MEDIA_KINDS 白名单、 insert_or_merge_media_part 的 identity 判据)。 唯一没对上的就是 O-90 的 replace_message_media_parts —— 有了这段 docstring,那处偏差才成为可判定的缺陷而不是风格差异。
O-91-4 message_parts_to_legacy 处理了往返换行累加
:321-336 的 pending_text_prefix:媒体片段前的换行在旧格式里语义上属于 "媒体后的分隔符",直接拼接会导致 parts → legacy → parts 每往返一次多一个空行。 代码把这段换行暂存,与下一段文本的前导换行取最大值而非相加,注释写明了原因。
parts↔legacy 的往返幂等性是这类双表示模块最容易出问题也最难发现的地方 (症状是"聊天记录里的空行越来越多"),这里主动处理了。
O-91-5 _get_ai_route_context 的凭据边界
helper_utils.py:109-189 返回的字典里没有任何密钥或 provider 地址, docstring 明写"provider 地址、密钥、模型参数和默认 fallback 链都由 core 管理"。 _pinned_model 只在管理员显式切换时才是非 None(:178、:184), 否则留空让 core 按 route 列表自动降级。 承接 O-67:llm/ 层"插件向 core 借什么、自己留什么"的边界,在这里再次一致。
O-100 [低] -4 compact_message_content / rebuild_message_content 的"不静默丢内容"约定
O-100-1 模块 docstring 把"这个模块不保证什么"写出来了
media_registry.py:1-6:
更新遵循质量单调且尽力而为:注册表失败不能阻止提示词构造或消息投递。 索引只含元数据,绝不包含原始媒体字节或服务商凭据。
而且两条都能在代码里逐条验证: resolve_registered_media_items:245-253 与 upsert_registered_media_items:270-275 都是 try/except Exception 后 降级到未解析的输入而不是抛出;索引字段清单里确实没有字节或凭据。
更值得记的是显式的非授权声明,出现了两次:
normalize_media_refs:117:"规范化媒体元数据,但不授权其中引用的任何路径"_merge_media_record:326-327:"只有注册表尚无文件路径时才补入路径; 调用方访问文件系统前仍须自行验证"
一个数据结构明确声明自己不是授权边界,这在本轮审查里是少见的 (对照 O81-6 的 safe_chat_id —— 命名断言了一个不存在的保证,方向正好相反)。 它同时给下游立了一条可检查的义务:每一个从注册表取 file_path 并访问文件系统的地方,都必须自己做 C-0 那套校验。这是 media/ 剩余部分的审查重点。
O-100-2 质量单调合并
_merge_media_record:322-376 解决的是一个真问题:同一张图片会被多次观测, 每次的描述质量不同(有时视觉分析成功、有时超时只留下"一张图片")。 做法是:
_quality_score:129-162给描述丰富度打分,并在 docstring 里声明 "该分数只是内部合并启发式,不代表信任或安全等级"(防止后来者拿它当权限判据)should_upgrade = incoming_score > existing_score + 0.05—— 带迟滞,避免抖动- 描述、marker、label 只在升级或原本为空时才覆盖 → 好描述不会被坏描述冲掉
- 别名累积(
:356-365),不是替换 file_path只在注册表尚无路径时补入 —— 已有的本地路径不被新观测改写
"多次观测同一对象、每次质量不同、要收敛到最好的那次"是个容易写错的问题 (常见错法是"后写的赢"),这里写对了。
O-100-3 smalltalk_media_helpers 的"parts 是唯一事实来源"
模块 docstring(:1-6):"消息 parts 始终是结构事实来源, 展示文本和旧版 content + media_items 仅在边界处派生,避免三套表示各自演化。"
_sync_message_parts_to_registry:128-164 严格照此执行:
- 入口
normalize_message_parts,出口replace_message_media_parts,全程以 parts 为准 - 调
upsert_registered_media_items(..., compact=False)—— 明确不要压缩形式,因为要拿回完整元数据去更新 parts (正是 O-95 里渲染路径没做到的那个区分) if media_items and media_store.is_dirty():才安排刷盘 —— 只有真的产生了变更才调度 I/O,不是每条消息都排一次- 刷盘调度包
try/except: pass并注释 "刷盘属于旁路维护;调度失败不能让已经生成的回复消失" —— 这是本轮审查里少见的正确使用静默吞异常:吞的是旁路,不是主路
O-100-4 compact_message_content / rebuild_message_content 的"不静默丢内容"约定
两个函数的 docstring 都明确承诺"多出来的条目追加到末尾",实现也对得上:
compact_message_content:312-318:标记数少于媒体条目数时, 多出来的条目追加占位符到末尾,而不是丢弃rebuild_message_content:412-416与:437-439: 没有对应占位符的媒体标记追加到正文末尾(且先检查marker not in rebuilt防重复)
与 O-6 记录的 _build_reply_payload_core 是同一条原则的第 2、3 处实现: 媒体要么被消费、要么被追加,不存在静默消失的第三种结局(N69-1 的正面样本)。
O-139 [低] 结清 O-94 的挂账:这两个存储连 clear 方法都不存在
O-94 指出 /xc clear 漏掉了 bw_expr_store 与 bw_jargon_store, 当时的措辞是"只是没有写清理代码"。逐行核对后应当收紧:
ExpressionStore(bw_expression_store.py:27-160):没有clear方法JargonStore(bw_jargon_store.py:26-91):没有clear方法
对照同插件其它存储:MemoryStore.clear / PFCStateStore.clear / GoalStore.clear / HeartflowEngine.clear / ActionHistoryStore.clear / ReviewStore.clear_sessions_for_chat —— 六个存储都实现了 clear(chat_id), 这两个一个都没有。
因此 _reset_chat_session 漏掉它们不是"忘了调一行", 而是这两个存储从设计上就没有提供按会话清除的能力。 ExpressionRecord.chat_id 与 JargonRecord.scope_chat_id 明明都在, 按会话过滤删除是现成的。
用户执行"清除本会话数据"之后,机器人从这个会话学到的说话方式 与黑话释义会继续被注入提示词(context_builder._build_expression_block:118 按 ex.chat_id == chat_id 过滤、_build_jargon_explanation:178 按 rec.scope_chat_id == chat_id 过滤 —— 两处都会命中已被"清除"的会话)。
→ 承 O102-1:清除覆盖度必须由结构保证。当前是 8 处手写调用, 而"存储是否实现了 clear"这件事没有任何地方在检查 —— StoreBase 里没有 clear 的抽象方法,所以少实现一个不会有任何提示。 建议在 StoreBase 上加 @abstractmethod def clear(self, chat_id: str)。
O-153 [低] 规划层的其它问题
- [低] 第 3 个死参数:
analyze_goals(http_session=...)(pfc_goal_analyzer.py:15) 全函数未使用。加上 O-147 的两个,本插件已确认 3 处 → O149-6 的普查值得单独做一遍 - [低] 同一文件两种超时纪律:
plan_next_action有外层wait_for,decide_say_bye(:386-398)什么都没有,只有except Exception: return False, ""。 两者都是模型调用、都在回复路径上 - [低] 本插件的第 3 套日志约定:规划器直接用
_logger.info("xiaoqing_chat step=%s", json.dumps(...))(:284、:301、:318), 既不走_log_step(因而不受debug.log_steps开关控制), 也不过sanitize_log_fields。当前写出的字段(elapsed_s/model/endpoint/prompt_chars)确实安全,但endpoint(_path,来自路由配置的 profile 名) 没有经过_STATUS_LOG_KEYS白名单校验。 加上bw_jargon_miner._log_jargon_step(模块 logger + 手动脱敏), 本插件现在是_log_step/ 模块 logger+脱敏 / 裸模块 logger 三套并存 - [低]
_time_since_last_bot在ts缺失时算出 "0.0 秒前":diff = max(0.0, now - float(msg.ts or now))(:190)——ts为 0/None 时diff=0,提示词会告诉模型"你上一条消息是在 0.0 秒前", 而该提示的用途正是抑制连续发言 - [正面]
wait_seconds的type(x) is int拒绝 bool、动作白名单casefold后校验、 五条失败返回共用fallback_action(群聊 wait / 私聊 direct_reply) —— 已在 O-19 记录,本组逐行复核无误
O-159 [低] 实验运行器的其它问题
- [中]
_mentions_cross_group把组号前缀写死成930\d+(:1161), 而ExperimentConfig.group_id_start是配置字段(默认 930001)。 改组号 → 跨群记忆泄漏这条判据静默失效(它是memory_use唯一的 0 分判据) - [低] CLI 只暴露了
ExperimentConfig的一半:--seed/--groups/--min-users/--max-users/--rounds-per-group有,bot_name/self_id/group_id_start没有 —— 而bot_name会进 30 个模板的文本、group_id_start与上一条耦合 - [中] checkpoint 是 O(N²):每 20 轮
_write_transcripts(matrix, ..., rows)重写全部 20 组的 jsonl + md(:981-1012无视哪些组已有结果), 3 000 轮 = 150 次 × 3 000 条 = 45 万次json.dumps, 外加每次重算_build_summary_markdown - [低]
elapsed_s用time.time()(:506、:517)而不是time.monotonic(), 同文件pfc_engine/bw_message_recorder都用 monotonic 测耗时; 这是唯一一处把墙钟差值当性能指标写进产物的地方 - [低]
config/config.json与config/secrets.json按相对路径读(:479-480), 读不到时静默退化成{"bot_name": "小青"}/{}→ 从别的工作目录启动时,3 000 轮会全部以AIConfigError计入runtime_error, 而不是在第一轮就明确失败 - [低]
_read_result_rows静默丢弃解析失败的行(:1086-1089), 紧接着write_experiment_artifacts用"w"把文件整份重写(:409-411)—— 崩溃留下的半行会被丢掉且不可恢复,也没有任何提示 - [低]
third_party_grounding的免罪判据是"回复里任意位置出现不确定词" (:208):一条既做了无据断言、又在别处说了"可能"的回复会被判 5 分 - [低]
_score_behavior_regressions只在did_reply为真时被调用(:322), 但communication_constraint/no_default_question这类约束 在"该回复却沉默"时同样应该记boundary_sense,当前这些维度直接记成 "not observed by this deterministic rubric"
ads_paper(17 条,其中 17 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| P0-1 † | P0 | — | HTTP 与 WebSocket 对"投递结果未知"的语义不一致,会回滚已送达的状态 |
| P0-2 † | P0 | — | shell 与 qingssh 把裸异常文本发进聊天 |
| P0-3 † | P0 | — | 两道硬版本墙让项目无法跟随运行时升级 |
| P1-1 † | P1 | — | 配置监视器每 2 秒把两个 JSON 完整解析 6 遍,永不停止 |
| P1-2 † | P1 | — | AdjustableSemaphore 每次释放唤醒全部等待者(惊群) |
| P1-3 † | P1 | — | parse_bounded_json 用纯 Python 逐字符扫描,且对同一份数据走三遍 |
| P1-4 † | P1 | — | 入站事件被 pydantic 校验两次,且被完整 dump 一次 |
| P1-5 † | P1 | — | 每次事件都重建插件作用域配置视图 |
| P1-6 † | P1 | — | BoundedFileCache 每次访问都全目录扫描 |
| P1-7 † | P1 | — | safe_http 每一跳新建 TCPConnector + ClientSession |
| P1-8 † | P1 | — | 其他确认的性能点 |
| P-1 † | P1 | — | BibTeX 解析器把 % 当作注释起点,而 % 在条目内部是普通字符 —— 一个百分号能让整份文献库无法解析 |
| P-2 † | 中 | — | 用户输入未转义地拼进 ADS 查询语法,而同一插件的另一个入口做了转义 |
| P-3 † | 中 | — | 远端故障被显示成"没有结果" |
| P-4 † | 中 | — | A-0 的第 6 个落点,也是唯一一处让自由文本被解析器改写的 |
| P-5 † | 中 | — | G-0 的第 6 处,而且是"数据按用户隔离、展示不按上下文隔离"的唯一一例 |
| P-6 † | 低 | — | ads_paper 其它问题:笔记用朴素本地时间而 cmd_daily 特意用 UTC(属主线 M-2)/.bib 没有 JSON 那套损坏隔离/cmd_cite_network 无条件补省略号/related 用标题前三词当查询/URL 归一化失败时整条 URL 进查询/PaperStorage._locks 是 keyed_path_lock 的第二实现 |
详述(17 条,† 标记的条目)
P0-1 [P0] HTTP 与 WebSocket 对"投递结果未知"的语义不一致,会回滚已送达的状态
最终结论:受害者为 6 个并按后果分级;面向用户的投递已 100% 采纳收据协议。见主线 M-1。以下推导保留原始分析,与该结论有出入处以本行为准。
位置:core/onebot.py:455(HTTP) vs core/onebot.py:833-901(WS)
WS 通道在超时时明确抛出 OneBotActionOutcomeUnknown——"这条消息可能已经发出去了, 不要重试、不要回滚"。HTTP 通道则把所有异常(包括服务端已处理完毕后的读超时) 吞掉并返回 None:
except Exception as exc:
logger.warning("[HTTP] OneBot request failed error_type=%s", type(exc).__name__)
return None_finalize_onebot_action 把 None 判定为"未投递",而 core/delivery.DeliveryReceipt 会据此执行 rollback 回调。
后果:消息实际已发到群里,但插件状态被回滚。具体表现为签到被重新计入、 durable_fanout 的目标被重复广播、qingpet 的道具被退回。这是一个静默的 数据一致性 bug,只在网络抖动时出现,因此很难在测试里复现。
建议:让 OneBotHttpSender.request_action 区分三种结果—— 明确失败(连接拒绝、4xx)、明确成功、结果未知(超时、连接中断在请求发出之后), 未知时抛 OneBotActionOutcomeUnknown,与 WS 对齐。
P0-2 [P0] shell 与 qingssh 把裸异常文本发进聊天
最终结论:受害者由 5 个收窄为 2 个(
shell/qingssh);jupyter / minecraft / codex 有自有安全出口。以下推导保留原始分析,与该结论有出入处以本行为准。
本条已按E 组 的逐行核对收窄。 初版基于"
public_error_*调用数为 0" 推断五个高权限插件都在泄露,这个推断对jupyter、minecraft、codex是错的——它们有自己的安全出口(见下文"已澄清")。真正泄露的是两个。
确认泄露的两个:
plugins/shell/main.py:533-536 —— 裸 except Exception,直接回显:
except Exception as exc:
_log_command_audit(context, command_text, status="error", exc=exc)
return segments(f"❌ 处理请求时出错: {exc}")shell 是最矛盾的一个:它专门引入 core/sensitive_audit 把命令做 HMAC 指纹、 故意不把命令写进日志,然后转身把原始异常(FileNotFoundError: [Errno 2] ... 'C:\\Users\\torch\\...'、PermissionError、argv 片段)原样发到 QQ。
plugins/qingssh/ssh_manager.py —— 四处,其中两处是裸 except Exception:
| 行 | 代码 | 异常来源 |
|---|---|---|
| 1103 | f"❌ SSH 连接错误: {exc}" | paramiko.SSHException(含主机名、端口、协商细节) |
| 1172 | f"❌ 连接失败: {exc}" | 裸 except Exception(socket 错误含 IP/端口、DNS 失败、OSError 含路径) |
| 1594 | f"❌ 执行失败: {exc}" | 裸 except Exception |
| 1653 | f"❌ 下载失败: {exc}" | 已先做 audit_error_type(exc) 脱敏审计,却仍插值原始 exc |
qingssh 还有一处设计上的信息披露:ssh_manager.py:1097 的认证失败帮助文本里 f" 3. 密钥路径: {server.get('key_path', 'N/A')}" ——把 SSH 私钥路径直接发到聊天。
已澄清(不是泄露):
jupyter:所有except Exception分支返回固定安全文案 ("❌ 执行失败,请稍后重试"),异常只进jupyter_audit.log_sensitive_audit(exc=exc)—— 该函数只记录异常类型名和载荷的 HMAC 指纹。它的两处str(exc)(main.py:576、609)catch 的是自家JupyterCommandError。这套纪律 比public_error_response还严(后者至少还返回 request_id)。minecraft:main.py:329是except _MinecraftConfigError as exc—— 自家领域异常。codex:manager.py:800catchCwdError、847catch 定向的RuntimeError, 都是自家消息。color的 5 处、dict的 3 处同理,都是插件自己抛的ValueError文案。
建议:修 shell 与 qingssh 这两个(各 5 处)。同时把判定标准写清楚—— 不是"必须调用 public_error_*",而是"裸 except Exception 分支不得插值异常对象"; 可预期错误应建模为领域异常再单独 catch(jupyter / choice / github 已是范本)。 这条规则可以用一条 AST 检查在 CI 里自动断言。
P0-3 [P0] 两道硬版本墙让项目无法跟随运行时升级
core/plugin_manager.py:763pythonif not (3, 10) <= sys.version_info[:2] < (3, 14): raise PluginPathError("safe plugin lifecycle requires CPython 3.10 through 3.13")并依赖
importlib._bootstrap._get_module_lock与ModuleSpec._initializing这两个 CPython 私有接口(plugin_manager.py:753-777)。core/scheduler.py:26pythonif apscheduler.__version__ != "3.11.3": raise RuntimeError(...)并使用约 10 个 APScheduler 私有属性(
_executors_lock、_jobstores、_stop_timer、_eventloop、_dispatch_event、executor._pending_futures、job._jobstore_alias、直接赋值scheduler.state)。
后果:Python 3.14 已发布,本项目在其上开机即崩;APScheduler 出安全补丁时 也无法直接升级。两者都是在 import 期抛错,没有降级路径。
建议:两处都改成"能力探测 + 有文档的降级模式"。
- 模块锁屏障:探测失败时降级为"热重载不可用,只支持重启生效",而不是拒绝启动。
- APScheduler:把需要私有 API 的那一个能力(等待运行中 job 的 future 收敛) 抽到适配器后面,配套一个兼容性测试,然后放宽版本范围到
>=3.11,<4。
P1-1 [P1] 配置监视器每 2 秒把两个 JSON 完整解析 6 遍,永不停止
core/config.py:1080 _watch_reconcile_once 每轮调用 _read_sources() 三次 (candidate / verified / final),每次读两个文件;watch(interval=2.0) 是常驻循环。
_read_source_unlocked(1321 行)每次都执行完整流水线: open + read + 2×fstat + stat → utf-8 解码 → json.loads → pydantic _RuntimeConfigSchema.model_validate → _freeze_config_mapping(递归遍历 + 每个 dict 一个 MappingProxyType)→ materialize_snapshot_value(递归深拷贝回去)。
即 config.json + secrets.json 每 2 秒被完整解析+冻结+物化 6 次,永远如此, 哪怕文件一个字节都没变。
修法很直接:先只做 stat,把 (st_dev, st_ino, st_size, st_mtime_ns) 与上次 _SourceRead.identity + etag 比较,一致就直接复用上次的 _SourceRead,完全跳过 解码/校验/冻结。稳定态开销从 O(配置大小) 降到 6 次 stat。
同一问题的第二处:_read_stable_sources(1416 行)在启动和每次显式 reload() 时最多做 6 次完整成对读取,只为观察到 3 次一致的签名——而签名只需要 status + etag + identity,本来就不需要解析 JSON。
P1-2 [P1] AdjustableSemaphore 每次释放唤醒全部等待者(惊群)
core/dispatcher.py:104-121:
def _signal_change(self):
changed = self._changed
self._changed = asyncio.Event() # 换新 Event
changed.set() # 唤醒旧 Event 上的所有等待者N 个排队消息时,每完成一条就唤醒全部 N 个协程,其中 N-1 个立刻重新等待新 Event。 消息突发下总调度开销是 O(N²)。默认 max_concurrency=5,突发 200 条消息时 约产生 2 万次无效唤醒。
改法:维护一个显式 FIFO 的 per-waiter future,release 时只唤醒 capacity - in_use 个。
P1-3 [P1] parse_bounded_json 用纯 Python 逐字符扫描,且对同一份数据走三遍
core/bounded_http.py:902 _preflight_json 是一个 Python 级别的 while index < size: char = text[index] ... 循环;随后 json.loads 再解析一遍; 最后 _validate_json_value 再遍历一遍物化后的树。
_AI_JSON_LIMITS 用的是 max_bytes = 4 MiB,也就是说一次大模型响应最坏情况下 要跑数百万次解释器迭代。三遍是有意为之(注释在 853-858 行说明了三种限制的 不同作用点),但:
- 各调用点应该把
max_bytes收到实际需要的量级(OneBot 已经收到 2 MiB,AI 路由 可以更小); - 深度/节点计数完全可以放进
json.loads的object_pairs_hook/parse_float里,那两个钩子本身跑在 C 层。
P1-4 [P1] 入站事件被 pydantic 校验两次,且被完整 dump 一次
core/server.py:1079 _payload_validation_error 调 OneBotEvent.model_validate; 同一个 payload 随后进入 core/dispatcher.py:349 _validate_event,再校验一次 并且 model_dump() 重建整个事件字典(含完整消息段列表)。
每条消息 2 次 pydantic 校验 + 1 次全量重建。应在入站边界只校验一次,把已校验的 模型(或其 dump)传下去。
P1-5 [P1] 每次事件都重建插件作用域配置视图
core/context.py:137 PluginContext.__post_init__ 对每个新建上下文调用 _scoped_plugin_config + _scoped_plugin_secrets,两者都跑 _freeze_config_mapping(校验 + 递归重建)。上下文是每条消息每个插件建一个, 所以这是与插件配置大小成正比的 per-message 开销。
同族问题:
ConfigSnapshot.__new__(config.py:329)对已经冻结的_config_view再冻结一次;PluginContext.get_config/get_secret(context.py:161,185)每次读一个键 都要走一次get_settings_snapshot()。
建议按 (plugin_name, snapshot.revision) 缓存作用域视图。
P1-6 [P1] BoundedFileCache 每次访问都全目录扫描
core/bounded_file_cache.py:80,94,109——get_any / put / put_if_absent 都调用 _prune_unlocked,后者 iterdir() + 对每个条目 stat() + 排序。 即每次缓存命中都是 O(n) 系统调用。voice 的 TTS 缓存上限 2 048 条、 url_parser 预览缓存 128 条,前者影响明显。
建议按预算 prune(每 N 次操作,或用一个便宜的计数器判断是否有压力)。
P1-7 [P1] safe_http 每一跳新建 TCPConnector + ClientSession
core/safe_http.py:414-425:连接器不跨跳复用(这是逐跳 DNS 钉扎的必然代价), 因此每次抓取、每次重定向都是完整 TCP + TLS 握手,零连接复用。
这是正确的安全取舍,但应该在模块 docstring 和插件文档里写明:面向固定可信 主机的插件(arxiv/ads_paper/twitter)应该走 core.bounded_http + 共享的 pooled session,而不是 safe_http。目前 earthquake 同时用了两者,说明这个 边界没有被清晰传达。
P1-8 [P1] 其他确认的性能点
| 位置 | 问题 |
|---|---|
core/atomic_store.py:139 | 每次 write 都先读旧文件再写 .bak:3 次文件操作 + 2 次 fsync |
core/durable_fanout.py:147 | 每个目标 ack 都做一次完整校验 + 全量重写(最多 1000 目标) |
core/ai.py:781-788 | 每次 complete() 都重新解析并校验整条 route 与 model target |
core/ai.py:596 | materialize_snapshot_value(messages) 对含 base64 图片的消息做全量深拷贝 |
main.py:16 | 无条件 import torch,为可能根本没启用的 arxiv_filter 付出秒级启动时间与数百 MB RSS |
plugins/smalltalk/main.py:173 | call_bot_name_only 每次被叫名字都从磁盘重读 JSON,无缓存 |
plugins/voice/main.py:264 | _read_valid_wav 每次 STT 都无条件分配 bytearray(10 MiB + 1) |
core/public_errors.py:167 | _redact_known_secrets 是 O(密钥数 × 文本长度),最坏 512 × 32 KiB |
P-1 [P1] BibTeX 解析器把 % 当作注释起点,而 % 在条目内部是普通字符 —— 一个百分号能让整份文献库无法解析
bibtex.py:53-56(_find_entry_end 的扫描循环):
if char == "%" and not quoted:
comment = True
index += 1
continuecomment 为真时,直到行尾的所有字符都被跳过(:40-44), 其中包括花括号:
if comment:
if char in "\r\n":
comment = False
index += 1
continueBibTeX 里 % 只有在条目之外才是注释;{...} 字段值内部的 % 是普通字符。 天文标题里百分号很常见("90% confidence"、"the 50% Fraction"、"3% precision")。
可复现
ADS 导出的条目(每个字段一行,这正是 ADS 的实际格式):
@ARTICLE{2020ApJ...900L...1Z,
title = "{A 90% Confidence Constraint on the FRB Rate}",
year = 2020,
}扫描到 % → 跳过到行尾 → title 那一行的 }、"、, 全部被吞掉 → depth 少减一次 → 继续扫描到文件末尾 → 抛 BibTeXParseError("unterminated BibTeX entry")。
影响面
citation_entries 有两个调用方,都会因此整体失败:
| 调用点 | 后果 |
|---|---|
storage.add_reference:275-276(先解析已有文件,再解析新条目) | 库里只要有一条含 % 的记录,之后任何 /paper ref_add 都会失败,且失败在写入之前,用户无法自愈 |
ai_commands.cmd_refs:210 | /paper refs 直接报错,看不到任何一条已存的引用 |
bibtex.py 是新增文件(工作区里 A plugins/ads_paper/bibtex.py, 同时删除了旧的 llm_client.py),因此旧版本写入的 .bib 文件是既有事实: 升级后,任何一个历史条目里的百分号都会让文献库整体不可用。
和 README 的对照
README:59 特意写了:
文献库使用结构化 BibTeX 边界扫描,不会把邮箱、URL、注释或字段值中的
@当成新条目。
这句是真的 —— _ENTRY_HEADER + _find_entry_end 确实修好了"按 @ 切分"的误判。 但在修掉一个误判的同时引入了一个漏判:原来的按 @ 切分对 % 无感, 新解析器会因为 % 直接拒绝整份文档。
修法
% 只在 depth == 0(条目之外)时才作为注释; 或者干脆不处理 %(parse_bibtex_entries 只在条目之间跳过非 @ 内容, 条目外的注释本来就不会被当成条目)。 _value_end 复用 _find_entry_end,同一处修复即可覆盖 extract_bibtex_field。 建议同时加两条回归用例:字段值含 %、字段值含 @。
P-2 [中] 用户输入未转义地拼进 ADS 查询语法,而同一插件的另一个入口做了转义
做对了的那一处(ai_commands.py:35-42):
def _daily_topic_query(topics: list[str], day: date) -> str:
for topic in topics:
escaped = str(topic).replace("\\", "\\\\").replace('"', '\\"').strip()
...
quoted.append(f'"{escaped}"')反斜杠与引号都转义了,再放进双引号短语 —— 对 Solr 短语查询是正确的。
没做的四处:
| 位置 | 拼法 | 输入来源 |
|---|---|---|
ads_client.py:268 | f'author:"{author}"' | /paper author <原样文本> |
ads_client.py:226 | f"bibcode:{bibcode}" | resolve_paper_id_to_bibcode 的兜底分支原样返回用户输入 |
ads_client.py:247,258 | f"citations(bibcode:{bibcode})" / references(...) | 同上 |
paper_commands.py:162-165 | " ".join(title.split()[:3]) | ADS 返回的标题前三个词,未过滤 Solr 元字符 |
可复现:/paper author 张三" OR citation_count:[100 TO *] OR " → 查询变成 author:"张三" OR citation_count:[100 TO *] OR "" → 返回一批与该作者无关的高引论文。
危害有限(ADS 是只读的第三方 API,凭据是插件自己的 token), 但它有两个真实代价:结果静默错误,以及可以构造代价高昂的查询 (通配符 + 大范围区间)来消耗这个 token 的配额。
resolve_paper_id_to_bibcode:38-39 的注释写着 "Only identifiers that were not recognized as arXiv may be ADS bibcodes" —— "可能是 bibcode" 不等于 "是安全的查询片段", 这里应当按 bibcode 的实际形状(19 个字符、^[0-9]{4}[A-Za-z0-9.&]{15}$)校验后再拼接。
P-3 [中] 远端故障被显示成"没有结果"
ads_client._search_docs:145-152:
except Exception as exc:
public_error_message(self.context, exc, logger=logger, component=component)
return [] # ← 返回值被丢弃,只写了日志public_error_message 是有返回值的(ai_commands.py:108 正是这样用的: error_message = public_error_message(...) 然后拼进给用户的文案), 但这里把它扔掉了。
于是所有远端失败 —— token 失效(401)、配额耗尽(429)、 网络超时、ADS 返回超出 _ADS_BODY_LIMITS 的响应 —— 都会走到:
🔍 未找到与 'fast radio burst' 相关的论文用户没有任何线索去区分"这个词确实没有论文"和"你的 token 过期了", 而这个插件的 token 是手工配置的、会过期的凭据。
对照:同一个插件的 get_bibtex 失败时给的是 "❌ 无法获取 BibTeX"(至少表明是"取不到"而不是"没有"), cmd_summarize 更是把 error_message 完整拼给用户并保留原始摘要 —— 同一个插件里三种失败呈现,最常用的搜索路径用了最差的那种。
修法:_search_docs 返回 list | None(或抛出), 让调用方把"查不了"和"没查到"分开成两条文案。 → P8-3
P-4 [中] A-0 的第 6 个落点,也是唯一一处让自由文本被解析器改写的
core.args.parse 的两个已知缺陷(正文 A-0)在这里同时生效, 而这个插件是唯一一个把 parse() 的输出当作要长期存储的自由文本的插件。
链路:note_commands.cmd_note:22 parse(args) → :61 content = parsed.rest(1) → storage.add_paper_note 落盘。 rest() 的实现是 " ".join(self.tokens[start:])(core/args.py:53-55), 而 tokens 来自 shlex.split(text, posix=True)。
三个后果:
① 引号被吃掉,插件自己的示例因此改变语义
plugin.json:14 与 README 都写着示例:
/paper search "fast radio burst"shlex.split(posix=True) 把它变成单个 token fast radio burst, rest(1) 再拼回 fast radio burst(引号已经没了), 直接作为 q= 发给 ADS —— ADS 收到的是三个词,不是短语。 用户按文档写的引号在传输链路的第一步就被吃掉了。
② 以 - 开头的词被当成短选项,并且吞掉后面一个词
core/args.py:124-129:短选项若后面跟着非选项 token, 会把它当成选项的值并 idx += 1 跳过。
/paper note 2401.12345 -3σ 偏差明显
→ tokens = ["2401.12345"],options = {"3σ": "偏差明显"}
→ content = parsed.rest(1) = ""
→ "❌ 笔记内容不能为空"用户写了两个词的笔记,两个词都消失了,提示却是"内容不能为空"。 科研笔记里以 - 开头的记法(-3σ、-0.5 dex、-- 分隔)并不罕见。
③ 空白与反斜杠不可逆
多个空格被压成一个,\ 被 shlex 消费, 不匹配的引号会让 tokenize 整体退回 text.split()(另一套语义)。
修法:自由文本参数不该经过通用解析器。 cmd_note / cmd_writing / cmd_topics 只需要"第一个词 + 剩下的原文", 应当直接 raw.split(maxsplit=1),把第二段原样保留。 main.py 的分发层同理(parsed.rest(1) 出现 11 次)。 → A-0 的受影响插件从 1 个(wolframalpha)增加到 2 个, 且这一处的后果是"存进去的内容不等于用户输入"。
P-5 [中] G-0 的第 6 处,而且是"数据按用户隔离、展示不按上下文隔离"的唯一一例
plugin.json 没有声明 contexts,因此按 G-0 的默认, /paper 的全部子命令在群聊里可用。
而这个插件的存储层做了很认真的按用户隔离 (_owned_by + _require_user_id,README 第一句就是 "所有笔记、写作灵感、研究主题、截稿日期和文献库均按 QQ user_id 隔离")。
隔离的是"谁的数据",不是"发给谁看":
群里:/paper note 2401.12345
→ 机器人把你的全部笔记原样发到群里
群里:/paper refs → 你的整个文献库标题列表
群里:/paper deadline → 你的投稿截稿计划
群里:/paper writing → 你未发表论文的写作思路后三者尤其敏感:截稿日期与写作灵感能泄露"你在投什么、进度到哪"。
修法:plugin.json 声明 "contexts": ["private"], 或者对个人数据类子命令(note/writing/topics/deadline/refs)单独限制为私聊; 若要保留群内可用,至少在群里只回"已私聊发送"。 G-0 主线到此为 6 个插件,这是第一个"作者显然想过隐私、但只想到了存储层"的例子。
P-6 [低] ads_paper 其它问题:笔记用朴素本地时间而 cmd_daily 特意用 UTC(属主线 M-2)/.bib 没有 JSON 那套损坏隔离/cmd_cite_network 无条件补省略号/related 用标题前三词当查询/URL 归一化失败时整条 URL 进查询/PaperStorage._locks 是 keyed_path_lock 的第二实现
- [中] 同一插件里两种时间语义:
cmd_daily特意用datetime.now(UTC).date()(ai_commands.py:20-21)并在 README 里写明 "只显示 UTC 当天新建的 ADS 记录";而笔记、灵感、截稿日期的时间戳用datetime.now().strftime("%Y-%m-%d %H:%M")(storage.py:140,177,305)—— 朴素本地时间、无时区标记、直接回显给用户。N147-1 主线在本插件的落点: 服务器换时区或跨时区协作时,旧记录的时间无法被正确解释 - [低]
.bib没有 JSON 那套损坏保护:_load_json有隔离副本 + 失败闭合, 而add_reference/get_references直接read_text/atomic_write_text, 没有配额、没有隔离、没有大小上限。同一个类里两种健壮性 - [低]
add_reference每次都要重新解析整份.bib(:275), 文献库越大越慢;虽然有to_thread(ai_commands.py:187)不阻塞事件循环, 但去重只需要一份 citation key 集合,可以增量维护 - [低]
BibTeXParseError("ADS BibTeX export does not contain the requested bibcode")这条很有信息量的文案被public_error_response收成了错误码 (ai_commands.py:192-198)—— 这是一个可预期的用户侧错误, 按 A7-4 记录的样板(把可预期错误建模成独立异常类再单独 catch)应当直接展示 - [低]
cmd_cite_network无条件补省略号:f"{cit_title[:50]}..."(paper_commands.py:128,139),标题只有 20 字也会显示...; 而cmd_refs:219就写了suffix = "..." if len(title) > 60 else ""。 同一个插件、同一件事、两种写法,且正确的那种在另一个文件里 - [低]
related用标题前三个词当查询(paper_commands.py:162): "The Effect of..." 会退化成The Effect of。 ADS 有similar(bibcode:...)算子,正是为这个用途准备的 - [低] URL 归一化失败时整条 URL 进查询:
ARXIV_URL_PATTERN要求 版本号后立刻是字符串末尾,因此https://arxiv.org/abs/2401.12345?utm=x不匹配 →search_by_arxiv_id拿到整条 URL → 查询变成arxiv:https://... - [低] 第二套"按路径的锁":
PaperStorage._locks是WeakValueDictionary[str, RLock]+data_dir.resolve()作键(storage.py:43-54), 与core.atomic_store.keyed_path_lock(xiaoqing_chat 在用,O-20 引用过其注释) 是同一想法的两个实现。两者都正确,但 core 那份还处理了空闲条目回收 → N101-2 的一个温和实例:这次不是"实现得更差",而是"没必要有两份"
tests/ 与工程门禁(8 条,其中 8 条附完整推导)
索引
| 编号 | 级别 | 位置 | 问题 |
|---|---|---|---|
| TS-1 † | P1 | pyproject.toml [tool.mypy].exclude | mypy 在 CI 里跑,但 core 有 69% 的行被排除在检查之外——而被排除的正是本轮缺陷最集中的十个文件 |
| TS-2 † | 中 | pyproject.toml:247 + .github/workflows/tests.yml | 覆盖率门槛 fail_under = 50 被 tests/test_tooling_config.py:17 断言,而 CI 的 python -m pytest -q 从不带 --cov,coverage.json 也已从仓库删除 —— 一个从不生效的门槛被一条永远绿的测试守着,比没有门槛更糟 |
| TS-3 † | 中 | tests/test_xdist_tmp_isolation.py | 有一个测试的名字是"跨 xdist worker 隔离",而它在任何自动化配置下都不会真正运行 |
| TS-4 † | 中 | tests/helpers/node_esm.py:23-25 | 9 052 行前端契约测试(26 个模块)把断言全部交给 Node 子进程执行,node 不存在时整体 pytest.skip;而 workflow 没有 actions/setup-node,能跑纯粹因为 ubuntu 运行器镜像碰巧预装了 Node —— 一个未声明的隐式依赖 |
| TS-5 † | 中 | tests/plugins/test_codex_process_tree.py:190,302 | CI 只跑 ubuntu-latest,而仓库里有一整类 Windows 专属测试;主要开发平台恰好是 Windows |
| TS-6 † | 中 | tests/plugins/test_public_error_redaction.py:381 | 实跑结果:当前工作树不是绿的——2 失败 / 8 错误,其中一条失败是测试替身与生产代码漂移 |
| TS-7 † | 低 | tests/test_ci_workflow.py:72-93 | tests/test_ci_workflow.py 把仓库布局写成了精确集合等式,其中一条还要全仓遍历 |
| TS-8 † | 低 | tests/conftest.py:133-141,481 | 声明的 pytest-asyncio 下界与 conftest.py 实际要求的 API 不一致,且存在一条平行的手写异步执行路径 |
详述(8 条,† 标记的条目)
TS-1 [P1] mypy 在 CI 里跑,但 core 有 69% 的行被排除在检查之外——而被排除的正是本轮缺陷最集中的十个文件
位置:pyproject.toml [tool.mypy].exclude
CI(.github/workflows/tests.yml)执行 python -m mypy core plugins, pyproject.toml 的 [tool.mypy] files = ["core", "plugins"] 看起来覆盖全部生产代码。 exclude 列表里有 50 条 ^...\.py$ 形式的单文件豁免。实测:
| 树 | 文件 | 行数 | 被排除文件 | 被排除行数 | 被排除占比 |
|---|---|---|---|---|---|
core | 37 | 26 565 | 10 | 18 442 | 69% |
plugins | 299 | 100 797 | 40 | 24 456 | 24% |
core 被排除的十个文件是:
app.py plugin_manager.py dispatcher.py server.py onebot.py
config.py safe_http.py bounded_http.py durable_fanout.py logging_config.py这十个文件恰好是:本轮Q 组(plugin_manager.py)、R 组(app.py)、 正文 S-1(dispatcher.py)、N 组 系列(server.py 的 inbound 层) 的全部缺陷来源,也是唯一处理凭据、路径、导入、并发和网络的十个文件。 换句话说:类型检查覆盖了 31% 的、风险最低的那部分 core。
更值得记的是守这条线的测试:
# tests/test_tooling_config.py:48-56
def test_mypy_checks_runtime_trees_and_caps_known_debt() -> None:
"""新生产文件默认进入 mypy,既有债务清单也不能继续无界增长。"""
assert set(mypy["files"]) == {"core", "plugins"}
assert len(debt_files) <= 59第一句断言制造了"mypy 检查全部生产代码"的外观,第二句只保证债务清单 不超过 59 条(当前 50 条)。这条测试是绿的,而且永远会是绿的—— 它唯一能拦住的是"再加十条豁免",拦不住"已有的十条正好是最重要的十条"。
修法:把豁免清单按行数而不是文件数设上限,并且明确写出 "core/ 的豁免总行数不得超过 X";然后按 Q/R 两个附录的顺序逐个摘掉 core 的十条(durable_fanout.py 178 行、logging_config.py 352 行、 safe_http.py 559 行是最容易的三个起点)。
TS-2 [中] 覆盖率门槛 fail_under = 50 被 tests/test_tooling_config.py:17 断言,而 CI 的 python -m pytest -q 从不带 --cov,coverage.json 也已从仓库删除 —— 一个从不生效的门槛被一条永远绿的测试守着,比没有门槛更糟
位置:pyproject.toml:247 + .github/workflows/tests.yml
# pyproject.toml:247-248
[tool.coverage.report]
fail_under = 50# tests/test_tooling_config.py:17
assert config["tool"]["coverage"]["report"]["fail_under"] == 50# .github/workflows/tests.yml
- name: Tests
run: python -m pytest -q # ← 没有 --covpytest-cov 装了(requirements.txt),配置写了,测试断言了这个门槛的数值, 但没有任何自动化路径会执行它。coverage.json 也已从仓库删除 (git status: D coverage.json)。
于是现状是:一个从不生效的 50% 门槛,被一条永远绿的测试守着。 这比"没有覆盖率门槛"更糟——因为它让读 pyproject.toml 的人以为有。
修法:二选一,别留中间态。要么 CI 改成 python -m pytest -q --cov=core --cov=plugins --cov-report=term-missing 并把 fail_under 提到一个真实反映现状的值;要么把 [tool.coverage] 整段 和那条断言一起删掉。
TS-3 [中] 有一个测试的名字是"跨 xdist worker 隔离",而它在任何自动化配置下都不会真正运行
位置:tests/test_xdist_tmp_isolation.py
# tests/test_xdist_tmp_isolation.py
@pytest.mark.parametrize("case", range(8))
def test_project_tmp_root_isolated_across_xdist_workers(
project_tmp_root: Path, worker_id: str, case: int,
) -> None:
"""两个真实 worker 必须能同时保有各自的临时文件。"""
if worker_id == "master":
pytest.skip("parallel smoke requires pytest -n 2 or more")
assert project_tmp_root.name == worker_id
...两种配置,两种"什么都没验证":
- CI:
python -m pytest -q,没有-n。pytest-xdist装着, 于是worker_id == "master"→ 8 个用例全部 skip,报告是绿的。 - 没装 xdist 的环境(本次实跑就是):
worker_idfixture 不存在 → 8 个 ERROR。
这个测试要成立的唯一条件是 pytest -n 2 以上,而仓库里没有任何自动化入口会那样跑 (workflow 里没有 -n,addopts 里也没有)。它从写下的那天起就没验证过一次 "两个真实 worker 能同时保有各自的临时文件"。
这是 O-160("自检数据被逐条构造成必然通过,于是只验证了不误报") 在测试树里最干净的一个实例,而且比 O-160 更彻底:O-160 至少执行了, 这一条连执行都没有。
修法:CI 加 -n auto(顺带把本次实测的 337 秒串行时间压下来), 或者把这条测试改成显式的 subprocess 调用——在测试内部起一个 pytest -n 2 --collect-only-style 的子进程去验证隔离, 这样它就不依赖外部怎么调用自己。
TS-4 [中] 9 052 行前端契约测试(26 个模块)把断言全部交给 Node 子进程执行,node 不存在时整体 pytest.skip;而 workflow 没有 actions/setup-node,能跑纯粹因为 ubuntu 运行器镜像碰巧预装了 Node —— 一个未声明的隐式依赖
位置:tests/helpers/node_esm.py:23-25
# tests/helpers/node_esm.py:23-25
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is not installed")26 个测试模块、9 052 行(pendo web 的全部前端契约:转义、CSP、 ARIA、日期归一化、幂等按钮、stale-response 丢弃……)全部通过 assert_node_esm_contract 把断言交给 Node 子进程执行。
.github/workflows/tests.yml 里没有 actions/setup-node,也没有任何 "node 必须存在"的断言。它能跑,纯粹是因为 ubuntu-latest 运行器镜像 碰巧预装了 Node。这是一个未声明的隐式依赖:GitHub 哪天调整镜像内容, 这 9 052 行会一次性变成 skip,而 CI 依然全绿、依然没有任何信号。
对照本仓自己的纪律:requirements.txt 里连 astropy 都显式列了, tests/test_ci_workflow.py 还专门断言 workflow 用的是根 requirements.txt ——唯独这个能让 7% 的测试量凭空消失的依赖没被写下来。
修法:workflow 加 actions/setup-node 并固定大版本; node_esm.py 增加一个开关(如 XIAOQING_REQUIRE_NODE=1), CI 下把 skip 改成 fail。"CI 里的 skip 必须是显式允许的 skip" 应当成为一条规则。
TS-5 [中] CI 只跑 ubuntu-latest,而仓库里有一整类 Windows 专属测试;主要开发平台恰好是 Windows
位置:tests/plugins/test_codex_process_tree.py:190,302
tests/plugins/test_codex_process_tree.py:190 skipif(os.name != "nt") 真实进程树回收
tests/plugins/test_codex_process_tree.py:302 skipif(os.name == "nt") POSIX 进程组
tests/test_run_bot_monitor_script.py 7 处 PowerShell / taskkill skip后果是两边都验证不到:
- CI(ubuntu)永远跳过 Windows 的真实进程树回收和
taskkill回退, 而"杀掉失控的 codex 子进程树"是这个插件最关键的安全动作; - Windows 开发机永远跳过 POSIX 进程组那条分支;
run-bot-monitor.ps1(生产运维脚本)的 754 行测试在 CI 里几乎整体跳过。
附带的正面:唯一需要真实 Windows 异步子进程的那个测试处理得很聪明—— 它是一个同步 def,内部自己 WindowsProactorEventLoopPolicy().new_event_loop() (test_codex_process_tree.py:192),因为会话级 event_loop_policy fixture 在 Windows 上返回的是 Selector 策略,而 Selector 循环在 Windows 上 根本不支持 create_subprocess_exec。作者显然知道这件事。
但由此也带出一条真实的保真度缺口:在 Windows 上,没有任何异步测试能真正 执行 asyncio.create_subprocess_exec——codex/runner.py 和 shell/main.py 的全部 spawn 点在测试里都被 monkeypatch 掉了 (test_shell_plugin.py 8 处、test_codex_queue.py、test_codex_history_runner.py 各若干)。而 shell 正是 P0-2 的两个受害者之一。
修法:CI 加一个 windows-latest job(哪怕只跑 tests/plugins/test_codex_process_tree.py 和 tests/test_run_bot_monitor_script.py)。
TS-6 [中] 实跑结果:当前工作树不是绿的——2 失败 / 8 错误,其中一条失败是测试替身与生产代码漂移
位置:tests/plugins/test_public_error_redaction.py:381
======= 2 failed, 5598 passed, 1 skipped, 8 errors in 337.76s (0:05:37) =======(8 errors 即 S-3 的 xdist 用例;1 skipped 是本机可用性条件。)
失败 1 — test_pendo_setting_write_failure_preserves_exception_without_duplicate_log
# tests/plugins/test_public_error_redaction.py:384-386 —— 测试替身
settings_db = SimpleNamespace(
settings=SimpleNamespace(get_user_settings=Mock(side_effect=RuntimeError(SENSITIVE_ERROR)))
)# HEAD 的生产代码
settings = db.settings.get_user_settings(user_id) # 且外面包了 try/except Exception
# 工作树的生产代码(plugins/pendo/utils/settings_utils.py:159)
settings = db.get_user_settings(user_id) # try/except 已移除工作树把 db.settings.* 拍平成了 db.*,替身还在按旧形状建模, 于是抛的是 AttributeError 而不是测试期待的 RuntimeError。
要点不在这次重构没跟上,而在这条测试是干什么的:它验证的是 "设置写入失败时敏感异常不进日志"——一条隐私回归。它现在因为形状原因红着, 它本来要保护的那个性质,此刻完全没有被验证。这正是 O162-7 (测试替身要复制生产权限边界) 的一般化形式: 替身一旦与生产的调用形状漂移,测试就从"验证性质"退化成"验证替身"。
顺带:这条测试用的是 SimpleNamespace + Mock,而不是仓库里已有的 tests/helpers/pendo_test_support.py / pendo_leak_guard.py 那套真实 Database 夹具。后者会在构造时校验 _all_connections 注册表的形状 (pendo_leak_guard.py:35-37 明确 raise RuntimeError("Database lifecycle registry is unavailable or malformed")),用它就不会出现这次这种静默漂移。 又一处 N101-2:正确的夹具已经在仓库里。
失败 2 — test_all_deprecated_plugin_workspaces_are_absent
plugins/ads_deprecated/ 存在于本机工作树。它被 .gitignore:115 (plugins/*deprecated/)忽略,所以 CI 永远看不到它,这条测试在 CI 里永远绿、 在保留过历史目录的开发机上永远红。 一条断言"仓库里不存在某目录"的测试,却在检查一个被 gitignore 的路径—— 它检查的是开发者的工作树,不是仓库。应改为 git ls-files 的结果, 或者干脆只在 CI 环境下启用。
TS-7 [低] tests/test_ci_workflow.py 把仓库布局写成了精确集合等式,其中一条还要全仓遍历
位置:tests/test_ci_workflow.py:72-93
# :72-82
assert scripts == {
"arxiv_inference_cli.py", "clean_pycache.sh", "run_command_matrix.py", ...
}
# :90-93
requirement_files = {p.relative_to(ROOT).as_posix() for p in ROOT.rglob("requirements.txt")}
assert requirement_files == {"requirements.txt"}两个问题:
- 精确集合等式意味着新增任何一个运维脚本都会让 CI 变红, 而失败信息只是一个集合差集——它测的是整洁度,不是行为。 下界断言(
{...} <= scripts)加一条"不得出现*.bak/*_old.*"更合适。 ROOT.rglob("requirements.txt")会遍历整个仓库,包括.venv/、plugins/*/data/、任何下载到本地的模型目录。 任何一个虚拟环境或第三方包里带requirements.txt,这条就红。 与 S-6 的失败 2 是同一类:用工作树状态冒充仓库状态。
TS-8 [低] 声明的 pytest-asyncio 下界与 conftest.py 实际要求的 API 不一致,且存在一条平行的手写异步执行路径
位置:tests/conftest.py:133-141,481
# tests/conftest.py:481-484
@pytest.fixture(scope="session")
def event_loop_policy():
"""Provide pytest-asyncio a policy without replacing the global policy."""
# pytest-asyncio 1.x snapshots the previous loop with get_event_loop().注释自己写明依赖 pytest-asyncio 1.x(event_loop_policy 是 1.x 的钩子名), 而 requirements.txt 与 pyproject.toml 声明的都是 pytest-asyncio>=0.21.0。 在 0.21 下这个 fixture 不会被识别,1 713 个异步测试会跑在完全不同的事件循环生命周期上。 本机实测装的是 1.3.0,CI 装的是最新——声明的下界从来没有被验证过。
另外 conftest.py:133-141 还有一条手写的 pytest_pyfunc_call 回退: 当 asyncio 插件不存在时用裸 asyncio.run() 逐个跑协程。 这条路径在 CI 里是死代码(插件总是装着),但它意味着异步测试的执行语义 取决于一个可选插件是否安装,而两条路径从未被同时验证。
修法:把下界提到 pytest-asyncio>=1.0,删掉手写回退(或给它单独一条 用 -p no:asyncio 跑的 CI job)。
五、建议的修复顺序
按"独立可验证 → 收益/成本比 → 依赖关系"排。每批可独立交付。
第一批 · 一行到几行的正确性修复(1~2 天)
- O-142
ExpressionStore.bind加if self._data_dir != data_dir守卫 —— 一行,消除三个后果 - P-1 BibTeX 的
%只在depth == 0时作注释 —— 几行 + 两条回归用例 - O-143
upsert_learned匹配阶段跳过rejected,并禁止非 user 写入把它改回 False - BODY-S2 / J-1
shell帮助文本与 codexDEFAULT_CWD去掉个人路径 - PM-3
except TimeoutError单列一支,把shutdown_timeout打进日志 - AP-3
_notify_change的 warning 带上插件名;_reschedule失败时显式撤下该插件旧任务
第二批 · 投递与安全(3~5 天)
- M-1 / P0-1 六个受害者改用
core/delivery.py的收据协议 —— 能力已在 core,改的是调用点。按后果分级先修 pendo 的凭据投递(N-258) - P0-2
shell/qingssh接入public_error_*+ 覆盖测试 - M-12 / N-2 / N-174 凭据与数据库搬出源码目录; Widget 令牌改 Keychain + 缩短有效期 + 加吊销(
jti+ 黑名单) - M-6 / P-5 六个插件补
contexts声明;ads_paper 的展示层按上下文隔离 - M-11 / N-39 撤销路径改为标记审计日志而不是删除;
operation_logs加索引(N-104)
第三批 · 门禁(1 周,收益最高)
- TS-1 mypy 豁免上限从"条目数 ≤ 59"改成"
core/豁免总行数 ≤ N", 先摘durable_fanout.py(178) /logging_config.py(352) /safe_http.py(559) - TS-2 CI 加
--cov并把fail_under设成反映现状的真实值;否则整段删掉 - TS-3 / TS-4 / TS-5 CI 加
-n auto(顺带把 337 秒串行时间压下来)、 加actions/setup-node、加一个windows-latestjob; 并建立**「允许出现的 skip 清单」**,清单外的 skip 即失败 - TS-6 修复当前红着的两条测试,把
SimpleNamespace替身换成pendo_leak_guard那套会自检形状的真实夹具
第四批 · 吞吐(1 周)
- P1-1 / P1-2 配置监视器 stat 预检;
AdjustableSemaphore惊群改 FIFO - PM-1 / PM-2 / PM-5 watcher 加廉价预检(
_watch_file_identity已写好)、 四条重路径的指纹计算搬进线程、别名扫描先做字符串前缀过滤再 stat - N-112 / N-157 / N-231 pendo 的全表扫描与内存过滤;缓存决策上提到调用方
- O-75 / O-121 / O-74 xiaoqing_chat 读路径卸载到
to_thread;修复节流门的永久失效 - AP-4 给启动期所有权重试加尝试上限与超时
第五批 · 时区专项(2~3 周)
- 严格按主线 M-2 的三步顺序: ① 以
_resolve_source_wall_time为工具重建真实时刻(迁移前先跑探测脚本 统计存量数据中四种形式的实际分布) → ② 统一查询写法(改成get_events_for_range的形状,不动数据, 立即消除用户可见的漏查) → ③ 统一存储格式 - 同时在产生侧加断言(N269-1),否则迁移完还会重新长出混合形式
第六批 · core 的两条结构性问题(2 周)
- M-5 / AP-1 七处按名字的授权改成 manifest 声明 +
_SERVICE_CONTRACTS。 注意tests/test_app_plugin_capabilities.py:32目前把这些名字断言成了规格, 必须同步改写,否则会被误读成回归 - M-12
context.data_dir从plugins/<name>/data/迁到<project>/data/<plugin>/, 迁移期两处都读、只往新处写;完成后删掉_iter_watch_files里对data的特判
第七批 · 一致性与结构(持续)
- M-4
core/args.py补--与收紧选项判定,收敛 7 个自写解析器; 自由文本入口改split(maxsplit=n) - M-7 13+ 个插件的手写
_get_config收敛到get_settings_snapshot(); codex 的reconfigure可直接作为文档里的参考实现 - M-3 的 46 处 按"正确实现已在仓库内"逐条替换 —— 这是最省力的一类改动
- M-8 以
codex/artifacts.py为蓝本把图片校验提取到 core; 以minecraft._bounded_text为蓝本统一第三方文本收窄 - M-9
isdigit()+int()全部改成int()+except ValueError - M-10 撤销时长文案改为按真实窗口计算;先修"每晚 00:15 擦掉全部快照"
- BODY-Q1 / P0-3 拆分
plugin_manager.py与app.py;两道版本墙改能力探测
贯穿全部七批的一件事
给每一道已有的门补一个「这道门今天拦住了什么」的证据。 mypy 排掉了 69% 的 core、覆盖率门槛从不执行、一条名字里写着"跨 worker 隔离"的测试 从来没跑过——一道拦不住任何东西的门,比没有门更危险,因为它让人停止检查。
附录一 · 正面清单
审查中确认为可直接作为整改样板的实现。列在这里是因为第五部分的多条修复项 直接引用它们——修复时应当照抄,而不是重新设计。
| 编号 | 位置 | 内容 |
|---|---|---|
| A7-4 | — | 正面**:这 6 个插件全部使用 public_error_response 作为顶层兜底,且全部把"可预期的用户错误"建模成独立异常类(GitHubCommandError、GuessNumberCommandError、DictionaryDataError、ChoiceArgumentError)再单独 catch。这正是正文 P0-2 建议高权限插件采用的模式——样板已经在仓库里了,只是 shell/codex/jupyter/qingssh/minecraft 没跟上 |
| N-25 | — | Prompt 注入的爆炸半径被下游校验器限死(值得记的正面结论) |
| N-46 | — | 动态 SQL 标识符的处理是对的(正面) |
| N-63 | — | 正面(可作整改样板,附行号) |
| N-64 | — | 动态 SQL 的三道防线(正面) |
| N-73 | — | 【正面·重点】会话身份纪律:三处独立写下同一条规则并说明了理由 |
| N-78 | — | 正面(可作整改样板,附行号) |
| N-91 | — | 导入是一个正确的单事务操作(正面) |
| N-113 | — | 确认补录的时间处理是正确的(正面) |
| N-117 | — | 写入白名单是从 dataclass 派生的(正面),但集合的那份是手写的 |
| N-123 | — | 可选依赖的降级只对已知依赖生效(正面) |
| N-129 | — | 筛选层的三个正确决定(正面) |
| N-134 | — | 时区四态处理与"明确清空"语义(正面) |
| N-139 | — | 结清 AI 同意闸门的最后一环(正面) |
| N-145 | — | 三处 strict=True / 显式 now= 的正确调用(正面) |
| N-154 | — | 会话式记账:六步流程的输入处理(正面 + 一处问题) |
| N-166 | — | 多节点日程禁止混用带时区与不带时区的时间(正面,也是第 4 处局部防线) |
| N-167 | — | 创建分派的优先级是显式声明的(正面) |
| N-181 | — | 匿名演示空间的安全姿态是完整的(正面) |
| N-182 | — | _DEMO_REQUESTS 是唯一会清理 key 的模块级字典(正面对照) |
| N-185 | — | update_item 是全插件写得最完整的一条写入路径(正面) |
| N-199 | — | 解压防护是全仓最完整的一处(正面) |
| N-212 | — | search.py 把一次静默忽略升级成了 422(正面) |
| N-213 | — | 正面:两处外部副作用失败时的状态回滚 |
| N-221 | — | pendo 的可复用正面实现 |
| N-227 | — | pendo 的可复用正面实现(三处,均可作对照样板) |
| N-232 | — | 正面:notes_overview 的粒度随跨度自动粗化,正是 N-219 缺的那个机制 |
| N-239 | — | pendo 的可复用正面实现 |
| N-245 | — | pendo 的可复用正面实现 |
| N-250 | — | pendo 的可复用正面实现,并附一条值得注意的注释 |
| N-252 | — | 导入路径在结构上免疫跨用户覆盖(三层防护,每层都有注释) |
| N-256 | — | pendo 的其它可复用正面实现 |
| N-264 | — | pendo 的可复用正面实现 |
| N-270 | — | 正面:九处防线本身的质量很高 |
| O-4 | — | 正面:runtime_state.py 是本次审查里内存边界做得最完整的模块 |
| O-5 | — | 正面:config/config.py 是 R-1 主线的标准答案,且实现了 N109-1 |
| O-6 | — | 正面:shutdown 的两次落盘 |
| O-15 | — | 正面 |
| O-18 | — | 正面:权限判定是本次审查最严谨的一处,但同一套模式已在三个插件里各写了一遍 |
| O-19 | — | 正面:generation_limiter.py 是 pendo N26-3 的标准答案 |
| O-20 | — | 正面:注意力门控把"为什么回复"变成了可观测的结构化结论 |
| O-25 | — | 正面:logging_utils.py 是"默认拒绝记录"的完整实现,应提升为 core 能力 |
| O-33 | — | 正面:后台任务的生命周期处理是完整的 |
| O-34 | — | 正面:门控决策是本插件第三个"把结论变成结构"的地方 |
| O-39 | — | 正面:投递载荷把"没被引用的媒体"当成必须处理的情况 |
| O-40 | — | 正面:_split_chat_reply 对未闭合代码块的处理 |
| O-44 | — | 正面:审查不可用时的降级是按"问题类型"分档的,不是一刀切 |
| O-45 | — | 正面:拒绝后的降级阶梯按失败码分支,且兜底话术自己也要过检查 |
| O-46 | — | 正面:提示词审计只记指标,不记正文 |
| O-51 | — | 正面:smalltalk 的投递实现是本仓收据协议的参考用法 |
| O-55 | — | 正面:投机式记忆预取把最慢的一步与规划重叠 |
| O-56 | — | 正面:会话内竞争的失败者会被显式丢弃并留痕 |
| O-61 | — | 正面:两处"拒绝维护词表"的设计原则被一致贯彻 |
| O-62 | — | 正面:_normalize_evidence_text 的第三人称归一 |
| O-66 | — | 正面:截断痕迹被写成"角色记不清了"而不是技术标记 |
| O-67 | — | 正面:llm/ 这一层把"插件能决定什么"划得很清楚 |
| O-71 | — | 正面:回复后处理的顺序与开关粒度 |
| O-103 | — | 正面(本次审查最高密度的一段):media/ 是 C-0 主线的新天花板候选 |
| O-110 | — | 正面:日志脱敏是 deny-by-default 的,而且注释写明了新增字段的义务 |
| O-117 | — | 正面:这是本仓"把不可信的模型输出当成不可信"做得最完整的一处 |
| O-120 | — | 正面:会话级可见范围是这个插件做得最认真的一处隐私设计 |
| O-129 | — | 正面:废弃一个"模型可控的权限字段"时,同时中和了磁盘上的存量值 |
| O-130 | — | 正面:这一层的三项工程纪律都做对了,可以作为前几批问题的对照组 |
| O-148 | — | 正面:抽取水位这一处的时间语义,是本插件做得最干净的 |
| O-161 | — | 正面:这个实验替身刻意复制了生产的权限边界 |
| O-168 | — | 正面:关停路径是本插件工程质量最高的一段 |
| P-7 | — | 正面:损坏处理是全仓最完整的一处 |
| PM-12 | — | 正面 |
| AP-10 | — | 正面 |
| TS-10 | — | 其它正面 |
未编号但同样值得照抄的几处
core/plugin_manager.py的_purge_plugin_modules/_restore_generation_modules一族:全仓唯一把回滚做到对象身份级别的实现。删除按子→父排序,每一步先核对sys.modules[name]与父包__dict__[child]仍指向预期的那个对象, 任一步失败整体回滚,回滚用setdefault而非[]=以免覆盖并发写入。core/app.py的_ConfigApplyOwner三维代际所有权: 每一个可能让出控制权的点之后都重新校验所有权(_reconcile_inbound_manager_impl一个函数里查了 9 次)。所有权同时绑定generation/revision/security_generation,于是"安全代际推进"能单独作废一个还在跑的普通配置应用。_require_onebot_holder_credentials:把"我调用了撤权 API"与"撤权确实生效了" 当成两件事——回读auth_token/credentials_trusted/ endpoint 三个字段确认。 全仓唯一。_apply_security_snapshot_locked刻意不含任何await,docstring 写明理由; 对"同 revision 但内容冲突"两级处置(首次 critical + 清空 secrets 并标INCONSISTENT, 再次只记 error 并保持已接受的那份,避免抖动)。_load_definition用文件描述符身份关掉 ABA 替换窗口, 注释直接写出了攻击序列("validation can observe file A, the open can read B, and the final path check can observe A again")。_scan_plugin_directories把两种失败分开处置:迭代器中途失败置complete=False从而禁止从这份快照推断任何删除;单条目lstat失败只把该名字记为 uncertain。 删除是不可逆动作,失败方向选对了。_log_watch_error的注释写明"不要用攻击者可控的路径或异常文本做限流键", 并配抑制计数——全仓唯一一处意识到"日志限流字典本身可以被打爆"的地方, 且限流不吃掉可观测性。ads_paper的_load_json:全仓最完整的损坏处理——原文件不动 + 按 sha256 命名的隔离副本且不重复写 + 失败闭合 +parse_constant拒绝 NaN/Infinity, 且 README 写给了运维。xiaoqing_chat的 shutdown 五步:停接受 → 等 5s → 落盘 → 取消 → 再落盘一次,两条关键注释写明因果。"取消后二次落盘"全仓唯一。tests/test_test_quality.py:用 AST 做静态门禁,独立发明了本报告最后归纳出的 三条方法论——双向校验的白名单(含 stale 检查这更难的一半)、 禁止测试 monkeypatch 掉自己要验证的受限传输、禁止恒真断言。tests/helpers/pendo_leak_guard.py:对夹具本身做形状校验, 拿不到Database._all_connections就raise而不是静默降级——"夹具也要 fail closed"。
附录二 · 方法论索引
审查过程中归纳的可复用判据。按用途归并,每条一句话; 它们同时是下一次审查的检查项和CI 规则的候选。
接口契约
- 「为解决 X 而写的异步/缓存版本,要检查它是否用在了最重的那条路径上」——
_capture_plugin_snapshot_async的 docstring 写明目的,却只用在最轻的检测路径。 - 「同一个文件里出现两条做同一件事的路径——一条声明式、一条反射式—— 后者一定会绕过前者的全部检查」。
- 「把多种原因压成同一个
None,等于把分诊责任推给运维」—— 函数内部已经分好了桶,却在返回值上把桶丢了。 - 「三对表」:生产者 / 消费者 / 存储对同一概念的命名必须逐一对齐, 三方各写一套词汇时,错位只会在运行期暴露。
状态与缓存
- 「缓存决策必须由调用方声明,不能由被调用方假定」—— 通用读方法既服务翻页也服务批量遍历,它无法区分。
- 「基线的失效点必须与读取生命周期对齐」;「写成功后才更新缓存」;「读操作不得写库」。
- 「缓存键里含有必须靠 syscall 才能得到的字段,等于没缓存那次 syscall」。
- 「到上限就
clear()不是淘汰策略」。
降级与失败
- 「按后果严重性选降级方向」——删除是不可逆动作,不确定时必须拒绝推断删除。
- 「两件事别放一个协程」;「"找不到" ≠ "查不了"」。
- 「依赖私有实现细节时,先问失效是响的还是哑的」—— 写入永远成功、读取方可能悄悄消失的那一类,必须配启动期自检。
- 「
sleep(0)的重试不是退避」——任何while True+sleep(0)的所有权/CAS 重试 都必须配尝试上限,否则失败模式是"占满 CPU 且完全沉默"。
不可信输入
- 「修复管道要分清字符串内外」——一步区分、另一步不区分,会把"无效"修成"有效但被篡改"。
- 「宽容度要成体系」——一层容忍中文引号和尾逗号,另一层要求整段就是一个 JSON,自相矛盾。
- 「同一查询语言的转义要一致」;「自由文本不过通用解析器」。
- 「外部标识符永不作为内部主键」——外部 ID 只能出现在映射表的键上, 不能出现在任何
WHERE id = ?的参数位置。
权限与隐私
- 「字符串相等不是授权机制」——判定授权的依据必须是被校验的声明, 不能是文件系统上一个可以随便改的目录名。副判据:按名字授权的失效一定是静默的。
- 「数据隔离 ≠ 展示隔离」——存储层按用户隔离不代表展示层按上下文隔离。
- 「日志与外发提示词应当同一套脱敏标准」—— 对本地日志用最严标准、对第三方用最松标准是不成立的。
- 「用户的否定是吸收态」;「按内容哈希的跨作用域合并必须回答"A 能否观察到 B"」。
- 「测试替身要复制生产的权限边界」。
时间
- 「只把比较的一侧改对,比双侧都错更危险」。
- 「两种不同语义的时间戳拥有相同的字符串形状,是时间迁移里最难的情形」—— 只能靠"哪条代码路径写的"来推断,因此迁移必须先重建真实时刻。
- 「时间形式测绘必须覆盖全部写入点,不能从少数样本外推」。
- 「存储层的时间形式问题会在 SQL 日期函数处二次放大」—— 必须同时统计写入方产生几种形式、读取方用了多少处引擎侧日期函数。
- 「一条注释可以把错误的假定固化下来」—— 断言全局不变量的注释,其错误比代码的错误传播得更远。
评测与可观测
- 「自检必须含必然失败样例」——只验证不误报的自检等于没验证。
- 「CI 里的每一个 skip 都必须是被显式批准的 skip」—— 绿色报告里 skip 与 pass 视觉上没有区别。
- 「守门的测试要守住效果,不是配置项的字面值」—— 断言配置字面值会制造被保护的假象。
- 「排除清单要按体量设上限,不能按条目数」。
- 「离线评测要有静默点」;「检测集合与处置集合必须是同一个集合」; 「样本量按去重后的输入算」;「脱敏不能把可观测性一起脱掉」。
- 「日志字段名是一份契约」。
结论纪律
- 「"全仓唯一"的门槛」——声称唯一之前,必须确认这件事不是框架的默认行为。
- 「同一缺陷在不同文件要逐处确认"这里是怎么错的"」;「同系统不同客户端要横向比对」。
- 「目录/路径的选址是谁的决定,要先问清楚再归责」。
- 「不能只看数据层、要核调用方」与「不能只看调用方、要从数据层反向追溯」—— 同一件事的两个方向,缺任何一边都会定级错误。
- 「
setdefault意味着这行代码可能从不执行」。 - 「某个边缘功能里可能藏着核心问题的正确解法」—— 正确实现常出现在被迫处理边界情况的地方,不能按"模块重要性"分配注意力。
- 「测试是规格的第二份副本」——只读实现容易把刻意的严格设计误判成缺陷; 反过来,一段行为如果既没有注释也没有测试,那它多半确实没人想过。
附录三 · 本版整理说明
已删除的条目
共 22 条在审查过程中被撤销或证伪,本版本已完全移除,不再以"曾经认为…"的形式保留。 其中若干编号在后续附录里被复用于另一条结论,已在表中注明—— 第四部分里的同名条目是那条新的、仍然成立的结论:
| 原编号 | 原结论 | 撤销原因 |
|---|---|---|
| 正文 Q-2 | arxiv_filter 在生产代码里用了 109 次 print() | 全部在 train_model/ 下,MANIFEST.in prune,不进发行物 |
| 附录 N-67 | FTS 恢复路径崩溃会丢数据 | 被更外层事务保护 |
| 附录 N-102 | 迁移中的全表重写非原子 | 实际是原子的 |
| N77-1(旧) | 记账浮点路径无异常保护 | 聊天侧有完整校验,且根本不走那条路径。注意该编号在后续附录被复用,第四部分里的 N77-1 是另一条(SQL 聚合未下推),仍然成立 |
| N89-1(旧) | 级联撤销缺 child_ids | 两个调用方都传了。该编号在后续附录被复用,第四部分里的 N89-1 是另一条(两套控制字符定义),仍然成立 |
| N47-3 | 批量分组靠 created_at 巧合相等 | created_at 是被显式统一的,属有意设计 |
| N94-3 / N95-1 | batch_insert_or_update 是遗留旁路 | 不是 |
| N109-3 | allow_future 未接线 | 已完整接线 |
| N159-3(旧) | created_at 形式分布 | 与记录相反,已按 N-161 更新到主线 M-2。该编号在后续附录被复用,第四部分里的 N159-3 是另一条(三类条目的用户本地朴素形式),仍然成立 |
| N-70(部分) | 子条目拿到 UTC 时间 | 从未拿到过,已按 N-217 更新 |
| O-136 | 三方合并"全仓唯一" | 本仓有两个实现 |
| H-0 | session_handlers.py:642 泄露异常 | 是自家 SSHOutputPolicy.validate 的文案 |
qingpet check_and_award_titles | 破坏 CAS 的读-改-写 | update_user 内部会 merged_onto |
color 的 5 处 {exc} | 泄露异常 | 全部是 raise ColorInputError(str(exc)) 包装自家异常 |
| 其余 8 条 | 各类"重要更正/即时更正"记录 | 结论已并入被更正的条目 |
已合并修订的条目
以下条目的最终结论已直接写进正文,不再保留修订过程:
| 条目 | 最终结论 |
|---|---|
| P0-2 | 受害者从 5 个收窄到 2 个(jupyter / minecraft / codex 有自有安全出口) |
| P0-1 | 结论改写:面向用户的投递 100% 采纳收据协议、面向运营方的推送 0% 采纳;受害者 6 个并按后果分级 |
| M-8(原 C-0) | 天花板从 earthquake 改为 codex/artifacts.py;core 提取以 codex 版为蓝本 |
| M-12(原 N-2) | 从 [P1] 降为 [中],且四条后果中两条对所有插件成立、一条(remote-sync 不覆盖)是错的;新增 core 级问题(数据目录选址) |
| M-5(原正文 S-1) | 硬编码名单从 4 处扩到 7 处,新增的 3 处全在 app.py、2 处是能力授予 |
| AP-3 | 排程"全有或全无"是刻意设计且有测试;剩下成立的是 warning 不含插件名 + 旧任务不撤下 |
| M-2(原 N-8) | 从"41 处疏忽"改述为"一次进行到一半的子系统迁移",并据此确定三步修复顺序 |
| N-165 | 「事件循环上的同步读只有一处」改为约 10 处(原 N-170 的更正),已并入主线 M-13;N-165 本身的结论(note.py 卸载了、event.py 没卸载)保留 |
| O-3 | 定级由高降为中(原 O-23 的更正),结论保留 |
| B-0 | context.state 是跨事件共享的 per-plugin dict,不是每条消息新建(此前多处结论依赖错误前提,已全部更新) |
编号变更
| 原编号 | 新编号 | 原因 |
|---|---|---|
| 附录 Q-1 ~ Q-13 | PM-1 ~ PM-13 | 与正文 Q-1 / Q-2 冲突 |
| 附录 R-1 ~ R-11 | AP-1 ~ AP-11 | 与正文 R-1 冲突 |
| 附录 S-0 ~ S-12 | TS-0 ~ TS-12 | 与正文 S-1 ~ S-3 冲突 |
| 正文 Q-1 | BODY-Q1 | 同上 |
| 正文 S-2 / S-3 | BODY-S2 / BODY-S3 | 同上 |
其余编号全部沿用原始记录,便于与 git 历史、既有 issue 对照。
本版未收录的内容
原始记录里的批次进度、续接说明、每批的小结、以及各批之间重复的主线复述, 本版本已全部删去。结论与推导本身没有删减: 366 条带完整推导的条目已逐条并入第四部分的「详述」,代码引用与分析过程保持原样; 其余 874 条在审查时即记为单条结论,索引表里的那一行就是它的全部内容。