后端领域模块——按领域组织

更新于 · 在 sijie.xyz 查看原条目 ↗

后端曾经(到 2026-07-27 为止)是按层打包的,而不是按领域打包的。这是一整类纠缠问题背后的根本技术债(比如 connector 为了自己的概念去导入 capabilities 的上帝包;一个 kernel 本不该持有的带类型 category 表面)。本节点定下的目标是:按领域打包——每个领域拥有自己完整的纵向切片,只对外暴露一个公共 facade。

病灶——三个按层切分的上帝包(已于 2026-07-27 治愈)

internal/usecases  (106)  ← every domain's usecase together   [dissolved]
internal/domain    ( 55)  ← every domain's entity together    [dissolved]
internal/postgres  ( 66)  ← every domain's repo together      [dissolved]

三者已全部消失,连同 internal/plugins(第四个这样的桶,装的是 owner 侧的 capability 代码)一起。internal/ 现在恰好只剩类图上的那一组:8 个核心模块 + capabilities + routes + infra。这道 fence 以一个空基线运行——纯红,没有任何历史豁免。

  • 反向依赖: connector/slots.go 导入 usecases(AgentToolConnector、ErrMailNotConfigured)——一个领域为了自己的概念反过来向上伸手,因为这些概念在该领域里没有家。
  • 带类型的 category 表面: contract.CalendarProxy 是一个编译期 Go interface——这正是 capabilities 的外部化原则禁止 kernel 持有的那种"带类型的 handle"。

原则

