This commit is contained in:
zpc 2025-12-25 22:42:33 +08:00
parent 08ed928fb9
commit 22647099c2
25 changed files with 2185 additions and 231 deletions

View 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

View File

@ -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 是否已存在

View File

@ -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);
}

View File

@ -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
};
}
}

View File

@ -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;
}
}

View File

@ -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>
/// 所属文件夹IDnull 为根目录)
/// </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; }
}

View File

@ -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; }
}

View 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);
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

@ -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; }
}

View 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>
/// 父文件夹IDnull 为根目录)
/// </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; }
}

View 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; }
}

View File

@ -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>

View File

@ -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);
}

View File

@ -8,6 +8,11 @@ namespace BookmarkApi.Shared.DTOs.Bookmark;
/// </summary>
public class CreateBookmarkRequest
{
/// <summary>
/// 所属文件夹IDnull 为根目录)
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// 书签标题
/// </summary>

View File

@ -8,6 +8,16 @@ namespace BookmarkApi.Shared.DTOs.Bookmark;
/// </summary>
public class UpdateBookmarkRequest
{
/// <summary>
/// 所属文件夹IDnull 为根目录)
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// 是否更新文件夹(用于区分 null 和不更新)
/// </summary>
public bool UpdateFolder { get; set; }
/// <summary>
/// 书签标题
/// </summary>

View File

@ -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>
/// 可见性类型

View File

@ -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; }
}

View 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
}

View File

@ -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();

View File

@ -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; }
}

View File

@ -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; }
}