标签分类与导入导出
这两个功能都服务于同一件事:让代码源清单可持续维护。标签解决「源多了以后怎么找」, 导入导出解决「换机器、换同事、备份时怎么搬」。
它们都不触碰分析引擎——不影响契约匹配、传播、评分与报告。
标签是什么,不是什么
| 是 | 不是 |
|---|---|
| 一种检索与分组手段 | 分析范围的过滤器(打标签不会限定扫描哪个子集) |
| 一个源可以有多个 | 一个源只能属于一个的分类目录 |
展示元数据,存在 repo_source.tags |
契约、模块或依赖的一部分 |
明确这一点很重要:如果标签参与分析,它就必须被索引、被缓存失效、被写进报告, 那会把它从「贴纸」变成「配置」,而绝大多数人只是想要前者。
归一化规则
规则集中在 com.geek.association.transfer.SourceTags,Web UI、REST、导入文件共用:
- 按英文逗号与中文逗号拆分(中文输入法产出的全角逗号同样识别);
trim,丢弃空串;- 大小写不敏感去重,保留首次出现的写法;
- 单个标签超过 24 字符的直接丢弃;
- 最多保留 12 个。
永不抛异常。 标签是检索辅助,一个多打的逗号不该让保存失败。
前端 normalizeTags() 是同一套规则的镜像,用于即时反馈;服务端的答案始终权威。
tags 字段同时接受两种形态:
curl -X POST localhost:9996/api/sources -H 'Content-Type: application/json' \
-d '{"type":"LOCAL_DIR","location":"/work/a","tags":["payment","core"]}'
curl -X POST localhost:9996/api/sources -H 'Content-Type: application/json' \
-d '{"type":"LOCAL_DIR","location":"/work/b","tags":"payment, core"}'
第二种能成立是因为 RepoSource.tags 走了自定义反序列化器(SourceTagsDeserializer)——
否则 Jackson 会在归一化之前就回 400。手写 curl 和表单习惯不同,没有必要为此设一道墙。
侧栏分组
GET /api/tags 返回全部在用标签与计数,按 count 降序、同 count 按字母序:
[{ "tag": "payment", "count": 3 }, { "tag": "legacy", "count": 1 }]
侧栏据此建组,顺序继承这个排序,所以最常用的组在最上面。「未分类」永远排最后。
一个源属于多个标签时在每个组里都出现。这是多标签与目录树的本质区别: 目录树要求你选一个归属,标签允许你从任意一个角度找到它。
折叠状态存在 localStorage 的 ca.navtags,是每个浏览器各自的偏好,不上服务端。
导出
GET /api/sources/export,带 Content-Disposition,文件名形如
code-association-sources-20261026-084158.json(时间戳避免同一天多次导出互相覆盖)。
{
"kind": "code-association.sources",
"version": 1,
"exportedAt": "2026-10-26T08:41:58.488818Z",
"sources": [
{ "name": "alpha", "type": "LOCAL_DIR", "location": "/work/alpha",
"defaultRef": "main", "credentialEnv": null, "tags": ["payment", "core"] }
]
}
字段顺序固定,所以同一个注册表导出两次的 diff 是空的——这让文件在导入别人机器之前是可审阅的。
不导出的字段及原因:
| 字段 | 为什么不导出 |
|---|---|
id |
自增主键,在另一台机器上没有意义 |
status / message |
本机的同步与可用性状态,带过去会覆盖对方的健康记录 |
createdAt / lastSyncedAt |
同上,是运行时痕迹 |
凭据本身从不入库,导出的只是环境变量名(如 GITHUB_TOKEN),不含任何密钥。
导入
POST /api/sources/import,请求体就是导出结构(也接受裸数组或单个对象)。
判定「同一个源」
按 type + location 匹配,location 比较前做归一化:去首尾空白、去尾部斜杠。
/work/a/ 与 /work/a 是同一个源,LOCAL_DIR 与 local_dir 也是同一个类型。
不做归一化的后果很具体:有人手工改过路径里的斜杠,再导一次就会多出一份重复的代码源, 而且两份都是「有效」的,谁也说不清该删哪个。
合并规则
| 情况 | 行为 |
|---|---|
| 已存在 | 更新这一行:标签取并集,defaultRef / credentialEnv 仅在传入值非空时覆盖 |
| 不存在 | 新建,name 为空时按位置推导(同 SourceNames) |
| 单条校验失败 | 记入 errors,继续处理其余条目 |
| 本地目录不存在 | 仍然导入,但置 status = MISSING |
三点设计取舍值得说明:
为什么标签是并集而不是替换。 本机的标签是有人花时间整理的,导入是「补充」 而不是「以这份文件为准」。替换会让一次导入抹掉别人的整理成果。
为什么空值不覆盖。 一份在配置分支之前导出的文件,不应该把对方已经设好的
defaultRef 清空。只有「文件里确实写明了一个值」才构成「我要改它」的意图。
为什么坏条目只记录不中断。 从新版本导出的文件可能带旧版本不认识的类型; 为此拒绝整份文件,会让这个功能恰好在最需要的时候失效。
为什么目录不存在还要导入。 导出导入的核心用途就是搬到另一台机器,
那里的路径当然还不存在。直接失败等于宣布这个功能不可用;
静默标 OK 又会让用户面对一个跑不动的源却没有任何提示。所以记录 MISSING 交给列表显示。
返回结构
{
"total": 3,
"created": 1,
"updated": 1,
"skipped": 1,
"errors": [{ "index": 2, "name": "broken", "reason": "不支持的 type:NOT_A_TYPE" }]
}
界面把它渲染成三行计数加逐条错误列表,而不是一句「导入失败」—— 部分成功是常态,用户需要知道哪些成功了。
幂等性
导入自己刚导出的文件,应当全部计为 updated,且标签与配置毫无变化。
这一条有测试守着(SourceTransferTest.roundTripIsIdempotent),因为它是判断
「合并规则有没有写错」最省事的信号。