---
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 导出格式

```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
<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 算法

```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/specs/tag-system-spec.md` 格式规范
