SKILL.md 8.6 KB


name: tag-system description: 标签系统的设计、存储、导入导出和打标模式。适用于需要实现标签/分类功能的 Spring Boot + Vue 项目。 version: 1.0.0

source: local-code-analysis

标签系统 (Tag System)

本项目的标签系统提供标签组管理、树形标签、导入导出、通用打标四大能力。

1. 数据模型

1.1 三表结构

tag_group          标签组(分类维度)
  ├── id, name, description, sortOrder, createdAt, updatedAt

tag                标签节点(自引用树,每个组有一个同名根节点 parentId=null)
  ├── id, groupId, parentId, name, sortOrder, createdAt, updatedAt

tag_assignment     打标关联(通用多对多)
  ├── id, entityType, entityId, tagId, createdAt
  └── 唯一约束: (entity_type, entity_id, tag_id)

1.2 关键约定

  • 同名根节点:创建标签组时自动生成一个 parentId=null 的根标签,名称与组名相同。用户操作的标签都是根节点的子孙。
  • 扁平存储,前端建树listTags 返回扁平列表,前端 buildTree() 根据 parentId 构建树结构。
  • 导出跳过根节点:导出时 buildTagTree 只输出根节点的子节点,不包含根节点本身。
  • 无 JPA 关系注解:三张表通过逻辑外键(groupId、parentId、tagId)关联,不使用 @OneToMany/@ManyToOne

2. 后端代码结构

model/entity/
  TagGroup.java          标签组实体 @Table(name = "tag_group")
  Tag.java               标签节点实体 @Table(name = "tag")
  TagAssignment.java     打标关联实体 @Table(name = "tag_assignment")

model/dto/
  TagExportDTO.java       导出格式(嵌套树结构)
    └── TagGroupExport    { name, description, sortOrder, tags: List<TagNodeExport> }
    └── TagNodeExport     { name, sortOrder, children: List<TagNodeExport> }  递归嵌套
  TagImportDTO.java       导入请求 { data: TagExportDTO, strategies: Map<name, strategy> }
  TagImportResultDTO.java 导入结果 { created, overwritten, merged, skipped, details }

model/vo/
  TagBriefVO.java         标签简要信息 { tagId, tagName, groupId, groupName }

repository/
  TagGroupRepository      findAllByOrderBySortOrderAsc
  TagRepository           findByGroupIdOrderBySortOrderAsc, findByParentIdOrderBySortOrderAsc, deleteAllByGroupId
  TagAssignmentRepository findByEntityTypeAndEntityId, deleteAllByEntityTypeAndEntityId

service/TagService        接口
service/impl/TagServiceImpl  实现(包含导入导出、合并算法、打标)

