首页
/ Memos 领域术语详解:基于 CONTEXT.md 的 Memo、Space 与集合作用域身份模型

Memos 领域术语详解:基于 CONTEXT.md 的 Memo、Space 与集合作用域身份模型

2026-09-04 16:04:31作者:邓越浪Henry

CONTEXT.md 是 Memos(开源、自托管、以 Markdown 为核心的快速记事工具)的领域语言规范,它用一组精确词条定义了系统中各类实体"到底是什么、不是什么",并为每个词条标注了应避免的同义混用(_Avoid_)。理解这些术语能帮你在使用 Memos API、阅读其源码或二次开发时,准确区分内部 ID、公开 UID、资源名与显示标题这四类极易混淆的标识符。读完本文,你将掌握 Memos 中 Memo 与 Space 的双层身份设计、邀请/成员两种状态模型,以及 Space-aware 集合查询中 all 与归档集合的作用域规则,并能对照仓库源码逐一验证这些定义。

Memo 的三重身份:Memo ID、Memo UID 与资源名

CONTEXT.md 对 Memo 定义了三个身份层面的术语,这是整个领域模型中最容易混淆的一组概念:

术语 定义 应避免的混用
Memo ID 备忘录的稳定内部标识(stable internal identity),当备忘录的公开标识符发生变化时,Memo ID 不会改变 Memo UID、slug
Memo UID 用于备忘录资源名和 URL 的唯一公开标识符;创建备忘录时可由用户自定义,也可由系统自动生成 Memo ID、database ID
Memo 资源名 备忘录在 API 中的身份,形式为 memos/{memo UID} Memo ID、content ID

这个设计的关键点是:内部 ID 与公开标识解耦。Memo UID 可以变化(例如用户自定义了一个带语义的 UID),而数据库层面的 Memo ID 始终保持稳定,因此所有内部外键关系不会因公开标识变更而失效。

在仓库中可以直接验证这一分层。所有公开资源的 UID 都受同一个文法约束,定义在 resource_name.go 中:

var (
	UIDMatcher = regexp.MustCompile(`^a-zA-Z0-9?$`)
)

即 UID 为 1 到 36 个字符,首尾必须是 ASCII 字母或数字,中间可含连字符。而 memos/{memo} 这一资源名形式则固化在 API 协议里,见 memo_service.protoMemo 消息的资源声明与生成代码 memo_service.pb.go 中反复出现的注释:

// Format: memos/{memo}, where memo is the user-defined UID.

注意这里明确写着 memo 是 "user-defined UID",印证了 CONTEXT.md 中"Memo UID 可由用户自定义"的定义;API 请求中传递的永远是 UID,数据库内部 ID 不出现在协议表面。

Memo reaction:只绑定备忘录,不是通用反应机制

CONTEXT.md 对 Memo reaction 的定义是:"挂载在恰好一条备忘录上的响应。反应不是一种面向其他类型内容的通用机制。"(Avoid: Content reaction, generic reaction, reaction target)

这条"否定式"定义在实现中体现得非常干净:反应的 API 路径被硬编码在 Memo 资源名之下,见 memo_service.proto

// Reaction is a reaction attached to a memo.
// pattern: "memos/{memo}/reactions/{reaction}"