每个领域 = 一个自包含的模块 internal/<domain>/,拥有 entity + usecase + repo + public facade。共享层只留给真正无所属领域的东西。Controller 只存在于 internal/routes/*(真正的入站层);一个模块自己的"内部 controller"其实就是它的 public facade。

两条插件轴——同一个元结构

capabilities 和 connector 是同一个抽象,区别只在调用约定上:

{ declaration (data) → implementation → instance → ONE opaque call door }
  • declaration = 一份存在 internal/ 之外的数据清单(backend/connectors/<id>/manifest.yaml 和 backend/capabilities/<id>/manifest.yaml,来自磁盘或由 owner 注册)——category+verbs / capability+tools 加上 schema。永远不是 Go interface,永远不在 internal/ 里。 calendarVerbs/mailVerbs 那些字面量已经没了,但截至 2026-09-07,contract.CalendarProxy / MailProxy(internal/connector/contract/contract.go)仍然是消费方面向编程的带类型 category 表面——这是还没迁移、需要提取成数据的那一块。
  • implementation = 满足某个 declaration 的 adapter/plugin。
  • instance = 运行时的、有作用域的、可调用的东西。作用域按轴而不同:connector 是 owner 持久化的(账号 + 凭证);capability 是 session 临时的(冷启动 sandbox)。
  • 每条轴一扇不透明的门——按 name 索引、不透明 JSON,被调用方永远看不到调用方。
classDiagram
  direction TB
  class CapabilityDeclaration { <<data manifest>> }
  class ConnectorDeclaration { <<data manifest>> }
  class CapabilityImpl { <<implementation>> }
  class ConnectorImpl { <<implementation>> }
  class CapabilityInstance { <<instance session-scoped>> }
  class ConnectorInstance { <<instance owner-scoped>> }
  class capreg { <<declaration registry>> }
  class connectorSpecStore { <<declaration registry>> }
  class perSessionBindings { <<instance registry>> }
  class Hub { <<instance registry>> }
  CapabilityDeclaration <|.. CapabilityImpl : implements
  ConnectorDeclaration <|.. ConnectorImpl : implements
  CapabilityImpl ..> CapabilityInstance : per-session spawn
  ConnectorImpl ..> ConnectorInstance : owner connect
  capreg o-- CapabilityDeclaration
  connectorSpecStore o-- ConnectorDeclaration
  perSessionBindings o-- CapabilityInstance
  Hub o-- ConnectorInstance

每条轴两个 registry

capreg 是一个declaration registry(类型)——它对应的是 connector 的 spec store,不是 Hub。Hub 是 connector 的instance registry,对应的是 per-session 的 capability 绑定。

declaration registry(类型) instance registry(运行时)
capability capreg — 内置 plugin + owner 注册的 MCP / 已安装的 skill 每个 session 冷启动的 sandbox
connector connector spec store — 内置项 + owner 上传的 spec Hub(owner 的账号 + 凭证)

Owner:两条轴各有一条类型路径和一条实例路径

注册一个类型 创建一个instance
connector POST /connectors(+/validate-spec)→ Hub.Upsert 上传的 spec /credentials + /connect + /activate
capability POST /mcp-servers · marketplace 的 InstallSkill 每个 session 的 sandbox spawn(+ enable 门控)

两扇门以及它们的交界处

flowchart TB
  agent["owner AI client / visitor agent"]
  mcph["routes/mcphandle"]
  conn["routes/connector"]
  capreg["capreg (capability decl registry)"]
  capInst["capability instance (booker sandbox)"]
  hub["connector Hub (instances)"]
  connInst["connector instance (gcal / fastmail)"]
  agent -- "tools/call(name, json)" --> mcph --> capreg --> capInst
  capInst -- "connector.invoke(cat, verb, json)  connector-deps" --> conn --> hub --> connInst

Capabilities 位于 connectors 之上;一个 capability instance 通过 connector.invoke 向下触达。connector 从不向 capreg 索取任何东西。唯一的跨轴边就是connector-deps。

为什么用 category: 自托管 → 每个 owner 自带自己的日历/邮箱。一个 capability 是针对一个category(可替换的接口)编程的;owner 绑定当前生效的 provider。参见 connector。

领域清单

核心模块(internal/<domain>/,各自拥有 entity+usecase+repo+facade):

  • corpus — raw/wiki/output/writing/note/tree/citation/subjectivity/crosslink/seo(+ 自己的 corpus/search Meili 子包——是 corpus 形状的 DAO,不是 infra)
  • conversation — chat/dialog/message/ghost/visitor-session(+ inference 作为 agent-core 引擎)
  • connector — connection/integration/mail_connector/connectorsvc + adapters(registry/door → platform 轴)
  • access — access_code/access_request/role/role_snapshot/dock_buttons/api_key(+ session)
  • owner — owner/account/instance/page_content/microsite(由 custom_page 改名,e7fe80e91,2026-09-05;+ microsite_store,每个 microsite 自己的持久化存储,a94909aa9,2026-09-04)/appearance/keypair/login/password/recovery + mail(mail_otp/outbound)+ prompts + owner/jobs(求职闭环)
  • security — captcha/banned_ip/login-guard/anti-replay(认证=access;防护=security)
  • marketplace — marketplace/skill/mcp_server
  • stats — stats_activity/growth/jobs / inference_usage / system_info

Capability 轴(internal/capabilities/)——standmeet 自己的 agent 加载/派发 MCP capability 的机制。子包:capreg(declaration registry)· capsocket(sandbox 里的 cap 用来回调 host 的 socket)· mcpclient/mcpplugin/mcputil(我们用来拨号连接 owner 注册的外部 MCP server 的client传输层)· capstore(每个 plugin 隔离的存储)· capconfig(plugin 声明的 owner 可调字段,cc5c1db47,2026-07-31)· capquota(按码的用量上限,在 plugin 自己的存储里计数,aab8abe90,2026-08-01)· sandbox(跑 owner skill 脚本的 docker run runner)· sandboxws(MCP 沙箱的 bwrap 工作区)。只是一个通用加载器——零具体 capability:每一个具体的 MCP(booker/retrieval/summarize/mail-sender/ask-visitor/……)都被外部化了(见下文),所以光读 capabilities 这一层代码,你根本看不出它们的存在(由 core-agnostic ratchet 强制保证)。它不是 connector(那条轴持有 owner 的凭证并触达外部服务),也不是入站的 MCP-server facade(routes/mcphandle,外部 agent 从这里触达我们的工具)。ghost 不是一个 capability——它是 conversation 的核心部分。

完全外部化的 capability(sandbox 化,不属于 core,住在顶层的 mcp-servers/ 里):booker · retrieval · summarize · ask-visitor · mail-sender —— 五个,正好对上 backend/capabilities/<id>/manifest.yaml 的五份声明(calendar.book、corpus.retrieval、summarize_conversation、ask_visitor、mail.send)。没有 report 这个 server,从来也没有过(git log -- mcp-servers/report 为空);报告是 summarize 产出的 chat_reports 产物。Owner 侧还在进程内的受信任 cap 只剩 owner/jobs(求职闭环);ownercore 已经没了(f35e82c04,2026-08-02 —— 它的 cap 变成了各领域自己声明的 op,见 owner-facade-from-registry)。

共享 infra(无所属领域,internal/infra/,截至 2026-09-07): pgstore(pgxpool + 启动时套迁移,bd45353f4,2026-08-31)· cryptobox · httpx · retry · storage · gotenberg · session · middleware · apierr · hostop · periodic · paritymanifest · facadeparity · providermodels · mailthrottle(按收件人的出站节流,9d6d10d95,2026-09-06)· snowflake(短 id,dbb803288,2026-09-06)· buildnotify · clientaddr · depcheck · plaintext · selfstat · textcut。(sandbox/sandboxws 和 capsocket 住在 capabilities 下;config 是 cmd/server/config;search→corpus——它是 corpus 形状的 DAO(硬编码 corpus_notes 索引),不是通用 infra;mailer→connector;prompts→owner;jobregistry→stats。)规则: 具体的、按领域的数据访问(SQL/索引)属于该领域自己的 repository;infra 只保留真正无所属领域的底座(pgx pool、http client、crypto box),绝不能是某个领域的行形状定制的查询/索引。

平台机制: capability 轴(上文的 capabilities)+ connector registry · paritymanifest · facadeparity。(plugins 这个进程内加载器已经拆解 —— fea7ae93f,2026-07-29:它的 Plugin/Registry 机制并入了 capabilities,它的实现并入了 owner/mcp-servers。)

结构上强制执行: infra/scripts/check-internal-dirs.sh 把 internal/ 严格圈定为 {上述 8 个核心模块 · capabilities · routes · infra} —— 它的 ALLOWED 列表,十一个名字,没有 usecases;任何其他子目录都算 lint 失败,而那份只收紧的基线文件已经删掉(纯红)。任何没有出现在类图里的目录(比如 plugins)都不许再回来。

会消失的东西

  • 三个按层组织的上帝包(usecases/domain/postgres)——被重新分配进各个模块。
  • contract.CalendarProxy / 任何带类型的 category 表面——declaration 变成数据。
  • connector → usecases 这条反向依赖——概念搬回自己的家。
  • 改名:capreg → capability 的 declaration registry;Hub → connection(instance) registry;socket-op handler → routes/<domain>/ 里的 controller。

外部化不等于搬家

一个 capability 只有在host 一点逻辑都不再保留时,才算真正被外部化。把 host 那份代码挪到 internal/ 里一个更整洁的地址,能通过每一道结构性关卡,却什么也没改变——这些关卡度量的是形状,而一个语义上的重复品完全可以拥有合法的形状。

booker 就是这样一个案例:mcp-servers/booker/policy.go 和 kernel 里的 booking-policy 求值器,是同一套规则的两份实现(同样的冲突 token、同样的时段常量),每个文件的头部注释都断言归属权在对方那里。根因是一个机制上的缺口——一个 sandbox 化的 capability 只能面向 visitor,所以它面向 owner 的那部分表面不得不在 host 侧重新实现一遍。修复方式是 mcpplugin.Manifest.OwnerTools:把 owner 工具做成 declaration 数据,在调用时才拨号进 sandbox。

这个重复品带来的不只是漂移风险:只有 host 二进制导入了 time/tzdata,所以一旦这个求值器跑在 sandbox 里,每一个具名的 IANA 时区都会失败,list_slots 就会返回一个空列表——和"没有可用时段"完全无法区分。一个重复品会把"到底哪一份代码携带着算法所依赖的环境"这件事藏起来。两份代码的错误约定也不一样(isError vs {ok:false,error,detail}),所以外部化这一步实际上改变了面向 owner 的契约。

已还清(f614c08f3,2026-07-31): 取消这一簇(uc_booking_cancel.go / uc_booking_cancel_own.go)曾重复了 sandbox 里的 deleteBooking;唯一的差别只在查找方式(booking-id 还是 conversation+event-id)。owner 作用域的 calendar_cancel_booking 工具现在住在 mcp-servers/booker/main.go,卡片早已不用的 REST 取消路径被退役,host 侧那两个 usecase 都删了。

迁移——connector 探路

  1. 已完成: connector.invoke controller → internal/routes/connector(瘦壳),已上架构锁。
  2. Declaration → 数据(不再把 contract.CalendarProxy 当作那个类型)—— 截至 2026-09-07 未做(internal/connector/contract/contract.go 里还定义着它)。
  3. 随结构自然完成(2026-07-27): usecases 已不存在,反向依赖也就不可能存在。
  4. 拆分 registry:spec store(类型)与 Hub(instance → connection)分开 —— 未做;hub.go 仍然同时 upsert 启动时和 owner 上传的 spec。
  5. 已完成(2026-07-27): 已按领域复制完毕;三个上帝包已拆解,每个切片先转绿再动下一个。最后剩下的残留:usecases/obsidian→corpus/obsidian、usecases/report_*→conversation/usecase、plugins/booker→owner/{entity,usecase}、plugins/ownercore→owner/ownercore。Connector 是唯一还没拆的核心模块——上面第 2-4 步就是尚待完成的工作。

子模块是它们自己的节点。 owner/jobs、corpus/obsidian 和 conversation/inference 保留自己的边界(有自己的入口点,不是所属领域的 DDD 内脏);owner/ownercore 在被拆解之前也是其中之一(f35e82c04,2026-08-02 —— 这个名字还留在 backend/tools/archcheck/main.go:46 的子模块集合里,无害,背后已没有目录)。check-domain-facade-boundary 和 check-domain-acyclic 都把这同一组东西当作独立节点处理——否则一个合理地横跨多个领域的聚合器(ownercore 曾触达每个领域的 facade)就会给它仅仅是挨着的那个 core 伪造出一条环。每个领域的core 仍然必须是一个干净的节点,acyclic 这道关卡在遇到真正的 core-to-core 环时依然会亮红。

相关:structure · capabilities · connector · key-designs。

相关笔记