312
This commit is contained in:
parent
08ed928fb9
commit
22647099c2
678
docs/数据库重构计划.md
Normal file
678
docs/数据库重构计划.md
Normal file
|
|
@ -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<TagDto>`;`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 `<span class="tag" style="${style}">${tag.name}</span>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="search-result-item">
|
||||
<img src="${bookmark.icon || 'default-icon.png'}" alt="">
|
||||
<div class="info">
|
||||
<div class="title">${bookmark.title}</div>
|
||||
<div class="url">${bookmark.url}</div>
|
||||
<div class="tags">${tagsHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
|
@ -12,7 +12,7 @@ public interface IBookmarkService
|
|||
/// <summary>
|
||||
/// 获取用户的书签列表(根据设备可见性过滤)
|
||||
/// </summary>
|
||||
Task<List<BookmarkDto>> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null);
|
||||
Task<List<BookmarkDto>> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null, Guid? folderId = null);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索书签
|
||||
|
|
@ -52,12 +52,12 @@ public interface IBookmarkService
|
|||
/// <summary>
|
||||
/// 记录访问
|
||||
/// </summary>
|
||||
Task<bool> RecordVisitAsync(Guid bookmarkId, Guid userId);
|
||||
Task<bool> RecordVisitAsync(Guid bookmarkId, Guid userId, Guid? deviceId = null);
|
||||
|
||||
/// <summary>
|
||||
/// 更新书签排序
|
||||
/// </summary>
|
||||
Task<bool> UpdateOrderAsync(Guid bookmarkId, Guid userId, long newOrder);
|
||||
Task<bool> UpdateOrderAsync(Guid bookmarkId, Guid userId, int newOrder);
|
||||
|
||||
/// <summary>
|
||||
/// 检查 URL 是否已存在
|
||||
|
|
|
|||
|
|
@ -10,7 +10,22 @@ public interface ITagService
|
|||
/// <summary>
|
||||
/// 获取用户的所有标签及使用数量
|
||||
/// </summary>
|
||||
Task<List<TagDto>> GetUserTagsAsync(Guid userId, Guid deviceId, bool isAdminDevice);
|
||||
Task<List<TagDto>> GetUserTagsAsync(Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签详情
|
||||
/// </summary>
|
||||
Task<TagDto?> GetTagAsync(Guid tagId, Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// 创建标签
|
||||
/// </summary>
|
||||
Task<TagDto> CreateTagAsync(Guid userId, string name, string? color = null, string? icon = null);
|
||||
|
||||
/// <summary>
|
||||
/// 更新标签
|
||||
/// </summary>
|
||||
Task<TagDto?> UpdateTagAsync(Guid tagId, Guid userId, string? name = null, string? color = null, string? icon = null);
|
||||
|
||||
/// <summary>
|
||||
/// 重命名标签
|
||||
|
|
@ -18,13 +33,13 @@ public interface ITagService
|
|||
Task<bool> RenameTagAsync(Guid userId, string oldName, string newName);
|
||||
|
||||
/// <summary>
|
||||
/// 删除标签(从所有书签中移除该标签)
|
||||
/// 删除标签
|
||||
/// </summary>
|
||||
Task<int> DeleteTagAsync(Guid userId, string tagName);
|
||||
Task<bool> DeleteTagAsync(Guid tagId, Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// 合并标签
|
||||
/// </summary>
|
||||
Task<int> MergeTagsAsync(Guid userId, string[] sourceNames, string targetName);
|
||||
Task<int> MergeTagsAsync(Guid userId, Guid[] sourceTagIds, Guid targetTagId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,12 +19,26 @@ public class BookmarkService : IBookmarkService
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<BookmarkDto>> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null)
|
||||
public async Task<List<BookmarkDto>> GetUserBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string? tag = null, Guid? folderId = null)
|
||||
{
|
||||
// 先查询所有书签,然后在内存中过滤
|
||||
// 避免 FreeSql 无法正确转换 AllowedDevices.Contains 和 Tags.Contains 为 SQL
|
||||
var bookmarks = await _freeSql.Select<Bookmark>()
|
||||
// 查询书签,包含关联数据
|
||||
var query = _freeSql.Select<Bookmark>()
|
||||
.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
|
|||
/// <inheritdoc />
|
||||
public async Task<List<BookmarkDto>> SearchBookmarksAsync(Guid userId, Guid deviceId, bool isAdminDevice, string keyword)
|
||||
{
|
||||
// 先在数据库层面进行关键词搜索,然后在内存中过滤可见性
|
||||
// 避免 FreeSql 无法正确转换 AllowedDevices.Contains 为 SQL
|
||||
var lowerKeyword = keyword.ToLower();
|
||||
var bookmarks = await _freeSql.Select<Bookmark>()
|
||||
.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<Bookmark>()
|
||||
.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
|
|||
/// <inheritdoc />
|
||||
public async Task<BookmarkDto> CreateBookmarkAsync(Guid userId, CreateBookmarkRequest request)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 获取当前最大排序值
|
||||
var maxOrder = await _freeSql.Select<Bookmark>()
|
||||
.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<string>(),
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -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<Bookmark>()
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteBookmarkAsync(Guid bookmarkId, Guid userId)
|
||||
{
|
||||
// 先删除关联数据
|
||||
await _freeSql.Delete<BookmarkTag>()
|
||||
.Where(bt => bt.BookmarkId == bookmarkId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
await _freeSql.Delete<BookmarkDevicePermission>()
|
||||
.Where(p => p.BookmarkId == bookmarkId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
await _freeSql.Delete<CollectionBookmark>()
|
||||
.Where(cb => cb.BookmarkId == bookmarkId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
await _freeSql.Delete<BookmarkVisit>()
|
||||
.Where(v => v.BookmarkId == bookmarkId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
// 删除书签
|
||||
var affectedRows = await _freeSql.Delete<Bookmark>()
|
||||
.Where(b => b.Id == bookmarkId && b.UserId == userId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
|
@ -199,6 +266,24 @@ public class BookmarkService : IBookmarkService
|
|||
/// <inheritdoc />
|
||||
public async Task<int> BatchDeleteBookmarksAsync(Guid userId, Guid[] bookmarkIds)
|
||||
{
|
||||
// 先删除关联数据
|
||||
await _freeSql.Delete<BookmarkTag>()
|
||||
.Where(bt => bookmarkIds.Contains(bt.BookmarkId))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
await _freeSql.Delete<BookmarkDevicePermission>()
|
||||
.Where(p => bookmarkIds.Contains(p.BookmarkId))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
await _freeSql.Delete<CollectionBookmark>()
|
||||
.Where(cb => bookmarkIds.Contains(cb.BookmarkId))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
await _freeSql.Delete<BookmarkVisit>()
|
||||
.Where(v => bookmarkIds.Contains(v.BookmarkId))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
// 删除书签
|
||||
var affectedRows = await _freeSql.Delete<Bookmark>()
|
||||
.Where(b => b.UserId == userId && bookmarkIds.Contains(b.Id))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
|
@ -211,16 +296,20 @@ public class BookmarkService : IBookmarkService
|
|||
{
|
||||
var affectedRows = await _freeSql.Update<Bookmark>()
|
||||
.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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RecordVisitAsync(Guid bookmarkId, Guid userId)
|
||||
public async Task<bool> RecordVisitAsync(Guid bookmarkId, Guid userId, Guid? deviceId = null)
|
||||
{
|
||||
var bookmark = await _freeSql.Select<Bookmark>()
|
||||
.Where(b => b.Id == bookmarkId && b.UserId == userId)
|
||||
|
|
@ -231,17 +320,30 @@ public class BookmarkService : IBookmarkService
|
|||
return false;
|
||||
}
|
||||
|
||||
// 更新书签访问统计
|
||||
await _freeSql.Update<Bookmark>()
|
||||
.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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateOrderAsync(Guid bookmarkId, Guid userId, long newOrder)
|
||||
public async Task<bool> UpdateOrderAsync(Guid bookmarkId, Guid userId, int newOrder)
|
||||
{
|
||||
var affectedRows = await _freeSql.Update<Bookmark>()
|
||||
.Set(b => b.Order, newOrder)
|
||||
|
|
@ -264,7 +366,9 @@ public class BookmarkService : IBookmarkService
|
|||
public async Task<int> ImportBookmarksAsync(Guid userId, List<CreateBookmarkRequest> bookmarks)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var baseOrder = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var maxOrder = await _freeSql.Select<Bookmark>()
|
||||
.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<string>(),
|
||||
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<Bookmark>()
|
||||
.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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步书签标签
|
||||
/// </summary>
|
||||
private async Task SyncBookmarkTagsAsync(Guid userId, Guid bookmarkId, string[] tagNames)
|
||||
{
|
||||
// 删除现有关联
|
||||
await _freeSql.Delete<BookmarkTag>()
|
||||
.Where(bt => bt.BookmarkId == bookmarkId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
if (tagNames.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取或创建标签
|
||||
var existingTags = await _freeSql.Select<Tag>()
|
||||
.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<Tag>().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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步设备权限
|
||||
/// </summary>
|
||||
private async Task SyncDevicePermissionsAsync(Guid bookmarkId, Guid[] deviceIds)
|
||||
{
|
||||
// 删除现有权限
|
||||
await _freeSql.Delete<BookmarkDevicePermission>()
|
||||
.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<TagDto>(),
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<TagDto>> GetUserTagsAsync(Guid userId, Guid deviceId, bool isAdminDevice)
|
||||
public async Task<List<TagDto>> GetUserTagsAsync(Guid userId)
|
||||
{
|
||||
// 先查询所有书签,然后在内存中过滤
|
||||
// 避免 FreeSql 无法正确转换 AllowedDevices.Contains 为 SQL
|
||||
var bookmarks = await _freeSql.Select<Bookmark>()
|
||||
.Where(b => b.UserId == userId)
|
||||
// 查询所有标签及其使用数量
|
||||
var tags = await _freeSql.Select<Tag>()
|
||||
.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<BookmarkTag>()
|
||||
.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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TagDto?> GetTagAsync(Guid tagId, Guid userId)
|
||||
{
|
||||
var tag = await _freeSql.Select<Tag>()
|
||||
.Where(t => t.Id == tagId && t.UserId == userId)
|
||||
.FirstAsync();
|
||||
|
||||
if (tag == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 统计标签使用数量
|
||||
var tagCounts = new Dictionary<string, int>();
|
||||
// 统计使用数量
|
||||
var count = await _freeSql.Select<BookmarkTag>()
|
||||
.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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TagDto> CreateTagAsync(Guid userId, string name, string? color = null, string? icon = null)
|
||||
{
|
||||
// 检查标签名是否已存在
|
||||
var exists = await _freeSql.Select<Tag>()
|
||||
.Where(t => t.UserId == userId && t.Name == name)
|
||||
.AnyAsync();
|
||||
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException($"标签 '{name}' 已存在");
|
||||
}
|
||||
|
||||
// 获取最大排序值
|
||||
var maxOrder = await _freeSql.Select<Tag>()
|
||||
.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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TagDto?> UpdateTagAsync(Guid tagId, Guid userId, string? name = null, string? color = null, string? icon = null)
|
||||
{
|
||||
var tag = await _freeSql.Select<Tag>()
|
||||
.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<Tag>()
|
||||
.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<Tag>()
|
||||
.SetSource(tag)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
// 统计使用数量
|
||||
var count = await _freeSql.Select<BookmarkTag>()
|
||||
.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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RenameTagAsync(Guid userId, string oldName, string newName)
|
||||
{
|
||||
// 获取包含旧标签的所有书签
|
||||
var bookmarks = await _freeSql.Select<Bookmark>()
|
||||
.Where(b => b.UserId == userId && b.Tags.Contains(oldName))
|
||||
.ToListAsync();
|
||||
var tag = await _freeSql.Select<Tag>()
|
||||
.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<Tag>()
|
||||
.Where(t => t.UserId == userId && t.Name == newName)
|
||||
.AnyAsync();
|
||||
|
||||
await _freeSql.Update<Bookmark>()
|
||||
.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<Tag>()
|
||||
.Set(t => t.Name, newName)
|
||||
.Where(t => t.Id == tag.Id)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteTagAsync(Guid userId, string tagName)
|
||||
public async Task<bool> DeleteTagAsync(Guid tagId, Guid userId)
|
||||
{
|
||||
// 获取包含该标签的所有书签
|
||||
var bookmarks = await _freeSql.Select<Bookmark>()
|
||||
.Where(b => b.UserId == userId && b.Tags.Contains(tagName))
|
||||
.ToListAsync();
|
||||
// 先删除关联
|
||||
await _freeSql.Delete<BookmarkTag>()
|
||||
.Where(bt => bt.TagId == tagId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
var affectedCount = 0;
|
||||
// 删除标签
|
||||
var affectedRows = await _freeSql.Delete<Tag>()
|
||||
.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<Bookmark>()
|
||||
.Set(b => b.Tags, tags)
|
||||
.Set(b => b.UpdatedAt, DateTime.UtcNow)
|
||||
.Where(b => b.Id == bookmark.Id)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
affectedCount++;
|
||||
}
|
||||
|
||||
return affectedCount;
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> MergeTagsAsync(Guid userId, string[] sourceNames, string targetName)
|
||||
public async Task<int> MergeTagsAsync(Guid userId, Guid[] sourceTagIds, Guid targetTagId)
|
||||
{
|
||||
// 获取包含任意源标签的所有书签
|
||||
var bookmarks = await _freeSql.Select<Bookmark>()
|
||||
.Where(b => b.UserId == userId)
|
||||
// 验证目标标签存在
|
||||
var targetTag = await _freeSql.Select<Tag>()
|
||||
.Where(t => t.Id == targetTagId && t.UserId == userId)
|
||||
.FirstAsync();
|
||||
|
||||
if (targetTag == null)
|
||||
{
|
||||
throw new InvalidOperationException("目标标签不存在");
|
||||
}
|
||||
|
||||
// 获取所有源标签关联的书签
|
||||
var sourceBookmarkTags = await _freeSql.Select<BookmarkTag>()
|
||||
.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<BookmarkTag>()
|
||||
.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<Bookmark>()
|
||||
.Set(b => b.Tags, tags)
|
||||
.Set(b => b.UpdatedAt, DateTime.UtcNow)
|
||||
.Where(b => b.Id == bookmark.Id)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
affectedCount++;
|
||||
}
|
||||
|
||||
// 删除源标签的所有关联
|
||||
await _freeSql.Delete<BookmarkTag>()
|
||||
.Where(bt => sourceTagIds.Contains(bt.TagId))
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
// 删除源标签
|
||||
await _freeSql.Delete<Tag>()
|
||||
.Where(t => sourceTagIds.Contains(t.Id) && t.UserId == userId)
|
||||
.ExecuteAffrowsAsync();
|
||||
|
||||
return affectedCount;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace BookmarkApi.Data.Entities;
|
|||
/// 书签实体
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属文件夹ID(null 为根目录)
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签标题
|
||||
/// </summary>
|
||||
|
|
@ -48,10 +53,16 @@ public class Bookmark
|
|||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签数组(JSON存储)
|
||||
/// 文件夹内排序
|
||||
/// </summary>
|
||||
[Column(MapType = typeof(string), StringLength = -1)]
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
[Column(IsNullable = false)]
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性类型
|
||||
/// </summary>
|
||||
[Column(MapType = typeof(int))]
|
||||
public VisibilityType Visibility { get; set; } = VisibilityType.Public;
|
||||
|
||||
/// <summary>
|
||||
/// 访问次数
|
||||
|
|
@ -62,25 +73,7 @@ public class Bookmark
|
|||
/// <summary>
|
||||
/// 最后访问时间
|
||||
/// </summary>
|
||||
public DateTime? LastVisitTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值(时间戳)
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public long Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性类型
|
||||
/// </summary>
|
||||
[Column(MapType = typeof(int))]
|
||||
public VisibilityType Visibility { get; set; } = VisibilityType.Public;
|
||||
|
||||
/// <summary>
|
||||
/// 允许查看的设备ID列表(JSON存储,可见性为Specified时使用)
|
||||
/// </summary>
|
||||
[Column(MapType = typeof(string), StringLength = -1)]
|
||||
public Guid[]? AllowedDevices { get; set; }
|
||||
public DateTime? LastVisitAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
|
|
@ -99,5 +92,35 @@ public class Bookmark
|
|||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 所属文件夹
|
||||
/// </summary>
|
||||
[Navigate(nameof(FolderId))]
|
||||
public virtual Folder? Folder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 书签标签关联
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkTag.BookmarkId))]
|
||||
public virtual List<BookmarkTag>? BookmarkTags { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 设备权限
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkDevicePermission.BookmarkId))]
|
||||
public virtual List<BookmarkDevicePermission>? DevicePermissions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 所属集合关联
|
||||
/// </summary>
|
||||
[Navigate(nameof(CollectionBookmark.BookmarkId))]
|
||||
public virtual List<CollectionBookmark>? CollectionBookmarks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 访问历史
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkVisit.BookmarkId))]
|
||||
public virtual List<BookmarkVisit>? Visits { get; set; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 书签-设备权限关联实体
|
||||
/// </summary>
|
||||
[Table(Name = "bookmark_device_permissions")]
|
||||
[Index("idx_permission_unique", nameof(BookmarkId) + "," + nameof(DeviceId), IsUnique = true)]
|
||||
[Index("idx_permission_device", nameof(DeviceId))]
|
||||
public class BookmarkDevicePermission
|
||||
{
|
||||
/// <summary>
|
||||
/// 权限ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid BookmarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设备ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 书签
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkId))]
|
||||
public virtual Bookmark? Bookmark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 设备
|
||||
/// </summary>
|
||||
[Navigate(nameof(DeviceId))]
|
||||
public virtual Device? Device { get; set; }
|
||||
}
|
||||
97
src/backend/BookmarkApi.Data/Entities/BookmarkShare.cs
Normal file
97
src/backend/BookmarkApi.Data/Entities/BookmarkShare.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
using BookmarkApi.Shared.Enums;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 分享链接实体
|
||||
/// </summary>
|
||||
[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
|
||||
{
|
||||
/// <summary>
|
||||
/// 分享ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建者用户ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享码(短链接标识)
|
||||
/// </summary>
|
||||
[Column(IsNullable = false, StringLength = 50)]
|
||||
public string ShareCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分享类型
|
||||
/// </summary>
|
||||
[Column(MapType = typeof(int), IsNullable = false)]
|
||||
public ShareType ShareType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标ID(书签/文件夹/集合的ID)
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid TargetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享标题(可自定义)
|
||||
/// </summary>
|
||||
[Column(StringLength = 200)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问密码(加密存储)
|
||||
/// </summary>
|
||||
[Column(StringLength = 100)]
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 查看次数
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大查看次数(null 为无限)
|
||||
/// </summary>
|
||||
public int? MaxViews { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(null 为永不过期)
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 创建者
|
||||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 检查分享是否有效
|
||||
/// </summary>
|
||||
public bool IsValid => IsActive
|
||||
&& (ExpiresAt == null || DateTime.UtcNow < ExpiresAt)
|
||||
&& (MaxViews == null || ViewCount < MaxViews);
|
||||
}
|
||||
48
src/backend/BookmarkApi.Data/Entities/BookmarkTag.cs
Normal file
48
src/backend/BookmarkApi.Data/Entities/BookmarkTag.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 书签-标签关联实体
|
||||
/// </summary>
|
||||
[Table(Name = "bookmark_tags")]
|
||||
[Index("idx_bookmark_tag_unique", nameof(BookmarkId) + "," + nameof(TagId), IsUnique = true)]
|
||||
[Index("idx_tag_bookmarks", nameof(TagId))]
|
||||
public class BookmarkTag
|
||||
{
|
||||
/// <summary>
|
||||
/// 关联ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid BookmarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid TagId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 书签
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkId))]
|
||||
public virtual Bookmark? Bookmark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 标签
|
||||
/// </summary>
|
||||
[Navigate(nameof(TagId))]
|
||||
public virtual Tag? Tag { get; set; }
|
||||
}
|
||||
60
src/backend/BookmarkApi.Data/Entities/BookmarkVisit.cs
Normal file
60
src/backend/BookmarkApi.Data/Entities/BookmarkVisit.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 书签访问历史实体
|
||||
/// </summary>
|
||||
[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
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid BookmarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问设备ID
|
||||
/// </summary>
|
||||
public Guid? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime VisitedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户
|
||||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 书签
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkId))]
|
||||
public virtual Bookmark? Bookmark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 设备
|
||||
/// </summary>
|
||||
[Navigate(nameof(DeviceId))]
|
||||
public virtual Device? Device { get; set; }
|
||||
}
|
||||
83
src/backend/BookmarkApi.Data/Entities/Collection.cs
Normal file
83
src/backend/BookmarkApi.Data/Entities/Collection.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 收藏集合实体
|
||||
/// </summary>
|
||||
[Table(Name = "collections")]
|
||||
[Index("idx_collection_user", nameof(UserId))]
|
||||
public class Collection
|
||||
{
|
||||
/// <summary>
|
||||
/// 集合ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属用户ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 集合名称
|
||||
/// </summary>
|
||||
[Column(IsNullable = false, StringLength = 200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 描述
|
||||
/// </summary>
|
||||
[Column(StringLength = 500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
[Column(StringLength = 100)]
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主题色
|
||||
/// </summary>
|
||||
[Column(StringLength = 20)]
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否公开
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public bool IsPublic { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc)]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 所属用户
|
||||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 集合书签关联
|
||||
/// </summary>
|
||||
[Navigate(nameof(CollectionBookmark.CollectionId))]
|
||||
public virtual List<CollectionBookmark>? CollectionBookmarks { get; set; }
|
||||
}
|
||||
54
src/backend/BookmarkApi.Data/Entities/CollectionBookmark.cs
Normal file
54
src/backend/BookmarkApi.Data/Entities/CollectionBookmark.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 集合-书签关联实体
|
||||
/// </summary>
|
||||
[Table(Name = "collection_bookmarks")]
|
||||
[Index("idx_collection_bookmark_unique", nameof(CollectionId) + "," + nameof(BookmarkId), IsUnique = true)]
|
||||
[Index("idx_bookmark_collections", nameof(BookmarkId))]
|
||||
public class CollectionBookmark
|
||||
{
|
||||
/// <summary>
|
||||
/// 关联ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 集合ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid BookmarkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 集合内排序
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 集合
|
||||
/// </summary>
|
||||
[Navigate(nameof(CollectionId))]
|
||||
public virtual Collection? Collection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 书签
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkId))]
|
||||
public virtual Bookmark? Bookmark { get; set; }
|
||||
}
|
||||
|
|
@ -70,5 +70,17 @@ public class Device
|
|||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 设备的书签权限
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkDevicePermission.DeviceId))]
|
||||
public virtual List<BookmarkDevicePermission>? BookmarkPermissions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 设备的访问历史
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkVisit.DeviceId))]
|
||||
public virtual List<BookmarkVisit>? Visits { get; set; }
|
||||
}
|
||||
|
||||
|
|
|
|||
82
src/backend/BookmarkApi.Data/Entities/Folder.cs
Normal file
82
src/backend/BookmarkApi.Data/Entities/Folder.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 文件夹实体
|
||||
/// </summary>
|
||||
[Table(Name = "folders")]
|
||||
[Index("idx_folder_user_parent", nameof(UserId) + "," + nameof(ParentId))]
|
||||
public class Folder
|
||||
{
|
||||
/// <summary>
|
||||
/// 文件夹ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属用户ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父文件夹ID(null 为根目录)
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件夹名称
|
||||
/// </summary>
|
||||
[Column(IsNullable = false, StringLength = 200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 图标(emoji 或图标名)
|
||||
/// </summary>
|
||||
[Column(StringLength = 100)]
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同级排序
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc)]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 所属用户
|
||||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 父文件夹
|
||||
/// </summary>
|
||||
[Navigate(nameof(ParentId))]
|
||||
public virtual Folder? Parent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 子文件夹列表
|
||||
/// </summary>
|
||||
[Navigate(nameof(ParentId))]
|
||||
public virtual List<Folder>? Children { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 文件夹内的书签
|
||||
/// </summary>
|
||||
[Navigate(nameof(Bookmark.FolderId))]
|
||||
public virtual List<Bookmark>? Bookmarks { get; set; }
|
||||
}
|
||||
65
src/backend/BookmarkApi.Data/Entities/Tag.cs
Normal file
65
src/backend/BookmarkApi.Data/Entities/Tag.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace BookmarkApi.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 标签实体
|
||||
/// </summary>
|
||||
[Table(Name = "tags")]
|
||||
[Index("idx_tag_user_name", nameof(UserId) + "," + nameof(Name), IsUnique = true)]
|
||||
public class Tag
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签ID
|
||||
/// </summary>
|
||||
[Column(IsPrimary = true)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属用户ID
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签名称
|
||||
/// </summary>
|
||||
[Column(IsNullable = false, StringLength = 100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 颜色值(如 #FF5733)
|
||||
/// </summary>
|
||||
[Column(StringLength = 20)]
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
[Column(StringLength = 100)]
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
[Column(IsNullable = false)]
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
[Column(ServerTime = DateTimeKind.Utc, CanUpdate = false)]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 所属用户
|
||||
/// </summary>
|
||||
[Navigate(nameof(UserId))]
|
||||
public virtual User? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 书签标签关联
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkTag.TagId))]
|
||||
public virtual List<BookmarkTag>? BookmarkTags { get; set; }
|
||||
}
|
||||
|
|
@ -69,12 +69,42 @@ public class User
|
|||
[Navigate(nameof(Device.UserId))]
|
||||
public virtual List<Device>? Devices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的文件夹列表
|
||||
/// </summary>
|
||||
[Navigate(nameof(Folder.UserId))]
|
||||
public virtual List<Folder>? Folders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的书签列表
|
||||
/// </summary>
|
||||
[Navigate(nameof(Bookmark.UserId))]
|
||||
public virtual List<Bookmark>? Bookmarks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的标签列表
|
||||
/// </summary>
|
||||
[Navigate(nameof(Tag.UserId))]
|
||||
public virtual List<Tag>? Tags { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的收藏集合列表
|
||||
/// </summary>
|
||||
[Navigate(nameof(Collection.UserId))]
|
||||
public virtual List<Collection>? Collections { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的分享链接列表
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkShare.UserId))]
|
||||
public virtual List<BookmarkShare>? Shares { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的访问历史
|
||||
/// </summary>
|
||||
[Navigate(nameof(BookmarkVisit.UserId))]
|
||||
public virtual List<BookmarkVisit>? Visits { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性 - 用户的刷新令牌列表
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,28 @@ namespace BookmarkApi.Data;
|
|||
/// </summary>
|
||||
public static class FreeSqlSetup
|
||||
{
|
||||
/// <summary>
|
||||
/// 所有实体类型(用于数据库初始化)
|
||||
/// </summary>
|
||||
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)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 添加 FreeSql 服务
|
||||
/// </summary>
|
||||
|
|
@ -42,14 +64,101 @@ public static class FreeSqlSetup
|
|||
/// </summary>
|
||||
public static async Task InitializeDatabaseAsync(IFreeSql freeSql)
|
||||
{
|
||||
// 同步表结构
|
||||
freeSql.CodeFirst.SyncStructure<User>();
|
||||
freeSql.CodeFirst.SyncStructure<Device>();
|
||||
freeSql.CodeFirst.SyncStructure<Bookmark>();
|
||||
freeSql.CodeFirst.SyncStructure<RefreshToken>();
|
||||
Console.WriteLine("[FreeSql] Starting database initialization...");
|
||||
|
||||
// 同步所有实体表结构
|
||||
SyncAllTables(freeSql);
|
||||
|
||||
// 创建超级管理员账号(如果不存在)
|
||||
await SeedSuperAdminAsync(freeSql);
|
||||
|
||||
Console.WriteLine("[FreeSql] Database initialization completed.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步所有表结构
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查数据库连接和表是否存在
|
||||
/// </summary>
|
||||
public static async Task<DatabaseStatus> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取实体对应的表名
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查表是否存在
|
||||
/// </summary>
|
||||
private static async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -82,3 +191,34 @@ public static class FreeSqlSetup
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据库状态信息
|
||||
/// </summary>
|
||||
public class DatabaseStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否已连接
|
||||
/// </summary>
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 各表是否存在
|
||||
/// </summary>
|
||||
public Dictionary<string, bool> Tables { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否所有表都存在
|
||||
/// </summary>
|
||||
public bool AllTablesExist => IsConnected && Tables.Count > 0 && Tables.Values.All(v => v);
|
||||
|
||||
/// <summary>
|
||||
/// 缺失的表
|
||||
/// </summary>
|
||||
public IEnumerable<string> MissingTables => Tables.Where(t => !t.Value).Select(t => t.Key);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ namespace BookmarkApi.Shared.DTOs.Bookmark;
|
|||
/// </summary>
|
||||
public class CreateBookmarkRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属文件夹ID(null 为根目录)
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签标题
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ namespace BookmarkApi.Shared.DTOs.Bookmark;
|
|||
/// </summary>
|
||||
public class UpdateBookmarkRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属文件夹ID(null 为根目录)
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否更新文件夹(用于区分 null 和不更新)
|
||||
/// </summary>
|
||||
public bool UpdateFolder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签标题
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ public class BookmarkDto
|
|||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属文件夹ID
|
||||
/// </summary>
|
||||
public Guid? FolderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 书签标题
|
||||
/// </summary>
|
||||
|
|
@ -33,9 +38,9 @@ public class BookmarkDto
|
|||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签数组
|
||||
/// 标签列表
|
||||
/// </summary>
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
public List<TagDto> Tags { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 访问次数
|
||||
|
|
@ -45,12 +50,12 @@ public class BookmarkDto
|
|||
/// <summary>
|
||||
/// 最后访问时间
|
||||
/// </summary>
|
||||
public DateTime? LastVisitTime { get; set; }
|
||||
public DateTime? LastVisitAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值
|
||||
/// </summary>
|
||||
public long Order { get; set; }
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性类型
|
||||
|
|
|
|||
|
|
@ -5,13 +5,33 @@ namespace BookmarkApi.Shared.DTOs;
|
|||
/// </summary>
|
||||
public class TagDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签ID
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签名称
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用数量
|
||||
/// 颜色值
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用数量(书签数)
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
|
|
|||
22
src/backend/BookmarkApi.Shared/Enums/ShareType.cs
Normal file
22
src/backend/BookmarkApi.Shared/Enums/ShareType.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
namespace BookmarkApi.Shared.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 分享类型枚举
|
||||
/// </summary>
|
||||
public enum ShareType
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个书签
|
||||
/// </summary>
|
||||
Bookmark = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 文件夹(含子内容)
|
||||
/// </summary>
|
||||
Folder = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 收藏集合
|
||||
/// </summary>
|
||||
Collection = 3
|
||||
}
|
||||
|
|
@ -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<TagDto>(),
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public class BookmarksController : ControllerBase
|
|||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<BookmarkDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<List<BookmarkDto>>>> GetBookmarks([FromQuery] string? tag = null)
|
||||
public async Task<ActionResult<ApiResponse<List<BookmarkDto>>>> 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<List<BookmarkDto>>.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<List<BookmarkDto>>.Ok(bookmarks));
|
||||
}
|
||||
|
|
@ -202,12 +202,13 @@ public class BookmarksController : ControllerBase
|
|||
public async Task<ActionResult<ApiResponse>> 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
|
|||
/// <summary>
|
||||
/// 新的排序值
|
||||
/// </summary>
|
||||
public long Order { get; set; }
|
||||
public int Order { get; set; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -30,21 +28,98 @@ public class TagsController : ControllerBase
|
|||
[ProducesResponseType(typeof(ApiResponse<List<TagDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<List<TagDto>>>> GetTags()
|
||||
{
|
||||
var (userId, deviceId, isAdmin) = await GetCurrentContext();
|
||||
if (userId == null || deviceId == null)
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return BadRequest(ApiResponse<List<TagDto>>.Fail("无效的用户或设备"));
|
||||
return BadRequest(ApiResponse<List<TagDto>>.Fail("无效的用户"));
|
||||
}
|
||||
|
||||
var tags = await _tagService.GetUserTagsAsync(userId.Value, deviceId.Value, isAdmin);
|
||||
var tags = await _tagService.GetUserTagsAsync(userId.Value);
|
||||
|
||||
return Ok(ApiResponse<List<TagDto>>.Ok(tags));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重命名标签
|
||||
/// 获取标签详情
|
||||
/// </summary>
|
||||
[HttpPut("{name}")]
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TagDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<TagDto>>> GetTag(Guid id)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return BadRequest(ApiResponse<TagDto>.Fail("无效的用户"));
|
||||
}
|
||||
|
||||
var tag = await _tagService.GetTagAsync(id, userId.Value);
|
||||
|
||||
if (tag == null)
|
||||
{
|
||||
return NotFound(ApiResponse<TagDto>.Fail("标签不存在"));
|
||||
}
|
||||
|
||||
return Ok(ApiResponse<TagDto>.Ok(tag));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建标签
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ApiResponse<TagDto>), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<ApiResponse<TagDto>>> CreateTag([FromBody] CreateTagRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return BadRequest(ApiResponse<TagDto>.Fail("无效的用户"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tag = await _tagService.CreateTagAsync(userId.Value, request.Name, request.Color, request.Icon);
|
||||
return CreatedAtAction(nameof(GetTag), new { id = tag.Id }, ApiResponse<TagDto>.Ok(tag));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ApiResponse<TagDto>.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新标签
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TagDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<TagDto>>> UpdateTag(Guid id, [FromBody] UpdateTagRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return BadRequest(ApiResponse<TagDto>.Fail("无效的用户"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tag = await _tagService.UpdateTagAsync(id, userId.Value, request.Name, request.Color, request.Icon);
|
||||
|
||||
if (tag == null)
|
||||
{
|
||||
return NotFound(ApiResponse<TagDto>.Fail("标签不存在"));
|
||||
}
|
||||
|
||||
return Ok(ApiResponse<TagDto>.Ok(tag));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ApiResponse<TagDto>.Fail(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重命名标签(按名称)
|
||||
/// </summary>
|
||||
[HttpPut("rename/{name}")]
|
||||
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse>> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除标签
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse>> 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("标签已重命名"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除标签
|
||||
/// </summary>
|
||||
[HttpDelete("{name}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ApiResponse<int>>> DeleteTag(string name)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return BadRequest(ApiResponse<int>.Fail("无效的用户"));
|
||||
}
|
||||
|
||||
var count = await _tagService.DeleteTagAsync(userId.Value, name);
|
||||
|
||||
return Ok(ApiResponse<int>.Ok(count, $"已从 {count} 个书签中移除该标签"));
|
||||
return Ok(ApiResponse.Ok("标签已删除"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -95,9 +182,15 @@ public class TagsController : ControllerBase
|
|||
return BadRequest(ApiResponse<int>.Fail("无效的用户"));
|
||||
}
|
||||
|
||||
var count = await _tagService.MergeTagsAsync(userId.Value, request.SourceNames, request.TargetName);
|
||||
|
||||
return Ok(ApiResponse<int>.Ok(count, $"已合并 {count} 个书签的标签"));
|
||||
try
|
||||
{
|
||||
var count = await _tagService.MergeTagsAsync(userId.Value, request.SourceTagIds, request.TargetTagId);
|
||||
return Ok(ApiResponse<int>.Ok(count, $"已合并 {count} 个书签的标签"));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ApiResponse<int>.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;
|
||||
}
|
||||
/// <summary>
|
||||
/// 创建标签请求
|
||||
/// </summary>
|
||||
public class CreateTagRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签名称
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
private async Task<(Guid? userId, Guid? deviceId, bool isAdmin)> GetCurrentContext()
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
var deviceId = GetCurrentDeviceId();
|
||||
/// <summary>
|
||||
/// 颜色值
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
if (userId == null || deviceId == null)
|
||||
{
|
||||
return (null, null, false);
|
||||
}
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
public string? Icon { get; set; }
|
||||
}
|
||||
|
||||
var isAdmin = await _deviceService.IsAdminDeviceAsync(deviceId.Value);
|
||||
/// <summary>
|
||||
/// 更新标签请求
|
||||
/// </summary>
|
||||
public class UpdateTagRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 标签名称
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
return (userId, deviceId, isAdmin);
|
||||
}
|
||||
/// <summary>
|
||||
/// 颜色值
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
public string? Icon { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -145,13 +259,12 @@ public class RenameTagRequest
|
|||
public class MergeTagsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 源标签名称数组
|
||||
/// 源标签ID数组
|
||||
/// </summary>
|
||||
public string[] SourceNames { get; set; } = Array.Empty<string>();
|
||||
public Guid[] SourceTagIds { get; set; } = Array.Empty<Guid>();
|
||||
|
||||
/// <summary>
|
||||
/// 目标标签名称
|
||||
/// 目标标签ID
|
||||
/// </summary>
|
||||
public string TargetName { get; set; } = string.Empty;
|
||||
public Guid TargetTagId { get; set; }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user