From 22647099c26fb24fb385f27c3e659279034c95bd Mon Sep 17 00:00:00 2001 From: zpc Date: Thu, 25 Dec 2025 22:42:33 +0800 Subject: [PATCH] 312 --- docs/数据库重构计划.md | 678 ++++++++++++++++++ .../Interfaces/IBookmarkService.cs | 6 +- .../Interfaces/ITagService.cs | 23 +- .../Services/BookmarkService.cs | 293 +++++++- .../BookmarkApi.Core/Services/TagService.cs | 302 +++++--- .../BookmarkApi.Data/Entities/Bookmark.cs | 69 +- .../Entities/BookmarkDevicePermission.cs | 48 ++ .../Entities/BookmarkShare.cs | 97 +++ .../BookmarkApi.Data/Entities/BookmarkTag.cs | 48 ++ .../Entities/BookmarkVisit.cs | 60 ++ .../BookmarkApi.Data/Entities/Collection.cs | 83 +++ .../Entities/CollectionBookmark.cs | 54 ++ .../BookmarkApi.Data/Entities/Device.cs | 12 + .../BookmarkApi.Data/Entities/Folder.cs | 82 +++ src/backend/BookmarkApi.Data/Entities/Tag.cs | 65 ++ src/backend/BookmarkApi.Data/Entities/User.cs | 30 + src/backend/BookmarkApi.Data/FreeSqlSetup.cs | 150 +++- .../DTOs/Bookmark/CreateBookmarkRequest.cs | 5 + .../DTOs/Bookmark/UpdateBookmarkRequest.cs | 10 + .../BookmarkApi.Shared/DTOs/BookmarkDto.cs | 13 +- src/backend/BookmarkApi.Shared/DTOs/TagDto.cs | 22 +- .../BookmarkApi.Shared/Enums/ShareType.cs | 22 + .../Controllers/AdminController.cs | 16 +- .../Controllers/BookmarksController.cs | 9 +- .../BookmarkApi/Controllers/TagsController.cs | 219 ++++-- 25 files changed, 2185 insertions(+), 231 deletions(-) create mode 100644 docs/数据库重构计划.md create mode 100644 src/backend/BookmarkApi.Data/Entities/BookmarkDevicePermission.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/BookmarkShare.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/BookmarkTag.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/BookmarkVisit.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/Collection.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/CollectionBookmark.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/Folder.cs create mode 100644 src/backend/BookmarkApi.Data/Entities/Tag.cs create mode 100644 src/backend/BookmarkApi.Shared/Enums/ShareType.cs diff --git a/docs/数据库重构计划.md b/docs/数据库重构计划.md new file mode 100644 index 0000000..cdaa4a2 --- /dev/null +++ b/docs/数据库重构计划.md @@ -0,0 +1,678 @@ +# 书签管理系统 - 数据库重构完整计划 + +## 一、重构背景 + +### 1.1 现有问题 + +原系统采用简化的数据表设计(4 张表:users, devices, bookmarks, refresh_tokens),存在以下问题: + +| 问题 | 现状 | 影响 | +|------|------|------| +| **标签存储** | 书签表中的 `Tags` 字段使用 JSON 数组存储 | 无法高效查询、无法统计标签使用量、无法为标签设置颜色/图标 | +| **设备权限** | 书签表中的 `AllowedDevices` 字段使用 JSON 数组存储 | 无法建立外键约束、删除设备后残留无效数据 | +| **组织结构** | 书签只能通过标签分类,无层级结构 | 不支持文件夹嵌套、无法按目录组织书签 | +| **功能缺失** | 无收藏集合、分享、访问历史功能 | 用户体验受限 | + +### 1.2 重构目标 + +将现有的 **4 张表**扩展为 **12 张表**,实现: + +- **标签管理**:独立 Tag 表 + 关联表,支持标签元数据(颜色、图标、排序) +- **层级结构**:新增文件夹表,支持多级嵌套 +- **设备权限**:改用关联表,支持外键约束和级联删除 +- **新增功能**:收藏集合、分享链接、访问历史记录 + +### 1.3 表结构变化概览 + +| 类别 | 改造前 | 改造后 | +|------|--------|--------| +| 核心表 | users, devices, refresh_tokens | 保持不变 | +| 书签表 | bookmarks (含 JSON 字段) | bookmarks (精简) | +| 标签 | - | tags, bookmark_tags | +| 文件夹 | - | folders | +| 设备权限 | - | bookmark_device_permissions | +| 收藏集合 | - | collections, collection_bookmarks | +| 分享功能 | - | bookmark_shares | +| 访问历史 | - | bookmark_visits | + +--- + +## 二、后端重构(已完成) + +### 2.1 实体类变更 + +#### 修改的实体 + +| 文件 | 变更内容 | +|------|----------| +| `Bookmark.cs` | 移除 `Tags`(string[])、`AllowedDevices`(Guid[]);新增 `FolderId`;`Order` 从 long 改为 int;`LastVisitTime` 改名为 `LastVisitAt`;新增导航属性 | + +#### 新增的实体 + +| 文件 | 说明 | +|------|------| +| `Folder.cs` | 文件夹实体,支持 ParentId 自引用实现多级嵌套 | +| `Tag.cs` | 标签实体,包含 Color、Icon、Order 字段 | +| `BookmarkTag.cs` | 书签-标签多对多关联表 | +| `BookmarkDevicePermission.cs` | 书签-设备权限关联表 | +| `Collection.cs` | 收藏集合实体 | +| `CollectionBookmark.cs` | 集合-书签多对多关联表 | +| `BookmarkShare.cs` | 分享链接实体,支持密码保护、过期时间、查看次数限制 | +| `BookmarkVisit.cs` | 访问历史实体 | + +#### 新增的枚举 + +| 文件 | 说明 | +|------|------| +| `ShareType.cs` | 分享类型:Bookmark(1)/Folder(2)/Collection(3) | + +### 2.2 DTO 变更 + +| DTO | 变更内容 | +|-----|----------| +| `BookmarkDto` | 新增 `FolderId`;`Tags` 从 `string[]` 改为 `List`;`LastVisitTime` 改名为 `LastVisitAt`;`Order` 从 long 改为 int | +| `TagDto` | 新增 `Id`、`Color`、`Icon`、`Order` 字段 | +| `CreateBookmarkRequest` | 新增 `FolderId` 字段 | +| `UpdateBookmarkRequest` | 新增 `FolderId`、`UpdateFolder` 字段 | + +### 2.3 服务层重构 + +| 服务 | 变更内容 | +|------|----------| +| `BookmarkService` | 完全重写,使用 `IncludeMany` 加载 BookmarkTags 和 DevicePermissions;新增 `SyncBookmarkTagsAsync`、`SyncDevicePermissionsAsync` 私有方法 | +| `TagService` | 完全重写,使用独立 Tag 表;新增 `CreateTagAsync`、`UpdateTagAsync`、`GetTagAsync` 方法 | + +### 2.4 接口变更 + +| 接口 | 变更内容 | +|------|----------| +| `ITagService` | 新增 `CreateTagAsync`、`UpdateTagAsync`、`GetTagAsync` 方法签名 | +| `IBookmarkService` | `GetUserBookmarksAsync` 新增 `folderId` 参数 | + +### 2.5 控制器变更 + +| 控制器 | 变更内容 | +|--------|----------| +| `TagsController` | 新增完整的 CRUD 操作(创建、更新、删除标签) | +| `BookmarksController` | `GetBookmarks` 新增 `folderId` 查询参数 | +| `AdminController` | 修复 GetUserBookmarks 方法,使用新的实体结构 | + +### 2.6 数据库初始化 + +`FreeSqlSetup.cs` 已更新: +- 注册所有 12 个实体类型 +- 启动时自动同步表结构 +- 提供 `SyncAllTables()` 方法手动同步 +- 提供 `CheckDatabaseStatusAsync()` 方法检查数据库状态 + +--- + +## 三、前端重构计划 + +### 3.1 需要修改的文件清单 + +#### 3.1.1 类型定义 (`src/frontend/src/types/index.ts`) + +| 修改项 | 当前 | 修改后 | +|--------|------|--------| +| `Bookmark.tags` | `string[]` | `Tag[]` | +| `Bookmark.lastVisitTime` | `lastVisitTime` | `lastVisitAt` | +| 新增 `Bookmark.folderId` | - | `folderId?: string` | +| `Tag` 接口 | `{ name: string; count: number }` | `{ id: string; name: string; color?: string; icon?: string; order: number; count: number }` | +| 新增 | - | `Folder` 接口 | +| 新增 | - | `Collection` 接口 | + +```typescript +// 修改后的 Tag 接口 +interface Tag { + id: string; + name: string; + color?: string; + icon?: string; + order: number; + count: number; +} + +// 修改后的 Bookmark 接口 +interface Bookmark { + id: string; + folderId?: string; // 新增 + title: string; + url: string; + description?: string; + icon?: string; + tags: Tag[]; // 从 string[] 改为 Tag[] + visitCount: number; + lastVisitAt?: string; // 重命名 + order: number; + visibility: VisibilityType; + allowedDevices?: string[]; + createdAt: string; + updatedAt: string; +} + +// 新增 Folder 接口 +interface Folder { + id: string; + parentId?: string; + name: string; + icon?: string; + order: number; + children?: Folder[]; + bookmarks?: Bookmark[]; +} +``` + +#### 3.1.2 API 层修改 + +**`src/frontend/src/api/bookmark.ts`** + +| 方法 | 修改内容 | +|------|----------| +| `getBookmarks()` | 新增 `folderId` 参数:`getBookmarks(tag?: string, folderId?: string)` | +| 新增 | `getFolders()` - 获取文件夹列表 | +| 新增 | `createFolder()` - 创建文件夹 | +| 新增 | `updateFolder()` - 更新文件夹 | +| 新增 | `deleteFolder()` - 删除文件夹 | +| 新增 | `moveBookmark()` - 移动书签到文件夹 | + +**`src/frontend/src/api/tag.ts`** + +| 方法 | 修改内容 | +|------|----------| +| 新增 | `createTag(name, color?, icon?)` - 创建标签 | +| 新增 | `updateTag(id, name?, color?, icon?)` - 更新标签 | +| 修改 | `deleteTag(id)` - 参数从标签名改为标签ID | +| 修改 | `mergeTags(sourceTagIds, targetTagId)` - 参数从标签名改为标签ID | + +#### 3.1.3 Store 层修改 + +**`src/frontend/src/stores/bookmark.ts`** + +| 修改项 | 说明 | +|--------|------| +| 新增状态 | `currentFolderId: string \| null` - 当前文件夹 | +| 新增状态 | `folders: Folder[]` - 文件夹列表 | +| 修改 `fetchBookmarks` | 支持 `folderId` 参数 | +| 新增方法 | `fetchFolders()` - 获取文件夹列表 | +| 新增方法 | `createFolder()` - 创建文件夹 | +| 新增方法 | `deleteFolder()` - 删除文件夹 | +| 新增方法 | `setCurrentFolder()` - 切换文件夹 | +| 修改计算属性 | 适配新的 Tag 对象结构 | + +**`src/frontend/src/stores/tag.ts`** + +| 修改项 | 说明 | +|--------|------| +| 新增方法 | `createTag(name, color?, icon?)` | +| 新增方法 | `updateTag(id, name?, color?, icon?)` | +| 修改方法 | `deleteTag` 和 `mergeTags` 使用 ID 而非名称 | + +#### 3.1.4 组件修改 + +**`src/frontend/src/components/bookmark/BookmarkList.vue`** + +| 修改项 | 说明 | +|--------|------| +| 标签显示 | 从显示字符串改为显示带颜色的标签组件 | +| 新增 | 面包屑导航(显示当前文件夹路径) | + +**`src/frontend/src/components/bookmark/BookmarkEditor.vue`** + +| 修改项 | 说明 | +|--------|------| +| 标签选择 | 从字符串输入改为标签选择器(支持颜色预览) | +| 新增 | 文件夹选择器(树形结构) | +| 标签管理 | 支持创建带颜色的新标签 | + +**新增组件** + +| 组件 | 说明 | +|------|------| +| `FolderTree.vue` | 文件夹树形组件,支持展开/折叠、拖拽移动 | +| `TagBadge.vue` | 带颜色的标签徽章组件 | +| `TagManager.vue` | 标签管理弹窗(编辑颜色、图标、排序) | + +#### 3.1.5 视图页面修改 + +**`src/frontend/src/views/HomeView.vue`** + +| 修改项 | 说明 | +|--------|------| +| 左侧边栏 | 新增文件夹树导航 | +| 标签列表 | 显示标签颜色 | +| 面包屑 | 显示当前文件夹路径 | + +### 3.2 前端修改优先级 + +| 优先级 | 任务 | 影响范围 | +|--------|------|----------| +| P0 | 修改类型定义 (types/index.ts) | 全局 | +| P0 | 修改 API 层适配新接口 | 全局 | +| P0 | 修改 Store 层适配新数据结构 | 全局 | +| P1 | 修改 BookmarkList 组件显示标签 | 书签列表 | +| P1 | 修改 BookmarkEditor 组件标签选择 | 书签编辑 | +| P2 | 新增文件夹相关组件和功能 | 新功能 | +| P2 | 新增标签管理功能 | 新功能 | + +--- + +## 四、浏览器插件重构计划 + +### 4.1 需要修改的文件清单 + +#### 4.1.1 API 层 (`src/extension/shared/api.js`) + +| 方法 | 修改内容 | +|------|----------| +| `getBookmarks(tag)` | 新增 `folderId` 参数:`getBookmarks(tag, folderId)` | +| `createBookmark(data)` | 请求体新增 `folderId` 字段 | +| 新增 | `getFolders()` - 获取文件夹列表 | +| 新增 | `createTag(name, color)` - 创建标签(可选) | + +```javascript +// 修改后的 getBookmarks +async getBookmarks(tag = null, folderId = null) { + const params = new URLSearchParams(); + if (tag) params.append('tag', tag); + if (folderId) params.append('folderId', folderId); + return this.request(`/bookmarks?${params.toString()}`); +} + +// 修改后的 createBookmark +async createBookmark(data) { + return this.request('/bookmarks', { + method: 'POST', + body: JSON.stringify({ + title: data.title, + url: data.url, + description: data.description, + tags: data.tags, // 标签名数组 + folderId: data.folderId, // 新增 + visibility: data.visibility || 0 + }) + }); +} +``` + +#### 4.1.2 后台脚本 (`src/extension/background/background.js`) + +| 修改项 | 说明 | +|--------|------| +| `onBookmarkCreated` | 收集标签时考虑新的标签对象结构 | +| 新增 | 支持将浏览器书签文件夹映射到系统文件夹(可选) | + +#### 4.1.3 弹窗界面 (`src/extension/popup/`) + +**popup.js 修改** + +| 修改项 | 说明 | +|--------|------| +| 标签输入 | 保持逗号分隔输入方式(后端会自动创建标签) | +| 新增 | 文件夹选择下拉框(可选功能) | + +**popup.html 修改** + +| 修改项 | 说明 | +|--------|------| +| 新增 | 文件夹选择器 UI(可选功能) | + +#### 4.1.4 内容脚本 (`src/extension/content/content.js`) + +| 修改项 | 说明 | +|--------|------| +| 搜索结果渲染 | 适配新的 Tag 对象结构,显示标签颜色 | + +```javascript +// 修改后的搜索结果渲染 +function renderSearchResult(bookmark) { + const tagsHtml = bookmark.tags.map(tag => { + const style = tag.color ? `background-color: ${tag.color}` : ''; + return `${tag.name}`; + }).join(''); + + return ` +
+ +
+
${bookmark.title}
+
${bookmark.url}
+
${tagsHtml}
+
+
+ `; +} +``` + +#### 4.1.5 新标签页 (`src/extension/newtab/index.html`) + +| 修改项 | 说明 | +|--------|------| +| 书签卡片 | 适配新的 Tag 对象结构 | +| 标签显示 | 显示标签颜色 | +| 新增 | 文件夹导航(可选功能) | + +### 4.2 插件修改优先级 + +| 优先级 | 任务 | 影响范围 | +|--------|------|----------| +| P0 | 修改 api.js 适配新接口 | 全局 | +| P0 | 修改 content.js 搜索结果渲染 | 全局搜索 | +| P0 | 修改 newtab 书签显示 | 新标签页 | +| P1 | 修改 popup 保存功能 | 快速保存 | +| P2 | 新增文件夹选择功能 | 新功能 | + +--- + +## 五、API 接口变更说明 + +### 5.1 书签相关接口 + +| 接口 | 方法 | 变更 | +|------|------|------| +| `/api/bookmarks` | GET | 新增 `folderId` 查询参数 | +| `/api/bookmarks` | POST | 请求体新增 `folderId` 字段 | +| `/api/bookmarks/{id}` | PUT | 请求体新增 `folderId`、`updateFolder` 字段 | + +### 5.2 标签相关接口 + +| 接口 | 方法 | 变更 | +|------|------|------| +| `/api/tags` | GET | 返回完整的 TagDto 对象(含 id, color, icon, order) | +| `/api/tags` | POST | **新增** - 创建标签 | +| `/api/tags/{id}` | GET | **新增** - 获取标签详情 | +| `/api/tags/{id}` | PUT | **新增** - 更新标签 | +| `/api/tags/{id}` | DELETE | 参数从标签名改为标签 ID | +| `/api/tags/merge` | POST | 请求体使用标签 ID 数组 | + +### 5.3 响应数据结构变更 + +**BookmarkDto 响应示例** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "folderId": "660e8400-e29b-41d4-a716-446655440001", + "title": "Example Site", + "url": "https://example.com", + "description": "An example website", + "icon": "data:image/png;base64,...", + "tags": [ + { + "id": "770e8400-e29b-41d4-a716-446655440002", + "name": "工作", + "color": "#FF5733", + "icon": "briefcase", + "order": 1 + }, + { + "id": "880e8400-e29b-41d4-a716-446655440003", + "name": "技术", + "color": "#33FF57", + "icon": "code", + "order": 2 + } + ], + "visitCount": 42, + "lastVisitAt": "2024-12-25T10:30:00Z", + "order": 1, + "visibility": 0, + "allowedDevices": null, + "createdAt": "2024-12-01T00:00:00Z", + "updatedAt": "2024-12-25T10:30:00Z" +} +``` + +--- + +## 六、数据表详细设计 + +### 6.1 users 表(用户)- 保持不变 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 用户ID | +| Email | string(200) | Unique, Not Null | 邮箱 | +| UserName | string(100) | Not Null | 用户名 | +| PasswordHash | string(500) | Not Null | 密码哈希 | +| Avatar | string(500) | Nullable | 头像URL | +| Role | int | Default 0 | 角色 | +| Status | int | Default 0 | 状态 | +| CreatedAt | DateTime | Not Null | 创建时间 | +| LastLoginAt | DateTime | Nullable | 最后登录 | + +### 6.2 devices 表(设备)- 保持不变 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 设备ID | +| UserId | Guid | FK | 所属用户 | +| DeviceName | string(200) | Not Null | 设备名称 | +| DeviceType | string(200) | Not Null | 设备类型 | +| DeviceFingerprint | string(500) | Nullable | 设备指纹 | +| IsAdmin | bool | Default false | 管理员设备 | +| Status | int | Default 0 | 状态 | +| CreatedAt | DateTime | Not Null | 创建时间 | +| LastActiveAt | DateTime | Not Null | 最后活跃 | + +### 6.3 folders 表(文件夹)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 文件夹ID | +| UserId | Guid | FK | 所属用户 | +| ParentId | Guid | FK, Nullable | 父文件夹 | +| Name | string(200) | Not Null | 名称 | +| Icon | string(100) | Nullable | 图标 | +| Order | int | Default 0 | 排序 | +| CreatedAt | DateTime | Not Null | 创建时间 | +| UpdatedAt | DateTime | Not Null | 更新时间 | + +**索引:** `idx_folder_user_parent` (UserId, ParentId) + +### 6.4 bookmarks 表(书签)- 重构 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 书签ID | +| UserId | Guid | FK | 所属用户 | +| FolderId | Guid | FK, Nullable | 所属文件夹 | +| Title | string(500) | Not Null | 标题 | +| Url | string(2000) | Not Null | URL | +| Description | string(2000) | Nullable | 描述 | +| Icon | string(MAX) | Nullable | 图标 | +| Order | int | Default 0 | 排序 | +| Visibility | int | Default 0 | 可见性 | +| VisitCount | int | Default 0 | 访问次数 | +| LastVisitAt | DateTime | Nullable | 最后访问 | +| CreatedAt | DateTime | Not Null | 创建时间 | +| UpdatedAt | DateTime | Not Null | 更新时间 | + +**变更:** 移除 Tags、AllowedDevices;新增 FolderId + +### 6.5 tags 表(标签)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 标签ID | +| UserId | Guid | FK | 所属用户 | +| Name | string(100) | Not Null | 名称 | +| Color | string(20) | Nullable | 颜色 | +| Icon | string(100) | Nullable | 图标 | +| Order | int | Default 0 | 排序 | +| CreatedAt | DateTime | Not Null | 创建时间 | + +**索引:** `idx_tag_user_name` (UserId, Name) - Unique + +### 6.6 bookmark_tags 表(书签-标签关联)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 关联ID | +| BookmarkId | Guid | FK | 书签ID | +| TagId | Guid | FK | 标签ID | +| CreatedAt | DateTime | Not Null | 创建时间 | + +**索引:** `idx_bookmark_tag_unique` (BookmarkId, TagId) - Unique + +### 6.7 bookmark_device_permissions 表(设备权限)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 权限ID | +| BookmarkId | Guid | FK | 书签ID | +| DeviceId | Guid | FK | 设备ID | +| CreatedAt | DateTime | Not Null | 创建时间 | + +**索引:** `idx_permission_unique` (BookmarkId, DeviceId) - Unique + +### 6.8 collections 表(收藏集合)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 集合ID | +| UserId | Guid | FK | 所属用户 | +| Name | string(200) | Not Null | 名称 | +| Description | string(500) | Nullable | 描述 | +| Icon | string(100) | Nullable | 图标 | +| Color | string(20) | Nullable | 主题色 | +| IsPublic | bool | Default false | 是否公开 | +| Order | int | Default 0 | 排序 | +| CreatedAt | DateTime | Not Null | 创建时间 | +| UpdatedAt | DateTime | Not Null | 更新时间 | + +### 6.9 collection_bookmarks 表(集合-书签关联)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 关联ID | +| CollectionId | Guid | FK | 集合ID | +| BookmarkId | Guid | FK | 书签ID | +| Order | int | Default 0 | 排序 | +| CreatedAt | DateTime | Not Null | 添加时间 | + +### 6.10 bookmark_shares 表(分享链接)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 分享ID | +| UserId | Guid | FK | 创建者 | +| ShareCode | string(50) | Unique | 分享码 | +| ShareType | int | Not Null | 类型 | +| TargetId | Guid | Not Null | 目标ID | +| Title | string(200) | Nullable | 标题 | +| Password | string(100) | Nullable | 密码 | +| ViewCount | int | Default 0 | 查看次数 | +| MaxViews | int | Nullable | 最大查看 | +| ExpiresAt | DateTime | Nullable | 过期时间 | +| IsActive | bool | Default true | 是否启用 | +| CreatedAt | DateTime | Not Null | 创建时间 | + +### 6.11 bookmark_visits 表(访问历史)- 新增 + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| Id | Guid | PK | 记录ID | +| UserId | Guid | FK | 用户ID | +| BookmarkId | Guid | FK | 书签ID | +| DeviceId | Guid | FK, Nullable | 设备ID | +| VisitedAt | DateTime | Not Null | 访问时间 | + +--- + +## 七、实体关系图 + +``` +┌─────────────┐ +│ users │ +└──────┬──────┘ + │ + │ 1:N + ├────────────────┬────────────────┬────────────────┬────────────────┐ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ devices │ │ folders │ │ tags │ │ collections │ │ shares │ +└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────────────┘ + │ │ 1:N │ │ + │ │ (self-ref) │ │ + │ ▼ │ │ + │ ┌─────────────┐ │ │ + │ │ bookmarks │◄────────┘ │ + │ └──────┬──────┘ │ + │ │ │ + │ │ N:M │ N:M + │ ├─────────────────────────────────┤ + │ │ │ + │ ▼ ▼ + │ ┌─────────────┐ ┌─────────────┐ + │ │bookmark_tags│ │ collection_ │ + │ └─────────────┘ │ bookmarks │ + │ + ├─────────────────────────────────────────────────┐ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────┐ +│ bookmark_device_ │ │ bookmark_ │ +│ permissions │ │ visits │ +└─────────────────────┘ └─────────────┘ +``` + +--- + +## 八、重构进度跟踪 + +### 8.1 后端重构(已完成 ✅) + +- [x] 修改 Bookmark.cs 实体 +- [x] 新增 Folder.cs 实体 +- [x] 新增 Tag.cs 实体 +- [x] 新增 BookmarkTag.cs 实体 +- [x] 新增 BookmarkDevicePermission.cs 实体 +- [x] 新增 Collection.cs 实体 +- [x] 新增 CollectionBookmark.cs 实体 +- [x] 新增 BookmarkShare.cs 实体 +- [x] 新增 BookmarkVisit.cs 实体 +- [x] 新增 ShareType.cs 枚举 +- [x] 修改 FreeSqlSetup.cs 注册新实体 +- [x] 重构 BookmarkService.cs +- [x] 重构 TagService.cs +- [x] 更新 DTO 类 +- [x] 更新控制器 +- [x] 构建验证通过 + +### 8.2 前端重构(待开始) + +- [ ] 修改类型定义 (types/index.ts) +- [ ] 修改 API 层 (api/bookmark.ts, api/tag.ts) +- [ ] 修改 Store 层 (stores/bookmark.ts, stores/tag.ts) +- [ ] 修改 BookmarkList 组件 +- [ ] 修改 BookmarkEditor 组件 +- [ ] 新增文件夹相关组件 +- [ ] 新增标签管理组件 + +### 8.3 浏览器插件重构(待开始) + +- [ ] 修改 shared/api.js +- [ ] 修改 content/content.js 搜索结果渲染 +- [ ] 修改 newtab/index.html 书签显示 +- [ ] 修改 popup/popup.js 保存功能 + +--- + +## 九、注意事项 + +1. **数据迁移**:本次重构不考虑旧数据迁移,直接使用新表结构 +2. **向后兼容**:API 响应格式已变更,前端和插件必须同步更新 +3. **标签创建**:创建书签时传入的标签名会自动创建不存在的标签 +4. **级联删除**:删除用户时需级联删除所有关联数据 +5. **性能考虑**:bookmark_visits 表数据量可能很大,考虑定期清理 + +--- + +## 十、版本信息 + +- 文档版本:v2.0 +- 创建日期:2024-12-25 +- 最后更新:2024-12-25 +- 作者:Claude Code diff --git a/src/backend/BookmarkApi.Core/Interfaces/IBookmarkService.cs b/src/backend/BookmarkApi.Core/Interfaces/IBookmarkService.cs index 280f6e7..3c0468f 100644 --- a/src/backend/BookmarkApi.Core/Interfaces/IBookmarkService.cs +++ b/src/backend/BookmarkApi.Core/Interfaces/IBookmarkService.cs @@ -12,7 +12,7 @@ public interface IBookmarkService /// /// 获取用户的书签列表(根据设备可见性过滤) /// - Task> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null); + Task> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null, Guid? folderId = null); /// /// 搜索书签 @@ -52,12 +52,12 @@ public interface IBookmarkService /// /// 记录访问 /// - Task RecordVisitAsync(Guid bookmarkId, Guid userId); + Task RecordVisitAsync(Guid bookmarkId, Guid userId, Guid? deviceId = null); /// /// 更新书签排序 /// - Task UpdateOrderAsync(Guid bookmarkId, Guid userId, long newOrder); + Task UpdateOrderAsync(Guid bookmarkId, Guid userId, int newOrder); /// /// 检查 URL 是否已存在 diff --git a/src/backend/BookmarkApi.Core/Interfaces/ITagService.cs b/src/backend/BookmarkApi.Core/Interfaces/ITagService.cs index 408d2b8..80b157b 100644 --- a/src/backend/BookmarkApi.Core/Interfaces/ITagService.cs +++ b/src/backend/BookmarkApi.Core/Interfaces/ITagService.cs @@ -10,7 +10,22 @@ public interface ITagService /// /// 获取用户的所有标签及使用数量 /// - Task> GetUserTagsAsync(Guid userId, Guid deviceId, bool isAdminDevice); + Task> GetUserTagsAsync(Guid userId); + + /// + /// 获取标签详情 + /// + Task GetTagAsync(Guid tagId, Guid userId); + + /// + /// 创建标签 + /// + Task CreateTagAsync(Guid userId, string name, string? color = null, string? icon = null); + + /// + /// 更新标签 + /// + Task UpdateTagAsync(Guid tagId, Guid userId, string? name = null, string? color = null, string? icon = null); /// /// 重命名标签 @@ -18,13 +33,13 @@ public interface ITagService Task RenameTagAsync(Guid userId, string oldName, string newName); /// - /// 删除标签(从所有书签中移除该标签) + /// 删除标签 /// - Task DeleteTagAsync(Guid userId, string tagName); + Task DeleteTagAsync(Guid tagId, Guid userId); /// /// 合并标签 /// - Task MergeTagsAsync(Guid userId, string[] sourceNames, string targetName); + Task MergeTagsAsync(Guid userId, Guid[] sourceTagIds, Guid targetTagId); } diff --git a/src/backend/BookmarkApi.Core/Services/BookmarkService.cs b/src/backend/BookmarkApi.Core/Services/BookmarkService.cs index f255b05..d55122b 100644 --- a/src/backend/BookmarkApi.Core/Services/BookmarkService.cs +++ b/src/backend/BookmarkApi.Core/Services/BookmarkService.cs @@ -19,12 +19,26 @@ public class BookmarkService : IBookmarkService } /// - public async Task> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null) + public async Task> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null, Guid? folderId = null) { - // 先查询所有书签,然后在内存中过滤 - // 避免 FreeSql 无法正确转换 AllowedDevices.Contains 和 Tags.Contains 为 SQL - var bookmarks = await _freeSql.Select() + // 查询书签,包含关联数据 + var query = _freeSql.Select() .Where(b => b.UserId == userId) + .IncludeMany(b => b.BookmarkTags, then => then.Include(bt => bt.Tag)) + .IncludeMany(b => b.DevicePermissions); + + // 按文件夹筛选 + if (folderId.HasValue) + { + query = query.Where(b => b.FolderId == folderId.Value); + } + else + { + // 默认只查询根目录的书签 + query = query.Where(b => b.FolderId == null); + } + + var bookmarks = await query .OrderByDescending(b => b.Order) .ToListAsync(); @@ -33,14 +47,19 @@ public class BookmarkService : IBookmarkService { bookmarks = bookmarks.Where(b => b.Visibility == VisibilityType.Public || - (b.Visibility == VisibilityType.Specified && b.AllowedDevices != null && b.AllowedDevices.Contains(deviceId)) + (b.Visibility == VisibilityType.Specified && + b.DevicePermissions != null && + b.DevicePermissions.Any(p => p.DeviceId == deviceId)) ).ToList(); } // 按标签筛选 if (!string.IsNullOrEmpty(tag)) { - bookmarks = bookmarks.Where(b => b.Tags.Contains(tag)).ToList(); + bookmarks = bookmarks.Where(b => + b.BookmarkTags != null && + b.BookmarkTags.Any(bt => bt.Tag != null && bt.Tag.Name == tag) + ).ToList(); } return bookmarks.Select(MapToDto).ToList(); @@ -49,8 +68,6 @@ public class BookmarkService : IBookmarkService /// public async Task> SearchBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string keyword) { - // 先在数据库层面进行关键词搜索,然后在内存中过滤可见性 - // 避免 FreeSql 无法正确转换 AllowedDevices.Contains 为 SQL var lowerKeyword = keyword.ToLower(); var bookmarks = await _freeSql.Select() .Where(b => b.UserId == userId) @@ -59,6 +76,8 @@ public class BookmarkService : IBookmarkService b.Url.ToLower().Contains(lowerKeyword) || (b.Description != null && b.Description.ToLower().Contains(lowerKeyword)) ) + .IncludeMany(b => b.BookmarkTags, then => then.Include(bt => bt.Tag)) + .IncludeMany(b => b.DevicePermissions) .OrderByDescending(b => b.VisitCount) .OrderByDescending(b => b.Order) .ToListAsync(); @@ -68,7 +87,9 @@ public class BookmarkService : IBookmarkService { bookmarks = bookmarks.Where(b => b.Visibility == VisibilityType.Public || - (b.Visibility == VisibilityType.Specified && b.AllowedDevices != null && b.AllowedDevices.Contains(deviceId)) + (b.Visibility == VisibilityType.Specified && + b.DevicePermissions != null && + b.DevicePermissions.Any(p => p.DeviceId == deviceId)) ).ToList(); } @@ -80,6 +101,8 @@ public class BookmarkService : IBookmarkService { var bookmark = await _freeSql.Select() .Where(b => b.Id == bookmarkId && b.UserId == userId) + .IncludeMany(b => b.BookmarkTags, then => then.Include(bt => bt.Tag)) + .IncludeMany(b => b.DevicePermissions) .FirstAsync(); if (bookmark == null) @@ -96,7 +119,7 @@ public class BookmarkService : IBookmarkService } if (bookmark.Visibility == VisibilityType.Specified && - (bookmark.AllowedDevices == null || !bookmark.AllowedDevices.Contains(deviceId))) + (bookmark.DevicePermissions == null || !bookmark.DevicePermissions.Any(p => p.DeviceId == deviceId))) { return null; } @@ -108,26 +131,45 @@ public class BookmarkService : IBookmarkService /// public async Task CreateBookmarkAsync(Guid userId, CreateBookmarkRequest request) { + var now = DateTime.UtcNow; + + // 获取当前最大排序值 + var maxOrder = await _freeSql.Select() + .Where(b => b.UserId == userId && b.FolderId == request.FolderId) + .MaxAsync(b => b.Order); + var bookmark = new Bookmark { Id = Guid.NewGuid(), UserId = userId, + FolderId = request.FolderId, Title = request.Title, Url = request.Url, Description = request.Description, Icon = request.Icon, - Tags = request.Tags ?? Array.Empty(), Visibility = request.Visibility, - AllowedDevices = request.AllowedDevices, VisitCount = 0, - Order = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + Order = maxOrder + 1, + CreatedAt = now, + UpdatedAt = now }; await _freeSql.Insert(bookmark).ExecuteAffrowsAsync(); - return MapToDto(bookmark); + // 处理标签 + if (request.Tags != null && request.Tags.Length > 0) + { + await SyncBookmarkTagsAsync(userId, bookmark.Id, request.Tags); + } + + // 处理设备权限 + if (request.AllowedDevices != null && request.AllowedDevices.Length > 0) + { + await SyncDevicePermissionsAsync(bookmark.Id, request.AllowedDevices); + } + + // 重新查询以获取完整数据 + return await GetBookmarkAsync(bookmark.Id, userId, Guid.Empty, true) ?? MapToDto(bookmark); } /// @@ -142,6 +184,11 @@ public class BookmarkService : IBookmarkService return null; } + if (request.UpdateFolder) + { + bookmark.FolderId = request.FolderId; + } + if (!string.IsNullOrEmpty(request.Title)) { bookmark.Title = request.Title; @@ -162,33 +209,53 @@ public class BookmarkService : IBookmarkService bookmark.Icon = request.Icon; } - if (request.Tags != null) - { - bookmark.Tags = request.Tags; - } - if (request.Visibility.HasValue) { bookmark.Visibility = request.Visibility.Value; } - if (request.AllowedDevices != null) - { - bookmark.AllowedDevices = request.AllowedDevices; - } - bookmark.UpdatedAt = DateTime.UtcNow; await _freeSql.Update() .SetSource(bookmark) .ExecuteAffrowsAsync(); - return MapToDto(bookmark); + // 处理标签 + if (request.Tags != null) + { + await SyncBookmarkTagsAsync(userId, bookmarkId, request.Tags); + } + + // 处理设备权限 + if (request.AllowedDevices != null) + { + await SyncDevicePermissionsAsync(bookmarkId, request.AllowedDevices); + } + + return await GetBookmarkAsync(bookmarkId, userId, Guid.Empty, true); } /// public async Task DeleteBookmarkAsync(Guid bookmarkId, Guid userId) { + // 先删除关联数据 + await _freeSql.Delete() + .Where(bt => bt.BookmarkId == bookmarkId) + .ExecuteAffrowsAsync(); + + await _freeSql.Delete() + .Where(p => p.BookmarkId == bookmarkId) + .ExecuteAffrowsAsync(); + + await _freeSql.Delete() + .Where(cb => cb.BookmarkId == bookmarkId) + .ExecuteAffrowsAsync(); + + await _freeSql.Delete() + .Where(v => v.BookmarkId == bookmarkId) + .ExecuteAffrowsAsync(); + + // 删除书签 var affectedRows = await _freeSql.Delete() .Where(b => b.Id == bookmarkId && b.UserId == userId) .ExecuteAffrowsAsync(); @@ -199,6 +266,24 @@ public class BookmarkService : IBookmarkService /// public async Task BatchDeleteBookmarksAsync(Guid userId, Guid[] bookmarkIds) { + // 先删除关联数据 + await _freeSql.Delete() + .Where(bt => bookmarkIds.Contains(bt.BookmarkId)) + .ExecuteAffrowsAsync(); + + await _freeSql.Delete() + .Where(p => bookmarkIds.Contains(p.BookmarkId)) + .ExecuteAffrowsAsync(); + + await _freeSql.Delete() + .Where(cb => bookmarkIds.Contains(cb.BookmarkId)) + .ExecuteAffrowsAsync(); + + await _freeSql.Delete() + .Where(v => bookmarkIds.Contains(v.BookmarkId)) + .ExecuteAffrowsAsync(); + + // 删除书签 var affectedRows = await _freeSql.Delete() .Where(b => b.UserId == userId && bookmarkIds.Contains(b.Id)) .ExecuteAffrowsAsync(); @@ -211,16 +296,20 @@ public class BookmarkService : IBookmarkService { var affectedRows = await _freeSql.Update() .Set(b => b.Visibility, visibility) - .Set(b => b.AllowedDevices, allowedDevices) .Set(b => b.UpdatedAt, DateTime.UtcNow) .Where(b => b.Id == bookmarkId && b.UserId == userId) .ExecuteAffrowsAsync(); + if (affectedRows > 0 && allowedDevices != null) + { + await SyncDevicePermissionsAsync(bookmarkId, allowedDevices); + } + return affectedRows > 0; } /// - public async Task RecordVisitAsync(Guid bookmarkId, Guid userId) + public async Task RecordVisitAsync(Guid bookmarkId, Guid userId, Guid? deviceId = null) { var bookmark = await _freeSql.Select() .Where(b => b.Id == bookmarkId && b.UserId == userId) @@ -231,17 +320,30 @@ public class BookmarkService : IBookmarkService return false; } + // 更新书签访问统计 await _freeSql.Update() .Set(b => b.VisitCount, bookmark.VisitCount + 1) - .Set(b => b.LastVisitTime, DateTime.UtcNow) + .Set(b => b.LastVisitAt, DateTime.UtcNow) .Where(b => b.Id == bookmarkId) .ExecuteAffrowsAsync(); + // 记录访问历史 + var visit = new BookmarkVisit + { + Id = Guid.NewGuid(), + UserId = userId, + BookmarkId = bookmarkId, + DeviceId = deviceId, + VisitedAt = DateTime.UtcNow + }; + + await _freeSql.Insert(visit).ExecuteAffrowsAsync(); + return true; } /// - public async Task UpdateOrderAsync(Guid bookmarkId, Guid userId, long newOrder) + public async Task UpdateOrderAsync(Guid bookmarkId, Guid userId, int newOrder) { var affectedRows = await _freeSql.Update() .Set(b => b.Order, newOrder) @@ -264,7 +366,9 @@ public class BookmarkService : IBookmarkService public async Task ImportBookmarksAsync(Guid userId, List bookmarks) { var now = DateTime.UtcNow; - var baseOrder = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var maxOrder = await _freeSql.Select() + .Where(b => b.UserId == userId) + .MaxAsync(b => b.Order); var importedCount = 0; foreach (var request in bookmarks) @@ -280,20 +384,32 @@ public class BookmarkService : IBookmarkService { Id = Guid.NewGuid(), UserId = userId, + FolderId = request.FolderId, Title = request.Title, Url = request.Url, Description = request.Description, Icon = request.Icon, - Tags = request.Tags ?? Array.Empty(), Visibility = request.Visibility, - AllowedDevices = request.AllowedDevices, VisitCount = 0, - Order = baseOrder + importedCount, + Order = maxOrder + importedCount + 1, CreatedAt = now, UpdatedAt = now }; await _freeSql.Insert(bookmark).ExecuteAffrowsAsync(); + + // 处理标签 + if (request.Tags != null && request.Tags.Length > 0) + { + await SyncBookmarkTagsAsync(userId, bookmark.Id, request.Tags); + } + + // 处理设备权限 + if (request.AllowedDevices != null && request.AllowedDevices.Length > 0) + { + await SyncDevicePermissionsAsync(bookmark.Id, request.AllowedDevices); + } + importedCount++; } @@ -305,30 +421,129 @@ public class BookmarkService : IBookmarkService { var bookmarks = await _freeSql.Select() .Where(b => b.UserId == userId) + .IncludeMany(b => b.BookmarkTags, then => then.Include(bt => bt.Tag)) + .IncludeMany(b => b.DevicePermissions) .OrderByDescending(b => b.Order) .ToListAsync(); return bookmarks.Select(MapToDto).ToList(); } + /// + /// 同步书签标签 + /// + private async Task SyncBookmarkTagsAsync(Guid userId, Guid bookmarkId, string[] tagNames) + { + // 删除现有关联 + await _freeSql.Delete() + .Where(bt => bt.BookmarkId == bookmarkId) + .ExecuteAffrowsAsync(); + + if (tagNames.Length == 0) + { + return; + } + + // 获取或创建标签 + var existingTags = await _freeSql.Select() + .Where(t => t.UserId == userId && tagNames.Contains(t.Name)) + .ToListAsync(); + + var existingTagNames = existingTags.Select(t => t.Name).ToHashSet(); + var newTagNames = tagNames.Where(n => !existingTagNames.Contains(n)).ToList(); + + // 创建新标签 + var maxOrder = existingTags.Count > 0 + ? await _freeSql.Select().Where(t => t.UserId == userId).MaxAsync(t => t.Order) + : 0; + + foreach (var tagName in newTagNames) + { + var newTag = new Tag + { + Id = Guid.NewGuid(), + UserId = userId, + Name = tagName, + Order = ++maxOrder, + CreatedAt = DateTime.UtcNow + }; + + await _freeSql.Insert(newTag).ExecuteAffrowsAsync(); + existingTags.Add(newTag); + } + + // 创建关联 + var bookmarkTags = existingTags + .Where(t => tagNames.Contains(t.Name)) + .Select(t => new BookmarkTag + { + Id = Guid.NewGuid(), + BookmarkId = bookmarkId, + TagId = t.Id, + CreatedAt = DateTime.UtcNow + }) + .ToList(); + + await _freeSql.Insert(bookmarkTags).ExecuteAffrowsAsync(); + } + + /// + /// 同步设备权限 + /// + private async Task SyncDevicePermissionsAsync(Guid bookmarkId, Guid[] deviceIds) + { + // 删除现有权限 + await _freeSql.Delete() + .Where(p => p.BookmarkId == bookmarkId) + .ExecuteAffrowsAsync(); + + if (deviceIds.Length == 0) + { + return; + } + + // 创建新权限 + var permissions = deviceIds.Select(deviceId => new BookmarkDevicePermission + { + Id = Guid.NewGuid(), + BookmarkId = bookmarkId, + DeviceId = deviceId, + CreatedAt = DateTime.UtcNow + }).ToList(); + + await _freeSql.Insert(permissions).ExecuteAffrowsAsync(); + } + private static BookmarkDto MapToDto(Bookmark bookmark) { return new BookmarkDto { Id = bookmark.Id, + FolderId = bookmark.FolderId, Title = bookmark.Title, Url = bookmark.Url, Description = bookmark.Description, Icon = bookmark.Icon, - Tags = bookmark.Tags, + Tags = bookmark.BookmarkTags? + .Where(bt => bt.Tag != null) + .Select(bt => new TagDto + { + Id = bt.Tag!.Id, + Name = bt.Tag.Name, + Color = bt.Tag.Color, + Icon = bt.Tag.Icon, + Order = bt.Tag.Order + }) + .ToList() ?? new List(), VisitCount = bookmark.VisitCount, - LastVisitTime = bookmark.LastVisitTime, + LastVisitAt = bookmark.LastVisitAt, Order = bookmark.Order, Visibility = bookmark.Visibility, - AllowedDevices = bookmark.AllowedDevices, + AllowedDevices = bookmark.DevicePermissions? + .Select(p => p.DeviceId) + .ToArray(), CreatedAt = bookmark.CreatedAt, UpdatedAt = bookmark.UpdatedAt }; } } - diff --git a/src/backend/BookmarkApi.Core/Services/TagService.cs b/src/backend/BookmarkApi.Core/Services/TagService.cs index 8e5f733..0a7f3f0 100644 --- a/src/backend/BookmarkApi.Core/Services/TagService.cs +++ b/src/backend/BookmarkApi.Core/Services/TagService.cs @@ -1,7 +1,6 @@ using BookmarkApi.Core.Interfaces; using BookmarkApi.Data.Entities; using BookmarkApi.Shared.DTOs; -using BookmarkApi.Shared.Enums; namespace BookmarkApi.Core.Services; @@ -18,142 +17,261 @@ public class TagService : ITagService } /// - public async Task> GetUserTagsAsync(Guid userId, Guid deviceId, bool isAdminDevice) + public async Task> GetUserTagsAsync(Guid userId) { - // 先查询所有书签,然后在内存中过滤 - // 避免 FreeSql 无法正确转换 AllowedDevices.Contains 为 SQL - var bookmarks = await _freeSql.Select() - .Where(b => b.UserId == userId) + // 查询所有标签及其使用数量 + var tags = await _freeSql.Select() + .Where(t => t.UserId == userId) + .OrderBy(t => t.Order) .ToListAsync(); - // 如果不是管理员设备,需要过滤可见性 - if (!isAdminDevice) + // 统计每个标签的使用数量 + var tagIds = tags.Select(t => t.Id).ToList(); + var bookmarkTags = await _freeSql.Select() + .Where(bt => tagIds.Contains(bt.TagId)) + .ToListAsync(); + + var countDict = bookmarkTags + .GroupBy(bt => bt.TagId) + .ToDictionary(g => g.Key, g => g.Count()); + + return tags.Select(t => new TagDto { - bookmarks = bookmarks.Where(b => - b.Visibility == VisibilityType.Public || - (b.Visibility == VisibilityType.Specified && b.AllowedDevices != null && b.AllowedDevices.Contains(deviceId)) - ).ToList(); + Id = t.Id, + Name = t.Name, + Color = t.Color, + Icon = t.Icon, + Order = t.Order, + Count = countDict.TryGetValue(t.Id, out var count) ? count : 0 + }).ToList(); + } + + /// + public async Task GetTagAsync(Guid tagId, Guid userId) + { + var tag = await _freeSql.Select() + .Where(t => t.Id == tagId && t.UserId == userId) + .FirstAsync(); + + if (tag == null) + { + return null; } - // 统计标签使用数量 - var tagCounts = new Dictionary(); + // 统计使用数量 + var count = await _freeSql.Select() + .Where(bt => bt.TagId == tagId) + .CountAsync(); - foreach (var bookmark in bookmarks) + return new TagDto { - foreach (var tag in bookmark.Tags) + Id = tag.Id, + Name = tag.Name, + Color = tag.Color, + Icon = tag.Icon, + Order = tag.Order, + Count = (int)count + }; + } + + /// + public async Task CreateTagAsync(Guid userId, string name, string? color = null, string? icon = null) + { + // 检查标签名是否已存在 + var exists = await _freeSql.Select() + .Where(t => t.UserId == userId && t.Name == name) + .AnyAsync(); + + if (exists) + { + throw new InvalidOperationException($"标签 '{name}' 已存在"); + } + + // 获取最大排序值 + var maxOrder = await _freeSql.Select() + .Where(t => t.UserId == userId) + .MaxAsync(t => t.Order); + + var tag = new Tag + { + Id = Guid.NewGuid(), + UserId = userId, + Name = name, + Color = color, + Icon = icon, + Order = maxOrder + 1, + CreatedAt = DateTime.UtcNow + }; + + await _freeSql.Insert(tag).ExecuteAffrowsAsync(); + + return new TagDto + { + Id = tag.Id, + Name = tag.Name, + Color = tag.Color, + Icon = tag.Icon, + Order = tag.Order, + Count = 0 + }; + } + + /// + public async Task UpdateTagAsync(Guid tagId, Guid userId, string? name = null, string? color = null, string? icon = null) + { + var tag = await _freeSql.Select() + .Where(t => t.Id == tagId && t.UserId == userId) + .FirstAsync(); + + if (tag == null) + { + return null; + } + + // 如果修改名称,检查是否与其他标签重复 + if (!string.IsNullOrEmpty(name) && name != tag.Name) + { + var exists = await _freeSql.Select() + .Where(t => t.UserId == userId && t.Name == name && t.Id != tagId) + .AnyAsync(); + + if (exists) { - if (tagCounts.ContainsKey(tag)) - { - tagCounts[tag]++; - } - else - { - tagCounts[tag] = 1; - } + throw new InvalidOperationException($"标签 '{name}' 已存在"); } + + tag.Name = name; } - return tagCounts - .Select(kv => new TagDto { Name = kv.Key, Count = kv.Value }) - .OrderByDescending(t => t.Count) - .ToList(); + if (color != null) + { + tag.Color = color; + } + + if (icon != null) + { + tag.Icon = icon; + } + + await _freeSql.Update() + .SetSource(tag) + .ExecuteAffrowsAsync(); + + // 统计使用数量 + var count = await _freeSql.Select() + .Where(bt => bt.TagId == tagId) + .CountAsync(); + + return new TagDto + { + Id = tag.Id, + Name = tag.Name, + Color = tag.Color, + Icon = tag.Icon, + Order = tag.Order, + Count = (int)count + }; } /// public async Task RenameTagAsync(Guid userId, string oldName, string newName) { - // 获取包含旧标签的所有书签 - var bookmarks = await _freeSql.Select() - .Where(b => b.UserId == userId && b.Tags.Contains(oldName)) - .ToListAsync(); + var tag = await _freeSql.Select() + .Where(t => t.UserId == userId && t.Name == oldName) + .FirstAsync(); - if (bookmarks.Count == 0) + if (tag == null) { return false; } - foreach (var bookmark in bookmarks) - { - var tags = bookmark.Tags.ToList(); - var index = tags.IndexOf(oldName); - if (index >= 0) - { - tags[index] = newName; - // 去重 - tags = tags.Distinct().ToList(); - } + // 检查新名称是否已存在 + var exists = await _freeSql.Select() + .Where(t => t.UserId == userId && t.Name == newName) + .AnyAsync(); - await _freeSql.Update() - .Set(b => b.Tags, tags.ToArray()) - .Set(b => b.UpdatedAt, DateTime.UtcNow) - .Where(b => b.Id == bookmark.Id) - .ExecuteAffrowsAsync(); + if (exists) + { + throw new InvalidOperationException($"标签 '{newName}' 已存在"); } + await _freeSql.Update() + .Set(t => t.Name, newName) + .Where(t => t.Id == tag.Id) + .ExecuteAffrowsAsync(); + return true; } /// - public async Task DeleteTagAsync(Guid userId, string tagName) + public async Task DeleteTagAsync(Guid tagId, Guid userId) { - // 获取包含该标签的所有书签 - var bookmarks = await _freeSql.Select() - .Where(b => b.UserId == userId && b.Tags.Contains(tagName)) - .ToListAsync(); + // 先删除关联 + await _freeSql.Delete() + .Where(bt => bt.TagId == tagId) + .ExecuteAffrowsAsync(); - var affectedCount = 0; + // 删除标签 + var affectedRows = await _freeSql.Delete() + .Where(t => t.Id == tagId && t.UserId == userId) + .ExecuteAffrowsAsync(); - foreach (var bookmark in bookmarks) - { - var tags = bookmark.Tags.Where(t => t != tagName).ToArray(); - - await _freeSql.Update() - .Set(b => b.Tags, tags) - .Set(b => b.UpdatedAt, DateTime.UtcNow) - .Where(b => b.Id == bookmark.Id) - .ExecuteAffrowsAsync(); - - affectedCount++; - } - - return affectedCount; + return affectedRows > 0; } /// - public async Task MergeTagsAsync(Guid userId, string[] sourceNames, string targetName) + public async Task MergeTagsAsync(Guid userId, Guid[] sourceTagIds, Guid targetTagId) { - // 获取包含任意源标签的所有书签 - var bookmarks = await _freeSql.Select() - .Where(b => b.UserId == userId) + // 验证目标标签存在 + var targetTag = await _freeSql.Select() + .Where(t => t.Id == targetTagId && t.UserId == userId) + .FirstAsync(); + + if (targetTag == null) + { + throw new InvalidOperationException("目标标签不存在"); + } + + // 获取所有源标签关联的书签 + var sourceBookmarkTags = await _freeSql.Select() + .Where(bt => sourceTagIds.Contains(bt.TagId)) .ToListAsync(); var affectedCount = 0; - foreach (var bookmark in bookmarks) + foreach (var bt in sourceBookmarkTags) { - var hasSourceTag = bookmark.Tags.Any(t => sourceNames.Contains(t)); - if (!hasSourceTag) + // 检查目标标签是否已经关联该书签 + var exists = await _freeSql.Select() + .Where(x => x.BookmarkId == bt.BookmarkId && x.TagId == targetTagId) + .AnyAsync(); + + if (!exists) { - continue; + // 创建新关联 + var newBt = new BookmarkTag + { + Id = Guid.NewGuid(), + BookmarkId = bt.BookmarkId, + TagId = targetTagId, + CreatedAt = DateTime.UtcNow + }; + + await _freeSql.Insert(newBt).ExecuteAffrowsAsync(); + affectedCount++; } - - // 移除源标签,添加目标标签 - var tags = bookmark.Tags - .Where(t => !sourceNames.Contains(t)) - .Append(targetName) - .Distinct() - .ToArray(); - - await _freeSql.Update() - .Set(b => b.Tags, tags) - .Set(b => b.UpdatedAt, DateTime.UtcNow) - .Where(b => b.Id == bookmark.Id) - .ExecuteAffrowsAsync(); - - affectedCount++; } + // 删除源标签的所有关联 + await _freeSql.Delete() + .Where(bt => sourceTagIds.Contains(bt.TagId)) + .ExecuteAffrowsAsync(); + + // 删除源标签 + await _freeSql.Delete() + .Where(t => sourceTagIds.Contains(t.Id) && t.UserId == userId) + .ExecuteAffrowsAsync(); + return affectedCount; } } - diff --git a/src/backend/BookmarkApi.Data/Entities/Bookmark.cs b/src/backend/BookmarkApi.Data/Entities/Bookmark.cs index 5a22a7f..6dd5557 100644 --- a/src/backend/BookmarkApi.Data/Entities/Bookmark.cs +++ b/src/backend/BookmarkApi.Data/Entities/Bookmark.cs @@ -7,7 +7,7 @@ namespace BookmarkApi.Data.Entities; /// 书签实体 /// [Table(Name = "bookmarks")] -[Index("idx_bookmark_user_order", nameof(UserId) + "," + nameof(Order))] +[Index("idx_bookmark_user_folder", nameof(UserId) + "," + nameof(FolderId) + "," + nameof(Order))] [Index("idx_bookmark_url", nameof(UserId) + "," + nameof(Url))] public class Bookmark { @@ -23,6 +23,11 @@ public class Bookmark [Column(IsNullable = false)] public Guid UserId { get; set; } + /// + /// 所属文件夹ID(null 为根目录) + /// + public Guid? FolderId { get; set; } + /// /// 书签标题 /// @@ -48,10 +53,16 @@ public class Bookmark public string? Icon { get; set; } /// - /// 标签数组(JSON存储) + /// 文件夹内排序 /// - [Column(MapType = typeof(string), StringLength = -1)] - public string[] Tags { get; set; } = Array.Empty(); + [Column(IsNullable = false)] + public int Order { get; set; } + + /// + /// 可见性类型 + /// + [Column(MapType = typeof(int))] + public VisibilityType Visibility { get; set; } = VisibilityType.Public; /// /// 访问次数 @@ -62,25 +73,7 @@ public class Bookmark /// /// 最后访问时间 /// - public DateTime? LastVisitTime { get; set; } - - /// - /// 排序值(时间戳) - /// - [Column(IsNullable = false)] - public long Order { get; set; } - - /// - /// 可见性类型 - /// - [Column(MapType = typeof(int))] - public VisibilityType Visibility { get; set; } = VisibilityType.Public; - - /// - /// 允许查看的设备ID列表(JSON存储,可见性为Specified时使用) - /// - [Column(MapType = typeof(string), StringLength = -1)] - public Guid[]? AllowedDevices { get; set; } + public DateTime? LastVisitAt { get; set; } /// /// 创建时间 @@ -99,5 +92,35 @@ public class Bookmark /// [Navigate(nameof(UserId))] public virtual User? User { get; set; } + + /// + /// 导航属性 - 所属文件夹 + /// + [Navigate(nameof(FolderId))] + public virtual Folder? Folder { get; set; } + + /// + /// 导航属性 - 书签标签关联 + /// + [Navigate(nameof(BookmarkTag.BookmarkId))] + public virtual List? BookmarkTags { get; set; } + + /// + /// 导航属性 - 设备权限 + /// + [Navigate(nameof(BookmarkDevicePermission.BookmarkId))] + public virtual List? DevicePermissions { get; set; } + + /// + /// 导航属性 - 所属集合关联 + /// + [Navigate(nameof(CollectionBookmark.BookmarkId))] + public virtual List? CollectionBookmarks { get; set; } + + /// + /// 导航属性 - 访问历史 + /// + [Navigate(nameof(BookmarkVisit.BookmarkId))] + public virtual List? Visits { get; set; } } diff --git a/src/backend/BookmarkApi.Data/Entities/BookmarkDevicePermission.cs b/src/backend/BookmarkApi.Data/Entities/BookmarkDevicePermission.cs new file mode 100644 index 0000000..c5d9a5d --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/BookmarkDevicePermission.cs @@ -0,0 +1,48 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 书签-设备权限关联实体 +/// +[Table(Name = "bookmark_device_permissions")] +[Index("idx_permission_unique", nameof(BookmarkId) + "," + nameof(DeviceId), IsUnique = true)] +[Index("idx_permission_device", nameof(DeviceId))] +public class BookmarkDevicePermission +{ + /// + /// 权限ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 书签ID + /// + [Column(IsNullable = false)] + public Guid BookmarkId { get; set; } + + /// + /// 设备ID + /// + [Column(IsNullable = false)] + public Guid DeviceId { get; set; } + + /// + /// 创建时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 导航属性 - 书签 + /// + [Navigate(nameof(BookmarkId))] + public virtual Bookmark? Bookmark { get; set; } + + /// + /// 导航属性 - 设备 + /// + [Navigate(nameof(DeviceId))] + public virtual Device? Device { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/BookmarkShare.cs b/src/backend/BookmarkApi.Data/Entities/BookmarkShare.cs new file mode 100644 index 0000000..4d1b4e6 --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/BookmarkShare.cs @@ -0,0 +1,97 @@ +using FreeSql.DataAnnotations; +using BookmarkApi.Shared.Enums; + +namespace BookmarkApi.Data.Entities; + +/// +/// 分享链接实体 +/// +[Table(Name = "bookmark_shares")] +[Index("idx_share_code", nameof(ShareCode), IsUnique = true)] +[Index("idx_share_user", nameof(UserId))] +[Index("idx_share_target", nameof(ShareType) + "," + nameof(TargetId))] +public class BookmarkShare +{ + /// + /// 分享ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 创建者用户ID + /// + [Column(IsNullable = false)] + public Guid UserId { get; set; } + + /// + /// 分享码(短链接标识) + /// + [Column(IsNullable = false, StringLength = 50)] + public string ShareCode { get; set; } = string.Empty; + + /// + /// 分享类型 + /// + [Column(MapType = typeof(int), IsNullable = false)] + public ShareType ShareType { get; set; } + + /// + /// 目标ID(书签/文件夹/集合的ID) + /// + [Column(IsNullable = false)] + public Guid TargetId { get; set; } + + /// + /// 分享标题(可自定义) + /// + [Column(StringLength = 200)] + public string? Title { get; set; } + + /// + /// 访问密码(加密存储) + /// + [Column(StringLength = 100)] + public string? Password { get; set; } + + /// + /// 查看次数 + /// + [Column(IsNullable = false)] + public int ViewCount { get; set; } + + /// + /// 最大查看次数(null 为无限) + /// + public int? MaxViews { get; set; } + + /// + /// 过期时间(null 为永不过期) + /// + public DateTime? ExpiresAt { get; set; } + + /// + /// 是否启用 + /// + [Column(IsNullable = false)] + public bool IsActive { get; set; } = true; + + /// + /// 创建时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 导航属性 - 创建者 + /// + [Navigate(nameof(UserId))] + public virtual User? User { get; set; } + + /// + /// 检查分享是否有效 + /// + public bool IsValid => IsActive + && (ExpiresAt == null || DateTime.UtcNow < ExpiresAt) + && (MaxViews == null || ViewCount < MaxViews); +} diff --git a/src/backend/BookmarkApi.Data/Entities/BookmarkTag.cs b/src/backend/BookmarkApi.Data/Entities/BookmarkTag.cs new file mode 100644 index 0000000..9d0b042 --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/BookmarkTag.cs @@ -0,0 +1,48 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 书签-标签关联实体 +/// +[Table(Name = "bookmark_tags")] +[Index("idx_bookmark_tag_unique", nameof(BookmarkId) + "," + nameof(TagId), IsUnique = true)] +[Index("idx_tag_bookmarks", nameof(TagId))] +public class BookmarkTag +{ + /// + /// 关联ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 书签ID + /// + [Column(IsNullable = false)] + public Guid BookmarkId { get; set; } + + /// + /// 标签ID + /// + [Column(IsNullable = false)] + public Guid TagId { get; set; } + + /// + /// 创建时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 导航属性 - 书签 + /// + [Navigate(nameof(BookmarkId))] + public virtual Bookmark? Bookmark { get; set; } + + /// + /// 导航属性 - 标签 + /// + [Navigate(nameof(TagId))] + public virtual Tag? Tag { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/BookmarkVisit.cs b/src/backend/BookmarkApi.Data/Entities/BookmarkVisit.cs new file mode 100644 index 0000000..23c1d1a --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/BookmarkVisit.cs @@ -0,0 +1,60 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 书签访问历史实体 +/// +[Table(Name = "bookmark_visits")] +[Index("idx_visit_user_time", nameof(UserId) + "," + nameof(VisitedAt))] +[Index("idx_visit_bookmark", nameof(BookmarkId))] +[Index("idx_visit_device", nameof(DeviceId))] +public class BookmarkVisit +{ + /// + /// 记录ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 用户ID + /// + [Column(IsNullable = false)] + public Guid UserId { get; set; } + + /// + /// 书签ID + /// + [Column(IsNullable = false)] + public Guid BookmarkId { get; set; } + + /// + /// 访问设备ID + /// + public Guid? DeviceId { get; set; } + + /// + /// 访问时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime VisitedAt { get; set; } + + /// + /// 导航属性 - 用户 + /// + [Navigate(nameof(UserId))] + public virtual User? User { get; set; } + + /// + /// 导航属性 - 书签 + /// + [Navigate(nameof(BookmarkId))] + public virtual Bookmark? Bookmark { get; set; } + + /// + /// 导航属性 - 设备 + /// + [Navigate(nameof(DeviceId))] + public virtual Device? Device { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/Collection.cs b/src/backend/BookmarkApi.Data/Entities/Collection.cs new file mode 100644 index 0000000..d6dfce0 --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/Collection.cs @@ -0,0 +1,83 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 收藏集合实体 +/// +[Table(Name = "collections")] +[Index("idx_collection_user", nameof(UserId))] +public class Collection +{ + /// + /// 集合ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 所属用户ID + /// + [Column(IsNullable = false)] + public Guid UserId { get; set; } + + /// + /// 集合名称 + /// + [Column(IsNullable = false, StringLength = 200)] + public string Name { get; set; } = string.Empty; + + /// + /// 描述 + /// + [Column(StringLength = 500)] + public string? Description { get; set; } + + /// + /// 图标 + /// + [Column(StringLength = 100)] + public string? Icon { get; set; } + + /// + /// 主题色 + /// + [Column(StringLength = 20)] + public string? Color { get; set; } + + /// + /// 是否公开 + /// + [Column(IsNullable = false)] + public bool IsPublic { get; set; } + + /// + /// 排序 + /// + [Column(IsNullable = false)] + public int Order { get; set; } + + /// + /// 创建时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 更新时间 + /// + [Column(ServerTime = DateTimeKind.Utc)] + public DateTime UpdatedAt { get; set; } + + /// + /// 导航属性 - 所属用户 + /// + [Navigate(nameof(UserId))] + public virtual User? User { get; set; } + + /// + /// 导航属性 - 集合书签关联 + /// + [Navigate(nameof(CollectionBookmark.CollectionId))] + public virtual List? CollectionBookmarks { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/CollectionBookmark.cs b/src/backend/BookmarkApi.Data/Entities/CollectionBookmark.cs new file mode 100644 index 0000000..10fd895 --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/CollectionBookmark.cs @@ -0,0 +1,54 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 集合-书签关联实体 +/// +[Table(Name = "collection_bookmarks")] +[Index("idx_collection_bookmark_unique", nameof(CollectionId) + "," + nameof(BookmarkId), IsUnique = true)] +[Index("idx_bookmark_collections", nameof(BookmarkId))] +public class CollectionBookmark +{ + /// + /// 关联ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 集合ID + /// + [Column(IsNullable = false)] + public Guid CollectionId { get; set; } + + /// + /// 书签ID + /// + [Column(IsNullable = false)] + public Guid BookmarkId { get; set; } + + /// + /// 集合内排序 + /// + [Column(IsNullable = false)] + public int Order { get; set; } + + /// + /// 添加时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 导航属性 - 集合 + /// + [Navigate(nameof(CollectionId))] + public virtual Collection? Collection { get; set; } + + /// + /// 导航属性 - 书签 + /// + [Navigate(nameof(BookmarkId))] + public virtual Bookmark? Bookmark { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/Device.cs b/src/backend/BookmarkApi.Data/Entities/Device.cs index 9c78e9d..83ad006 100644 --- a/src/backend/BookmarkApi.Data/Entities/Device.cs +++ b/src/backend/BookmarkApi.Data/Entities/Device.cs @@ -70,5 +70,17 @@ public class Device /// [Navigate(nameof(UserId))] public virtual User? User { get; set; } + + /// + /// 导航属性 - 设备的书签权限 + /// + [Navigate(nameof(BookmarkDevicePermission.DeviceId))] + public virtual List? BookmarkPermissions { get; set; } + + /// + /// 导航属性 - 设备的访问历史 + /// + [Navigate(nameof(BookmarkVisit.DeviceId))] + public virtual List? Visits { get; set; } } diff --git a/src/backend/BookmarkApi.Data/Entities/Folder.cs b/src/backend/BookmarkApi.Data/Entities/Folder.cs new file mode 100644 index 0000000..53d33b5 --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/Folder.cs @@ -0,0 +1,82 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 文件夹实体 +/// +[Table(Name = "folders")] +[Index("idx_folder_user_parent", nameof(UserId) + "," + nameof(ParentId))] +public class Folder +{ + /// + /// 文件夹ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 所属用户ID + /// + [Column(IsNullable = false)] + public Guid UserId { get; set; } + + /// + /// 父文件夹ID(null 为根目录) + /// + public Guid? ParentId { get; set; } + + /// + /// 文件夹名称 + /// + [Column(IsNullable = false, StringLength = 200)] + public string Name { get; set; } = string.Empty; + + /// + /// 图标(emoji 或图标名) + /// + [Column(StringLength = 100)] + public string? Icon { get; set; } + + /// + /// 同级排序 + /// + [Column(IsNullable = false)] + public int Order { get; set; } + + /// + /// 创建时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 更新时间 + /// + [Column(ServerTime = DateTimeKind.Utc)] + public DateTime UpdatedAt { get; set; } + + /// + /// 导航属性 - 所属用户 + /// + [Navigate(nameof(UserId))] + public virtual User? User { get; set; } + + /// + /// 导航属性 - 父文件夹 + /// + [Navigate(nameof(ParentId))] + public virtual Folder? Parent { get; set; } + + /// + /// 导航属性 - 子文件夹列表 + /// + [Navigate(nameof(ParentId))] + public virtual List? Children { get; set; } + + /// + /// 导航属性 - 文件夹内的书签 + /// + [Navigate(nameof(Bookmark.FolderId))] + public virtual List? Bookmarks { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/Tag.cs b/src/backend/BookmarkApi.Data/Entities/Tag.cs new file mode 100644 index 0000000..85f71a0 --- /dev/null +++ b/src/backend/BookmarkApi.Data/Entities/Tag.cs @@ -0,0 +1,65 @@ +using FreeSql.DataAnnotations; + +namespace BookmarkApi.Data.Entities; + +/// +/// 标签实体 +/// +[Table(Name = "tags")] +[Index("idx_tag_user_name", nameof(UserId) + "," + nameof(Name), IsUnique = true)] +public class Tag +{ + /// + /// 标签ID + /// + [Column(IsPrimary = true)] + public Guid Id { get; set; } + + /// + /// 所属用户ID + /// + [Column(IsNullable = false)] + public Guid UserId { get; set; } + + /// + /// 标签名称 + /// + [Column(IsNullable = false, StringLength = 100)] + public string Name { get; set; } = string.Empty; + + /// + /// 颜色值(如 #FF5733) + /// + [Column(StringLength = 20)] + public string? Color { get; set; } + + /// + /// 图标 + /// + [Column(StringLength = 100)] + public string? Icon { get; set; } + + /// + /// 排序 + /// + [Column(IsNullable = false)] + public int Order { get; set; } + + /// + /// 创建时间 + /// + [Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)] + public DateTime CreatedAt { get; set; } + + /// + /// 导航属性 - 所属用户 + /// + [Navigate(nameof(UserId))] + public virtual User? User { get; set; } + + /// + /// 导航属性 - 书签标签关联 + /// + [Navigate(nameof(BookmarkTag.TagId))] + public virtual List? BookmarkTags { get; set; } +} diff --git a/src/backend/BookmarkApi.Data/Entities/User.cs b/src/backend/BookmarkApi.Data/Entities/User.cs index 9b0af7f..8635e5e 100644 --- a/src/backend/BookmarkApi.Data/Entities/User.cs +++ b/src/backend/BookmarkApi.Data/Entities/User.cs @@ -69,12 +69,42 @@ public class User [Navigate(nameof(Device.UserId))] public virtual List? Devices { get; set; } + /// + /// 导航属性 - 用户的文件夹列表 + /// + [Navigate(nameof(Folder.UserId))] + public virtual List? Folders { get; set; } + /// /// 导航属性 - 用户的书签列表 /// [Navigate(nameof(Bookmark.UserId))] public virtual List? Bookmarks { get; set; } + /// + /// 导航属性 - 用户的标签列表 + /// + [Navigate(nameof(Tag.UserId))] + public virtual List? Tags { get; set; } + + /// + /// 导航属性 - 用户的收藏集合列表 + /// + [Navigate(nameof(Collection.UserId))] + public virtual List? Collections { get; set; } + + /// + /// 导航属性 - 用户的分享链接列表 + /// + [Navigate(nameof(BookmarkShare.UserId))] + public virtual List? Shares { get; set; } + + /// + /// 导航属性 - 用户的访问历史 + /// + [Navigate(nameof(BookmarkVisit.UserId))] + public virtual List? Visits { get; set; } + /// /// 导航属性 - 用户的刷新令牌列表 /// diff --git a/src/backend/BookmarkApi.Data/FreeSqlSetup.cs b/src/backend/BookmarkApi.Data/FreeSqlSetup.cs index 121c806..ba6b620 100644 --- a/src/backend/BookmarkApi.Data/FreeSqlSetup.cs +++ b/src/backend/BookmarkApi.Data/FreeSqlSetup.cs @@ -10,6 +10,28 @@ namespace BookmarkApi.Data; /// public static class FreeSqlSetup { + /// + /// 所有实体类型(用于数据库初始化) + /// + private static readonly Type[] EntityTypes = new[] + { + // 核心表 + typeof(User), + typeof(Device), + typeof(RefreshToken), + // 书签与组织 + typeof(Folder), + typeof(Bookmark), + typeof(Tag), + typeof(BookmarkTag), + // 扩展功能 + typeof(BookmarkDevicePermission), + typeof(Collection), + typeof(CollectionBookmark), + typeof(BookmarkShare), + typeof(BookmarkVisit) + }; + /// /// 添加 FreeSql 服务 /// @@ -42,14 +64,101 @@ public static class FreeSqlSetup /// public static async Task InitializeDatabaseAsync(IFreeSql freeSql) { - // 同步表结构 - freeSql.CodeFirst.SyncStructure(); - freeSql.CodeFirst.SyncStructure(); - freeSql.CodeFirst.SyncStructure(); - freeSql.CodeFirst.SyncStructure(); + Console.WriteLine("[FreeSql] Starting database initialization..."); + + // 同步所有实体表结构 + SyncAllTables(freeSql); // 创建超级管理员账号(如果不存在) await SeedSuperAdminAsync(freeSql); + + Console.WriteLine("[FreeSql] Database initialization completed."); + } + + /// + /// 同步所有表结构 + /// + public static void SyncAllTables(IFreeSql freeSql) + { + Console.WriteLine($"[FreeSql] Syncing {EntityTypes.Length} tables..."); + + foreach (var entityType in EntityTypes) + { + try + { + freeSql.CodeFirst.SyncStructure(entityType); + Console.WriteLine($"[FreeSql] Synced table for {entityType.Name}"); + } + catch (Exception ex) + { + Console.WriteLine($"[FreeSql] Error syncing table for {entityType.Name}: {ex.Message}"); + throw; + } + } + } + + /// + /// 检查数据库连接和表是否存在 + /// + public static async Task CheckDatabaseStatusAsync(IFreeSql freeSql) + { + var status = new DatabaseStatus(); + + try + { + // 测试数据库连接 + await freeSql.Ado.ExecuteScalarAsync("SELECT 1"); + status.IsConnected = true; + + // 检查各表是否存在 + foreach (var entityType in EntityTypes) + { + var tableName = GetTableName(entityType); + var exists = await CheckTableExistsAsync(freeSql, tableName); + status.Tables[entityType.Name] = exists; + } + } + catch (Exception ex) + { + status.IsConnected = false; + status.ErrorMessage = ex.Message; + } + + return status; + } + + /// + /// 获取实体对应的表名 + /// + private static string GetTableName(Type entityType) + { + var tableAttr = entityType.GetCustomAttributes(typeof(FreeSql.DataAnnotations.TableAttribute), false) + .FirstOrDefault() as FreeSql.DataAnnotations.TableAttribute; + return tableAttr?.Name ?? entityType.Name.ToLower() + "s"; + } + + /// + /// 检查表是否存在 + /// + private static async Task CheckTableExistsAsync(IFreeSql freeSql, string tableName) + { + try + { + var dbType = freeSql.Ado.DataType; + string sql = dbType switch + { + DataType.PostgreSQL => $"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '{tableName}')", + DataType.SqlServer => $"SELECT CASE WHEN EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '{tableName}') THEN 1 ELSE 0 END", + _ => throw new NotSupportedException($"Database type {dbType} is not supported for table existence check") + }; + + var result = await freeSql.Ado.ExecuteScalarAsync(sql); + return Convert.ToBoolean(result); + } + catch + { + return false; + } } /// @@ -82,3 +191,34 @@ public static class FreeSqlSetup } } +/// +/// 数据库状态信息 +/// +public class DatabaseStatus +{ + /// + /// 是否已连接 + /// + public bool IsConnected { get; set; } + + /// + /// 错误信息 + /// + public string? ErrorMessage { get; set; } + + /// + /// 各表是否存在 + /// + public Dictionary Tables { get; set; } = new(); + + /// + /// 是否所有表都存在 + /// + public bool AllTablesExist => IsConnected && Tables.Count > 0 && Tables.Values.All(v => v); + + /// + /// 缺失的表 + /// + public IEnumerable MissingTables => Tables.Where(t => !t.Value).Select(t => t.Key); +} + diff --git a/src/backend/BookmarkApi.Shared/DTOs/Bookmark/CreateBookmarkRequest.cs b/src/backend/BookmarkApi.Shared/DTOs/Bookmark/CreateBookmarkRequest.cs index e792623..e33953e 100644 --- a/src/backend/BookmarkApi.Shared/DTOs/Bookmark/CreateBookmarkRequest.cs +++ b/src/backend/BookmarkApi.Shared/DTOs/Bookmark/CreateBookmarkRequest.cs @@ -8,6 +8,11 @@ namespace BookmarkApi.Shared.DTOs.Bookmark; /// public class CreateBookmarkRequest { + /// + /// 所属文件夹ID(null 为根目录) + /// + public Guid? FolderId { get; set; } + /// /// 书签标题 /// diff --git a/src/backend/BookmarkApi.Shared/DTOs/Bookmark/UpdateBookmarkRequest.cs b/src/backend/BookmarkApi.Shared/DTOs/Bookmark/UpdateBookmarkRequest.cs index 3cd9148..841f625 100644 --- a/src/backend/BookmarkApi.Shared/DTOs/Bookmark/UpdateBookmarkRequest.cs +++ b/src/backend/BookmarkApi.Shared/DTOs/Bookmark/UpdateBookmarkRequest.cs @@ -8,6 +8,16 @@ namespace BookmarkApi.Shared.DTOs.Bookmark; /// public class UpdateBookmarkRequest { + /// + /// 所属文件夹ID(null 为根目录) + /// + public Guid? FolderId { get; set; } + + /// + /// 是否更新文件夹(用于区分 null 和不更新) + /// + public bool UpdateFolder { get; set; } + /// /// 书签标题 /// diff --git a/src/backend/BookmarkApi.Shared/DTOs/BookmarkDto.cs b/src/backend/BookmarkApi.Shared/DTOs/BookmarkDto.cs index 1b53aa4..d43eea3 100644 --- a/src/backend/BookmarkApi.Shared/DTOs/BookmarkDto.cs +++ b/src/backend/BookmarkApi.Shared/DTOs/BookmarkDto.cs @@ -12,6 +12,11 @@ public class BookmarkDto /// public Guid Id { get; set; } + /// + /// 所属文件夹ID + /// + public Guid? FolderId { get; set; } + /// /// 书签标题 /// @@ -33,9 +38,9 @@ public class BookmarkDto public string? Icon { get; set; } /// - /// 标签数组 + /// 标签列表 /// - public string[] Tags { get; set; } = Array.Empty(); + public List Tags { get; set; } = new(); /// /// 访问次数 @@ -45,12 +50,12 @@ public class BookmarkDto /// /// 最后访问时间 /// - public DateTime? LastVisitTime { get; set; } + public DateTime? LastVisitAt { get; set; } /// /// 排序值 /// - public long Order { get; set; } + public int Order { get; set; } /// /// 可见性类型 diff --git a/src/backend/BookmarkApi.Shared/DTOs/TagDto.cs b/src/backend/BookmarkApi.Shared/DTOs/TagDto.cs index 8edef9e..260fc38 100644 --- a/src/backend/BookmarkApi.Shared/DTOs/TagDto.cs +++ b/src/backend/BookmarkApi.Shared/DTOs/TagDto.cs @@ -5,13 +5,33 @@ namespace BookmarkApi.Shared.DTOs; /// public class TagDto { + /// + /// 标签ID + /// + public Guid Id { get; set; } + /// /// 标签名称 /// public string Name { get; set; } = string.Empty; /// - /// 使用数量 + /// 颜色值 + /// + public string? Color { get; set; } + + /// + /// 图标 + /// + public string? Icon { get; set; } + + /// + /// 排序 + /// + public int Order { get; set; } + + /// + /// 使用数量(书签数) /// public int Count { get; set; } } diff --git a/src/backend/BookmarkApi.Shared/Enums/ShareType.cs b/src/backend/BookmarkApi.Shared/Enums/ShareType.cs new file mode 100644 index 0000000..ecaa422 --- /dev/null +++ b/src/backend/BookmarkApi.Shared/Enums/ShareType.cs @@ -0,0 +1,22 @@ +namespace BookmarkApi.Shared.Enums; + +/// +/// 分享类型枚举 +/// +public enum ShareType +{ + /// + /// 单个书签 + /// + Bookmark = 1, + + /// + /// 文件夹(含子内容) + /// + Folder = 2, + + /// + /// 收藏集合 + /// + Collection = 3 +} diff --git a/src/backend/BookmarkApi/Controllers/AdminController.cs b/src/backend/BookmarkApi/Controllers/AdminController.cs index 8e805f6..32bbda4 100644 --- a/src/backend/BookmarkApi/Controllers/AdminController.cs +++ b/src/backend/BookmarkApi/Controllers/AdminController.cs @@ -279,6 +279,8 @@ public class AdminController : ControllerBase var total = await query.CountAsync(); var bookmarks = await query + .IncludeMany(b => b.BookmarkTags, then => then.Include(bt => bt.Tag)) + .IncludeMany(b => b.DevicePermissions) .OrderByDescending(b => b.CreatedAt) .Page(page, pageSize) .ToListAsync(); @@ -286,16 +288,24 @@ public class AdminController : ControllerBase var bookmarkDtos = bookmarks.Select(b => new BookmarkDto { Id = b.Id, + FolderId = b.FolderId, Title = b.Title, Url = b.Url, Description = b.Description, Icon = b.Icon, - Tags = b.Tags, + Tags = b.BookmarkTags?.Where(bt => bt.Tag != null).Select(bt => new TagDto + { + Id = bt.Tag!.Id, + Name = bt.Tag.Name, + Color = bt.Tag.Color, + Icon = bt.Tag.Icon, + Order = bt.Tag.Order + }).ToList() ?? new List(), VisitCount = b.VisitCount, - LastVisitTime = b.LastVisitTime, + LastVisitAt = b.LastVisitAt, Order = b.Order, Visibility = b.Visibility, - AllowedDevices = b.AllowedDevices, + AllowedDevices = b.DevicePermissions?.Select(dp => dp.DeviceId).ToArray(), CreatedAt = b.CreatedAt, UpdatedAt = b.UpdatedAt }).ToList(); diff --git a/src/backend/BookmarkApi/Controllers/BookmarksController.cs b/src/backend/BookmarkApi/Controllers/BookmarksController.cs index 20d2410..0bf1590 100644 --- a/src/backend/BookmarkApi/Controllers/BookmarksController.cs +++ b/src/backend/BookmarkApi/Controllers/BookmarksController.cs @@ -30,7 +30,7 @@ public class BookmarksController : ControllerBase /// [HttpGet] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task>>> GetBookmarks([FromQuery] string? tag = null) + public async Task>>> GetBookmarks([FromQuery] string? tag = null, [FromQuery] Guid? folderId = null) { var (userId, deviceId, isAdmin) = await GetCurrentContext(); if (userId == null || deviceId == null) @@ -38,7 +38,7 @@ public class BookmarksController : ControllerBase return BadRequest(ApiResponse>.Fail("无效的用户或设备")); } - var bookmarks = await _bookmarkService.GetUserBookmarksAsync(userId.Value, deviceId.Value, isAdmin, tag); + var bookmarks = await _bookmarkService.GetUserBookmarksAsync(userId.Value, deviceId.Value, isAdmin, tag, folderId); return Ok(ApiResponse>.Ok(bookmarks)); } @@ -202,12 +202,13 @@ public class BookmarksController : ControllerBase public async Task> RecordVisit(Guid id) { var userId = GetCurrentUserId(); + var deviceId = GetCurrentDeviceId(); if (userId == null) { return BadRequest(ApiResponse.Fail("无效的用户")); } - var success = await _bookmarkService.RecordVisitAsync(id, userId.Value); + var success = await _bookmarkService.RecordVisitAsync(id, userId.Value, deviceId); if (!success) { @@ -346,6 +347,6 @@ public class UpdateOrderRequest /// /// 新的排序值 /// - public long Order { get; set; } + public int Order { get; set; } } diff --git a/src/backend/BookmarkApi/Controllers/TagsController.cs b/src/backend/BookmarkApi/Controllers/TagsController.cs index dbea7bf..254cf90 100644 --- a/src/backend/BookmarkApi/Controllers/TagsController.cs +++ b/src/backend/BookmarkApi/Controllers/TagsController.cs @@ -15,12 +15,10 @@ namespace BookmarkApi.Controllers; public class TagsController : ControllerBase { private readonly ITagService _tagService; - private readonly IDeviceService _deviceService; - public TagsController(ITagService tagService, IDeviceService deviceService) + public TagsController(ITagService tagService) { _tagService = tagService; - _deviceService = deviceService; } /// @@ -30,21 +28,98 @@ public class TagsController : ControllerBase [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>>> GetTags() { - var (userId, deviceId, isAdmin) = await GetCurrentContext(); - if (userId == null || deviceId == null) + var userId = GetCurrentUserId(); + if (userId == null) { - return BadRequest(ApiResponse>.Fail("无效的用户或设备")); + return BadRequest(ApiResponse>.Fail("无效的用户")); } - var tags = await _tagService.GetUserTagsAsync(userId.Value, deviceId.Value, isAdmin); + var tags = await _tagService.GetUserTagsAsync(userId.Value); return Ok(ApiResponse>.Ok(tags)); } /// - /// 重命名标签 + /// 获取标签详情 /// - [HttpPut("{name}")] + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> GetTag(Guid id) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return BadRequest(ApiResponse.Fail("无效的用户")); + } + + var tag = await _tagService.GetTagAsync(id, userId.Value); + + if (tag == null) + { + return NotFound(ApiResponse.Fail("标签不存在")); + } + + return Ok(ApiResponse.Ok(tag)); + } + + /// + /// 创建标签 + /// + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + public async Task>> CreateTag([FromBody] CreateTagRequest request) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return BadRequest(ApiResponse.Fail("无效的用户")); + } + + try + { + var tag = await _tagService.CreateTagAsync(userId.Value, request.Name, request.Color, request.Icon); + return CreatedAtAction(nameof(GetTag), new { id = tag.Id }, ApiResponse.Ok(tag)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// 更新标签 + /// + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task>> UpdateTag(Guid id, [FromBody] UpdateTagRequest request) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return BadRequest(ApiResponse.Fail("无效的用户")); + } + + try + { + var tag = await _tagService.UpdateTagAsync(id, userId.Value, request.Name, request.Color, request.Icon); + + if (tag == null) + { + return NotFound(ApiResponse.Fail("标签不存在")); + } + + return Ok(ApiResponse.Ok(tag)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// 重命名标签(按名称) + /// + [HttpPut("rename/{name}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> RenameTag(string name, [FromBody] RenameTagRequest request) { @@ -54,32 +129,44 @@ public class TagsController : ControllerBase return BadRequest(ApiResponse.Fail("无效的用户")); } - var success = await _tagService.RenameTagAsync(userId.Value, name, request.NewName); + try + { + var success = await _tagService.RenameTagAsync(userId.Value, name, request.NewName); + + if (!success) + { + return NotFound(ApiResponse.Fail("标签不存在")); + } + + return Ok(ApiResponse.Ok("标签已重命名")); + } + catch (InvalidOperationException ex) + { + return BadRequest(ApiResponse.Fail(ex.Message)); + } + } + + /// + /// 删除标签 + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteTag(Guid id) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return BadRequest(ApiResponse.Fail("无效的用户")); + } + + var success = await _tagService.DeleteTagAsync(id, userId.Value); if (!success) { return NotFound(ApiResponse.Fail("标签不存在")); } - return Ok(ApiResponse.Ok("标签已重命名")); - } - - /// - /// 删除标签 - /// - [HttpDelete("{name}")] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> DeleteTag(string name) - { - var userId = GetCurrentUserId(); - if (userId == null) - { - return BadRequest(ApiResponse.Fail("无效的用户")); - } - - var count = await _tagService.DeleteTagAsync(userId.Value, name); - - return Ok(ApiResponse.Ok(count, $"已从 {count} 个书签中移除该标签")); + return Ok(ApiResponse.Ok("标签已删除")); } /// @@ -95,9 +182,15 @@ public class TagsController : ControllerBase return BadRequest(ApiResponse.Fail("无效的用户")); } - var count = await _tagService.MergeTagsAsync(userId.Value, request.SourceNames, request.TargetName); - - return Ok(ApiResponse.Ok(count, $"已合并 {count} 个书签的标签")); + try + { + var count = await _tagService.MergeTagsAsync(userId.Value, request.SourceTagIds, request.TargetTagId); + return Ok(ApiResponse.Ok(count, $"已合并 {count} 个书签的标签")); + } + catch (InvalidOperationException ex) + { + return BadRequest(ApiResponse.Fail(ex.Message)); + } } private Guid? GetCurrentUserId() @@ -105,27 +198,48 @@ public class TagsController : ControllerBase var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; return Guid.TryParse(userIdClaim, out var userId) ? userId : null; } +} - private Guid? GetCurrentDeviceId() - { - var deviceIdClaim = User.FindFirst("device_id")?.Value; - return Guid.TryParse(deviceIdClaim, out var deviceId) ? deviceId : null; - } +/// +/// 创建标签请求 +/// +public class CreateTagRequest +{ + /// + /// 标签名称 + /// + public string Name { get; set; } = string.Empty; - private async Task<(Guid? userId, Guid? deviceId, bool isAdmin)> GetCurrentContext() - { - var userId = GetCurrentUserId(); - var deviceId = GetCurrentDeviceId(); + /// + /// 颜色值 + /// + public string? Color { get; set; } - if (userId == null || deviceId == null) - { - return (null, null, false); - } + /// + /// 图标 + /// + public string? Icon { get; set; } +} - var isAdmin = await _deviceService.IsAdminDeviceAsync(deviceId.Value); +/// +/// 更新标签请求 +/// +public class UpdateTagRequest +{ + /// + /// 标签名称 + /// + public string? Name { get; set; } - return (userId, deviceId, isAdmin); - } + /// + /// 颜色值 + /// + public string? Color { get; set; } + + /// + /// 图标 + /// + public string? Icon { get; set; } } /// @@ -145,13 +259,12 @@ public class RenameTagRequest public class MergeTagsRequest { /// - /// 源标签名称数组 + /// 源标签ID数组 /// - public string[] SourceNames { get; set; } = Array.Empty(); + public Guid[] SourceTagIds { get; set; } = Array.Empty(); /// - /// 目标标签名称 + /// 目标标签ID /// - public string TargetName { get; set; } = string.Empty; + public Guid TargetTagId { get; set; } } -