controller/TagController  REST API(/api/tags/*)
  └── 请求体 DTO 均定义为 static inner class(CreateGroupReq, SetTagsReq 等)

3. API 端点一览

3.1 标签组 CRUD

GET    /api/tags/groups              列出所有标签组
POST   /api/tags/groups              创建标签组
POST   /api/tags/groups/{id}/edit    更新标签组
POST   /api/tags/groups/{id}/delete  删除标签组

3.2 标签节点 CRUD

GET    /api/tags/groups/{groupId}/tags     获取该组全部标签(扁平列表)
POST   /api/tags/groups/{groupId}/tags     创建标签节点
POST   /api/tags/{id}/edit                 重命名标签
POST   /api/tags/{id}/delete               删除标签(递归删除子孙)
POST   /api/tags/{id}/move                 移动标签(含防循环检测)

3.3 导入导出

GET    /api/tags/export     导出所有标签组(嵌套树 JSON)
POST   /api/tags/import     导入标签(支持 overwrite/merge/skip 策略)

3.4 打标

POST   /api/tags/assignments              设置实体标签(全量替换)
GET    /api/tags/assignments?entityType=&entityId=  获取实体标签
GET    /api/tags/all-with-tags            所有标签组+标签(标签选择器用)

3.5 设计约定

  • 写操作用 POST(非 RESTful DELETE/PUT),保持项目统一风格。
  • 请求体定义为 Controller 内部 static class,使用 @Data(Lombok)。

4. 导入导出规范

4.1 导出格式

{
  "version": "1.0",
  "exportedAt": "2026-06-11T14:30:00",
  "groups": [
    {
      "name": "标签组名",
      "description": "可选",
      "sortOrder": 0,
      "tags": [
        {
          "name": "标签名",
          "sortOrder": 0,
          "children": [ { "name": "子标签", "children": [] } ]
        }
      ]
    }
  ]
}
  • 导出数据不包含数据库 ID,以 name 作为匹配键。
  • tags 是根节点的子节点,不包含标签组同名根节点。

4.2 导入策略

策略 行为
新建 create 无冲突时自动新建
覆盖 overwrite 删除旧组+旧标签,用导入数据重建
合并 merge 递归按名称匹配:同名保留+递归子节点,新名追加
跳过 skip 不做任何修改

4.3 合并算法

对于导入标签树的每一层节点:
  1. 在目标标签组的同级子节点中查找同名标签
  2. 找到 → 保留该标签,递归处理其子节点
  3. 未找到 → 创建新标签节点

幂等安全:同名标签不会重复创建。

5. 打标规范

5.1 打标原则

  1. 多标签:一个实体可以打 0~N 个标签
  2. 同组多标:同一标签组内可选多个标签
  3. 跨组打标:可选择不同标签组的标签
  4. 非叶子可标:树形标签中任意层级节点都可选中
  5. 全量替换:每次保存提交完整 tagIds 列表,后端先删旧关联再建新关联

5.2 实现

// TagServiceImpl.setTags()
assignmentRepo.deleteAllByEntityTypeAndEntityId(entityType, entityId);
for (Long tagId : tagIds) {
    // 逐条插入
}

5.3 实体类型

entityType entityId 来源 说明
skill folderName 技能(文件系统存储,无数据库 ID)

新增实体类型只需在调用方传入不同的 entityTypeentityId,无需后端改动。

6. 前端架构

api/tag.js                    所有标签 API 封装
stores/tag.js                 Pinia store(groups, activeGroupId, currentTags, buildTree)
components/tag/
  TreeNodeItem.vue            递归树节点(编辑模式)
  TagSelector.vue             标签选择器弹窗(打标模式)
views/TagManagement.vue       标签管理页面(左右分栏)

6.1 TagSelector 组件使用方式

<TagSelector
  v-model:show="showTagSelector"
  entity-type="skill"
  :entity-id="skill.folderName"
  :selected-tag-ids="skill.tags?.map(t => t.tagId) || []"
  @saved="onTagsSaved"
/>

6.2 标签选择器交互

  1. 弹窗打开时调用 getAllTagsForSelector() 加载所有标签
  2. 树形浏览:按标签组展开/折叠,checkbox 勾选任意层级节点
  3. 搜索模式:关键词过滤标签名和组名
  4. 已选标签在顶部展示为可移除 Tag
  5. 确认后调用 setEntityTags() 全量替换,触发 @saved 回调

6.3 buildTree 算法

// 将扁平 parentId 列表构建为嵌套树
function buildTree() {
  const map = {}
  const roots = []
  currentTags.value.forEach(t => { map[t.id] = { ...t, children: [] } })
  currentTags.value.forEach(t => {
    if (t.parentId == null) roots.push(map[t.id])
    else if (map[t.parentId]) map[t.parentId].children.push(map[t.id])
  })
  return roots
}

7. 关键实现细节

7.1 根节点处理

标签组的根节点是隐式的(parentId=null,名称等于组名):

  • CRUD:前端在根节点的 children 上操作
  • 导出buildTagTree 跳过根节点,直接输出其子节点
  • 导入importCreate 找到自动创建的根节点,将导入的标签作为其子节点插入

7.2 防循环移动

// TagServiceImpl.isDescendant()
// 检查 targetId 是否是 ancestorId 的子孙,防止 moveTag 形成环
while (current != null) {
    if (current.equals(ancestorId)) return true;
    // 向上遍历 parentId 链
}

7.3 Skill 列表携带标签

// SkillController.toVOWithCache()
vo.setTags(tagService.getEntityTags("skill", dto.getFolderName()));

Skill 无数据库实体,使用 folderName 作为 entityId

8. 扩展指南

新增业务对象打标

  1. 前端在对应页面引入 TagSelector 组件
  2. 传入 entityType="your-type"entityId="your-id"
  3. 无需后端改动,通用 API 自动支持

新增标签组级字段

  1. TagGroup 实体添加字段
  2. 更新 CreateGroupReq / UpdateGroupReq
  3. 更新 TagGroupExportimportCreate / importOverwrite
  4. 更新前端 TagManagement.vue 表单
  5. 更新 docs/specs/tag-system-spec.md 格式规范