Skip to content

Latest commit

 

History

History
166 lines (125 loc) · 29 KB

File metadata and controls

166 lines (125 loc) · 29 KB

MaiMBot多租户风险分析(2025-12-05)

本版本仅保留 2025-12-05 的最新风险研判,聚焦当前仓库的静态核对结果、Todo #6 风险地图、旧项目 DB 风险复盘、后台异步任务租户上下文缺口以及新增整改建议。

🔐 四种租户隔离手段评估

  1. 配置代理(消息处理链路内的 with TenantContext

    • 通过 GlobalConfigProxy / ModelConfigProxy__getattr__ 全量 hook,除基础类型以外的属性访问都先查租户配置,再回退到默认配置。
    • 在消息处理 with tenant_context(chat_id, tenant_id) 中引用这些代理,LLMRequest/manager 初始化依旧使用原路径但会在访问阶段获取正确租户值。
    • 风险:仍有 6 个基本类型在 __init__ 里被固化,需要懒加载或延迟求值才能真正享受代理。
  2. chat_id 加租户前缀

    • 把所有以 chat_id 为 key 的缓存、文件、任务命名统一改成 f"{tenant_id}:{chat_id}",确保单个租户的消息/状态不会与其他租户碰撞。
    • 适用模块:情绪、频控、Heartflow、Replyer、Hippo 缓存、全局公告等纯内存/文件缓存。
    • 风险:ChatManager 本体仍用 MD5(platform+group) 生成 key;在未实现 TenantChatManager 前,任何后台任务都会读写共享字典。
  3. ORM 层自动添加 tenant_id

    • 在消息上下文中设置 TenantContext 后,TenantModel/TenantPeeweeDatabase 能在每次 select/update/insert 时自动附带 tenant_id,实现“同表多租”。
    • 主要依赖:模型补齐 tenant_id 字段、索引与默认值,并确保请求/任务运行期间上下文未被中途覆盖。
    • 风险:批量后台任务如果在无上下文环境运行(当前实际情况),仍会全表扫描;而文件系统/缓存一类资源与 ORM 无关,仍需其他手段。
  4. (新增)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.pysrc/chat/emoji_system/emoji_manager.pysrc/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。

🎯 2025-12-08:配置懒加载整改计划

模块 现状风险 整改思路 备注
src/person_info/person_info.py 模块级 relation_selection_modelPersonInfoManager 在导入时一次性绑定 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 在构造函数里缓存 LLMRequestemoji_num_max 等基本类型,导致后续租户无法覆盖;目录扫描、注册任务都读取这些缓存。 引入只读属性/方法(例如 _current_emoji_configvlm_request)替换成员变量;所有调用点改为实时取值;为将来拆分 per-tenant 目录预留注释。 与目录/缓存串租问题解耦,此处先确保运行时代理生效。
src/express/expression_learner.py 每个聊天学习器在 __init__ 时缓存 enable_learninglearning_intensity、三个 LLMRequest;学习循环长期运行不会刷新配置。 新增 _resolve_expression_config()should_trigger_learninglearn_expression 里动态计算;为三个 LLM 请求提供轻量工厂方法,按调用即时创建;阈值改为临时变量而非实例属性。 与 chat_id 前缀、磁盘路径整改互相独立,可并行推进。
  • 实施顺序:先完成上述三个模块的懒加载改造,再逐一复测 Emoji 注册、人物信息命名、表达学习链路,确认不同租户在同进程内互不干扰。验证完成后在本文件更新状态。

实施结果(2025-12-08)

  • src/person_info/person_info.py:移除了模块级 LLMRequest 单例,改用工厂函数按需创建;PersonInfoManager 通过懒加载代理导出,qv_person_name 在每次调用时重新构造模型;Person 的昵称/人名属性改为属性函数,机器人身份实时透传 global_config.bot.*
  • src/chat/emoji_system/emoji_manager.pyEmojiManager 不再缓存 VLM/LLM 请求或配置阈值,改用私有方法/属性实时读取;表情替换与描述生成流程注入局部模型实例,容量上限通过属性读取最新的租户配置。
  • src/express/expression_learner.py:删除所有构造期缓存,新增模型工厂与 _resolve_learning_thresholds,确保学习开关、强度与请求模型均在运行时读取;should_trigger_learninglearn_expression_summarize_situations 已按照新的入口调用。

✅ Todo #6:全局/单例状态风险地图

下表记录仍依赖 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_listMoodRegressionTask 所有租户共享 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.reflectorsreflect_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._repliersToolExecutor.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) ⚠️ TenantModel 仅能隔离 ORM 记录,文件系统仍共享;需和目录拆分配合 建立租户感知的 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_storagemaibot_statistics.html 在线时长与统计输出任务一次性汇总全部租户数据 结构性 ✖(无 chat 上下文) ⚠️ 即使底层模型已带 tenant_id,任务在 AsyncTask 中运行时未设置租户上下文,代理无法知道当前租户 将统计任务拆分为“每租户一份”的调度器,独立写入 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.pyChatManager._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(记忆、梦境、统计等)脱离消息上下文,无法获知当前租户 结构性 ✖(任务级别) ⚠️ 即使模型变 TenantModel,调度器若不设置 TenantContext,代理仍拿不到 tenant_id 扩展 AsyncTaskManager:支持 per-tenant 实例化、执行前设置 TenantContext,或为任务提供租户循环

