--- 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 { name, sortOrder, children: List } 递归嵌套 TagImportDTO.java 导入请求 { data: TagExportDTO, strategies: Map } 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 导出格式 ```json { "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 实现 ```java // TagServiceImpl.setTags() assignmentRepo.deleteAllByEntityTypeAndEntityId(entityType, entityId); for (Long tagId : tagIds) { // 逐条插入 } ``` ### 5.3 实体类型 | entityType | entityId 来源 | 说明 | |------------|--------------|------| | `skill` | folderName | 技能(文件系统存储,无数据库 ID) | 新增实体类型只需在调用方传入不同的 `entityType` 和 `entityId`,无需后端改动。 ## 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 组件使用方式 ```vue ``` ### 6.2 标签选择器交互 1. 弹窗打开时调用 `getAllTagsForSelector()` 加载所有标签 2. 树形浏览:按标签组展开/折叠,checkbox 勾选任意层级节点 3. 搜索模式:关键词过滤标签名和组名 4. 已选标签在顶部展示为可移除 Tag 5. 确认后调用 `setEntityTags()` 全量替换,触发 `@saved` 回调 ### 6.3 buildTree 算法 ```javascript // 将扁平 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 防循环移动 ```java // TagServiceImpl.isDescendant() // 检查 targetId 是否是 ancestorId 的子孙,防止 moveTag 形成环 while (current != null) { if (current.equals(ancestorId)) return true; // 向上遍历 parentId 链 } ``` ### 7.3 Skill 列表携带标签 ```java // 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. 更新 `TagGroupExport` 和 `importCreate` / `importOverwrite` 4. 更新前端 `TagManagement.vue` 表单 5. 更新 `docs/tag-system-spec.md` 格式规范