Memos ADR 0002 详解:统一的用户名格式、大小写敏感解析与 @mention 识别规则
本文基于 Memos 仓库中已接受的架构决策记录 ADR 0002: Username Format and References(Status: Accepted,Date: 2026-08-02)展开,完整解析 Memos 可写用户名的规范性语法、大小写敏感的恒等与解析规则、三大存储后端的排序规则落地方式,以及 Markdown @mention 作为用户名引用语法的全部边界规则;并对照 internal/base/username.go、internal/markdown/parser/mention.go 等源码,展示该决策在验证、解析与数据库层面的真实实现。读完本文,你可以准确回答:一个字符串在 Memos 中何时是合法的新用户名、何时能被 @mention 识别、以及为什么 Alice 与 alice 必须是两个不同的用户。
一、背景:多个写入路径曾各自定义“类用户名”
ADR 的 Context 部分指出,用户名(username)是贯穿以下场景的用户标识符:账号创建与更新、认证、公开用户资源名、API 查找、SSO 供给,以及用户对用户的引用(references)。这些用途必须共享一种格式,而不是各自发明一套“类用户名”的 token。
Memos 早已把新写入的用户名限制为 ASCII 字母、数字与连字符,长度不超过 36,且首尾必须是字母或数字;但既有安装中仍可能存在旧格式用户名,例如邮箱地址或含下划线的值。Memos 继续读取并解析这些遗留值,只是它们不再是合法的新用户名。
真正制造漂移的是 Markdown mention:在决策落地前,后端最多接受 63 个 Unicode 字母/数字/连字符,前端最多接受 63 个 ASCII 字符,两者都允许非法的用户名形态,且抽取时会把结果转小写;编辑器高亮还会在 Markdown 应保持不透明(opaque)的上下文中直接扫描原始源码。
因此 ADR 0002 明确定义了三件事:
- 可写用户名的格式与大小写保留语义;
- 用户名引用(username reference)如何解析;
- Markdown mention 作为用户名引用的一种来源形式。
同时明确划出范围外事项:遗留用户名数据的迁移、用户名改名与复用策略、已解析 mention 在这些生命周期事件中的持久绑定、通知时机、去重、访问策略与自提及行为等下游效应,均不在本决策之内。领域词汇表见 Memos domain glossary。
决策驱动因素
ADR 列出了六条决策驱动因素,它们是理解后文每条规则“为什么这样定”的钥匙:
- 为所有用户名写入路径提供一条小而稳定的验证规则;
- 让引用直接复用用户名格式,而不是另造一种标识符语法;
- 保留用户所选拼写与大小写;
- 保持“写入验证、引用渲染、抽取、编辑器识别、通知解析”五处行为对齐;
- 使用解析后的 Markdown 上下文(而非特判正则)来处理代码、链接、邮箱等不透明语法;
- 保持识别过程确定且对 memo 尺寸线性。
核心术语
ADR 的 Terminology 部分定义了一组精确术语,后文的规则描述全部建立在这些定义之上:
| 术语 | 定义 |
|---|---|
| User | 由不可变内部 user ID 标识的持久 Memos 账号 |
| Username | 用户自选、大小写敏感的公开标识,用于账号寻址;区别于显示名(display name)与内部 ID |
| Writable username | 本决策生效后,账号创建、改名或供给时接受的用户名 |
| Legacy username | 不满足可写格式、但为兼容仍可读、可寻址的已存储用户名 |
| Username reference | 以文本形式指向某用户名的源码片段,供 Memos 尝试解析为 user ID;源拼写本身不构成持久关系 |
| Mention candidate | 形为 @ + 完整可写用户名、且满足 mention 边界与上下文规则的 Markdown 源码片段 |
| Resolved mention | 其用户名在使用方操作既有的账号状态与可见性策略下能解析到用户的 mention candidate |
二、可写用户名格式:一条规范性文法
2.1 文法与规则
ADR 给出的规范性文法如下:
Username := Alphanumeric
| Alphanumeric UsernameCharacter{0,34} Alphanumeric
UsernameCharacter := Alphanumeric | "-"
Alphanumeric := ASCII letter | ASCII digit
ASCII letter := "A".."Z" | "a".."z"
ASCII digit := "0".."9"
展开为可操作的规则:
- 用户名为 1 到 36 个 ASCII 字符;
- 仅允许 ASCII 字母、数字与
-; - 首尾字符必须是字母或数字;
- 内部连续连字符合法,没有额外数量限制;
- 纯数字值合法;
- 大小写被原样保留(case preserved)。
要求“首尾必须是字母数字”一举两得:既排除了纯连字符值,也避免末尾连字符在 Markdown mention 中变成看似属于用户名的一部分的歧义字符。
长度下限为 1,是因为 Username 不需要非空之外的产品级命名稀缺策略;上限取 36,是为了让每个 UUID 值的规范文本表示都能装下,同时保持公开 URL 与引用 token 有界。仅允许 ASCII 也是有意为之:Username 是用于 URL、认证与引用的机器面向公开标识符,国际化的人类可读命名应归 Display Name 所有,而不是扩大 Username 的规范化与比较规则。
2.2 完整示例表
ADR 给出的合法性示例(与仓库测试用例一一对应):
| 值 | 可写? | 原因 |
|---|---|---|
alice |
是 | 仅字母 |
Alice-2 |
是 | 大小写保留;内部连字符合法 |
1alice |
是 | 含字母时数字可以开头 |
a--b |
是 | 内部连续连字符合法 |
a---b |
是 | 内部连字符无重复上限 |
123 |
是 | 纯数字无特殊含义 |
123-456 |
是 | 数字加内部连字符满足文法 |
00000000-0000-0000-0000-000000000000 |
是 | 规范 UUID 恰好占 36 字符 |
-alice |
否 | 连字符不能开头 |
alice- |
否 | 连字符不能结尾 |
alice_smith |
否 | _ 不在格式内 |
alice@example.com |
否 | 邮箱语法不在格式内 |
álîçé |
否 | 可写格式是 ASCII |
张三 |
否 | 国际化命名属于 Display Name |
这些示例并非凭空规定:单测 internal/base/username_test.go 中 TestIsValidUsername 逐字覆盖了上表全部用例(包括 36 字符上限、37 字符越界、-、---、alice、alice smith 等非法形态),是验证规则与 ADR 保持一致的回归防线。
2.3 源码实现:IsValidUsername
可写格式的唯一实现位于 internal/base/username.go:
// 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
}
// IsUsernameCharacter reports whether an ASCII byte may occur in a writable username.
func IsUsernameCharacter(char byte) bool {
return isASCIIAlphanumeric(char) || char == '-'
}
两个实现细节值得注意:
- 函数按字节(而非
rune)遍历,天然保证 ASCII 语义——任何非 ASCII 字节都会直接落入IsUsernameCharacter的 false 分支; - 首尾检查先于逐字符扫描执行,
len(username) == 0的短路同时覆盖了“非空”下限。
API 层的写入校验直接复用该原语。例如 server/router/api/v1/user_resource_name.go 中的 validateWritableUsername 只是对 base.IsValidUsername 的一次包装,错误信息为 invalid username %q;同一文件中的 BuildUserName 将用户名拼进公开资源名(UserNamePrefix + username),ResolveUserByName 则按精确用户名查询存储——这正是 ADR 所说“资源名段总是按用户名解析”的实现路径。
ADR 特别强调:通用资源名规则不是用户名契约,即使当前正则相似也不得作为替代复用。对照 internal/base/resource_name.go 可以看到它确实是一套独立的正则 UIDMatcher(^a-zA-Z0-9?$),两者形态相近但契约不同——这正是 ADR 提醒“不得因为正则相似就混用”的原因。
三、用户名分配:无保留拼写与 SSO 供给策略
ADR 的 Username allocation 小节规定:
- 可写格式没有任何保留拼写。
admin、root、system、api、memos、support都是普通用户名,与其他值一样受完全相同的大小写精确唯一规则约束; - 用户名本身不授予任何权限或信任——这些属性来自解析后的 user ID、角色与显式的产品展示;
- 手动注册、改名与自动供给执行同一策略;
- 若未来需要系统占用的用户名,对应账号通过普通唯一性规则“抢占”该拼写,而不是扩大词法保留清单。
SSO 供给的规则是:当外部标识符本身是一个合法且可用的可写用户名时,SSO 供给原样采用它;否则(非法或已被他人占用),供给一个生成的规范 UUID。匹配到同名用户名永远不会关联或接管某个账号——识别回归 SSO 用户的,只有由 identity-provider key 与外部 subject 构成的持久身份绑定。
由于可写格式允许纯数字且上限恰好容纳 UUID,这条“供给失败退化为 UUID”的路径在格式层面是自洽的:00000000-0000-0000-0000-000000000000 本身就通过 IsValidUsername(单测中有对应用例)。
四、用户名恒等与解析:大小写敏感的精确字节相等
4.1 相等性规则
ADR 的 Username identity and resolution 小节是整篇决策中最强的约束:
- 用户名的拼写是大小写保留的,相等性判断是精确 ASCII 字节相等;
- Memos 不在验证、唯一性检查、认证、资源查找或引用解析之前对用户名做小写化、case-fold 或任何规范化;
- 前后空白不会被 trim 成另一个用户名——
alice直接非法; Alice与alice是两个不同的用户名,可以指向不同的用户。
并且这一规则被明确要求落到存储层:所有存储后端与应用查找路径都必须保持该相等性规则。任何把 Alice 与 alice 视为相等的数据库 collation,或把一种拼写静默解析到另一种的查找实现,都不符合本决策。
4.2 数据库 collation 的具体落地
ADR 规定 username 列在每个受支持的存储后端上使用显式的大小写敏感二进制 collation:MySQL 用 utf8mb4_bin,PostgreSQL 用 C,SQLite 用 BINARY。对可写的 ASCII 格式而言,这让“精确字节的唯一性与查找”成为模式属性,而非对数据库默认 collation 的假设。
仓库中的迁移文件印证了这一点:
- MySQL:store/migration/mysql/0.31/05__case_sensitive_username.sql 执行
MODIFY username VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;, 而 store/migration/mysql/LATEST.sql 中user表定义即为`username` VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL UNIQUE; - PostgreSQL:store/migration/postgres/0.31/05__case_sensitive_username.sql 执行
ALTER COLUMN username TYPE TEXT COLLATE "C";, store/migration/postgres/LATEST.sql 中对应username TEXT COLLATE "C" NOT NULL UNIQUE; - SQLite:store/migration/sqlite/LATEST.sql 中对应
username TEXT COLLATE BINARY NOT NULL UNIQUE。
三处 LATEST 模式文件保持同一语义,意味着从任一头开始的新装或升级都得到相同的大小写敏感唯一约束。
4.3 解析算法与遗留用户名
带用户名的字段或资源名段总是按用户名解析,即使其拼写全是数字;字符形态不得用于选择内部 user-ID 命名空间——按内部 ID 寻址的操作必须单独显式表达这一选择。
用户的稳定身份是内部 user ID。用户名引用在使用方操作允许的最晚时机解析:
- 原样保留所写的用户名;
- 在该操作的可见性与账号状态范围内查找该精确用户名;
- 若解析成功,对持久效应使用解析出的 user ID;
- 若解析失败,保留普通源文本,不产生任何用户指向效应。
产品意图允许用户名改名与后续复用,同时要求已解析的 mention 不改变指向的用户。仅靠源拼写无法满足这三者:保留原始目标需要在 @username 文本之外持久绑定到 user ID。ADR 明确不定义该绑定、历史重解析行为或支撑它的用户名生命周期——这是留给后续决策的显式跟进项。
遗留用户名(邮箱样、含下划线等)对既有 API 资源名与认证流而言仍是有效的精确查找键;它们不被写入验证接受,也不会进入新的引用语法。所有查找都保留其存储拼写。
五、Markdown mention:第一个定义的用户名引用语法
5.1 文法
MentionCandidate := "@" Username
其中 Username 就是上文完整可写格式。mention 解析没有独立的字符集或长度上限——它完全复用用户名文法,这正是决策驱动因素中“引用不另造标识符语法”的直接体现。
5.2 候选识别的四条规则
@必须是 U+0040 COMMERCIAL AT,且位于合格的字面源码 run(literal-source run)中;- 若位于文档开头,左边界天然满足;否则紧邻的前一个源码字符不得是
UsernameCharacter。也就是说用户名格式自身定义了边界:前面出现字母、数字或连字符都会阻断识别; - 词法器消费
@之后完整的 ASCII 字母/数字/连字符连续段,然后把整段作为可写用户名验证;绝不把一个非法段截短成合法前缀; - GFM 邮箱识别优先:被识别为邮箱的地址的子串不是 mention。
_、@、非 ASCII 等格式外字符是普通边界,不做特殊处理。
5.3 完整行为表
ADR 规定的输入输出(与测试 internal/markdown/parser/mention_test.go 的用例对齐):
| 源码 | Mention candidate |
|---|---|
@alice |
alice |
@Alice-2 |
Alice-2 |
hi, @alice. |
alice |
中文@alice |
alice |
hello@alice |
无 |
foo-@alice |
无 |
foo_@alice |
alice |
@123 |
123 |
@-alice |
无 |
@alice- |
无 |
@alice_smith |
alice |
@alice@example |
alice |
@alice@bob |
alice |
@alice@example.com |
无 |
@ 后跟 37 个合法用户名字符 |
无 |
5.4 源码实现:整段验证与边界判定
核心词法器是 internal/markdown/parser/mention.go 中的 FindMentionMatches(L28-L58)。其行为与规则逐条对应:
- 左边界由
hasMentionLeftBoundary(L19-L24)判定:pos == 0时取决于调用方传入的runHasLeftBoundary(即该 run 在文档中是否位于开头),否则要求source[pos-1]不是base.IsUsernameCharacter——这就是hello@alice、foo-@bob被排除而中文@alice、foo_@alice被接受的机制; - 整段消费:
for end < len(source) && base.IsUsernameCharacter(source[end]) { end++ }先吃满整个[A-Za-z0-9-]连续段,然后对整段调用base.IsValidUsername验证。因此@alice-整段alice-非法即整体拒绝(不会截成alice),@后 37 个字符因超长整体拒绝;@alice_smith中词法段是alice(_是边界),故候选为alice; - 转义处理:循环开头
\+ 标点直接跳过两个字符,使得\@alice中的@不构成引导符(测试用例escaped introducer验证了\@alice @bob只产出bob)。
匹配结果结构 MentionMatch 携带 Start/End(相对 run 的字节偏移)与不含 @ 前缀的精确 Username 字节,保证大小写在解析阶段即被保留。
AST 节点定义见 internal/markdown/ast/mention.go:
// MentionNode represents an @mention in the markdown AST.
type MentionNode struct {
gast.BaseInline
// Username without the @ prefix.
Username []byte
// Source is the exact recognized source spelling, including the @ prefix.
Source []byte
}
Username 存精确用户名,Source 存含 @ 的完整源拼写——两者都原样保留,不做任何归一化。
扩展的注册在 internal/markdown/extensions/mention.go:MentionExtension 以 AST 变换器形式挂入 goldmark,优先级 1050,注释明确写道 “GFM email recognition runs at 950 and tag recognition at 1000”——即 mention 识别运行在邮箱与 tag 之后,这正是识别规则第 4 条(GFM 邮箱优先)在管线中的位置保证。Transform(L32-L42)只对 eligibleLiteralTextNodes 返回的合格字面文本节点工作,先 mergeAdjacentLiteralText 合并相邻字面文本,再逐节点执行 replaceMentionsInText,把命中的片段从 ast.Text 中拆出并替换为 MentionNode。
5.5 Markdown 上下文规则
mention 识别在 GFM 与 Memos 的块级/行内结构解析完成之后、作用于原始 Markdown 源码,与其他 Memos 行内扩展共用同一套字面源码 run 与不透明节点模型:
- 暴露普通文本的上下文:段落、标题、引用、列表项、表格文本、强调、强强调、删除线;
- 不透明的上下文:行内代码、代码块、已解析的链接与图片、autolink、被识别的 GFM 邮箱、原始 HTML、数学;
- 转义、字符引用、行结束与语法分隔符都会终止一个字面源码 run;由转义或字符引用产生的
@不是引导符; - 未知的未来 Markdown 扩展节点默认不透明,除非其定义显式暴露普通文本。
ADR 要求:解析、只读渲染、元数据抽取、编辑器高亮与任何未来的 mention 补全都必须使用同一套词法与上下文规则。该决策只改变“哪些精确用户名拼写被抽取为 mention 候选”,既有的解析、渲染、通知与访问策略消费这些候选时行为不变;自动补全、建议排序与专用结构化 mention payload 亦不在本决策范围。
六、影响(Consequences)
按 ADR 原文整理,本决策带来的系统级后果:
- 用户名验证成为所有写入路径共享的领域原语;
- 用户名唯一性、认证、资源查找与引用解析在所有存储后端使用同一条大小写敏感相等规则;
- mention 语法只在 ADR 被取代且两处用法同步更新时,才会跟随未来的用户名格式变化;
- 既有的合法混合大小写用户名可以在不转小写的情况下被 mention;
- 仅被旧 63 字符或 Unicode mention 词法器接受的值,因其从未是可写用户名,不再是 mention 候选;
- 遗留的邮箱样或含下划线用户名仍可通过兼容查找使用,但不能再作为新的 Markdown mention 书写;
- 基于 Markdown 上下文的识别消除了来自代码、链接、邮箱、HTML、数学、转义与字符引用的假 mention;
- 用户名改名、复用与历史 mention 稳定性仍是显式跟进项——仅凭源文本无法表达预期的稳定用户绑定。
七、被考虑的替代方案
ADR 记录了四条被拒绝或推迟的路线,理解它们有助于把握设计边界:
- 先定义 mention 语法:被拒。它会制造第二套类用户名语法,允许账号验证与引用再次漂移。
- 允许比可写用户名更宽的 mention token:被拒。这类候选无法可靠地指向新可写账号,并使边界、验证与 UI 文案复杂化。
- 在 mention 中存储稳定 user ID:推迟。稳定 ID 能让改名与复用更稳健,但需要新的源表示或持久化出现位置绑定,外加编辑体验、导入导出行为与兼容性策略。精确用户名引用足以支撑当前范围内的候选识别,但解决不了历史绑定问题。
- Unicode 用户名:推迟。安全的 Unicode 标识符设计需要固定的 Unicode 版本、规范化、书写系统与易混淆字符策略,以及一致的数据库 collation;ASCII 让决策保持最小,并与既有可写格式一致。
ADR 的 References 一节还列出了 GFM 0.29、Unicode TR31(Unicode Identifiers and Syntax)、Unicode TR39(Unicode Security Mechanisms)、ActivityP Streams 2.0 Vocabulary 的 Mention 定义等业界参照,用于说明 mention 与国际化标识符在生态中的通行做法。
八、如何继续深入
- ADR 原文与索引:docs/adr/0002-username-format-and-references.md、docs/adr/README.md(同系列另有 ADR 0001 Tag 语法 与 ADR 0003 Space UID,可对照阅读其共享的“字面源码 run”模型);
- 格式验证原语及其测试:internal/base/username.go、internal/base/username_test.go、internal/base/resource_name.go;
- mention 词法、AST 与扩展管线:internal/markdown/parser/mention.go、internal/markdown/parser/mention_test.go、internal/markdown/ast/mention.go、internal/markdown/extensions/mention.go;
- 大小写敏感的 schema 迁移:store/migration/mysql/0.31/05__case_sensitive_username.sql、store/migration/postgres/0.31/05__case_sensitive_username.sql、store/migration/sqlite/LATEST.sql;
- 资源名构建与精确查找的 API 侧实现:server/router/api/v1/user_resource_name.go。
一句话总结 ADR 0002 的工程价值:它把“用户名”从散落在前后端与数据库默认行为中的隐式约定,收敛为一条可单测的文法(IsValidUsername)、一条可单测的词法规则(FindMentionMatches)和一组显式的 schema 属性(三种后端的二进制 collation),让写入验证、引用识别、渲染与查找在同一个大小写敏感的精确相等语义下永久对齐。
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