说明:✅ 表示该策略即可彻底解决此行风险;⚠️ 表示仍需配合其它改造(如 TenantModel、目录拆分、任务上下文);✖ 表示该策略对问题无影响;“—”表示不适用。

风险归类(结合四种手段)

  • 可由配置代理 + chat_id 前缀直接化解:情绪、频控、Heartflow、Replyer、Hippo 摘要、公告禁用列表,只要先让 ChatStream 代理提供 tenant:chat 并延迟读取配置即可。
  • 需要配置代理 + ORM 租户化:表达学习/反思、黑话、Emoji/图片、WebUI 后台、记忆/梦境工具,这些既读配置又写数据库,必须在 TenantModel/上下文齐备的前提下才能安全运行。
  • 必须叠加 per-tenant AsyncTaskManagerMemoryForgetTaskThinkingBack 清理、LLMUsage 汇总等纯数据库后台任务,只有拆成 per-tenant 调用后 ORM 才能插入 tenant_id
  • 四种手段仍无解(需额外改造):统计/在线时长、Telemetry 心跳、Emoji 目录扫描、ChatManager 自动保存、WebUI 无租户路由等,它们要么写文件/缓存,要么定义上是平台级操作,必须先改业务结构。

分类说明:仅 expression_selector 这类“只读配置且不缓存状态”的模块可以纯靠配置代理解决,其余条目都要么需要租户前缀、要么需要 TenantModel,再或者需要重写任务调度逻辑。

📚 旧项目 DB 风险复盘

  1. 模型层仍是单租户 schemaChatHistoryThinkingBackLLMUsageMessagesEmoji 等 Peewee 模型尚未增加 tenant_id 字段,所有 WebUI/API/后台任务都在整表操作。
  2. 后台维护任务跨表读写MemoryForgetTaskrun_dream_cycle_once()、统计/在线时长任务全表扫描并更新/删除记录,即便 DB Proxy 上线也需要执行前显式设置 TenantContext
  3. WebUI 采用全局 ID 语义jargon_routesexpression_routeschat_routes 仅按主键或 group_id 操作,无租户过滤,最容易触发串租操作。
  4. 文件系统与本地缓存共享data/emoji*data/imageHIPPO_CACHE_DIRmaibot_statistics.html 等目录/文件均为全局路径,若不拆分目录即使数据库多租户化仍会相互覆盖。
  5. Telemetry/统计类任务不分租户TelemetryHeartBeatTask、OnlineTime/Statistic 等任务维护单一视角,需要明确它们是平台级心跳还是 per-tenant 指标。

结论:风险核心在“模型无 tenant_id + 运行时任务无租户上下文”,处理顺序应为:① 模型迁移到 TenantModel;② AsyncTask/WebUI 注入租户上下文;③ 文件系统与统计输出拆分路径。

♻️ 后台异步任务租户上下文缺口

✅ 预期解决方案:per-tenant AsyncTaskManager

  1. 按租户切分任务:枚举租户 ID,逐个执行 task.run_for_tenant(tenant_id);避免同一次 run() 混合多个租户的数据。
  2. 上下文管理器注入 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()
  1. 并发运行所有租户:在 Manager 中为每个租户创建一个协程并 await asyncio.gather(*per_tenant_tasks)。Python 的 contextvars 会为每个协程保留自己的租户上下文,因此数据库代理可以在每个任务内部注入 tenant_id

