Memos 自托管笔记系统的领域语言:Username、Space 与 Tag 术语规范详解
本篇基于 Memos 仓库中的 领域术语表 展开,系统讲解其三大核心领域的术语定义——用户与用户名(Username)、协作空间(Space)与标签(Tag)——并结合已接受的 ADR 决策记录与 Go 源码实现,说明每个术语背后的格式规则、相等性语义与派生关系。读完本文,你可以准确理解 @mention 的识别边界、Space UID 的分配格式、标签层级展开与计数逻辑,并能在修改相关功能时定位到对应的验证函数与数据结构。
为什么 Memos 需要一份领域术语表
Memos 的设计文档(docs/ 目录)中,术语被分为两层管理:
- 产品与领域语言由 术语表 统一定义,覆盖用户、空间、标签三大领域中会出现在产品行为、API 字段与设计文档里的概念;
- 协议、Unicode 与解析器专属术语则保留在使用它们的 ADR 中,例如 ADR 0001(标签语法与识别) 中的 "literal-source run"、"ApostropheJoiner",以及 ADR 0002(用户名格式与引用) 中的规范化文法。
这种划分意味着:术语表回答"这个概念是什么、不是什么",ADR 回答"识别规则具体如何执行",而仓库中的源码(校验函数、解析器、protobuf 定义)则是两者的可执行落地。下面按术语表的原始脉络,分三组逐项展开。
一、用户与用户名
User:稳定身份是内部 ID,不是用户名
术语表第一条定义即确立了整个用户模型的基石:User 是一个持久的 Memos 账户,其稳定身份是内部 user ID,而非可变的 username 或显示名。这意味着用户名可以重命名、可以变更,但所有持久的关系(memo 归属、反应、通知目标)都必须绑定到 user ID 上。
Username 与 Writable username:一个格式,三条写路径
Username(用户名) 是用户自选的、大小写敏感的公共账户标识,用于认证、用户资源名、查询与引用。其核心语义可以概括为四条:
- 大小写保留:拼写不做隐式修剪或归一化,相等性是精确的 ASCII 字节相等——
Alice与alice是两个不同的用户名; - 与显示名和内部 ID 严格区分:用户名的拼写不授予任何角色,也不建立"可信"属性;
- 可写格式(writable username) 适用于账户创建、重命名和自动供应(provisioning)三条路径,规则为:
- 长度为 1~36 个 ASCII 字母、数字或连字符;
- 首尾必须是 ASCII 字母或数字;
- 内部连字符可以连续重复,无额外限制;
- 纯数字拼写没有特殊含义,
123也是合法的用户名; - 国际化的人类可读命名属于显示名(display name)的职责。
对应的规范化文法(来自 ADR 0002):
Username := Alphanumeric
| Alphanumeric UsernameCharacter{0,34} Alphanumeric
UsernameCharacter := Alphanumeric | "-"
Alphanumeric := ASCII letter | ASCII digit
上限取 36 的用意是:让每个 UUID 值都有一个能装下的规范化文本表示,同时保证公共 URL 与引用 token 保持有界。仓库中的实际实现是 IsValidUsername:
// MaxUsernameLength is the maximum number of ASCII characters in a writable username.
const MaxUsernameLength = 36
// IsValidUsername reports whether username satisfies the writable username format.
func IsValidUsername(username string) bool {
if len(username) == 0 || len(username) > MaxUsernameLength || !isASCIIAlphanumeric(username[0]) || !isASCIIAlphanumeric(username[len(username)-1]) {
return false
}
for i := 0; i < len(username); i++ {
if !IsUsernameCharacter(username[i]) {
return false
}
}
return true
}
实现与文法逐条对应:先做长度与首尾字符检查,再逐字节验证内部字符只属于字母、数字或 -。行为验证见 username_test.go 与前端镜像测试 username.test.ts。
ADR 0002 给出的一组示例边界值得保留:
| 值 | 是否可写 | 原因 |
|---|---|---|
alice、Alice-2、1alice |
是 | 仅字母 / 大小写保留、内部连字符 / 数字可开头 |
a--b、123、123-456 |
是 | 内部连字符无重复限制 / 纯数字合法 |
00000000-0000-0000-0000-000000000000 |
是 | 规范 UUID 恰好 36 字符 |
-alice、alice- |
否 | 连字符不能位于首尾 |
alice_smith、alice@example.com |
否 | _ 与邮箱语法在格式之外 |
álîçé、张三 |
否 | 可写格式仅限 ASCII |
此外,没有保留拼写:admin、root、memos 等都是普通用户名,按同样的精确大小写唯一性规则可用。
Legacy username:只读、可寻址、不可新写
Legacy username(遗留用户名) 指不满足可写格式、但为兼容性仍可读、可寻址的已存储用户名——ADR 0002 的上下文提到,存量安装中可能存在邮箱地址或含下划线的旧用户名。它们的边界是:不被新的写入校验接受,也不会被加入新的引用语法;所有查询路径必须保留其存储拼写。
数据库层面的大小写敏感相等性
由于"相等是精确 ASCII 字节相等",ADR 0002 进一步要求每个受支持的存储后端的 username 列都使用显式的大小写敏感二进制排序规则:MySQL 上用 utf8mb4_bin,PostgreSQL 上用 C,SQLite 上用 BINARY。这样精确字节唯一性就成为 schema 属性,而不是对各数据库默认排序规则的假设——任何把 Alice 与 alice 视为相等的 collation 或查询都不符合该决策。
Username reference、Mention candidate 与 Resolved mention
这三个术语构成"@ 提及"的完整识别—解析链条:
- Username reference(用户名引用):以精确、大小写敏感的方式命名一个用户名的源文本,使 Memos 可以尝试将其解析为 user ID。注意:源文本拼写本身不是持久的用户绑定——跨用户名重命名与复用的稳定性需要额外的持久化身份,而当前规范未定义它。
- Mention candidate(提及候选):由 ASCII
@后跟一个完整可写用户名形成的合格 markdown 源片段。识别时应用当前用户名引用规范中的边界与 markdown 上下文规则。关键设计是:用户名格式本身定义了提及边界——用户名允许的字母、数字、连字符,若出现在@右侧会阻断提及识别,若出现在候选左侧则被视为完整候选的一部分。其余字符不享受特殊边界处理。 - Resolved mention(已解析提及):其精确用户名在消费方操作既有的账户状态与可见性策略下解析到某个用户的提及候选。针对用户的效果作用于解析出的 user ID,而非未解析的源文本。
ADR 0002 规定的候选识别规则可归纳为:
@必须是合格字面源片段中的 U+0040;- 除文档开头外,紧邻的前一个源字符不得是
UsernameCharacter(即前置的字母、数字或连字符都会阻断识别); - 词法器消费
@之后完整的字母/数字/连字符连续段,然后对整段做可写用户名校验——不会把一个非法连续段截短成合法前缀; - GFM 邮箱识别优先,被识别为邮箱的子串不是提及。
由此产生的典型边界行为:
| 源文本 | 提及候选 |
|---|---|
@alice、hi, @alice.、中文@alice、foo_@alice |
alice |
@Alice-2、@123 |
Alice-2、123 |
hello@alice、foo-@alice、@-alice、@alice- |
无 |
@alice_smith、@alice@bob |
alice(_ 与 @ 是普通边界) |
@alice@example.com |
无(邮箱优先) |
@ 后跟 37 个合法字符 |
无(超出 36 字符上限) |
在 markdown 上下文层面,提及识别运行于 GFM 解析之后:段落、标题、引用、列表、表格文本、强调等暴露普通文本;而代码片段、代码块、已解析的链接与图片、autolink、GFM 邮箱、原始 HTML 与数学公式都是**不透明(opaque)**的。解析、阅读渲染、元数据提取、编辑器装饰与未来的提及补全必须使用同一套词法与上下文规则。
二、Spaces
Space 与 Space ID
Space(空间) 是实例范围内的协作边界,约束已接受成员与 memo 的放置。术语表特别强调它不是租户(tenant)、文件夹或应用级授权角色——这一界定防止把 Space 误当作多租户隔离或权限模型来使用。
Space ID 是 Space 的稳定内部身份,与公共的 Space UID 和可变的 Space title 三者严格区分。三者对照如下:
| 术语 | 可变性 | 用途 |
|---|---|---|
| Space ID | 不变 | 内部稳定身份 |
| Space UID | 不变(创建时分配,实例内唯一) | 公共标识,可用户自定义或自动生成 |
| Space title | 可变、不唯一 | 显示标签 |
Space 资源名与 UID 格式
Space 资源名 是 Space 在 API 中的身份,形式为 spaces/{space UID}。ADR 0003(Space UID 分配与格式) 决定了 UID 的分配方式与文法:
- 第一方客户端为每个新 Space 生成规范小写 UUID v4,通过
CreateSpaceRequest.space_id发送;该值可以在创建前暴露给用户并允许替换为自定义 UID,同一创建交互的重试复用同一生成值; - API 字段保持可选以兼容旧客户端,为空时服务器生成规范小写 UUID v4;
- 提供的值使用与 username 共享的公共资源文法:
SpaceUID := Alphanumeric
| Alphanumeric UIDCharacter{0,34} Alphanumeric
UIDCharacter := Alphanumeric | "-"
Alphanumeric := ASCII letter | ASCII digit
即 UID 为 1~36 字符,内部连字符可连续、允许大写字母、纯数字也合法,拼写保留。这一文法在仓库中的实现是 UIDMatcher:
UIDMatcher = regexp.MustCompile(`^a-zA-Z0-9?$`)
与用户名文法完全同构——这正是 ADR 0003 决策驱动中"复用已建立的公共资源 UID 文法,而不是引入 Space 专属 slug 格式"的落地。ADR 同时明确:旧客户端省略该字段时由服务器回退生成,存量短 UID 保持有效且不做数据迁移,UID 冲突由既有的实例内唯一性约束拒绝。多空间设计背景可参见 multi-spaces 设计文档,各存储后端的实现分别在 sqlite/space.go、mysql/space.go 与 postgres/space.go。
三、Tags
标签是 Memos markdown 语言的一部分,而不只是编辑器装饰:一次标签出现会影响渲染内容、memo 载荷中派生的 tags、API 响应、标签计数、过滤、元数据与编辑器补全。术语表对标签域的定义可组织为"识别 → 取值 → 相等 → 集合 → 计数"的链条。
识别层:Tag、Tag introducer 与 Tag occurrence
- Tag(标签) 是 memo 标签集合中的一个分类值,由一个或多个标签出现派生,形式可以是直接值(direct tag value)也可以是隐含祖先(implied ancestor)。标签不是持久实体,不能被独立创建或重命名——改一个标签的拼写意味着编辑产生它的 memo 源文本。
- Tag introducer(标签引导符) 是 ASCII
#(U+0023),且不构成已匹配的完全限定 emoji 序列的一部分。视觉上相似的全角井号(U+FF03)与小号井号(U+FE5F)不是引导符,只是普通文本。 - Tag occurrence(标签出现) 是由引导符与其消费的源拼写组成的合格源片段;拼写发出一个 tag identifier,即直接标签值。识别规则(包括排除的 markdown 上下文)由当前标签语法配置(ADR 0001)定义。
- Eligible tag text(合格标签文本) 是不含任何 markdown 语法边界的中断源范围,按 GFM 0.29-gfm 归类为文本内容,或被 Memos markdown 扩展显式暴露为普通文本。扩展节点默认不透明,除非其定义显式选择参与识别——这保证了未来自定义扩展不会意外泄漏出标签。
取值层:Source spelling 与 Direct tag value
- Source spelling(源拼写) 是
#引导符之后被消费的精确源子串,包含两个词法器会忽略的部分:已匹配的完全限定 emoji 序列之外的默认可忽略码点(default-ignorable code points),以及每个段(segment)起始符之前被忽略的前置组合标记。行内渲染与保源操作用这个拼写。 - Direct tag value(直接标签值) 是一个标签出现经过层级展开之前发出的 identifier。与源拼写的差异在于:上述两类被忽略的码点从源拼写中被消费,但从值中省略。例如
#book/fiction产生的直接值就是book/fiction。
层级层:Tag segment、Implied ancestor 与 Tag identifier
- Tag segment(标签段) 是层级标签 identifier 中的非空组件。
/分隔各段,且只在其后跟随另一个非空段时才被消费:前导斜杠不产生标签;尾部或重复的斜杠在该斜杠处终结 identifier。-、+、&是普通段单元:可出现在任意位置、可重复、可以构成整个段。默认可忽略码点与被忽略的前置组合标记本身不能让一个段变为非空。 - Implied ancestor tag(隐含祖先标签) 由直接值按斜杠切分的前缀派生:直接值
book/fiction/history隐含祖先标签book与book/fiction。 - Tag identifier(标签标识符) 是从标签源拼写发出的非空 Unicode 码点序列,排除引导符与所有被忽略的源码点。
相等性层:Display value 与 Comparison key
- Display value(显示值) 是作为派生标签标签呈现的直接值或隐含值。Memos 不对发出的码点做归一化或 case-fold,但被忽略的默认可忽略码点与被忽略的前置组合标记不属于显示值。
- Comparison key(比较键) 是用于去重、计数、过滤、导航与精确元数据匹配的值,与显示值完全相同。两个标签只有当发出的 Unicode 码点序列相同时才比较相等;大小写不同、规范化等价或兼容性等价的拼写都是不同标签。反过来,仅因被忽略码点而不同的源拼写比较为相等。另外,精确值过滤与元数据查询的输入已经是标签值,按原样比较,不会被重新词法分析。
ADR 0001 给出的相等性示例很能说明问题:#Work 与 #work、#A 与 #A、#straße 与 #STRASSE、#O'Brien(U+0027)与 #O'Brien(U+2019)各是两对不同的标签;而 #AB、#AB(含 ZWJ)、#AB(含 ZWNJ)三者发出同一比较键 AB。
集合层:Memo tag set、Tag metadata rule 与 Tag count
- Memo tag set 是一个 memo 的直接标签值与其隐含祖先的并集,通过
Memo.tags字段暴露。它是可从 memo 的 markdown 重建的派生索引,不是标签的权威来源;同一 memo 中精确相等的值只出现一次(即使它既被直接产生又作为祖先产生)。对应的数据定义在 memo.proto 中:repeated string tags = 3;,即标签只是 memo 上的一组字符串投影。 - Tag metadata rule(标签元数据规则) 是用户配置,用于选择标签值并提供展示或行为元数据(如颜色、内容模糊)。一条规则可以匹配多个值,但不创建、不拥有、不重命名标签。
- Tag count(标签计数) 是包含精确相等直接值或隐含值的 memo 标签集合的数量,而不是文本出现次数。一个只含
#book/fiction的 memo,对book与book/fiction的计数各贡献 1。
源码落地:标签识别在哪里执行
Go 端的词法实现在 internal/markdown/parser/tag.go 中。TagMatch 结构保存一次识别的字节偏移范围与发出的直接值:
type TagMatch struct {
// Start is the byte offset of the introducer within the source run.
Start int
// End is the exclusive byte offset of the recognized source spelling.
End int
// Value is the emitted direct tag value without the introducer.
Value []byte
}
这正体现了术语表中"识别源 span 含引导符,发出值不含引导符"的区分。识别基于固定版本的 Unicode 17.0 属性表(XID_Continue、Default_Ignorable_Code_Point 等),由 tagdata 生成器 离线生成 tag_unicode_tables.go;emoji 匹配则使用 Emoji 17.0 数据中的 fully-qualified 条目做最长序列匹配(见 matchFullyQualifiedEmoji),而不是简单码点字符类——这就是术语表反复强调"已匹配的完全限定 emoji 序列"的原因。词法行为由 tag_test.go 与前端镜像测试(如 tag-grammar.test.ts、tag-unicode-parity.test.ts)共同锁定,前端实现入口在 web/src/lib/tag.ts 与 remark 扩展 tag.go 对应的 extensions/tag.go。
ADR 0001 中的两个关键语义值得单独点出,因为它们直接决定"计数与过滤"的正确性:
- 极大前缀扫描:
#book/产生book(尾部斜杠不消费),#book//fiction产生book(重复斜杠在第一个斜杠前终结),#/book不产生标签——即"保留已消费的有效前缀,后续非法字符不使其失效"。 - markdown 上下文过滤:
**#urgent**中urgent是标签(格式化文本仍合格),而#tag、\#tag、内联代码`#tag`、链接hello#tag、数学节点$#tag$中都不产生标签——转义、字符引用与不透明节点是硬边界,候选不能跨越它们。
术语间的一致性设计
通读术语表后可以提炼出贯穿三个领域的统一原则,这也是把术语表与 ADR、源码放在一起读的价值所在:
- 稳定身份与公共标识分离:User 的 user ID、Space 的 Space ID 是稳定身份;username、Space UID、title 是公共/显示层。三者都不可互换。
- 公共标识共用一套文法:username 与 Space UID 使用同构的"1~36 字符、字母数字首尾、内部连字符"文法,对应源码中 IsValidUsername 与 UIDMatcher 两处实现,避免各写路径各自发明 token 语法。
- 派生值永远从源文本重建:
Memo.tags是派生投影,标签不是实体;username reference 的源拼写不是持久绑定。凡是"从源派生"的东西,其正确性以 markdown/源文本为准,存储副本只是可重建索引。 - 相等性处处精确:用户名是精确 ASCII 字节相等(并落到数据库二进制 collation),标签是精确 Unicode 码点序列相等(不做 case fold 或规范化)。Memos 刻意不引入任何 locale 敏感的模糊相等,让"两个拼写是否相同"成为确定性的机器判定。
如果你要扩展这些领域(例如给编辑器加提及补全、给 Space 加新的标识展示位、或调整标签元数据行为),建议的路径是:先对照 docs/glossary.md 确认术语边界,再查对应的 ADR 决策记录(ADR 0001、ADR 0002、ADR 0003),最后到 internal/base、internal/markdown 与 store/db 中定位既有实现与测试,保证新行为与"精确相等、源文为准、派生可重建"这三条原则一致。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00