本版本仅保留 2025-12-05 的最新风险研判,聚焦当前仓库的静态核对结果、Todo #6 风险地图、旧项目 DB 风险复盘、后台异步任务租户上下文缺口以及新增整改建议。
-
配置代理(消息处理链路内的
with TenantContext)- 通过
GlobalConfigProxy/ModelConfigProxy将__getattr__全量 hook,除基础类型以外的属性访问都先查租户配置,再回退到默认配置。 - 在消息处理
with tenant_context(chat_id, tenant_id)中引用这些代理,LLMRequest/manager 初始化依旧使用原路径但会在访问阶段获取正确租户值。 - 风险:仍有 6 个基本类型在
__init__里被固化,需要懒加载或延迟求值才能真正享受代理。
- 通过
-
chat_id 加租户前缀
- 把所有以 chat_id 为 key 的缓存、文件、任务命名统一改成
f"{tenant_id}:{chat_id}",确保单个租户的消息/状态不会与其他租户碰撞。 - 适用模块:情绪、频控、Heartflow、Replyer、Hippo 缓存、全局公告等纯内存/文件缓存。
- 风险:ChatManager 本体仍用
MD5(platform+group)生成 key;在未实现TenantChatManager前,任何后台任务都会读写共享字典。
- 把所有以 chat_id 为 key 的缓存、文件、任务命名统一改成
-
ORM 层自动添加
tenant_id- 在消息上下文中设置 TenantContext 后,
TenantModel/TenantPeeweeDatabase能在每次select/update/insert时自动附带tenant_id,实现“同表多租”。 - 主要依赖:模型补齐
tenant_id字段、索引与默认值,并确保请求/任务运行期间上下文未被中途覆盖。 - 风险:批量后台任务如果在无上下文环境运行(当前实际情况),仍会全表扫描;而文件系统/缓存一类资源与 ORM 无关,仍需其他手段。
- 在消息上下文中设置 TenantContext 后,
-
(新增)per-tenant AsyncTaskManager
- 不修改具体任务逻辑,仅在
AsyncTaskManager内把每个任务拆成“每租户一个协程”,通过asyncio.gather并发执行,并为每个协程包一层async with tenant_context(tenant_id)。 - 优点:任何已经迁移到
TenantModel的纯数据库型任务都能自动获得正确租户过滤,无需触碰任务代码。 - 风险:对仍依赖共享缓存/文件/全局 key 的任务无能为力——如统计 HTML、Emoji 目录、心跳、ChatManager 自动保存 —— 因为这些任务的副作用发生在上下文之外。
- 不修改具体任务逻辑,仅在
- 总体结论: 代码中尚未接入 GlobalConfigProxy / ModelConfigProxy、ChatStream 租户代理与 TenantModel,现状仍是单租户实现。
- 代表性问题:
src/config/config.py在导入阶段即加载配置,任何读取都会被单例固化。src/person_info/person_info.py、src/chat/emoji_system/emoji_manager.py、src/express/expression_learner.py在__init__阶段固化基本类型值,需懒加载。src/chat/message_receive/chat_stream.py/chat_manager.py缺少TenantChatManager,chat_id 仍是全局命名空间。src/common/database/database_model.py中 Peewee 模型没有tenant_id,数据库访问无法自动隔离。
- 额外风险:
- 插件通过
plugin_manager.load_all_plugins()静态加载,无法按租户切换。 - LLMRequest 直接持有
model_config的 TaskConfig 实例,未被代理时所有租户共用同一份温度/最大 token 等配置。
- 插件通过
- 优先级建议: 先改造
global_config/model_config为代理并清理__init__固化,再着手 ChatStream/数据库的租户化,实现上下游一致的 TenantContext。
| 模块 | 现状风险 | 整改思路 | 备注 |
|---|---|---|---|
src/person_info/person_info.py |
模块级 relation_selection_model 及 PersonInfoManager 在导入时一次性绑定 model_config,LLM 请求始终使用首个租户配置;Person 初始化时也会把 bot 配置写进实例,跨租户复用。 |
提供 build_relation_selection_model()/get_person_info_manager() 等懒加载工厂;PersonInfoManager.qv_name_llm 改为按需创建;对 bot 自身属性改为实时读取 global_config.bot.*。 |
模块其余逻辑已通过 global_config 动态访问,不需大改。 |
src/chat/emoji_system/emoji_manager.py |
单例 EmojiManager 在构造函数里缓存 LLMRequest、emoji_num_max 等基本类型,导致后续租户无法覆盖;目录扫描、注册任务都读取这些缓存。 |
引入只读属性/方法(例如 _current_emoji_config、vlm_request)替换成员变量;所有调用点改为实时取值;为将来拆分 per-tenant 目录预留注释。 |
与目录/缓存串租问题解耦,此处先确保运行时代理生效。 |
src/express/expression_learner.py |
每个聊天学习器在 __init__ 时缓存 enable_learning、learning_intensity、三个 LLMRequest;学习循环长期运行不会刷新配置。 |
新增 _resolve_expression_config() 在 should_trigger_learning、learn_expression 里动态计算;为三个 LLM 请求提供轻量工厂方法,按调用即时创建;阈值改为临时变量而非实例属性。 |
与 chat_id 前缀、磁盘路径整改互相独立,可并行推进。 |
- 实施顺序:先完成上述三个模块的懒加载改造,再逐一复测 Emoji 注册、人物信息命名、表达学习链路,确认不同租户在同进程内互不干扰。验证完成后在本文件更新状态。
src/person_info/person_info.py:移除了模块级LLMRequest单例,改用工厂函数按需创建;PersonInfoManager通过懒加载代理导出,qv_person_name在每次调用时重新构造模型;Person的昵称/人名属性改为属性函数,机器人身份实时透传global_config.bot.*。src/chat/emoji_system/emoji_manager.py:EmojiManager不再缓存 VLM/LLM 请求或配置阈值,改用私有方法/属性实时读取;表情替换与描述生成流程注入局部模型实例,容量上限通过属性读取最新的租户配置。src/express/expression_learner.py:删除所有构造期缓存,新增模型工厂与_resolve_learning_thresholds,确保学习开关、强度与请求模型均在运行时读取;should_trigger_learning、learn_expression、_summarize_situations已按照新的入口调用。
下表记录仍依赖 chat_id 或全局单例的模块,并给出是否可通过 chat_id 前缀、数据库 TenantModel、或更高层改造来化解风险。
| 模块/文件 | 共享状态 | 风险 | 分类 | chat_id 加租户前缀 | DB 层策略(TenantModel / Proxy) | 推荐策略 |
|---|---|---|---|---|---|---|
src/express/expression_selector.py |
模块级单例 expression_selector,仅缓存 LLMRequest |
初始化时读取 model_config,若未被代理则固化为单租户配置 |
代理可解 | ✖(问题与 chat_id 无关) | —(仅读配置,不触库,DB 代理无意义) | 在加载该模块前确保 GlobalConfigProxy / ModelConfigProxy 已替换原始实例 |
src/mood/mood_manager.py |
全局 mood_manager.mood_list 与 MoodRegressionTask |
所有租户共享 ChatMood 列表,情绪/回归状态串扰 |
结构性 | ✅(前缀化 stream_id 即可隔离) | —(纯内存,不落库,无法靠 DB 隔离) | 借助 TenantChatManager 输出 tenant:chat key,并为 async_task_manager 注入上下文 |
src/chat/frequency_control/frequency_control.py |
frequency_control_manager.frequency_control_dict |
talk_frequency_adjust / last_frequency_adjust_time 以 chat_id 为 key 复用 |
结构性 | ✅(key 改为 tenant:chat) |
—(计数保存在内存,DB 代理接不住) | 推出 TenantFrequencyControlManager,所有缓存键添加租户前缀 |
src/express/expression_learner.py |
expression_learner_manager.expression_learners + data/expression 目录 |
学习缓存、锁及落盘文件按 chat_id 共享,易造成跨租户学习 | 结构性 | ✅(内存/目录按 tenant:chat 区分) |
✅ Expression/ExpressionDraft 表迁移到 TenantModel 后,代理可自动注入 tenant_id;仍需处理文件系统 | 字典、磁盘路径改前缀,同时让 Expression 继承 TenantModel |
src/express/expression_reflector.py & src/express/reflect_tracker.py |
expression_reflector_manager.reflectors、reflect_tracker_manager.trackers |
反思频控和 Tracker 任务互相阻塞 | 结构性 | ✅(key 改为 tenant:chat) |
✅ Expression / Tracker 相关表使用 TenantModel 后,代理可自动过滤;需业务层传入租户上下文 | 统一改为 (tenant, chat) 级管理器,并同步改造相关 ORM |
src/chat/replyer/replyer_manager.py & src/plugin_system/core/tool_use.py |
replyer_manager._repliers、ToolExecutor.tool_cache |
Replyer/ToolExecutor 复用 stream_id,缓存命中跨租户复用工具结果 | 结构性 | ✅(tenant:stream key 去除串扰) |
—(不访问数据库,代理无用) | 依赖 ChatStream 代理输出带租户的 stream_id 或封装 TenantReplyerManager |
src/chat/heart_flow/heartflow.py & src/chat/brain_chat/brain_chat.py |
heartflow.heartflow_chat_list 缓存 HeartFChatting/BrainChatting |
心流实例共享 mute 状态、循环上下文,租户之间不可互见 | 结构性 | ✅(chat_id 前缀可隔离实例缓存) | —(只存内存,DB 无法介入) | Heartflow 顶层按 tenant 分桶,再在桶内缓存 chat_id |
src/hippo_memorizer/chat_history_summarizer.py |
HIPPO_CACHE_DIR/<chat_id>.json |
相同 chat_id 的不同租户互相覆盖话题摘要 | 结构性 | ✅(前缀化 chat_id 或独立目录) | —(命中文件系统,DB 不参与) | 缓存路径改为 data/hippo_cache/<tenant>/<chat>.json 或加载时添加租户前缀 |
src/jargon/jargon_miner.py |
JargonMinerManager._miners + OrderedDict 缓存 |
黑话缓存、异步任务与 Peewee 查询都未带租户 | 结构性 | ✅(manager key 扩展为 tenant:chat) |
✅ Jargon/JargonInference 迁移为 TenantModel 后,代理可据上下文追加 tenant 条件 |
缓存与数据库同步引入 tenant_id,所有查询走租户上下文 |
src/chat/utils/utils_image.py |
ImageManager 单例 + data/image |
图片/VLM 缓存与 Images 表共用目录/记录,易串租户 |
结构性 | ✖(不依赖 chat_id) | 建立租户感知的 ImageManager,目录/ORM 均写入 tenant_id |
|
src/plugin_system/core/global_announcement_manager.py |
_user_disabled_* 字典 |
某租户禁用动作影响同 chat_id 的其他租户 | 结构性 | ✅(chat_id 前缀可隔离禁用列表) | —(状态全在内存,DB 未介入) | 字典 key 扩展为 (tenant, chat),必要时将状态迁移到带 tenant_id 的表 |
src/chat/utils/memory_forget_task.py |
四阶段批量删除 ChatHistory |
定时任务遍历整表,未限制租户,直接清理所有记忆 | 结构性 | ✖(整表扫描,前缀无效) | ✅ ChatHistory 变为 TenantModel 后代理可自动追加租户过滤,但任务仍要在执行前设置租户上下文 |
先为 ChatHistory 添加 tenant_id 并强制 ORM where,再重启任务 |
src/memory_system/memory_retrieval.py |
_cleanup_stale_not_found_thinking_back 批量清理 ThinkingBack |
“未找到答案”记录按时间删除,无租户条件 | 结构性 | ✖(未按 chat_id 查询) | ✅ ThinkingBack 使用 TenantModel 后代理会自动插入租户 where,但清理脚本必须运行在正确上下文 |
将 ThinkingBack 迁移到 TenantModel,并在 DB 代理中注入租户过滤 |
src/dream/dream_agent.py |
工具 API 直接按 id 操作 ChatHistory |
LLM 可跨租户读取/修改/删除记忆 | 结构性 | ✖(操作主键无 chat_id) | ✅ 需依赖 TenantModel,否则代理无法识别租户 | ORM 级别强制租户过滤,并在工具层校验 chat_id 属于当租户 |
src/webui/jargon_routes.py |
WebUI 黑话管理 API 按 id 操作 |
WebUI 可跨租户查看/删除/更新黑话 | 结构性 | ✖(接口与 chat_id 脱钩) | ✅ 一旦 Jargon 表是 TenantModel,DB 代理就能根据请求上下文过滤;关键在于路由传递 tenant_id |
WebUI 请求需带租户上下文,所有 ORM 查询追加租户条件 |
src/webui/expression_routes.py |
WebUI 表达管理 API | WebUI 批量操作遍历整个 Expression 表 |
结构性 | ✖(接口仅按 id 操作) | ✅ 同理,让 Expression 继承 TenantModel 后代理可隔离;需要 WebUI 将 tenant_id 注入 ORM |
API 层校验租户,ORM 自动追加 tenant 过滤 |
src/webui/chat_routes.py |
WebUI 聊天历史查询/清空仅按 group_id |
同群多租户时一次清空会删除所有租户记录 | 结构性 | ✖(group_id≠租户) |
✅ Messages 等模型转 TenantModel 后代理能拦截,只要 WebUI 请求设置租户上下文 |
WebUI 会话创建时写入 tenant_id,所有查询/清理依赖 DB 代理 |
src/chat/utils/statistic.py(OnlineTimeRecordTask & StatisticOutputTask) |
OnlineTimeRecordTask.record_id、全局 local_storage、maibot_statistics.html |
在线时长与统计输出任务一次性汇总全部租户数据 | 结构性 | ✖(无 chat 上下文) | 将统计任务拆分为“每租户一份”的调度器,独立写入 maibot_statistics/<tenant>.html 或 API |
|
src/common/remote.py(TelemetryHeartBeatTask) |
local_storage["mmc_uuid"]、系统级遥测 payload |
仅上报一个实例视角,无法体现租户粒度,允许跨租户泄露部署信息 | 结构性 | ✖(与 chat 无关) | —(任务完全不访问 DB,代理无从插入) | 定义平台级/租户级遥测策略:要么明确声明其为“平台心跳”,要么复制为 per-tenant 任务 |
src/chat/emoji_system/emoji_manager.py(周期扫描) |
emoji_objects 列表、data/emoji* 目录 |
偷表情/清理任务跨租户共享目录与缓存 | 结构性 | ✖(全局目录) | Emoji 表支持 TenantModel,该循环仍直接操作磁盘且无租户上下文,代理帮不上忙 |
为每个租户拆分 emoji/emoji_registered 目录,并让 ORM 带 tenant_id |
src/chat/message_receive/chat_stream.py(ChatManager._auto_save_task) |
全局 streams/last_messages 字典 + 5 分钟一次的自动保存循环 |
相同平台/群号跨租户覆盖聊天流,保存到 ChatStreams 时无租户信息 |
结构性 | TenantChatManager 注入前缀 |
ChatStreams 是 TenantModel,这个后台任务仍在“无租户上下文”里批量写入,代理不知写给谁 |
先上 ChatStream 代理生成 tenant:stream,再让 _auto_save_task 分租户写入 |
src/manager/async_task_manager.py(全局调度器) |
所有后台任务在一个 async_task_manager 中共享调度 |
任意 AsyncTask(记忆、梦境、统计等)脱离消息上下文,无法获知当前租户 | 结构性 | ✖(任务级别) | TenantContext,代理仍拿不到 tenant_id |
扩展 AsyncTaskManager:支持 per-tenant 实例化、执行前设置 TenantContext,或为任务提供租户循环 |
说明:✅ 表示该策略即可彻底解决此行风险;
⚠️ 表示仍需配合其它改造(如 TenantModel、目录拆分、任务上下文);✖ 表示该策略对问题无影响;“—”表示不适用。
- 可由配置代理 + chat_id 前缀直接化解:情绪、频控、Heartflow、Replyer、Hippo 摘要、公告禁用列表,只要先让 ChatStream 代理提供
tenant:chat并延迟读取配置即可。 - 需要配置代理 + ORM 租户化:表达学习/反思、黑话、Emoji/图片、WebUI 后台、记忆/梦境工具,这些既读配置又写数据库,必须在 TenantModel/上下文齐备的前提下才能安全运行。
- 必须叠加 per-tenant AsyncTaskManager:
MemoryForgetTask、ThinkingBack清理、LLMUsage汇总等纯数据库后台任务,只有拆成 per-tenant 调用后 ORM 才能插入tenant_id。 - 四种手段仍无解(需额外改造):统计/在线时长、Telemetry 心跳、Emoji 目录扫描、ChatManager 自动保存、WebUI 无租户路由等,它们要么写文件/缓存,要么定义上是平台级操作,必须先改业务结构。
分类说明:仅
expression_selector这类“只读配置且不缓存状态”的模块可以纯靠配置代理解决,其余条目都要么需要租户前缀、要么需要 TenantModel,再或者需要重写任务调度逻辑。
- 模型层仍是单租户 schema:
ChatHistory、ThinkingBack、LLMUsage、Messages、Emoji等 Peewee 模型尚未增加tenant_id字段,所有 WebUI/API/后台任务都在整表操作。 - 后台维护任务跨表读写:
MemoryForgetTask、run_dream_cycle_once()、统计/在线时长任务全表扫描并更新/删除记录,即便 DB Proxy 上线也需要执行前显式设置TenantContext。 - WebUI 采用全局 ID 语义:
jargon_routes、expression_routes、chat_routes仅按主键或group_id操作,无租户过滤,最容易触发串租操作。 - 文件系统与本地缓存共享:
data/emoji*、data/image、HIPPO_CACHE_DIR、maibot_statistics.html等目录/文件均为全局路径,若不拆分目录即使数据库多租户化仍会相互覆盖。 - Telemetry/统计类任务不分租户:
TelemetryHeartBeatTask、OnlineTime/Statistic 等任务维护单一视角,需要明确它们是平台级心跳还是 per-tenant 指标。
结论:风险核心在“模型无 tenant_id + 运行时任务无租户上下文”,处理顺序应为:① 模型迁移到 TenantModel;② AsyncTask/WebUI 注入租户上下文;③ 文件系统与统计输出拆分路径。
- 按租户切分任务:枚举租户 ID,逐个执行
task.run_for_tenant(tenant_id);避免同一次run()混合多个租户的数据。 - 上下文管理器注入 tenant_id:通过 contextvar 封装
async with tenant_context(tenant_id): ...,让 ORM/TenantModel 自动感知当前租户;示例:
@asynccontextmanager
async def tenant_context(tenant_id: str):
token = TenantContext.set(tenant_id)
try:
yield
finally:
TenantContext.reset(token)
async def run_for_tenant(task: AsyncTask, tenant_id: str):
async with tenant_context(tenant_id):
await task.run()- 并发运行所有租户:在 Manager 中为每个租户创建一个协程并
await asyncio.gather(*per_tenant_tasks)。Python 的 contextvars 会为每个协程保留自己的租户上下文,因此数据库代理可以在每个任务内部注入tenant_id。
该方案可直接修复“纯数据库型批处理任务”(例如 MemoryForgetTask、ThinkingBack 清理、LLMUsage 归档等),前提是对应 ORM 已迁移到 TenantModel,且任务实现中不再访问共享缓存/磁盘。
- OnlineTimeRecordTask / StatisticOutputTask(
src/chat/utils/statistic.py):即便按租户运行,仍然写入同一个record_id与maibot_statistics.html,会互相覆盖,必须先拆分输出路径或改成 API。 - TelemetryHeartBeatTask(
src/common/remote.py):任务不依赖 DB,tenant 上下文无用;需要重新定义平台级 vs. 租户级心跳逻辑。 - EmojiManager 周期扫描(
src/chat/emoji_system/emoji_manager.py):循环遍历全局data/emoji*目录和共享emoji_objects,必须拆出 per-tenant 目录/缓存后才能注入上下文。 - ChatManager
_auto_save_task(src/chat/message_receive/chat_stream.py):内存中的streams/last_messages仍按MD5(platform+group)共享;必须先上线TenantChatManager/ChatStreamProxy,再按租户分桶保存。 - Online Emotion / MoodRegressionTask(
src/mood/mood_manager.py):mood_list是全局 dict,哪怕上下文正确也会在共享缓存里读写;需要把 key 改成(tenant, chat)。 - DreamAgent / 记忆工具(
src/dream/dream_agent.py):工具 API 仍按记录主键操作ChatHistory,没有租户约束;必须把接口改为(tenant_id, chat_id)并校验归属。 - WebUI 批量操作路由(
webui/*_routes.py):路由层未传租户 ID,后台任务即使感知上下文也拿不到正确租户;需要在 HTTP 请求里传入并绑定租户。
以上任务要么依赖全局缓存/文件,要么定义上就是平台级操作,单靠 AsyncTaskManager 注入上下文无法彻底隔离,仍需对应模块完成缓存分桶、目录拆分或接口改造。
- 情绪 / 频控 / 规划链路:先完成
TenantContext注入或 ChatStream 代理,再让MoodManager、FrequencyControlManager、ReplyerManager、Heartflow 等通过(tenant_id, chat_id)管理缓存。 - 学习 / 反思 / 黑话链路:
ExpressionLearner、ExpressionReflector、ReflectTracker、JargonMiner需要在内存、磁盘和 ORM 三层同时写入 tenant_id,并把异步任务调度粒度切到租户级。 - 缓存与磁盘:
ChatHistorySummarizer、ImageManager等所有落盘路径需加入 tenant_id,等待数据库 TenantModel 改造完成后统一迁移。 - 插件控制面:
global_announcement_manager、ToolExecutor缓存依赖 stream_id,ChatStream 代理上线时统一切换到tenant:stream命名并清理旧缓存。 - 验证顺序:先完成
TenantContext+ 配置代理初始化,再逐个迁移管理器与数据库,最后处理插件与统计/文件系统的长尾问题。
- 在
global_config、model_config、TenantContext上实现代理后,再次逐项跑通表格中的模块,确认缓存/任务是否已经获得租户上下文。 - 对
ChatHistory、ThinkingBack、LLMUsage、Messages、Emoji等核心模型先行添加tenant_id字段及索引,再为 WebUI/API 注入租户过滤。 - 建立 per-tenant AsyncTask 调度框架,让 OnlineTime、Statistic、记忆清理、梦境循环等任务都能带着租户身份运行。
- 逐步拆分
data/emoji*、data/image、HIPPO_CACHE_DIR、maibot_statistics.html等磁盘路径,避免 DB 层改造完成后仍由文件覆盖造成串租。
本文档将持续只保留最新分析结果,后续若有新增风险或整改验证,请直接在对应章节更新。
- 配置代理 解决“读配置”类固化(除仍需懒加载的 6 个基础类型),是所有手段的前置条件。
- chat_id 前缀 负责兜住内存/缓存/文件路径,只要 ChatStream/manager 能导出
tenant:chat,大部分运行态状态即可隔离。 - ORM 租户化 让数据库层自动追加
tenant_id,但前提是调用发生在正确的TenantContext中;与 per-tenant AsyncTaskManager 联动后,批量任务才能真正被隔离。 - per-tenant AsyncTaskManager 是当前新增手段,用于“只改调度器就能把任务拆租户”的场景;它无法解决仍依赖共享缓存或平台级输出的任务,因此这些模块仍列入风险清单,需要架构层改造。