该方案可直接修复“纯数据库型批处理任务”(例如 MemoryForgetTaskThinkingBack 清理、LLMUsage 归档等),前提是对应 ORM 已迁移到 TenantModel,且任务实现中不再访问共享缓存/磁盘。

⚠️ 套用上述方案后仍存在问题的模块

  • OnlineTimeRecordTask / StatisticOutputTask(src/chat/utils/statistic.py:即便按租户运行,仍然写入同一个 record_idmaibot_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_tasksrc/chat/message_receive/chat_stream.py:内存中的 streams/last_messages 仍按 MD5(platform+group) 共享;必须先上线 TenantChatManager/ChatStreamProxy,再按租户分桶保存。
  • Online Emotion / MoodRegressionTask(src/mood/mood_manager.pymood_list 是全局 dict,哪怕上下文正确也会在共享缓存里读写;需要把 key 改成 (tenant, chat)
  • DreamAgent / 记忆工具(src/dream/dream_agent.py:工具 API 仍按记录主键操作 ChatHistory,没有租户约束;必须把接口改为 (tenant_id, chat_id) 并校验归属。
  • WebUI 批量操作路由(webui/*_routes.py:路由层未传租户 ID,后台任务即使感知上下文也拿不到正确租户;需要在 HTTP 请求里传入并绑定租户。

以上任务要么依赖全局缓存/文件,要么定义上就是平台级操作,单靠 AsyncTaskManager 注入上下文无法彻底隔离,仍需对应模块完成缓存分桶、目录拆分或接口改造。

🛠️ Todo #7:新增整改建议

  • 情绪 / 频控 / 规划链路:先完成 TenantContext 注入或 ChatStream 代理,再让 MoodManagerFrequencyControlManagerReplyerManager、Heartflow 等通过 (tenant_id, chat_id) 管理缓存。
  • 学习 / 反思 / 黑话链路ExpressionLearnerExpressionReflectorReflectTrackerJargonMiner 需要在内存、磁盘和 ORM 三层同时写入 tenant_id,并把异步任务调度粒度切到租户级。
  • 缓存与磁盘ChatHistorySummarizerImageManager 等所有落盘路径需加入 tenant_id,等待数据库 TenantModel 改造完成后统一迁移。
  • 插件控制面global_announcement_managerToolExecutor 缓存依赖 stream_id,ChatStream 代理上线时统一切换到 tenant:stream 命名并清理旧缓存。
  • 验证顺序:先完成 TenantContext + 配置代理初始化,再逐个迁移管理器与数据库,最后处理插件与统计/文件系统的长尾问题。

✅ 下一步验证建议

  • global_configmodel_configTenantContext 上实现代理后,再次逐项跑通表格中的模块,确认缓存/任务是否已经获得租户上下文。
  • ChatHistoryThinkingBackLLMUsageMessagesEmoji 等核心模型先行添加 tenant_id 字段及索引,再为 WebUI/API 注入租户过滤。
  • 建立 per-tenant AsyncTask 调度框架,让 OnlineTime、Statistic、记忆清理、梦境循环等任务都能带着租户身份运行。
  • 逐步拆分 data/emoji*data/imageHIPPO_CACHE_DIRmaibot_statistics.html 等磁盘路径,避免 DB 层改造完成后仍由文件覆盖造成串租。

本文档将持续只保留最新分析结果,后续若有新增风险或整改验证,请直接在对应章节更新。

🧾 结论回顾

  • 配置代理 解决“读配置”类固化(除仍需懒加载的 6 个基础类型),是所有手段的前置条件。
  • chat_id 前缀 负责兜住内存/缓存/文件路径,只要 ChatStream/manager 能导出 tenant:chat,大部分运行态状态即可隔离。
  • ORM 租户化 让数据库层自动追加 tenant_id,但前提是调用发生在正确的 TenantContext 中;与 per-tenant AsyncTaskManager 联动后,批量任务才能真正被隔离。
  • per-tenant AsyncTaskManager 是当前新增手段,用于“只改调度器就能把任务拆租户”的场景;它无法解决仍依赖共享缓存或平台级输出的任务,因此这些模块仍列入风险清单,需要架构层改造。