对应的 HTTP 端点形如 GET /api/v1/{name=memos/*}/reactionsPOST /api/v1/{name=memos/*}/reactions(Upsert),没有任何针对其他内容类型的抽象层。换言之,如果你想给其他实体加"表情回应",Memos 不提供现成机制——这个术语条目实际上是在划定架构边界,防止后续设计者把 reaction 泛化成通用组件。

Space 的身份三元组:Space ID、Space UID 与资源名

CONTEXT.md 为 Space(协作空间)定义了三个平行的身份术语:

  • Space:实例作用域内的协作边界(instance-scoped collaboration boundary),面向已接受的成员与备忘录的放置(memo placement)。它不是租户(tenant)、文件夹(folder),也不是应用级的授权角色。
  • Space ID:Space 的稳定内部标识,不对外暴露为公开标识。(Avoid: Space UID, Space title)
  • Space UID:创建时分配的、不可变的、全实例唯一的公开标识符;可由用户定义或自动生成。
  • Space 资源名:API 身份,形式为 spaces/{space UID}
  • Space title:可修改、不唯一的显示标签。

"Space ID 不对外暴露、UID 才是公开身份"与 Memo 的 ID/UID 分层完全同构。这个模型在 space.go 的存储层结构体中一目了然:

// Space groups memos and memberships.
type Space struct {
	ID int32          // 内部稳定标识,即 Space ID
	UID string       // 不可变的公开标识,即 Space UID
	Title string     // 可修改的显示标签,即 Space title
	Description     string
	CurrentUserRole SpaceMemberRole
	MemberCount     int32
}

创建时的校验逻辑同样印证了术语定义——UID 走 base.UIDMatcher 文法,Title 仅要求非空(即可修改、不唯一),见 space.goCreateSpace

if !base.UIDMatcher.MatchString(create.UID) {
	return nil, errors.New("invalid uid")
}
if strings.TrimSpace(create.Title) == "" {
	return nil, errors.New("space title is required")
}

Space UID 的分配规则(ADR 0003 补充)

关于 Space UID "可由用户定义或自动生成"这一条,仓库中的 ADR 文档 0003-space-uid-allocation-and-format.md 给出了完整的分配决策:第一方客户端会为每个新 Space 生成规范的小写 UUID v4 并放入 CreateSpaceRequest.space_id 字段;该字段为可选,留空时服务端生成 UUID v4;提供的自定义值则复用同一套公开资源 UID 文法(1–36 字符,内部允许连续连字符,纯数字亦合法,大小写保留)。UID 碰撞由既有的全实例唯一约束拒绝。这一决策还解释了为什么 CONTEXT.md 特别强调 Space UID 是 immutable(不可变)——它一旦写入资源名 spaces/{space UID} 就构成 API 身份,重写会造成所有客户端缓存的引用失效,因此文档明确"现有 Space UID 保持可读且不被重写"。

对应的请求消息定义在 space_service.proto

message CreateSpaceRequest {
  // Required. The space to create.
  Space space = 1 [(google.api.field_behavior) = REQUIRED];

  // Optional. The space UID to use for this space.
  // If empty, a canonical UUID v4 will be generated.
  // Format: ^a-zA-Z0-9?$
  string space_id = 2 [(google.api.field_behavior) = OPTIONAL];
}

Space 邀请与成员:两种状态、一种关系

CONTEXT.md 对 Space 的关系模型定义了最后两个术语:

  • Space invitation(邀请):向"已存在的活跃 Memos 用户"发出的待处理加入提议,并指定一个 Space 角色;在被接受之前,它不赋予任何成员资格或 Space 访问权限。(Avoid: Pending membership, direct add)
  • Space membership(成员关系):一个活跃 Memos 用户与一个 Space 之间已被接受的关系,携带 ADMINUSER 角色。(Avoid: Invitation, application role)

这两个术语的区别在存储层被建模为明确的状态机,见 space.go

// SpaceMemberStatus is the state of a user's relationship with a space.
type SpaceMemberStatus string

const (
	// SpaceMemberStatusInvited represents an invitation that the user has not accepted.
	SpaceMemberStatusInvited SpaceMemberStatus = "INVITED"
	// SpaceMemberStatusActive represents a membership that the user has accepted.
	SpaceMemberStatusActive SpaceMemberStatus = "ACTIVE"
)

角色定义与术语条目逐字对应:

const (
	// SpaceMemberRoleAdmin can manage the space and its membership.
	SpaceMemberRoleAdmin SpaceMemberRole = "ADMIN"
	// SpaceMemberRoleUser is a regular space member.
	SpaceMemberRoleUser SpaceMemberRole = "USER"
)

而"邀请在未被接受前不赋予任何访问"这一语义,则由 API 与存储层共同保证。API 侧,邀请是独立的子资源(spaces/{space}/invitations/{username}),接受后返回的是 SpaceMember 而非 SpaceInvitation,见 space_service.proto

// AcceptSpaceInvitation accepts a pending invitation and creates a membership.
rpc AcceptSpaceInvitation(AceptSpaceInvitationRequest) returns (SpaceMember) { ... }

存储层进一步收紧了操作主体:AcceptSpaceInvitationDeclineSpaceInvitation 都校验 actorUserID 必须等于被邀请用户本身,否则返回 ErrSpacePermissionDeniedCreateSpaceInvitation 的注释也明确"它从不创建活跃成员关系,只有被邀请人接受后才能创建",见 space.go

func (s *Store) AcceptSpaceInvitation(ctx context.Context, accept *AcceptSpaceInvitation, actorUserID int32) (*SpaceMember, error) {
	...
	if accept.UserID != actorUserID {
		return nil, ErrSpacePermissionDenied
	}
	return s.driver.AcceptSpaceInvitation(ctx, accept, actorUserID)
}

这也解释了术语条目为何要求"被邀请者必须是已存在的活跃用户"——store 包中还专门定义了 ErrSpaceMemberNotActive("space member user is not active")错误用于拦截此类目标。

Memo collection scope:all 与单一 Space 二选一

Memo collection scope 是 CONTEXT.md 中最精细的一个条目,它定义了 Space-aware 的备忘录(及其派生资源)集合查询中的一个维度,取值只有两种:

  1. all——不附加任何 Space 谓词,即查询整个实例;
  2. 某一个精确的 Space。

条目同时澄清了一个容易误解的点:"未分配的 Memo(unassigned)仍然属于 all;unassigned 是一种放置状态(placement),而不是一种集合作用域(collection scope)。"也就是说,客户端不允许、也不需要为"未放进任何 Space 的备忘录"单独发起一个查询——它们天然被 all 覆盖。

在 API 协议中,这个作用域通过 ListMemosRequestspace 字段表达,且协议注释给出的示例正是 space == "spaces/team"space == null 的二值形式,见 memo_service.proto

// Optional. The space in which this memo is placed. Format: spaces/{space}.
optional string space = 19

注释中列举的过滤条件形式(space (string resource name or null when the memo has no space)space == "spaces/team" or space == null)与"一个精确 Space 或 null(无 Space)"的语义直接对应,验证了术语条目中"exact Space"的取值约束。

Archived memo collection:用户级、跨放置状态、独立于 Space 作用域

最后一个术语 Archived memo collection(归档备忘录集合) 定义为:"已登录用户处于归档生命周期状态的备忘录,跨越所有放置状态(across all placements)。它是用户级的(user-level),独立于当前的 Space collection scope。"(Avoid: Space archive, Space-scoped archive)

这句话拆成三个可验证的技术断言:

  1. 按用户过滤——只列当前登录者自己的备忘录;
  2. 按生命周期状态过滤——备忘录的 State 字段取 ARCHIVED
  3. 不带 Space 谓词——即使调用方当前正处于某个具体 Space 上下文中,归档集合也返回该用户全部放置位置的归档备忘录。

状态枚举定义在 common.protoState(生成代码 common.pb.go 中可见 NORMALARCHIVED 两个取值),而查询入口是 memo_service.go 中的归档分支:

if request.State == v1pb.State_ARCHIVED {
	...
}

生成代码中该字段的注释恰好是术语定义的复述,见 memo_service.pb.go

// Default to `NORMAL`. Set to `ARCHIVED` to list archived memos.

此外,归档状态的访问控制在 access/memo.go 中有专门语义——MemoReadDenialNotFound 用于"隐藏缺失的、归档的及无效状态的备忘录",而 access/memo_test.go 中的测试用例明确验证了一条关键行为:"an archived SPACE memo still requires active membership"(一条被归档的 Space 内备忘录在被访问方已失去活跃成员资格时同样不可见)。这说明"归档集合独立于 Space 作用域"是指查询维度上的独立,而权限校验仍按备忘录原属 Space 的成员关系执行。

小结:术语表作为架构契约

通读 CONTEXT.md 可以看到,它并非普通的产品词汇表,而是一份与代码强耦合的架构契约,每个条目都能在仓库中找到对应的实现证据:

术语 对应的实现证据
Memo ID / Memo UID / 资源名 resource_name.goUIDMatchermemo_service.protomemos/{memo} 资源模式
Memo reaction 仅限备忘录 memo_service.protomemos/{memo}/reactions/{reaction} 的硬编码路径
Space ID / UID / title 三元组 space.goSpace 结构体与 CreateSpace 校验
Space UID 分配 ADR 0003space_service.protoCreateSpaceRequest.space_id
邀请 vs 成员 space.goSpaceMemberStatus、接受/拒绝操作对 actor 的校验
collection scope 的 all / 精确 Space memo_service.protospace 过滤字段
用户级归档集合 common.protoState_ARCHIVEDmemo_service.go 的归档查询分支

对于参与 Memos 开发或扩展其 API 的工程师,实践建议是:在新增功能前先对照 CONTEXT.md(以及 docs/glossary.md 中用户与用户名、标签部分的更大领域词汇表)确认自己使用的词汇落在已有词条定义内;当实现产生新的概念时,按同款的"定义 + _Avoid_"格式补充词条,可以显著减少 ID、UID、title、resource name 混用带来的接口歧义。

登录后查看全文
热门项目推荐
相关项目推荐