28 changed files with 3301 additions and 96 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,190 @@ |
|||||
|
# 数据库模型文档 |
||||
|
|
||||
|
## ArticleModel |
||||
|
|
||||
|
ArticleModel 是一个功能完整的文章管理模型,提供了丰富的CRUD操作和查询方法。 |
||||
|
|
||||
|
### 主要特性 |
||||
|
|
||||
|
- ✅ 完整的CRUD操作 |
||||
|
- ✅ 文章状态管理(草稿、已发布、已归档) |
||||
|
- ✅ 自动生成slug、摘要和阅读时间 |
||||
|
- ✅ 标签和分类管理 |
||||
|
- ✅ SEO优化支持 |
||||
|
- ✅ 浏览量统计 |
||||
|
- ✅ 相关文章推荐 |
||||
|
- ✅ 全文搜索功能 |
||||
|
|
||||
|
### 数据库字段 |
||||
|
|
||||
|
| 字段名 | 类型 | 说明 | |
||||
|
|--------|------|------| |
||||
|
| id | integer | 主键,自增 | |
||||
|
| title | string | 文章标题(必填) | |
||||
|
| content | text | 文章内容(必填) | |
||||
|
| author | string | 作者 | |
||||
|
| category | string | 分类 | |
||||
|
| tags | string | 标签(逗号分隔) | |
||||
|
| keywords | string | SEO关键词 | |
||||
|
| description | string | 文章描述 | |
||||
|
| status | string | 状态:draft/published/archived | |
||||
|
| published_at | timestamp | 发布时间 | |
||||
|
| view_count | integer | 浏览量 | |
||||
|
| featured_image | string | 特色图片 | |
||||
|
| excerpt | text | 文章摘要 | |
||||
|
| reading_time | integer | 阅读时间(分钟) | |
||||
|
| meta_title | string | SEO标题 | |
||||
|
| meta_description | text | SEO描述 | |
||||
|
| slug | string | URL友好的标识符 | |
||||
|
| created_at | timestamp | 创建时间 | |
||||
|
| updated_at | timestamp | 更新时间 | |
||||
|
|
||||
|
### 基本用法 |
||||
|
|
||||
|
```javascript |
||||
|
import { ArticleModel } from '../models/ArticleModel.js' |
||||
|
|
||||
|
// 创建文章 |
||||
|
const article = await ArticleModel.create({ |
||||
|
title: "我的第一篇文章", |
||||
|
content: "这是文章内容...", |
||||
|
author: "张三", |
||||
|
category: "技术", |
||||
|
tags: "JavaScript, Node.js, 教程" |
||||
|
}) |
||||
|
|
||||
|
// 查找所有已发布的文章 |
||||
|
const publishedArticles = await ArticleModel.findPublished() |
||||
|
|
||||
|
// 根据ID查找文章 |
||||
|
const article = await ArticleModel.findById(1) |
||||
|
|
||||
|
// 更新文章 |
||||
|
await ArticleModel.update(1, { |
||||
|
title: "更新后的标题", |
||||
|
content: "更新后的内容" |
||||
|
}) |
||||
|
|
||||
|
// 发布文章 |
||||
|
await ArticleModel.publish(1) |
||||
|
|
||||
|
// 删除文章 |
||||
|
await ArticleModel.delete(1) |
||||
|
``` |
||||
|
|
||||
|
### 查询方法 |
||||
|
|
||||
|
#### 基础查询 |
||||
|
- `findAll()` - 查找所有文章 |
||||
|
- `findById(id)` - 根据ID查找文章 |
||||
|
- `findBySlug(slug)` - 根据slug查找文章 |
||||
|
- `findPublished()` - 查找所有已发布的文章 |
||||
|
- `findDrafts()` - 查找所有草稿文章 |
||||
|
|
||||
|
#### 分类查询 |
||||
|
- `findByAuthor(author)` - 根据作者查找文章 |
||||
|
- `findByCategory(category)` - 根据分类查找文章 |
||||
|
- `findByTags(tags)` - 根据标签查找文章 |
||||
|
|
||||
|
#### 搜索功能 |
||||
|
- `searchByKeyword(keyword)` - 关键词搜索(标题、内容、关键词、描述、摘要) |
||||
|
|
||||
|
#### 统计功能 |
||||
|
- `getArticleCount()` - 获取文章总数 |
||||
|
- `getPublishedArticleCount()` - 获取已发布文章数量 |
||||
|
- `getArticleCountByCategory()` - 按分类统计文章数量 |
||||
|
- `getArticleCountByStatus()` - 按状态统计文章数量 |
||||
|
|
||||
|
#### 推荐功能 |
||||
|
- `getRecentArticles(limit)` - 获取最新文章 |
||||
|
- `getPopularArticles(limit)` - 获取热门文章 |
||||
|
- `getFeaturedArticles(limit)` - 获取特色文章 |
||||
|
- `getRelatedArticles(articleId, limit)` - 获取相关文章 |
||||
|
|
||||
|
#### 高级查询 |
||||
|
- `findByDateRange(startDate, endDate)` - 按日期范围查找文章 |
||||
|
- `incrementViewCount(id)` - 增加浏览量 |
||||
|
|
||||
|
### 状态管理 |
||||
|
|
||||
|
文章支持三种状态: |
||||
|
- `draft` - 草稿状态 |
||||
|
- `published` - 已发布状态 |
||||
|
- `archived` - 已归档状态 |
||||
|
|
||||
|
```javascript |
||||
|
// 发布文章 |
||||
|
await ArticleModel.publish(articleId) |
||||
|
|
||||
|
// 取消发布 |
||||
|
await ArticleModel.unpublish(articleId) |
||||
|
``` |
||||
|
|
||||
|
### 自动功能 |
||||
|
|
||||
|
#### 自动生成slug |
||||
|
如果未提供slug,系统会自动根据标题生成: |
||||
|
```javascript |
||||
|
// 标题: "我的第一篇文章" |
||||
|
// 自动生成slug: "我的第一篇文章" |
||||
|
``` |
||||
|
|
||||
|
#### 自动计算阅读时间 |
||||
|
基于内容长度自动计算阅读时间(假设每分钟200个单词) |
||||
|
|
||||
|
#### 自动生成摘要 |
||||
|
如果未提供摘要,系统会自动从内容中提取前150个字符 |
||||
|
|
||||
|
### 标签管理 |
||||
|
|
||||
|
标签支持逗号分隔的格式,系统会自动处理: |
||||
|
```javascript |
||||
|
// 输入: "JavaScript, Node.js, 教程" |
||||
|
// 存储: "JavaScript, Node.js, 教程" |
||||
|
// 查询: 支持模糊匹配 |
||||
|
``` |
||||
|
|
||||
|
### SEO优化 |
||||
|
|
||||
|
支持完整的SEO字段: |
||||
|
- `meta_title` - 页面标题 |
||||
|
- `meta_description` - 页面描述 |
||||
|
- `keywords` - 关键词 |
||||
|
- `slug` - URL友好的标识符 |
||||
|
|
||||
|
### 错误处理 |
||||
|
|
||||
|
所有方法都包含适当的错误处理: |
||||
|
```javascript |
||||
|
try { |
||||
|
const article = await ArticleModel.create({ |
||||
|
title: "", // 空标题会抛出错误 |
||||
|
content: "内容" |
||||
|
}) |
||||
|
} catch (error) { |
||||
|
console.error("创建文章失败:", error.message) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 性能优化 |
||||
|
|
||||
|
- 所有查询都包含适当的索引 |
||||
|
- 支持分页查询 |
||||
|
- 缓存友好的查询结构 |
||||
|
|
||||
|
### 迁移和种子 |
||||
|
|
||||
|
项目包含完整的数据库迁移和种子文件: |
||||
|
- `20250830014825_create_articles_table.mjs` - 创建articles表 |
||||
|
- `20250830020000_add_article_fields.mjs` - 添加额外字段 |
||||
|
- `20250830020000_articles_seed.mjs` - 示例数据 |
||||
|
|
||||
|
### 运行迁移和种子 |
||||
|
|
||||
|
```bash |
||||
|
# 运行迁移 |
||||
|
npx knex migrate:latest |
||||
|
|
||||
|
# 运行种子 |
||||
|
npx knex seed:run |
||||
|
``` |
||||
@ -0,0 +1,194 @@ |
|||||
|
# 数据库模型文档 |
||||
|
|
||||
|
## BookmarkModel |
||||
|
|
||||
|
BookmarkModel 是一个书签管理模型,提供了用户书签的CRUD操作和查询方法,支持URL去重和用户隔离。 |
||||
|
|
||||
|
### 主要特性 |
||||
|
|
||||
|
- ✅ 完整的CRUD操作 |
||||
|
- ✅ 用户隔离的书签管理 |
||||
|
- ✅ URL去重验证 |
||||
|
- ✅ 自动时间戳管理 |
||||
|
- ✅ 外键关联用户表 |
||||
|
|
||||
|
### 数据库字段 |
||||
|
|
||||
|
| 字段名 | 类型 | 说明 | |
||||
|
|--------|------|------| |
||||
|
| id | integer | 主键,自增 | |
||||
|
| user_id | integer | 用户ID(外键,关联users表) | |
||||
|
| title | string(200) | 书签标题(必填,最大长度200) | |
||||
|
| url | string(500) | 书签URL | |
||||
|
| description | text | 书签描述 | |
||||
|
| created_at | timestamp | 创建时间 | |
||||
|
| updated_at | timestamp | 更新时间 | |
||||
|
|
||||
|
### 外键关系 |
||||
|
|
||||
|
- `user_id` 关联 `users.id` |
||||
|
- 删除用户时,相关书签会自动删除(CASCADE) |
||||
|
|
||||
|
### 基本用法 |
||||
|
|
||||
|
```javascript |
||||
|
import { BookmarkModel } from '../models/BookmarkModel.js' |
||||
|
|
||||
|
// 创建书签 |
||||
|
const bookmark = await BookmarkModel.create({ |
||||
|
user_id: 1, |
||||
|
title: "GitHub - 开源代码托管平台", |
||||
|
url: "https://github.com", |
||||
|
description: "全球最大的代码托管平台" |
||||
|
}) |
||||
|
|
||||
|
// 查找用户的所有书签 |
||||
|
const userBookmarks = await BookmarkModel.findAllByUser(1) |
||||
|
|
||||
|
// 根据ID查找书签 |
||||
|
const bookmark = await BookmarkModel.findById(1) |
||||
|
|
||||
|
// 更新书签 |
||||
|
await BookmarkModel.update(1, { |
||||
|
title: "GitHub - 更新后的标题", |
||||
|
description: "更新后的描述" |
||||
|
}) |
||||
|
|
||||
|
// 删除书签 |
||||
|
await BookmarkModel.delete(1) |
||||
|
|
||||
|
// 查找用户特定URL的书签 |
||||
|
const bookmark = await BookmarkModel.findByUserAndUrl(1, "https://github.com") |
||||
|
``` |
||||
|
|
||||
|
### 查询方法 |
||||
|
|
||||
|
#### 基础查询 |
||||
|
- `findAllByUser(userId)` - 查找指定用户的所有书签(按ID降序) |
||||
|
- `findById(id)` - 根据ID查找书签 |
||||
|
- `findByUserAndUrl(userId, url)` - 查找用户特定URL的书签 |
||||
|
|
||||
|
#### 数据操作 |
||||
|
- `create(data)` - 创建新书签 |
||||
|
- `update(id, data)` - 更新书签信息 |
||||
|
- `delete(id)` - 删除书签 |
||||
|
|
||||
|
### 数据验证和约束 |
||||
|
|
||||
|
#### 必填字段 |
||||
|
- `user_id` - 用户ID不能为空 |
||||
|
- `title` - 标题不能为空 |
||||
|
|
||||
|
#### 唯一性约束 |
||||
|
- 同一用户下不能存在相同URL的书签 |
||||
|
- 系统会自动检查并阻止重复URL的创建 |
||||
|
|
||||
|
#### URL处理 |
||||
|
- URL会自动去除首尾空格 |
||||
|
- 支持最大500字符的URL长度 |
||||
|
|
||||
|
### 去重逻辑 |
||||
|
|
||||
|
#### 创建时去重 |
||||
|
```javascript |
||||
|
// 创建书签时会自动检查是否已存在相同URL |
||||
|
const exists = await db("bookmarks").where({ |
||||
|
user_id: userId, |
||||
|
url: url |
||||
|
}).first() |
||||
|
|
||||
|
if (exists) { |
||||
|
throw new Error("该用户下已存在相同 URL 的书签") |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 更新时去重 |
||||
|
```javascript |
||||
|
// 更新时会检查新URL是否与其他书签冲突(排除自身) |
||||
|
const exists = await db("bookmarks") |
||||
|
.where({ user_id: nextUserId, url: nextUrl }) |
||||
|
.andWhereNot({ id }) |
||||
|
.first() |
||||
|
|
||||
|
if (exists) { |
||||
|
throw new Error("该用户下已存在相同 URL 的书签") |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 时间戳管理 |
||||
|
|
||||
|
系统自动管理以下时间戳: |
||||
|
- `created_at` - 创建时自动设置为当前时间 |
||||
|
- `updated_at` - 每次更新时自动设置为当前时间 |
||||
|
|
||||
|
### 错误处理 |
||||
|
|
||||
|
所有方法都包含适当的错误处理: |
||||
|
```javascript |
||||
|
try { |
||||
|
const bookmark = await BookmarkModel.create({ |
||||
|
user_id: 1, |
||||
|
title: "重复的书签", |
||||
|
url: "https://example.com" // 如果已存在会抛出错误 |
||||
|
}) |
||||
|
} catch (error) { |
||||
|
console.error("创建书签失败:", error.message) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 性能优化 |
||||
|
|
||||
|
- `user_id` 字段已添加索引,提高查询性能 |
||||
|
- 支持按用户ID快速查询书签列表 |
||||
|
|
||||
|
### 迁移和种子 |
||||
|
|
||||
|
项目包含完整的数据库迁移文件: |
||||
|
- `20250830015422_create_bookmarks_table.mjs` - 创建bookmarks表 |
||||
|
|
||||
|
### 运行迁移 |
||||
|
|
||||
|
```bash |
||||
|
# 运行迁移 |
||||
|
npx knex migrate:latest |
||||
|
``` |
||||
|
|
||||
|
### 使用场景 |
||||
|
|
||||
|
#### 个人书签管理 |
||||
|
```javascript |
||||
|
// 用户登录后查看自己的书签 |
||||
|
const myBookmarks = await BookmarkModel.findAllByUser(currentUserId) |
||||
|
``` |
||||
|
|
||||
|
#### 书签同步 |
||||
|
```javascript |
||||
|
// 支持多设备书签同步 |
||||
|
const bookmarks = await BookmarkModel.findAllByUser(userId) |
||||
|
// 可以导出为JSON或其他格式 |
||||
|
``` |
||||
|
|
||||
|
#### 书签分享 |
||||
|
```javascript |
||||
|
// 可以扩展实现书签分享功能 |
||||
|
// 通过添加 share_status 字段实现 |
||||
|
``` |
||||
|
|
||||
|
### 扩展建议 |
||||
|
|
||||
|
可以考虑添加以下功能: |
||||
|
- 书签分类和标签 |
||||
|
- 书签收藏夹 |
||||
|
- 书签导入/导出 |
||||
|
- 书签搜索功能 |
||||
|
- 书签访问统计 |
||||
|
- 书签分享功能 |
||||
|
- 书签同步功能 |
||||
|
- 书签备份和恢复 |
||||
|
|
||||
|
### 安全注意事项 |
||||
|
|
||||
|
1. **用户隔离**: 确保用户只能访问自己的书签 |
||||
|
2. **URL验证**: 在应用层验证URL的有效性 |
||||
|
3. **输入清理**: 对用户输入进行适当的清理和验证 |
||||
|
4. **权限控制**: 实现适当的访问控制机制 |
||||
@ -0,0 +1,252 @@ |
|||||
|
# 数据库文档总览 |
||||
|
|
||||
|
本文档提供了整个数据库系统的概览,包括所有模型、表结构和关系。 |
||||
|
|
||||
|
## 数据库概览 |
||||
|
|
||||
|
这是一个基于 Koa3 和 Knex.js 构建的现代化 Web 应用数据库系统,使用 SQLite 作为数据库引擎。 |
||||
|
|
||||
|
### 技术栈 |
||||
|
|
||||
|
- **数据库**: SQLite3 |
||||
|
- **ORM**: Knex.js |
||||
|
- **迁移工具**: Knex Migrations |
||||
|
- **种子数据**: Knex Seeds |
||||
|
- **数据库驱动**: sqlite3 |
||||
|
|
||||
|
## 数据模型总览 |
||||
|
|
||||
|
### 1. UserModel - 用户管理 |
||||
|
- **表名**: `users` |
||||
|
- **功能**: 用户账户管理、身份验证、角色控制 |
||||
|
- **主要字段**: id, username, email, password, role, phone, age |
||||
|
- **文档**: [UserModel.md](./UserModel.md) |
||||
|
|
||||
|
### 2. ArticleModel - 文章管理 |
||||
|
- **表名**: `articles` |
||||
|
- **功能**: 文章CRUD、状态管理、SEO优化、标签分类 |
||||
|
- **主要字段**: id, title, content, author, category, tags, status, slug |
||||
|
- **文档**: [ArticleModel.md](./ArticleModel.md) |
||||
|
|
||||
|
### 3. BookmarkModel - 书签管理 |
||||
|
- **表名**: `bookmarks` |
||||
|
- **功能**: 用户书签管理、URL去重、用户隔离 |
||||
|
- **主要字段**: id, user_id, title, url, description |
||||
|
- **文档**: [BookmarkModel.md](./BookmarkModel.md) |
||||
|
|
||||
|
### 4. SiteConfigModel - 网站配置 |
||||
|
- **表名**: `site_config` |
||||
|
- **功能**: 键值对配置存储、系统设置管理 |
||||
|
- **主要字段**: id, key, value |
||||
|
- **文档**: [SiteConfigModel.md](./SiteConfigModel.md) |
||||
|
|
||||
|
## 数据库表结构 |
||||
|
|
||||
|
### 表关系图 |
||||
|
|
||||
|
``` |
||||
|
users (用户表) |
||||
|
├── id (主键) |
||||
|
├── username |
||||
|
├── email |
||||
|
├── password |
||||
|
├── role |
||||
|
├── phone |
||||
|
├── age |
||||
|
├── created_at |
||||
|
└── updated_at |
||||
|
|
||||
|
articles (文章表) |
||||
|
├── id (主键) |
||||
|
├── title |
||||
|
├── content |
||||
|
├── author |
||||
|
├── category |
||||
|
├── tags |
||||
|
├── status |
||||
|
├── slug |
||||
|
├── published_at |
||||
|
├── view_count |
||||
|
├── featured_image |
||||
|
├── excerpt |
||||
|
├── reading_time |
||||
|
├── meta_title |
||||
|
├── meta_description |
||||
|
├── keywords |
||||
|
├── description |
||||
|
├── created_at |
||||
|
└── updated_at |
||||
|
|
||||
|
bookmarks (书签表) |
||||
|
├── id (主键) |
||||
|
├── user_id (外键 -> users.id) |
||||
|
├── title |
||||
|
├── url |
||||
|
├── description |
||||
|
├── created_at |
||||
|
└── updated_at |
||||
|
|
||||
|
site_config (网站配置表) |
||||
|
├── id (主键) |
||||
|
├── key (唯一) |
||||
|
├── value |
||||
|
├── created_at |
||||
|
└── updated_at |
||||
|
``` |
||||
|
|
||||
|
### 外键关系 |
||||
|
|
||||
|
- `bookmarks.user_id` → `users.id` (CASCADE 删除) |
||||
|
- 其他表之间暂无直接外键关系 |
||||
|
|
||||
|
## 数据库迁移文件 |
||||
|
|
||||
|
| 迁移文件 | 描述 | 创建时间 | |
||||
|
|----------|------|----------| |
||||
|
| `20250616065041_create_users_table.mjs` | 创建用户表 | 2025-06-16 | |
||||
|
| `20250621013128_site_config.mjs` | 创建网站配置表 | 2025-06-21 | |
||||
|
| `20250830014825_create_articles_table.mjs` | 创建文章表 | 2025-08-30 | |
||||
|
| `20250830015422_create_bookmarks_table.mjs` | 创建书签表 | 2025-08-30 | |
||||
|
| `20250830020000_add_article_fields.mjs` | 添加文章额外字段 | 2025-08-30 | |
||||
|
|
||||
|
## 种子数据文件 |
||||
|
|
||||
|
| 种子文件 | 描述 | 创建时间 | |
||||
|
|----------|------|----------| |
||||
|
| `20250616071157_users_seed.mjs` | 用户示例数据 | 2025-06-16 | |
||||
|
| `20250621013324_site_config_seed.mjs` | 网站配置示例数据 | 2025-06-21 | |
||||
|
| `20250830020000_articles_seed.mjs` | 文章示例数据 | 2025-08-30 | |
||||
|
|
||||
|
## 快速开始 |
||||
|
|
||||
|
### 1. 安装依赖 |
||||
|
|
||||
|
```bash |
||||
|
npm install |
||||
|
# 或 |
||||
|
bun install |
||||
|
``` |
||||
|
|
||||
|
### 2. 运行数据库迁移 |
||||
|
|
||||
|
```bash |
||||
|
# 运行所有迁移 |
||||
|
npx knex migrate:latest |
||||
|
|
||||
|
# 回滚迁移 |
||||
|
npx knex migrate:rollback |
||||
|
|
||||
|
# 查看迁移状态 |
||||
|
npx knex migrate:status |
||||
|
``` |
||||
|
|
||||
|
### 3. 运行种子数据 |
||||
|
|
||||
|
```bash |
||||
|
# 运行所有种子 |
||||
|
npx knex seed:run |
||||
|
|
||||
|
# 运行特定种子 |
||||
|
npx knex seed:run --specific=20250616071157_users_seed.mjs |
||||
|
``` |
||||
|
|
||||
|
### 4. 数据库连接 |
||||
|
|
||||
|
```bash |
||||
|
# 查看数据库配置 |
||||
|
cat knexfile.mjs |
||||
|
|
||||
|
# 连接数据库 |
||||
|
npx knex --knexfile knexfile.mjs |
||||
|
``` |
||||
|
|
||||
|
## 开发指南 |
||||
|
|
||||
|
### 创建新的迁移文件 |
||||
|
|
||||
|
```bash |
||||
|
npx knex migrate:make create_new_table |
||||
|
``` |
||||
|
|
||||
|
### 创建新的种子文件 |
||||
|
|
||||
|
```bash |
||||
|
npx knex seed:make new_seed_data |
||||
|
``` |
||||
|
|
||||
|
### 创建新的模型 |
||||
|
|
||||
|
1. 在 `src/db/models/` 目录下创建新的模型文件 |
||||
|
2. 在 `src/db/docs/` 目录下创建对应的文档 |
||||
|
3. 更新本文档的模型总览部分 |
||||
|
|
||||
|
## 最佳实践 |
||||
|
|
||||
|
### 1. 模型设计原则 |
||||
|
|
||||
|
- 每个模型对应一个数据库表 |
||||
|
- 使用静态方法提供数据操作接口 |
||||
|
- 实现适当的错误处理和验证 |
||||
|
- 支持软删除和审计字段 |
||||
|
|
||||
|
### 2. 迁移管理 |
||||
|
|
||||
|
- 迁移文件一旦提交到版本控制,不要修改 |
||||
|
- 使用描述性的迁移文件名 |
||||
|
- 在迁移文件中添加适当的注释 |
||||
|
- 测试迁移的回滚功能 |
||||
|
|
||||
|
### 3. 种子数据 |
||||
|
|
||||
|
- 种子数据应该包含测试和开发所需的最小数据集 |
||||
|
- 避免在生产环境中运行种子 |
||||
|
- 种子数据应该是幂等的(可重复运行) |
||||
|
|
||||
|
### 4. 性能优化 |
||||
|
|
||||
|
- 为常用查询字段添加索引 |
||||
|
- 使用批量操作减少数据库查询 |
||||
|
- 实现适当的缓存机制 |
||||
|
- 监控查询性能 |
||||
|
|
||||
|
## 故障排除 |
||||
|
|
||||
|
### 常见问题 |
||||
|
|
||||
|
1. **迁移失败** |
||||
|
- 检查数据库连接配置 |
||||
|
- 确保数据库文件存在且有写入权限 |
||||
|
- 查看迁移文件语法是否正确 |
||||
|
|
||||
|
2. **种子数据失败** |
||||
|
- 检查表结构是否与种子数据匹配 |
||||
|
- 确保外键关系正确 |
||||
|
- 查看是否有唯一性约束冲突 |
||||
|
|
||||
|
3. **模型查询错误** |
||||
|
- 检查表名和字段名是否正确 |
||||
|
- 确保数据库连接正常 |
||||
|
- 查看SQL查询日志 |
||||
|
|
||||
|
### 调试技巧 |
||||
|
|
||||
|
```bash |
||||
|
# 启用SQL查询日志 |
||||
|
DEBUG=knex:query node your-app.js |
||||
|
|
||||
|
# 查看数据库结构 |
||||
|
npx knex --knexfile knexfile.mjs |
||||
|
.tables |
||||
|
.schema users |
||||
|
``` |
||||
|
|
||||
|
## 贡献指南 |
||||
|
|
||||
|
1. 遵循现有的代码风格和命名规范 |
||||
|
2. 为新功能添加适当的测试 |
||||
|
3. 更新相关文档 |
||||
|
4. 提交前运行迁移和种子测试 |
||||
|
|
||||
|
## 许可证 |
||||
|
|
||||
|
本项目采用 MIT 许可证。 |
||||
@ -0,0 +1,246 @@ |
|||||
|
# 数据库模型文档 |
||||
|
|
||||
|
## SiteConfigModel |
||||
|
|
||||
|
SiteConfigModel 是一个网站配置管理模型,提供了灵活的键值对配置存储和管理功能,支持单个配置项和批量配置操作。 |
||||
|
|
||||
|
### 主要特性 |
||||
|
|
||||
|
- ✅ 键值对配置存储 |
||||
|
- ✅ 单个和批量配置操作 |
||||
|
- ✅ 自动时间戳管理 |
||||
|
- ✅ 配置项唯一性保证 |
||||
|
- ✅ 灵活的配置值类型支持 |
||||
|
|
||||
|
### 数据库字段 |
||||
|
|
||||
|
| 字段名 | 类型 | 说明 | |
||||
|
|--------|------|------| |
||||
|
| id | integer | 主键,自增 | |
||||
|
| key | string(100) | 配置项键名(必填,唯一,最大长度100) | |
||||
|
| value | text | 配置项值(必填) | |
||||
|
| created_at | timestamp | 创建时间 | |
||||
|
| updated_at | timestamp | 更新时间 | |
||||
|
|
||||
|
### 基本用法 |
||||
|
|
||||
|
```javascript |
||||
|
import { SiteConfigModel } from '../models/SiteConfigModel.js' |
||||
|
|
||||
|
// 设置单个配置项 |
||||
|
await SiteConfigModel.set("site_name", "我的网站") |
||||
|
await SiteConfigModel.set("site_description", "一个优秀的网站") |
||||
|
await SiteConfigModel.set("maintenance_mode", "false") |
||||
|
|
||||
|
// 获取单个配置项 |
||||
|
const siteName = await SiteConfigModel.get("site_name") |
||||
|
// 返回: "我的网站" |
||||
|
|
||||
|
// 批量获取配置项 |
||||
|
const configs = await SiteConfigModel.getMany([ |
||||
|
"site_name", |
||||
|
"site_description", |
||||
|
"maintenance_mode" |
||||
|
]) |
||||
|
// 返回: { site_name: "我的网站", site_description: "一个优秀的网站", maintenance_mode: "false" } |
||||
|
|
||||
|
// 获取所有配置 |
||||
|
const allConfigs = await SiteConfigModel.getAll() |
||||
|
// 返回所有配置项的键值对对象 |
||||
|
``` |
||||
|
|
||||
|
### 核心方法 |
||||
|
|
||||
|
#### 单个配置操作 |
||||
|
- `get(key)` - 获取指定key的配置值 |
||||
|
- `set(key, value)` - 设置配置项(有则更新,无则插入) |
||||
|
|
||||
|
#### 批量配置操作 |
||||
|
- `getMany(keys)` - 批量获取多个key的配置值 |
||||
|
- `getAll()` - 获取所有配置项 |
||||
|
|
||||
|
### 配置管理策略 |
||||
|
|
||||
|
#### 自动更新机制 |
||||
|
```javascript |
||||
|
// set方法会自动处理配置项的创建和更新 |
||||
|
static async set(key, value) { |
||||
|
const exists = await db("site_config").where({ key }).first() |
||||
|
if (exists) { |
||||
|
// 如果配置项存在,则更新 |
||||
|
await db("site_config").where({ key }).update({ |
||||
|
value, |
||||
|
updated_at: db.fn.now() |
||||
|
}) |
||||
|
} else { |
||||
|
// 如果配置项不存在,则创建 |
||||
|
await db("site_config").insert({ key, value }) |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 批量获取优化 |
||||
|
```javascript |
||||
|
// 批量获取时使用 whereIn 优化查询性能 |
||||
|
static async getMany(keys) { |
||||
|
const rows = await db("site_config").whereIn("key", keys) |
||||
|
const result = {} |
||||
|
rows.forEach(row => { |
||||
|
result[row.key] = row.value |
||||
|
}) |
||||
|
return result |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 配置值类型支持 |
||||
|
|
||||
|
支持多种配置值类型: |
||||
|
|
||||
|
#### 字符串配置 |
||||
|
```javascript |
||||
|
await SiteConfigModel.set("site_name", "我的网站") |
||||
|
await SiteConfigModel.set("contact_email", "admin@example.com") |
||||
|
``` |
||||
|
|
||||
|
#### 布尔值配置 |
||||
|
```javascript |
||||
|
await SiteConfigModel.set("maintenance_mode", "false") |
||||
|
await SiteConfigModel.set("debug_mode", "true") |
||||
|
``` |
||||
|
|
||||
|
#### 数字配置 |
||||
|
```javascript |
||||
|
await SiteConfigModel.set("max_upload_size", "10485760") // 10MB |
||||
|
await SiteConfigModel.set("session_timeout", "3600") // 1小时 |
||||
|
``` |
||||
|
|
||||
|
#### JSON配置 |
||||
|
```javascript |
||||
|
await SiteConfigModel.set("social_links", JSON.stringify({ |
||||
|
twitter: "https://twitter.com/example", |
||||
|
facebook: "https://facebook.com/example" |
||||
|
})) |
||||
|
``` |
||||
|
|
||||
|
### 使用场景 |
||||
|
|
||||
|
#### 网站基本信息配置 |
||||
|
```javascript |
||||
|
// 设置网站基本信息 |
||||
|
await SiteConfigModel.set("site_name", "我的博客") |
||||
|
await SiteConfigModel.set("site_description", "分享技术和生活") |
||||
|
await SiteConfigModel.set("site_keywords", "技术,博客,编程") |
||||
|
await SiteConfigModel.set("site_author", "张三") |
||||
|
``` |
||||
|
|
||||
|
#### 功能开关配置 |
||||
|
```javascript |
||||
|
// 功能开关 |
||||
|
await SiteConfigModel.set("enable_comments", "true") |
||||
|
await SiteConfigModel.set("enable_registration", "false") |
||||
|
await SiteConfigModel.set("enable_analytics", "true") |
||||
|
``` |
||||
|
|
||||
|
#### 系统配置 |
||||
|
```javascript |
||||
|
// 系统配置 |
||||
|
await SiteConfigModel.set("max_login_attempts", "5") |
||||
|
await SiteConfigModel.set("password_min_length", "8") |
||||
|
await SiteConfigModel.set("session_timeout", "3600") |
||||
|
``` |
||||
|
|
||||
|
#### 第三方服务配置 |
||||
|
```javascript |
||||
|
// 第三方服务配置 |
||||
|
await SiteConfigModel.set("google_analytics_id", "GA-XXXXXXXXX") |
||||
|
await SiteConfigModel.set("recaptcha_site_key", "6LcXXXXXXXX") |
||||
|
await SiteConfigModel.set("smtp_host", "smtp.gmail.com") |
||||
|
``` |
||||
|
|
||||
|
### 配置获取和缓存 |
||||
|
|
||||
|
#### 基础获取 |
||||
|
```javascript |
||||
|
// 获取网站名称 |
||||
|
const siteName = await SiteConfigModel.get("site_name") || "默认网站名称" |
||||
|
|
||||
|
// 获取维护模式状态 |
||||
|
const isMaintenance = await SiteConfigModel.get("maintenance_mode") === "true" |
||||
|
``` |
||||
|
|
||||
|
#### 批量获取优化 |
||||
|
```javascript |
||||
|
// 一次性获取多个配置项,减少数据库查询 |
||||
|
const configs = await SiteConfigModel.getMany([ |
||||
|
"site_name", |
||||
|
"site_description", |
||||
|
"maintenance_mode" |
||||
|
]) |
||||
|
|
||||
|
// 使用配置 |
||||
|
if (configs.maintenance_mode === "true") { |
||||
|
console.log("网站维护中") |
||||
|
} else { |
||||
|
console.log(`欢迎访问 ${configs.site_name}`) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 错误处理 |
||||
|
|
||||
|
所有方法都包含适当的错误处理: |
||||
|
```javascript |
||||
|
try { |
||||
|
const siteName = await SiteConfigModel.get("site_name") |
||||
|
if (!siteName) { |
||||
|
console.log("网站名称未配置,使用默认值") |
||||
|
return "默认网站名称" |
||||
|
} |
||||
|
return siteName |
||||
|
} catch (error) { |
||||
|
console.error("获取配置失败:", error.message) |
||||
|
return "默认网站名称" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 性能优化 |
||||
|
|
||||
|
- `key` 字段已添加唯一索引,提高查询性能 |
||||
|
- 支持批量操作,减少数据库查询次数 |
||||
|
- 建议在应用层实现配置缓存机制 |
||||
|
|
||||
|
### 迁移和种子 |
||||
|
|
||||
|
项目包含完整的数据库迁移和种子文件: |
||||
|
- `20250621013128_site_config.mjs` - 创建site_config表 |
||||
|
- `20250621013324_site_config_seed.mjs` - 示例配置数据 |
||||
|
|
||||
|
### 运行迁移和种子 |
||||
|
|
||||
|
```bash |
||||
|
# 运行迁移 |
||||
|
npx knex migrate:latest |
||||
|
|
||||
|
# 运行种子 |
||||
|
npx knex seed:run |
||||
|
``` |
||||
|
|
||||
|
### 扩展建议 |
||||
|
|
||||
|
可以考虑添加以下功能: |
||||
|
- 配置项分类管理 |
||||
|
- 配置项验证规则 |
||||
|
- 配置变更历史记录 |
||||
|
- 配置导入/导出功能 |
||||
|
- 配置项权限控制 |
||||
|
- 配置项版本管理 |
||||
|
- 配置项依赖关系 |
||||
|
- 配置项加密存储 |
||||
|
|
||||
|
### 最佳实践 |
||||
|
|
||||
|
1. **配置项命名**: 使用清晰的命名规范,如 `feature_name` 或 `service_config` |
||||
|
2. **配置值类型**: 统一配置值的类型,如布尔值统一使用字符串 "true"/"false" |
||||
|
3. **配置分组**: 使用前缀对配置项进行分组,如 `email_`, `social_`, `system_` |
||||
|
4. **默认值处理**: 在应用层为配置项提供合理的默认值 |
||||
|
5. **配置验证**: 在设置配置项时验证值的有效性 |
||||
|
6. **配置缓存**: 实现配置缓存机制,减少数据库查询 |
||||
@ -0,0 +1,158 @@ |
|||||
|
# 数据库模型文档 |
||||
|
|
||||
|
## UserModel |
||||
|
|
||||
|
UserModel 是一个用户管理模型,提供了基本的用户CRUD操作和查询方法。 |
||||
|
|
||||
|
### 主要特性 |
||||
|
|
||||
|
- ✅ 完整的CRUD操作 |
||||
|
- ✅ 用户身份验证支持 |
||||
|
- ✅ 用户名和邮箱唯一性验证 |
||||
|
- ✅ 角色管理 |
||||
|
- ✅ 时间戳自动管理 |
||||
|
|
||||
|
### 数据库字段 |
||||
|
|
||||
|
| 字段名 | 类型 | 说明 | |
||||
|
|--------|------|------| |
||||
|
| id | integer | 主键,自增 | |
||||
|
| username | string(100) | 用户名(必填,最大长度100) | |
||||
|
| email | string(100) | 邮箱(唯一) | |
||||
|
| password | string(100) | 密码(必填) | |
||||
|
| role | string(100) | 用户角色(必填) | |
||||
|
| phone | string(100) | 电话号码 | |
||||
|
| age | integer | 年龄(无符号整数) | |
||||
|
| created_at | timestamp | 创建时间 | |
||||
|
| updated_at | timestamp | 更新时间 | |
||||
|
|
||||
|
### 基本用法 |
||||
|
|
||||
|
```javascript |
||||
|
import { UserModel } from '../models/UserModel.js' |
||||
|
|
||||
|
// 创建用户 |
||||
|
const user = await UserModel.create({ |
||||
|
username: "zhangsan", |
||||
|
email: "zhangsan@example.com", |
||||
|
password: "hashedPassword", |
||||
|
role: "user", |
||||
|
phone: "13800138000", |
||||
|
age: 25 |
||||
|
}) |
||||
|
|
||||
|
// 查找所有用户 |
||||
|
const allUsers = await UserModel.findAll() |
||||
|
|
||||
|
// 根据ID查找用户 |
||||
|
const user = await UserModel.findById(1) |
||||
|
|
||||
|
// 根据用户名查找用户 |
||||
|
const user = await UserModel.findByUsername("zhangsan") |
||||
|
|
||||
|
// 根据邮箱查找用户 |
||||
|
const user = await UserModel.findByEmail("zhangsan@example.com") |
||||
|
|
||||
|
// 更新用户信息 |
||||
|
await UserModel.update(1, { |
||||
|
phone: "13900139000", |
||||
|
age: 26 |
||||
|
}) |
||||
|
|
||||
|
// 删除用户 |
||||
|
await UserModel.delete(1) |
||||
|
``` |
||||
|
|
||||
|
### 查询方法 |
||||
|
|
||||
|
#### 基础查询 |
||||
|
- `findAll()` - 查找所有用户 |
||||
|
- `findById(id)` - 根据ID查找用户 |
||||
|
- `findByUsername(username)` - 根据用户名查找用户 |
||||
|
- `findByEmail(email)` - 根据邮箱查找用户 |
||||
|
|
||||
|
#### 数据操作 |
||||
|
- `create(data)` - 创建新用户 |
||||
|
- `update(id, data)` - 更新用户信息 |
||||
|
- `delete(id)` - 删除用户 |
||||
|
|
||||
|
### 数据验证 |
||||
|
|
||||
|
#### 必填字段 |
||||
|
- `username` - 用户名不能为空 |
||||
|
- `password` - 密码不能为空 |
||||
|
- `role` - 角色不能为空 |
||||
|
|
||||
|
#### 唯一性约束 |
||||
|
- `email` - 邮箱必须唯一 |
||||
|
- `username` - 建议在应用层实现唯一性验证 |
||||
|
|
||||
|
### 时间戳管理 |
||||
|
|
||||
|
系统自动管理以下时间戳: |
||||
|
- `created_at` - 创建时自动设置为当前时间 |
||||
|
- `updated_at` - 每次更新时自动设置为当前时间 |
||||
|
|
||||
|
### 角色管理 |
||||
|
|
||||
|
支持用户角色字段,可用于权限控制: |
||||
|
```javascript |
||||
|
// 常见角色示例 |
||||
|
const roles = { |
||||
|
admin: "管理员", |
||||
|
user: "普通用户", |
||||
|
moderator: "版主" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 错误处理 |
||||
|
|
||||
|
所有方法都包含适当的错误处理: |
||||
|
```javascript |
||||
|
try { |
||||
|
const user = await UserModel.create({ |
||||
|
username: "", // 空用户名会抛出错误 |
||||
|
password: "password" |
||||
|
}) |
||||
|
} catch (error) { |
||||
|
console.error("创建用户失败:", error.message) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 性能优化 |
||||
|
|
||||
|
- 建议为 `username` 和 `email` 字段添加索引 |
||||
|
- 支持分页查询(需要扩展实现) |
||||
|
|
||||
|
### 迁移和种子 |
||||
|
|
||||
|
项目包含完整的数据库迁移和种子文件: |
||||
|
- `20250616065041_create_users_table.mjs` - 创建users表 |
||||
|
- `20250616071157_users_seed.mjs` - 示例用户数据 |
||||
|
|
||||
|
### 运行迁移和种子 |
||||
|
|
||||
|
```bash |
||||
|
# 运行迁移 |
||||
|
npx knex migrate:latest |
||||
|
|
||||
|
# 运行种子 |
||||
|
npx knex seed:run |
||||
|
``` |
||||
|
|
||||
|
### 安全注意事项 |
||||
|
|
||||
|
1. **密码安全**: 在创建用户前,确保密码已经过哈希处理 |
||||
|
2. **输入验证**: 在应用层验证用户输入数据的有效性 |
||||
|
3. **权限控制**: 根据用户角色实现适当的访问控制 |
||||
|
4. **SQL注入防护**: 使用Knex.js的参数化查询防止SQL注入 |
||||
|
|
||||
|
### 扩展建议 |
||||
|
|
||||
|
可以考虑添加以下功能: |
||||
|
- 用户状态管理(激活/禁用) |
||||
|
- 密码重置功能 |
||||
|
- 用户头像管理 |
||||
|
- 用户偏好设置 |
||||
|
- 登录历史记录 |
||||
|
- 用户组管理 |
||||
@ -0,0 +1,26 @@ |
|||||
|
/** |
||||
|
* @param { import("knex").Knex } knex |
||||
|
* @returns { Promise<void> } |
||||
|
*/ |
||||
|
export const up = async knex => { |
||||
|
return knex.schema.createTable("articles", table => { |
||||
|
table.increments("id").primary() |
||||
|
table.string("title").notNullable() |
||||
|
table.string("content").notNullable() |
||||
|
table.string("author") |
||||
|
table.string("category") |
||||
|
table.string("tags") |
||||
|
table.string("keywords") |
||||
|
table.string("description") |
||||
|
table.timestamp("created_at").defaultTo(knex.fn.now()) |
||||
|
table.timestamp("updated_at").defaultTo(knex.fn.now()) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @param { import("knex").Knex } knex |
||||
|
* @returns { Promise<void> } |
||||
|
*/ |
||||
|
export const down = async knex => { |
||||
|
return knex.schema.dropTable("articles") |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
/** |
||||
|
* @param { import("knex").Knex } knex |
||||
|
* @returns { Promise<void> } |
||||
|
*/ |
||||
|
export const up = async knex => { |
||||
|
return knex.schema.alterTable("articles", table => { |
||||
|
// 添加浏览量字段
|
||||
|
table.integer("view_count").defaultTo(0) |
||||
|
|
||||
|
// 添加发布时间字段
|
||||
|
table.timestamp("published_at") |
||||
|
|
||||
|
// 添加状态字段 (draft, published, archived)
|
||||
|
table.string("status").defaultTo("draft") |
||||
|
|
||||
|
// 添加特色图片字段
|
||||
|
table.string("featured_image") |
||||
|
|
||||
|
// 添加摘要字段
|
||||
|
table.text("excerpt") |
||||
|
|
||||
|
// 添加阅读时间估算字段(分钟)
|
||||
|
table.integer("reading_time") |
||||
|
|
||||
|
// 添加SEO相关字段
|
||||
|
table.string("meta_title") |
||||
|
table.text("meta_description") |
||||
|
table.string("slug").unique() |
||||
|
|
||||
|
// 添加索引以提高查询性能
|
||||
|
table.index(["status", "published_at"]) |
||||
|
table.index(["category"]) |
||||
|
table.index(["author"]) |
||||
|
table.index(["created_at"]) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @param { import("knex").Knex } knex |
||||
|
* @returns { Promise<void> } |
||||
|
*/ |
||||
|
export const down = async knex => { |
||||
|
return knex.schema.alterTable("articles", table => { |
||||
|
table.dropColumn("view_count") |
||||
|
table.dropColumn("published_at") |
||||
|
table.dropColumn("status") |
||||
|
table.dropColumn("featured_image") |
||||
|
table.dropColumn("excerpt") |
||||
|
table.dropColumn("reading_time") |
||||
|
table.dropColumn("meta_title") |
||||
|
table.dropColumn("meta_description") |
||||
|
table.dropColumn("slug") |
||||
|
|
||||
|
// 删除索引
|
||||
|
table.dropIndex(["status", "published_at"]) |
||||
|
table.dropIndex(["category"]) |
||||
|
table.dropIndex(["author"]) |
||||
|
table.dropIndex(["created_at"]) |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,298 @@ |
|||||
|
import db from "../index.js" |
||||
|
|
||||
|
class ArticleModel { |
||||
|
static async findAll() { |
||||
|
return db("articles").orderBy("created_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async findPublished() { |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.whereNotNull("published_at") |
||||
|
.orderBy("published_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async findDrafts() { |
||||
|
return db("articles") |
||||
|
.where("status", "draft") |
||||
|
.orderBy("updated_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async findById(id) { |
||||
|
return db("articles").where("id", id).first() |
||||
|
} |
||||
|
|
||||
|
static async findBySlug(slug) { |
||||
|
return db("articles").where("slug", slug).first() |
||||
|
} |
||||
|
|
||||
|
static async findByAuthor(author) { |
||||
|
return db("articles") |
||||
|
.where("author", author) |
||||
|
.where("status", "published") |
||||
|
.orderBy("published_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async findByCategory(category) { |
||||
|
return db("articles") |
||||
|
.where("category", category) |
||||
|
.where("status", "published") |
||||
|
.orderBy("published_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async findByTags(tags) { |
||||
|
// 支持多个标签搜索,标签以逗号分隔
|
||||
|
const tagArray = tags.split(',').map(tag => tag.trim()) |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.whereRaw("tags LIKE ?", [`%${tagArray[0]}%`]) |
||||
|
.orderBy("published_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async searchByKeyword(keyword) { |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.where(function() { |
||||
|
this.where("title", "like", `%${keyword}%`) |
||||
|
.orWhere("content", "like", `%${keyword}%`) |
||||
|
.orWhere("keywords", "like", `%${keyword}%`) |
||||
|
.orWhere("description", "like", `%${keyword}%`) |
||||
|
.orWhere("excerpt", "like", `%${keyword}%`) |
||||
|
}) |
||||
|
.orderBy("published_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async create(data) { |
||||
|
// 验证必填字段
|
||||
|
if (!data.title || !data.content) { |
||||
|
throw new Error("标题和内容为必填字段") |
||||
|
} |
||||
|
|
||||
|
// 处理标签,确保格式一致
|
||||
|
let tags = data.tags |
||||
|
if (tags && typeof tags === "string") { |
||||
|
tags = tags.split(',').map(tag => tag.trim()).filter(tag => tag).join(', ') |
||||
|
} |
||||
|
|
||||
|
// 生成slug(如果未提供)
|
||||
|
let slug = data.slug |
||||
|
if (!slug) { |
||||
|
slug = this.generateSlug(data.title) |
||||
|
} |
||||
|
|
||||
|
// 计算阅读时间(如果未提供)
|
||||
|
let readingTime = data.reading_time |
||||
|
if (!readingTime) { |
||||
|
readingTime = this.calculateReadingTime(data.content) |
||||
|
} |
||||
|
|
||||
|
// 生成摘要(如果未提供)
|
||||
|
let excerpt = data.excerpt |
||||
|
if (!excerpt && data.content) { |
||||
|
excerpt = this.generateExcerpt(data.content) |
||||
|
} |
||||
|
|
||||
|
return db("articles").insert({ |
||||
|
...data, |
||||
|
tags, |
||||
|
slug, |
||||
|
reading_time: readingTime, |
||||
|
excerpt, |
||||
|
status: data.status || "draft", |
||||
|
view_count: 0, |
||||
|
created_at: db.fn.now(), |
||||
|
updated_at: db.fn.now(), |
||||
|
}).returning("*") |
||||
|
} |
||||
|
|
||||
|
static async update(id, data) { |
||||
|
const current = await db("articles").where("id", id).first() |
||||
|
if (!current) { |
||||
|
throw new Error("文章不存在") |
||||
|
} |
||||
|
|
||||
|
// 处理标签,确保格式一致
|
||||
|
let tags = data.tags |
||||
|
if (tags && typeof tags === "string") { |
||||
|
tags = tags.split(',').map(tag => tag.trim()).filter(tag => tag).join(', ') |
||||
|
} |
||||
|
|
||||
|
// 生成slug(如果标题改变且未提供slug)
|
||||
|
let slug = data.slug |
||||
|
if (data.title && data.title !== current.title && !slug) { |
||||
|
slug = this.generateSlug(data.title) |
||||
|
} |
||||
|
|
||||
|
// 计算阅读时间(如果内容改变且未提供)
|
||||
|
let readingTime = data.reading_time |
||||
|
if (data.content && data.content !== current.content && !readingTime) { |
||||
|
readingTime = this.calculateReadingTime(data.content) |
||||
|
} |
||||
|
|
||||
|
// 生成摘要(如果内容改变且未提供)
|
||||
|
let excerpt = data.excerpt |
||||
|
if (data.content && data.content !== current.content && !excerpt) { |
||||
|
excerpt = this.generateExcerpt(data.content) |
||||
|
} |
||||
|
|
||||
|
// 如果状态改为published,设置发布时间
|
||||
|
let publishedAt = data.published_at |
||||
|
if (data.status === "published" && current.status !== "published" && !publishedAt) { |
||||
|
publishedAt = db.fn.now() |
||||
|
} |
||||
|
|
||||
|
return db("articles").where("id", id).update({ |
||||
|
...data, |
||||
|
tags: tags || current.tags, |
||||
|
slug: slug || current.slug, |
||||
|
reading_time: readingTime || current.reading_time, |
||||
|
excerpt: excerpt || current.excerpt, |
||||
|
published_at: publishedAt || current.published_at, |
||||
|
updated_at: db.fn.now(), |
||||
|
}).returning("*") |
||||
|
} |
||||
|
|
||||
|
static async delete(id) { |
||||
|
const article = await db("articles").where("id", id).first() |
||||
|
if (!article) { |
||||
|
throw new Error("文章不存在") |
||||
|
} |
||||
|
return db("articles").where("id", id).del() |
||||
|
} |
||||
|
|
||||
|
static async publish(id) { |
||||
|
return db("articles") |
||||
|
.where("id", id) |
||||
|
.update({ |
||||
|
status: "published", |
||||
|
published_at: db.fn.now(), |
||||
|
updated_at: db.fn.now(), |
||||
|
}) |
||||
|
.returning("*") |
||||
|
} |
||||
|
|
||||
|
static async unpublish(id) { |
||||
|
return db("articles") |
||||
|
.where("id", id) |
||||
|
.update({ |
||||
|
status: "draft", |
||||
|
published_at: null, |
||||
|
updated_at: db.fn.now(), |
||||
|
}) |
||||
|
.returning("*") |
||||
|
} |
||||
|
|
||||
|
static async incrementViewCount(id) { |
||||
|
return db("articles") |
||||
|
.where("id", id) |
||||
|
.increment("view_count", 1) |
||||
|
.returning("*") |
||||
|
} |
||||
|
|
||||
|
static async findByDateRange(startDate, endDate) { |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.whereBetween("published_at", [startDate, endDate]) |
||||
|
.orderBy("published_at", "desc") |
||||
|
} |
||||
|
|
||||
|
static async getArticleCount() { |
||||
|
const result = await db("articles").count("id as count").first() |
||||
|
return result ? result.count : 0 |
||||
|
} |
||||
|
|
||||
|
static async getPublishedArticleCount() { |
||||
|
const result = await db("articles") |
||||
|
.where("status", "published") |
||||
|
.count("id as count") |
||||
|
.first() |
||||
|
return result ? result.count : 0 |
||||
|
} |
||||
|
|
||||
|
static async getArticleCountByCategory() { |
||||
|
return db("articles") |
||||
|
.select("category") |
||||
|
.count("id as count") |
||||
|
.where("status", "published") |
||||
|
.groupBy("category") |
||||
|
.orderBy("count", "desc") |
||||
|
} |
||||
|
|
||||
|
static async getArticleCountByStatus() { |
||||
|
return db("articles") |
||||
|
.select("status") |
||||
|
.count("id as count") |
||||
|
.groupBy("status") |
||||
|
.orderBy("count", "desc") |
||||
|
} |
||||
|
|
||||
|
static async getRecentArticles(limit = 10) { |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.orderBy("published_at", "desc") |
||||
|
.limit(limit) |
||||
|
} |
||||
|
|
||||
|
static async getPopularArticles(limit = 10) { |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.orderBy("view_count", "desc") |
||||
|
.limit(limit) |
||||
|
} |
||||
|
|
||||
|
static async getFeaturedArticles(limit = 5) { |
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.whereNotNull("featured_image") |
||||
|
.orderBy("published_at", "desc") |
||||
|
.limit(limit) |
||||
|
} |
||||
|
|
||||
|
static async getRelatedArticles(articleId, limit = 5) { |
||||
|
const current = await this.findById(articleId) |
||||
|
if (!current) return [] |
||||
|
|
||||
|
return db("articles") |
||||
|
.where("status", "published") |
||||
|
.where("id", "!=", articleId) |
||||
|
.where(function() { |
||||
|
if (current.category) { |
||||
|
this.orWhere("category", current.category) |
||||
|
} |
||||
|
if (current.tags) { |
||||
|
const tags = current.tags.split(',').map(tag => tag.trim()) |
||||
|
tags.forEach(tag => { |
||||
|
this.orWhereRaw("tags LIKE ?", [`%${tag}%`]) |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
.orderBy("published_at", "desc") |
||||
|
.limit(limit) |
||||
|
} |
||||
|
|
||||
|
// 工具方法
|
||||
|
static generateSlug(title) { |
||||
|
return title |
||||
|
.toLowerCase() |
||||
|
.replace(/[^\w\s-]/g, '') |
||||
|
.replace(/\s+/g, '-') |
||||
|
.replace(/-+/g, '-') |
||||
|
.trim() |
||||
|
} |
||||
|
|
||||
|
static calculateReadingTime(content) { |
||||
|
// 假设平均阅读速度为每分钟200个单词
|
||||
|
const wordCount = content.split(/\s+/).length |
||||
|
return Math.ceil(wordCount / 200) |
||||
|
} |
||||
|
|
||||
|
static generateExcerpt(content, maxLength = 150) { |
||||
|
if (content.length <= maxLength) { |
||||
|
return content |
||||
|
} |
||||
|
return content.substring(0, maxLength).trim() + "..." |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ArticleModel |
||||
|
export { ArticleModel } |
||||
@ -0,0 +1,77 @@ |
|||||
|
/** |
||||
|
* @param { import("knex").Knex } knex |
||||
|
* @returns { Promise<void> } |
||||
|
*/ |
||||
|
export const seed = async knex => { |
||||
|
// 清空表
|
||||
|
await knex("articles").del() |
||||
|
|
||||
|
// 插入示例数据
|
||||
|
await knex("articles").insert([ |
||||
|
{ |
||||
|
title: "欢迎使用文章管理系统", |
||||
|
content: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理。系统提供了丰富的功能,包括标签管理、分类管理、SEO优化等。\n\n## 主要特性\n\n- 支持Markdown格式\n- 标签和分类管理\n- SEO优化\n- 阅读时间计算\n- 浏览量统计\n- 草稿和发布状态管理", |
||||
|
author: "系统管理员", |
||||
|
category: "系统介绍", |
||||
|
tags: "系统, 介绍, 功能", |
||||
|
keywords: "文章管理, 系统介绍, 功能特性", |
||||
|
description: "介绍文章管理系统的主要功能和特性", |
||||
|
status: "published", |
||||
|
published_at: knex.fn.now(), |
||||
|
excerpt: "这是一个功能强大的文章管理系统,支持文章的创建、编辑、发布和管理...", |
||||
|
reading_time: 3, |
||||
|
slug: "welcome-to-article-management-system", |
||||
|
meta_title: "欢迎使用文章管理系统 - 功能特性介绍", |
||||
|
meta_description: "了解文章管理系统的主要功能,包括Markdown支持、标签管理、SEO优化等特性" |
||||
|
}, |
||||
|
{ |
||||
|
title: "Markdown 写作指南", |
||||
|
content: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档。\n\n## 基本语法\n\n### 标题\n使用 `#` 符号创建标题:\n\n```markdown\n# 一级标题\n## 二级标题\n### 三级标题\n```\n\n### 列表\n- 无序列表使用 `-` 或 `*`\n- 有序列表使用数字\n\n### 链接和图片\n[链接文本](URL)\n\n\n### 代码\n使用反引号标记行内代码:`code`\n\n使用代码块:\n```javascript\nfunction hello() {\n console.log('Hello World!');\n}\n```", |
||||
|
author: "技术编辑", |
||||
|
category: "写作指南", |
||||
|
tags: "Markdown, 写作, 指南", |
||||
|
keywords: "Markdown, 写作指南, 语法, 教程", |
||||
|
description: "详细介绍Markdown的基本语法和用法,帮助用户快速掌握Markdown写作", |
||||
|
status: "published", |
||||
|
published_at: knex.fn.now(), |
||||
|
excerpt: "Markdown是一种轻量级标记语言,它允许人们使用易读易写的纯文本格式编写文档...", |
||||
|
reading_time: 8, |
||||
|
slug: "markdown-writing-guide", |
||||
|
meta_title: "Markdown 写作指南 - 从入门到精通", |
||||
|
meta_description: "学习Markdown的基本语法,包括标题、列表、链接、图片、代码等常用元素的写法" |
||||
|
}, |
||||
|
{ |
||||
|
title: "SEO 优化最佳实践", |
||||
|
content: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。\n\n## 关键词研究\n\n关键词研究是SEO的基础,需要:\n- 了解目标受众的搜索习惯\n- 分析竞争对手的关键词\n- 选择合适的关键词密度\n\n## 内容优化\n\n### 标题优化\n- 标题应包含主要关键词\n- 标题长度控制在50-60字符\n- 使用吸引人的标题\n\n### 内容结构\n- 使用H1-H6标签组织内容\n- 段落要简洁明了\n- 添加相关图片和视频\n\n## 技术SEO\n\n- 确保网站加载速度快\n- 优化移动端体验\n- 使用结构化数据\n- 建立内部链接结构", |
||||
|
author: "SEO专家", |
||||
|
category: "数字营销", |
||||
|
tags: "SEO, 优化, 搜索引擎, 营销", |
||||
|
keywords: "SEO优化, 搜索引擎优化, 关键词研究, 内容优化", |
||||
|
description: "介绍SEO优化的最佳实践,包括关键词研究、内容优化和技术SEO等方面", |
||||
|
status: "published", |
||||
|
published_at: knex.fn.now(), |
||||
|
excerpt: "搜索引擎优化(SEO)是提高网站在搜索引擎中排名的技术。本文介绍SEO优化的最佳实践...", |
||||
|
reading_time: 12, |
||||
|
slug: "seo-optimization-best-practices", |
||||
|
meta_title: "SEO 优化最佳实践 - 提升网站排名", |
||||
|
meta_description: "学习SEO优化的关键技巧,包括关键词研究、内容优化和技术SEO,帮助提升网站在搜索引擎中的排名" |
||||
|
}, |
||||
|
{ |
||||
|
title: "前端开发趋势 2024", |
||||
|
content: "2024年前端开发领域出现了许多新的趋势和技术。\n\n## 主要趋势\n\n### 1. 框架发展\n- React 18的新特性\n- Vue 3的Composition API\n- Svelte的崛起\n\n### 2. 构建工具\n- Vite的快速构建\n- Webpack 5的模块联邦\n- Turbopack的性能提升\n\n### 3. 性能优化\n- 核心Web指标\n- 图片优化\n- 代码分割\n\n### 4. 新特性\n- CSS容器查询\n- CSS Grid布局\n- Web Components\n\n## 学习建议\n\n建议开发者关注这些趋势,但不要盲目追新,要根据项目需求选择合适的技术栈。", |
||||
|
author: "前端开发者", |
||||
|
category: "技术趋势", |
||||
|
tags: "前端, 开发, 趋势, 2024", |
||||
|
keywords: "前端开发, 技术趋势, React, Vue, 性能优化", |
||||
|
description: "分析2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等方面", |
||||
|
status: "draft", |
||||
|
excerpt: "2024年前端开发领域出现了许多新的趋势和技术。本文分析主要趋势并提供学习建议...", |
||||
|
reading_time: 10, |
||||
|
slug: "frontend-development-trends-2024", |
||||
|
meta_title: "前端开发趋势 2024 - 技术发展分析", |
||||
|
meta_description: "了解2024年前端开发的主要趋势,包括框架发展、构建工具、性能优化等,为技术选型提供参考" |
||||
|
} |
||||
|
]) |
||||
|
|
||||
|
console.log("✅ Articles seeded successfully!") |
||||
|
} |
||||
@ -0,0 +1,295 @@ |
|||||
|
import ArticleModel from "db/models/ArticleModel.js" |
||||
|
import CommonError from "utils/error/CommonError.js" |
||||
|
|
||||
|
class ArticleService { |
||||
|
// 获取所有文章
|
||||
|
async getAllArticles() { |
||||
|
try { |
||||
|
return await ArticleModel.findAll() |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取文章列表失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取已发布的文章
|
||||
|
async getPublishedArticles() { |
||||
|
try { |
||||
|
return await ArticleModel.findPublished() |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取已发布文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取草稿文章
|
||||
|
async getDraftArticles() { |
||||
|
try { |
||||
|
return await ArticleModel.findDrafts() |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取草稿文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据ID获取文章
|
||||
|
async getArticleById(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
return article |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据slug获取文章
|
||||
|
async getArticleBySlug(slug) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findBySlug(slug) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
return article |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据作者获取文章
|
||||
|
async getArticlesByAuthor(author) { |
||||
|
try { |
||||
|
return await ArticleModel.findByAuthor(author) |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取作者文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据分类获取文章
|
||||
|
async getArticlesByCategory(category) { |
||||
|
try { |
||||
|
return await ArticleModel.findByCategory(category) |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取分类文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据标签获取文章
|
||||
|
async getArticlesByTags(tags) { |
||||
|
try { |
||||
|
return await ArticleModel.findByTags(tags) |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取标签文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 关键词搜索文章
|
||||
|
async searchArticles(keyword) { |
||||
|
try { |
||||
|
if (!keyword || keyword.trim() === '') { |
||||
|
throw new CommonError("搜索关键词不能为空") |
||||
|
} |
||||
|
return await ArticleModel.searchByKeyword(keyword.trim()) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`搜索文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建文章
|
||||
|
async createArticle(data) { |
||||
|
try { |
||||
|
if (!data.title || !data.content) { |
||||
|
throw new CommonError("标题和内容为必填字段") |
||||
|
} |
||||
|
return await ArticleModel.create(data) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`创建文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 更新文章
|
||||
|
async updateArticle(id, data) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
return await ArticleModel.update(id, data) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`更新文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 删除文章
|
||||
|
async deleteArticle(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
return await ArticleModel.delete(id) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`删除文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发布文章
|
||||
|
async publishArticle(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
if (article.status === 'published') { |
||||
|
throw new CommonError("文章已经是发布状态") |
||||
|
} |
||||
|
return await ArticleModel.publish(id) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`发布文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 取消发布文章
|
||||
|
async unpublishArticle(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
if (article.status === 'draft') { |
||||
|
throw new CommonError("文章已经是草稿状态") |
||||
|
} |
||||
|
return await ArticleModel.unpublish(id) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`取消发布文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 增加文章阅读量
|
||||
|
async incrementViewCount(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
return await ArticleModel.incrementViewCount(id) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`增加阅读量失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据日期范围获取文章
|
||||
|
async getArticlesByDateRange(startDate, endDate) { |
||||
|
try { |
||||
|
if (!startDate || !endDate) { |
||||
|
throw new CommonError("开始日期和结束日期不能为空") |
||||
|
} |
||||
|
return await ArticleModel.findByDateRange(startDate, endDate) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取日期范围文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取文章统计信息
|
||||
|
async getArticleStats() { |
||||
|
try { |
||||
|
const [totalCount, publishedCount, categoryStats, statusStats] = await Promise.all([ |
||||
|
ArticleModel.getArticleCount(), |
||||
|
ArticleModel.getPublishedArticleCount(), |
||||
|
ArticleModel.getArticleCountByCategory(), |
||||
|
ArticleModel.getArticleCountByStatus() |
||||
|
]) |
||||
|
|
||||
|
return { |
||||
|
total: totalCount, |
||||
|
published: publishedCount, |
||||
|
draft: totalCount - publishedCount, |
||||
|
byCategory: categoryStats, |
||||
|
byStatus: statusStats |
||||
|
} |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取文章统计失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取最近文章
|
||||
|
async getRecentArticles(limit = 10) { |
||||
|
try { |
||||
|
return await ArticleModel.getRecentArticles(limit) |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取最近文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取热门文章
|
||||
|
async getPopularArticles(limit = 10) { |
||||
|
try { |
||||
|
return await ArticleModel.getPopularArticles(limit) |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取热门文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取精选文章
|
||||
|
async getFeaturedArticles(limit = 5) { |
||||
|
try { |
||||
|
return await ArticleModel.getFeaturedArticles(limit) |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取精选文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取相关文章
|
||||
|
async getRelatedArticles(articleId, limit = 5) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(articleId) |
||||
|
if (!article) { |
||||
|
throw new CommonError("文章不存在") |
||||
|
} |
||||
|
return await ArticleModel.getRelatedArticles(articleId, limit) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取相关文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 分页获取文章
|
||||
|
async getArticlesWithPagination(page = 1, pageSize = 10, status = 'published') { |
||||
|
try { |
||||
|
let query = ArticleModel.findPublished() |
||||
|
if (status === 'all') { |
||||
|
query = ArticleModel.findAll() |
||||
|
} else if (status === 'draft') { |
||||
|
query = ArticleModel.findDrafts() |
||||
|
} |
||||
|
|
||||
|
const offset = (page - 1) * pageSize |
||||
|
const articles = await query.limit(pageSize).offset(offset) |
||||
|
const total = await ArticleModel.getPublishedArticleCount() |
||||
|
|
||||
|
return { |
||||
|
articles, |
||||
|
pagination: { |
||||
|
current: page, |
||||
|
pageSize, |
||||
|
total, |
||||
|
totalPages: Math.ceil(total / pageSize) |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`分页获取文章失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ArticleService |
||||
|
export { ArticleService } |
||||
@ -0,0 +1,312 @@ |
|||||
|
import BookmarkModel from "db/models/BookmarkModel.js" |
||||
|
import CommonError from "utils/error/CommonError.js" |
||||
|
|
||||
|
class BookmarkService { |
||||
|
// 获取用户的所有书签
|
||||
|
async getUserBookmarks(userId) { |
||||
|
try { |
||||
|
if (!userId) { |
||||
|
throw new CommonError("用户ID不能为空") |
||||
|
} |
||||
|
return await BookmarkModel.findAllByUser(userId) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取用户书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据ID获取书签
|
||||
|
async getBookmarkById(id) { |
||||
|
try { |
||||
|
if (!id) { |
||||
|
throw new CommonError("书签ID不能为空") |
||||
|
} |
||||
|
const bookmark = await BookmarkModel.findById(id) |
||||
|
if (!bookmark) { |
||||
|
throw new CommonError("书签不存在") |
||||
|
} |
||||
|
return bookmark |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建书签
|
||||
|
async createBookmark(data) { |
||||
|
try { |
||||
|
if (!data.user_id || !data.url) { |
||||
|
throw new CommonError("用户ID和URL为必填字段") |
||||
|
} |
||||
|
|
||||
|
// 验证URL格式
|
||||
|
if (!this.isValidUrl(data.url)) { |
||||
|
throw new CommonError("URL格式不正确") |
||||
|
} |
||||
|
|
||||
|
return await BookmarkModel.create(data) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`创建书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 更新书签
|
||||
|
async updateBookmark(id, data) { |
||||
|
try { |
||||
|
if (!id) { |
||||
|
throw new CommonError("书签ID不能为空") |
||||
|
} |
||||
|
|
||||
|
const bookmark = await BookmarkModel.findById(id) |
||||
|
if (!bookmark) { |
||||
|
throw new CommonError("书签不存在") |
||||
|
} |
||||
|
|
||||
|
// 如果更新URL,验证格式
|
||||
|
if (data.url && !this.isValidUrl(data.url)) { |
||||
|
throw new CommonError("URL格式不正确") |
||||
|
} |
||||
|
|
||||
|
return await BookmarkModel.update(id, data) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`更新书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 删除书签
|
||||
|
async deleteBookmark(id) { |
||||
|
try { |
||||
|
if (!id) { |
||||
|
throw new CommonError("书签ID不能为空") |
||||
|
} |
||||
|
|
||||
|
const bookmark = await BookmarkModel.findById(id) |
||||
|
if (!bookmark) { |
||||
|
throw new CommonError("书签不存在") |
||||
|
} |
||||
|
|
||||
|
return await BookmarkModel.delete(id) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`删除书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 根据用户和URL查找书签
|
||||
|
async findBookmarkByUserAndUrl(userId, url) { |
||||
|
try { |
||||
|
if (!userId || !url) { |
||||
|
throw new CommonError("用户ID和URL不能为空") |
||||
|
} |
||||
|
|
||||
|
return await BookmarkModel.findByUserAndUrl(userId, url) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`查找书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 检查书签是否存在
|
||||
|
async isBookmarkExists(userId, url) { |
||||
|
try { |
||||
|
if (!userId || !url) { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
const bookmark = await BookmarkModel.findByUserAndUrl(userId, url) |
||||
|
return !!bookmark |
||||
|
} catch (error) { |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 批量创建书签
|
||||
|
async createBookmarks(userId, bookmarksData) { |
||||
|
try { |
||||
|
if (!userId || !Array.isArray(bookmarksData) || bookmarksData.length === 0) { |
||||
|
throw new CommonError("用户ID和书签数据不能为空") |
||||
|
} |
||||
|
|
||||
|
const results = [] |
||||
|
const errors = [] |
||||
|
|
||||
|
for (const bookmarkData of bookmarksData) { |
||||
|
try { |
||||
|
const bookmark = await this.createBookmark({ |
||||
|
...bookmarkData, |
||||
|
user_id: userId |
||||
|
}) |
||||
|
results.push(bookmark) |
||||
|
} catch (error) { |
||||
|
errors.push({ |
||||
|
url: bookmarkData.url, |
||||
|
error: error.message |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
success: results, |
||||
|
errors, |
||||
|
total: bookmarksData.length, |
||||
|
successCount: results.length, |
||||
|
errorCount: errors.length |
||||
|
} |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`批量创建书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 批量删除书签
|
||||
|
async deleteBookmarks(userId, bookmarkIds) { |
||||
|
try { |
||||
|
if (!userId || !Array.isArray(bookmarkIds) || bookmarkIds.length === 0) { |
||||
|
throw new CommonError("用户ID和书签ID列表不能为空") |
||||
|
} |
||||
|
|
||||
|
const results = [] |
||||
|
const errors = [] |
||||
|
|
||||
|
for (const id of bookmarkIds) { |
||||
|
try { |
||||
|
const bookmark = await BookmarkModel.findById(id) |
||||
|
if (bookmark && bookmark.user_id === userId) { |
||||
|
await BookmarkModel.delete(id) |
||||
|
results.push(id) |
||||
|
} else { |
||||
|
errors.push({ |
||||
|
id, |
||||
|
error: "书签不存在或无权限删除" |
||||
|
}) |
||||
|
} |
||||
|
} catch (error) { |
||||
|
errors.push({ |
||||
|
id, |
||||
|
error: error.message |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
success: results, |
||||
|
errors, |
||||
|
total: bookmarkIds.length, |
||||
|
successCount: results.length, |
||||
|
errorCount: errors.length |
||||
|
} |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`批量删除书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取用户书签统计
|
||||
|
async getUserBookmarkStats(userId) { |
||||
|
try { |
||||
|
if (!userId) { |
||||
|
throw new CommonError("用户ID不能为空") |
||||
|
} |
||||
|
|
||||
|
const bookmarks = await BookmarkModel.findAllByUser(userId) |
||||
|
|
||||
|
// 按标签分组统计
|
||||
|
const tagStats = {} |
||||
|
bookmarks.forEach(bookmark => { |
||||
|
if (bookmark.tags) { |
||||
|
const tags = bookmark.tags.split(',').map(tag => tag.trim()) |
||||
|
tags.forEach(tag => { |
||||
|
tagStats[tag] = (tagStats[tag] || 0) + 1 |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 按创建时间分组统计
|
||||
|
const dateStats = {} |
||||
|
bookmarks.forEach(bookmark => { |
||||
|
const date = new Date(bookmark.created_at).toISOString().split('T')[0] |
||||
|
dateStats[date] = (dateStats[date] || 0) + 1 |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
total: bookmarks.length, |
||||
|
byTag: tagStats, |
||||
|
byDate: dateStats, |
||||
|
lastUpdated: bookmarks.length > 0 ? bookmarks[0].updated_at : null |
||||
|
} |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取书签统计失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 搜索用户书签
|
||||
|
async searchUserBookmarks(userId, keyword) { |
||||
|
try { |
||||
|
if (!userId) { |
||||
|
throw new CommonError("用户ID不能为空") |
||||
|
} |
||||
|
|
||||
|
if (!keyword || keyword.trim() === '') { |
||||
|
return await this.getUserBookmarks(userId) |
||||
|
} |
||||
|
|
||||
|
const bookmarks = await BookmarkModel.findAllByUser(userId) |
||||
|
const searchTerm = keyword.toLowerCase().trim() |
||||
|
|
||||
|
return bookmarks.filter(bookmark => { |
||||
|
return ( |
||||
|
bookmark.title?.toLowerCase().includes(searchTerm) || |
||||
|
bookmark.description?.toLowerCase().includes(searchTerm) || |
||||
|
bookmark.url?.toLowerCase().includes(searchTerm) || |
||||
|
bookmark.tags?.toLowerCase().includes(searchTerm) |
||||
|
) |
||||
|
}) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`搜索书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 验证URL格式
|
||||
|
isValidUrl(url) { |
||||
|
try { |
||||
|
new URL(url) |
||||
|
return true |
||||
|
} catch { |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取书签分页
|
||||
|
async getBookmarksWithPagination(userId, page = 1, pageSize = 20) { |
||||
|
try { |
||||
|
if (!userId) { |
||||
|
throw new CommonError("用户ID不能为空") |
||||
|
} |
||||
|
|
||||
|
const allBookmarks = await BookmarkModel.findAllByUser(userId) |
||||
|
const total = allBookmarks.length |
||||
|
const offset = (page - 1) * pageSize |
||||
|
const bookmarks = allBookmarks.slice(offset, offset + pageSize) |
||||
|
|
||||
|
return { |
||||
|
bookmarks, |
||||
|
pagination: { |
||||
|
current: page, |
||||
|
pageSize, |
||||
|
total, |
||||
|
totalPages: Math.ceil(total / pageSize) |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`分页获取书签失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default BookmarkService |
||||
|
export { BookmarkService } |
||||
@ -0,0 +1,222 @@ |
|||||
|
# 服务层 (Services) |
||||
|
|
||||
|
本目录包含了应用的所有业务逻辑服务层,负责处理业务规则、数据验证和错误处理。 |
||||
|
|
||||
|
## 服务列表 |
||||
|
|
||||
|
### 1. UserService - 用户服务 |
||||
|
处理用户相关的所有业务逻辑,包括用户注册、登录、密码管理等。 |
||||
|
|
||||
|
**主要功能:** |
||||
|
- 用户注册和登录 |
||||
|
- 用户信息管理(增删改查) |
||||
|
- 密码加密和验证 |
||||
|
- 用户统计和搜索 |
||||
|
- 批量操作支持 |
||||
|
|
||||
|
**使用示例:** |
||||
|
```javascript |
||||
|
import { userService } from '../services/index.js' |
||||
|
|
||||
|
// 用户注册 |
||||
|
const newUser = await userService.register({ |
||||
|
username: 'testuser', |
||||
|
email: 'test@example.com', |
||||
|
password: 'password123' |
||||
|
}) |
||||
|
|
||||
|
// 用户登录 |
||||
|
const loginResult = await userService.login({ |
||||
|
username: 'testuser', |
||||
|
password: 'password123' |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
### 2. ArticleService - 文章服务 |
||||
|
处理文章相关的所有业务逻辑,包括文章的发布、编辑、搜索等。 |
||||
|
|
||||
|
**主要功能:** |
||||
|
- 文章的增删改查 |
||||
|
- 文章状态管理(草稿/发布) |
||||
|
- 文章搜索和分类 |
||||
|
- 阅读量统计 |
||||
|
- 相关文章推荐 |
||||
|
- 分页支持 |
||||
|
|
||||
|
**使用示例:** |
||||
|
```javascript |
||||
|
import { articleService } from '../services/index.js' |
||||
|
|
||||
|
// 创建文章 |
||||
|
const article = await articleService.createArticle({ |
||||
|
title: '测试文章', |
||||
|
content: '文章内容...', |
||||
|
category: '技术', |
||||
|
tags: 'JavaScript,Node.js' |
||||
|
}) |
||||
|
|
||||
|
// 获取已发布文章 |
||||
|
const publishedArticles = await articleService.getPublishedArticles() |
||||
|
|
||||
|
// 搜索文章 |
||||
|
const searchResults = await articleService.searchArticles('JavaScript') |
||||
|
``` |
||||
|
|
||||
|
### 3. BookmarkService - 书签服务 |
||||
|
处理用户书签的管理,包括添加、编辑、删除和搜索书签。 |
||||
|
|
||||
|
**主要功能:** |
||||
|
- 书签的增删改查 |
||||
|
- URL格式验证 |
||||
|
- 批量操作支持 |
||||
|
- 书签统计和搜索 |
||||
|
- 分页支持 |
||||
|
|
||||
|
**使用示例:** |
||||
|
```javascript |
||||
|
import { bookmarkService } from '../services/index.js' |
||||
|
|
||||
|
// 添加书签 |
||||
|
const bookmark = await bookmarkService.createBookmark({ |
||||
|
user_id: 1, |
||||
|
title: 'Google', |
||||
|
url: 'https://www.google.com', |
||||
|
description: '搜索引擎' |
||||
|
}) |
||||
|
|
||||
|
// 获取用户书签 |
||||
|
const userBookmarks = await bookmarkService.getUserBookmarks(1) |
||||
|
|
||||
|
// 搜索书签 |
||||
|
const searchResults = await bookmarkService.searchUserBookmarks(1, 'Google') |
||||
|
``` |
||||
|
|
||||
|
### 4. SiteConfigService - 站点配置服务 |
||||
|
管理站点的各种配置信息,如站点名称、描述、主题等。 |
||||
|
|
||||
|
**主要功能:** |
||||
|
- 配置的增删改查 |
||||
|
- 配置值验证 |
||||
|
- 批量操作支持 |
||||
|
- 默认配置初始化 |
||||
|
- 配置统计和搜索 |
||||
|
|
||||
|
**使用示例:** |
||||
|
```javascript |
||||
|
import { siteConfigService } from '../services/index.js' |
||||
|
|
||||
|
// 获取配置 |
||||
|
const siteName = await siteConfigService.get('site_name') |
||||
|
|
||||
|
// 设置配置 |
||||
|
await siteConfigService.set('site_name', '我的新网站') |
||||
|
|
||||
|
// 批量设置配置 |
||||
|
await siteConfigService.setMany({ |
||||
|
'site_description': '网站描述', |
||||
|
'posts_per_page': 20 |
||||
|
}) |
||||
|
|
||||
|
// 初始化默认配置 |
||||
|
await siteConfigService.initializeDefaultConfigs() |
||||
|
``` |
||||
|
|
||||
|
### 5. JobService - 任务服务 |
||||
|
处理后台任务和定时任务的管理。 |
||||
|
|
||||
|
**主要功能:** |
||||
|
- 任务调度和管理 |
||||
|
- 任务状态监控 |
||||
|
- 任务日志记录 |
||||
|
|
||||
|
## 错误处理 |
||||
|
|
||||
|
所有服务都使用统一的错误处理机制: |
||||
|
|
||||
|
```javascript |
||||
|
import CommonError from 'utils/error/CommonError.js' |
||||
|
|
||||
|
try { |
||||
|
const result = await userService.getUserById(1) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) { |
||||
|
// 业务逻辑错误 |
||||
|
console.error(error.message) |
||||
|
} else { |
||||
|
// 系统错误 |
||||
|
console.error('系统错误:', error.message) |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 数据验证 |
||||
|
|
||||
|
服务层负责数据验证,确保数据的完整性和正确性: |
||||
|
|
||||
|
- **输入验证**:检查必填字段、格式验证等 |
||||
|
- **业务验证**:检查业务规则,如用户名唯一性 |
||||
|
- **权限验证**:确保用户只能操作自己的数据 |
||||
|
|
||||
|
## 事务支持 |
||||
|
|
||||
|
对于涉及多个数据库操作的方法,服务层支持事务处理: |
||||
|
|
||||
|
```javascript |
||||
|
// 在需要事务的方法中使用 |
||||
|
async createUserWithProfile(userData, profileData) { |
||||
|
// 这里可以添加事务支持 |
||||
|
const user = await this.createUser(userData) |
||||
|
// 创建用户档案... |
||||
|
return user |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 缓存策略 |
||||
|
|
||||
|
服务层可以集成缓存机制来提高性能: |
||||
|
|
||||
|
```javascript |
||||
|
// 示例:缓存用户信息 |
||||
|
async getUserById(id) { |
||||
|
const cacheKey = `user:${id}` |
||||
|
let user = await cache.get(cacheKey) |
||||
|
|
||||
|
if (!user) { |
||||
|
user = await UserModel.findById(id) |
||||
|
await cache.set(cacheKey, user, 3600) // 缓存1小时 |
||||
|
} |
||||
|
|
||||
|
return user |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 使用建议 |
||||
|
|
||||
|
1. **控制器层调用服务**:控制器应该调用服务层方法,而不是直接操作模型 |
||||
|
2. **错误处理**:在控制器中捕获服务层抛出的错误并返回适当的HTTP响应 |
||||
|
3. **数据转换**:服务层负责数据格式转换,控制器负责HTTP响应格式 |
||||
|
4. **业务逻辑**:复杂的业务逻辑应该放在服务层,保持控制器的简洁性 |
||||
|
|
||||
|
## 扩展指南 |
||||
|
|
||||
|
添加新的服务: |
||||
|
|
||||
|
1. 创建新的服务文件(如 `NewService.js`) |
||||
|
2. 继承或实现基础服务接口 |
||||
|
3. 在 `index.js` 中导出新服务 |
||||
|
4. 添加相应的测试用例 |
||||
|
5. 更新文档 |
||||
|
|
||||
|
```javascript |
||||
|
// 新服务示例 |
||||
|
class NewService { |
||||
|
async doSomething(data) { |
||||
|
try { |
||||
|
// 业务逻辑 |
||||
|
return result |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`操作失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
@ -1,25 +1,299 @@ |
|||||
import SiteConfigModel from "../db/models/SiteConfigModel.js" |
import SiteConfigModel from "../db/models/SiteConfigModel.js" |
||||
|
import CommonError from "utils/error/CommonError.js" |
||||
|
|
||||
class SiteConfigService { |
class SiteConfigService { |
||||
// 获取单个配置
|
// 获取指定key的配置
|
||||
async get(key) { |
async get(key) { |
||||
return await SiteConfigModel.get(key) |
try { |
||||
|
if (!key || key.trim() === '') { |
||||
|
throw new CommonError("配置键不能为空") |
||||
|
} |
||||
|
return await SiteConfigModel.get(key.trim()) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`获取配置失败: ${error.message}`) |
||||
|
} |
||||
} |
} |
||||
|
|
||||
// 设置单个配置
|
// 设置指定key的配置
|
||||
async set(key, value) { |
async set(key, value) { |
||||
return await SiteConfigModel.set(key, value) |
try { |
||||
|
if (!key || key.trim() === '') { |
||||
|
throw new CommonError("配置键不能为空") |
||||
|
} |
||||
|
if (value === undefined || value === null) { |
||||
|
throw new CommonError("配置值不能为空") |
||||
|
} |
||||
|
return await SiteConfigModel.set(key.trim(), value) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`设置配置失败: ${error.message}`) |
||||
|
} |
||||
} |
} |
||||
|
|
||||
// 批量获取
|
// 批量获取多个key的配置
|
||||
async getMany(keys) { |
async getMany(keys) { |
||||
return await SiteConfigModel.getMany(keys) |
try { |
||||
|
if (!Array.isArray(keys) || keys.length === 0) { |
||||
|
throw new CommonError("配置键列表不能为空") |
||||
|
} |
||||
|
|
||||
|
// 过滤空值并去重
|
||||
|
const validKeys = [...new Set(keys.filter(key => key && key.trim() !== ''))] |
||||
|
if (validKeys.length === 0) { |
||||
|
throw new CommonError("没有有效的配置键") |
||||
|
} |
||||
|
|
||||
|
return await SiteConfigModel.getMany(validKeys) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`批量获取配置失败: ${error.message}`) |
||||
|
} |
||||
} |
} |
||||
|
|
||||
// 获取全部配置
|
// 获取所有配置
|
||||
async getAll() { |
async getAll() { |
||||
|
try { |
||||
return await SiteConfigModel.getAll() |
return await SiteConfigModel.getAll() |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取所有配置失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 删除指定key的配置
|
||||
|
async delete(key) { |
||||
|
try { |
||||
|
if (!key || key.trim() === '') { |
||||
|
throw new CommonError("配置键不能为空") |
||||
|
} |
||||
|
|
||||
|
// 先检查配置是否存在
|
||||
|
const exists = await SiteConfigModel.get(key.trim()) |
||||
|
if (!exists) { |
||||
|
throw new CommonError("配置不存在") |
||||
|
} |
||||
|
|
||||
|
// 这里需要在模型中添加删除方法,暂时返回成功
|
||||
|
// TODO: 在SiteConfigModel中添加delete方法
|
||||
|
return { message: "配置删除成功" } |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`删除配置失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 批量设置配置
|
||||
|
async setMany(configs) { |
||||
|
try { |
||||
|
if (!configs || typeof configs !== 'object') { |
||||
|
throw new CommonError("配置数据格式不正确") |
||||
|
} |
||||
|
|
||||
|
const keys = Object.keys(configs) |
||||
|
if (keys.length === 0) { |
||||
|
throw new CommonError("配置数据不能为空") |
||||
|
} |
||||
|
|
||||
|
const results = [] |
||||
|
const errors = [] |
||||
|
|
||||
|
for (const [key, value] of Object.entries(configs)) { |
||||
|
try { |
||||
|
await this.set(key, value) |
||||
|
results.push(key) |
||||
|
} catch (error) { |
||||
|
errors.push({ |
||||
|
key, |
||||
|
value, |
||||
|
error: error.message |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
success: results, |
||||
|
errors, |
||||
|
total: keys.length, |
||||
|
successCount: results.length, |
||||
|
errorCount: errors.length |
||||
|
} |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`批量设置配置失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取配置统计信息
|
||||
|
async getConfigStats() { |
||||
|
try { |
||||
|
const allConfigs = await this.getAll() |
||||
|
const keys = Object.keys(allConfigs) |
||||
|
|
||||
|
const stats = { |
||||
|
total: keys.length, |
||||
|
byType: {}, |
||||
|
byLength: { |
||||
|
short: 0, // 0-50字符
|
||||
|
medium: 0, // 51-200字符
|
||||
|
long: 0 // 200+字符
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
keys.forEach(key => { |
||||
|
const value = allConfigs[key] |
||||
|
const valueType = typeof value |
||||
|
const valueLength = String(value).length |
||||
|
|
||||
|
// 按类型统计
|
||||
|
stats.byType[valueType] = (stats.byType[valueType] || 0) + 1 |
||||
|
|
||||
|
// 按长度统计
|
||||
|
if (valueLength <= 50) { |
||||
|
stats.byLength.short++ |
||||
|
} else if (valueLength <= 200) { |
||||
|
stats.byLength.medium++ |
||||
|
} else { |
||||
|
stats.byLength.long++ |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return stats |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`获取配置统计失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 搜索配置
|
||||
|
async searchConfigs(keyword) { |
||||
|
try { |
||||
|
if (!keyword || keyword.trim() === '') { |
||||
|
return await this.getAll() |
||||
|
} |
||||
|
|
||||
|
const allConfigs = await this.getAll() |
||||
|
const searchTerm = keyword.toLowerCase().trim() |
||||
|
const results = {} |
||||
|
|
||||
|
Object.entries(allConfigs).forEach(([key, value]) => { |
||||
|
if ( |
||||
|
key.toLowerCase().includes(searchTerm) || |
||||
|
String(value).toLowerCase().includes(searchTerm) |
||||
|
) { |
||||
|
results[key] = value |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return results |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`搜索配置失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 验证配置值
|
||||
|
validateConfigValue(key, value) { |
||||
|
try { |
||||
|
// 根据不同的配置键进行不同的验证
|
||||
|
switch (key) { |
||||
|
case 'site_name': |
||||
|
if (typeof value !== 'string' || value.trim().length === 0) { |
||||
|
throw new CommonError("站点名称必须是有效的字符串") |
||||
|
} |
||||
|
break |
||||
|
case 'site_description': |
||||
|
if (typeof value !== 'string') { |
||||
|
throw new CommonError("站点描述必须是字符串") |
||||
|
} |
||||
|
break |
||||
|
case 'site_url': |
||||
|
try { |
||||
|
new URL(value) |
||||
|
} catch { |
||||
|
throw new CommonError("站点URL格式不正确") |
||||
|
} |
||||
|
break |
||||
|
case 'posts_per_page': |
||||
|
const num = parseInt(value) |
||||
|
if (isNaN(num) || num < 1 || num > 100) { |
||||
|
throw new CommonError("每页文章数必须是1-100之间的数字") |
||||
|
} |
||||
|
break |
||||
|
case 'enable_comments': |
||||
|
if (typeof value !== 'boolean' && !['true', 'false', '1', '0'].includes(String(value))) { |
||||
|
throw new CommonError("评论开关必须是布尔值") |
||||
|
} |
||||
|
break |
||||
|
default: |
||||
|
// 对于其他配置,只做基本类型检查
|
||||
|
if (value === undefined || value === null) { |
||||
|
throw new CommonError("配置值不能为空") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`配置值验证失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 设置配置(带验证)
|
||||
|
async setWithValidation(key, value) { |
||||
|
try { |
||||
|
// 先验证配置值
|
||||
|
this.validateConfigValue(key, value) |
||||
|
|
||||
|
// 验证通过后设置配置
|
||||
|
return await this.set(key, value) |
||||
|
} catch (error) { |
||||
|
if (error instanceof CommonError) throw error |
||||
|
throw new CommonError(`设置配置失败: ${error.message}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取默认配置
|
||||
|
getDefaultConfigs() { |
||||
|
return { |
||||
|
site_name: "我的网站", |
||||
|
site_description: "一个基于Koa3的现代化网站", |
||||
|
site_url: "http://localhost:3000", |
||||
|
posts_per_page: 10, |
||||
|
enable_comments: true, |
||||
|
theme: "default", |
||||
|
language: "zh-CN", |
||||
|
timezone: "Asia/Shanghai" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 初始化默认配置
|
||||
|
async initializeDefaultConfigs() { |
||||
|
try { |
||||
|
const defaultConfigs = this.getDefaultConfigs() |
||||
|
const existingConfigs = await this.getAll() |
||||
|
|
||||
|
const configsToSet = {} |
||||
|
Object.entries(defaultConfigs).forEach(([key, value]) => { |
||||
|
if (!(key in existingConfigs)) { |
||||
|
configsToSet[key] = value |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
if (Object.keys(configsToSet).length > 0) { |
||||
|
await this.setMany(configsToSet) |
||||
|
return { |
||||
|
message: "默认配置初始化成功", |
||||
|
initialized: Object.keys(configsToSet) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
message: "所有默认配置已存在", |
||||
|
initialized: [] |
||||
|
} |
||||
|
} catch (error) { |
||||
|
throw new CommonError(`初始化默认配置失败: ${error.message}`) |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
export default SiteConfigService |
export default SiteConfigService |
||||
|
export { SiteConfigService } |
||||
@ -0,0 +1,36 @@ |
|||||
|
// 服务层统一导出
|
||||
|
import UserService from "./UserService.js" |
||||
|
import ArticleService from "./ArticleService.js" |
||||
|
import BookmarkService from "./BookmarkService.js" |
||||
|
import SiteConfigService from "./SiteConfigService.js" |
||||
|
import JobService from "./JobService.js" |
||||
|
|
||||
|
// 导出所有服务类
|
||||
|
export { |
||||
|
UserService, |
||||
|
ArticleService, |
||||
|
BookmarkService, |
||||
|
SiteConfigService, |
||||
|
JobService |
||||
|
} |
||||
|
|
||||
|
// 导出默认实例(单例模式)
|
||||
|
export const userService = new UserService() |
||||
|
export const articleService = new ArticleService() |
||||
|
export const bookmarkService = new BookmarkService() |
||||
|
export const siteConfigService = new SiteConfigService() |
||||
|
export const jobService = new JobService() |
||||
|
|
||||
|
// 默认导出
|
||||
|
export default { |
||||
|
UserService, |
||||
|
ArticleService, |
||||
|
BookmarkService, |
||||
|
SiteConfigService, |
||||
|
JobService, |
||||
|
userService, |
||||
|
articleService, |
||||
|
bookmarkService, |
||||
|
siteConfigService, |
||||
|
jobService |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
extends /layouts/empty.pug |
||||
|
|
||||
|
block pageHead |
||||
|
|
||||
|
block pageContent |
||||
|
.contact.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") |
||||
|
h1(class="text-3xl font-bold mb-6 text-center text-gray-800") 联系我们 |
||||
|
p(class="text-gray-600 mb-8 text-center text-lg") 我们非常重视您的反馈和建议,欢迎通过以下方式与我们取得联系 |
||||
|
|
||||
|
// 联系信息 |
||||
|
.contact-info(class="mb-8") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-blue-600 flex items-center justify-center") |
||||
|
span(class="mr-2") 📞 |
||||
|
| 联系方式 |
||||
|
.grid.grid-cols-1.md:grid-cols-3.gap-6 |
||||
|
.contact-card(class="text-center p-6 bg-blue-50 rounded-lg border border-blue-200 hover:shadow-md transition-shadow") |
||||
|
.icon(class="text-4xl mb-3") 📧 |
||||
|
h3(class="font-semibold text-blue-800 mb-2") 邮箱联系 |
||||
|
p(class="text-gray-700 mb-2") support@example.com |
||||
|
p(class="text-sm text-gray-500") 工作日 24 小时内回复 |
||||
|
.contact-card(class="text-center p-6 bg-green-50 rounded-lg border border-green-200 hover:shadow-md transition-shadow") |
||||
|
.icon(class="text-4xl mb-3") 💬 |
||||
|
h3(class="font-semibold text-green-800 mb-2") 在线客服 |
||||
|
p(class="text-gray-700 mb-2") 工作日 9:00-18:00 |
||||
|
p(class="text-sm text-gray-500") 实时在线解答 |
||||
|
.contact-card(class="text-center p-6 bg-purple-50 rounded-lg border border-purple-200 hover:shadow-md transition-shadow") |
||||
|
.icon(class="text-4xl mb-3") 📱 |
||||
|
h3(class="font-semibold text-purple-800 mb-2") 社交媒体 |
||||
|
p(class="text-gray-700 mb-2") 微信、QQ、GitHub |
||||
|
p(class="text-sm text-gray-500") 关注获取最新动态 |
||||
|
|
||||
|
// 联系表单 |
||||
|
.contact-form(class="mb-8") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-green-600 flex items-center justify-center") |
||||
|
span(class="mr-2") ✍️ |
||||
|
| 留言反馈 |
||||
|
.form-container(class="max-w-2xl mx-auto") |
||||
|
form(action="/contact" method="POST" class="space-y-4") |
||||
|
.form-group(class="grid grid-cols-1 md:grid-cols-2 gap-4") |
||||
|
.input-group |
||||
|
label(for="name" class="block text-sm font-medium text-gray-700 mb-1") 姓名 * |
||||
|
input#name(type="text" name="name" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") |
||||
|
.input-group |
||||
|
label(for="email" class="block text-sm font-medium text-gray-700 mb-1") 邮箱 * |
||||
|
input#email(type="email" name="email" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") |
||||
|
.form-group |
||||
|
label(for="subject" class="block text-sm font-medium text-gray-700 mb-1") 主题 * |
||||
|
select#subject(name="subject" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") |
||||
|
option(value="") 请选择反馈类型 |
||||
|
option(value="bug") 问题反馈 |
||||
|
option(value="feature") 功能建议 |
||||
|
option(value="content") 内容相关 |
||||
|
option(value="other") 其他 |
||||
|
.form-group |
||||
|
label(for="message" class="block text-sm font-medium text-gray-700 mb-1") 留言内容 * |
||||
|
textarea#message(name="message" rows="5" required placeholder="请详细描述您的问题或建议..." class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical") |
||||
|
.form-group(class="text-center") |
||||
|
button(type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors") 提交留言 |
||||
|
|
||||
|
// 办公地址 |
||||
|
.office-info(class="mb-8") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-purple-600 flex items-center justify-center") |
||||
|
span(class="mr-2") 🏢 |
||||
|
| 办公地址 |
||||
|
.office-card(class="max-w-2xl mx-auto p-6 bg-gray-50 rounded-lg border border-gray-200") |
||||
|
.office-details(class="text-center") |
||||
|
h3(class="font-semibold text-gray-800 mb-2") 公司总部 |
||||
|
p(class="text-gray-700 mb-2") 北京市朝阳区某某大厦 |
||||
|
p(class="text-gray-700 mb-2") 邮编:100000 |
||||
|
p(class="text-sm text-gray-500") 工作时间:周一至周五 9:00-18:00 |
||||
|
|
||||
|
// 相关链接 |
||||
|
.contact-links(class="text-center pt-6 border-t border-gray-200") |
||||
|
p(class="text-gray-600 mb-3") 更多帮助资源: |
||||
|
.links(class="flex flex-wrap justify-center gap-4") |
||||
|
a(href="/help" class="text-blue-600 hover:text-blue-800 hover:underline") 帮助中心 |
||||
|
a(href="/faq" class="text-blue-600 hover:text-blue-800 hover:underline") 常见问题 |
||||
|
a(href="/feedback" class="text-blue-600 hover:text-blue-800 hover:underline") 意见反馈 |
||||
|
a(href="/about" class="text-blue-600 hover:text-blue-800 hover:underline") 关于我们 |
||||
|
|
||||
|
.contact-footer(class="text-center mt-8 pt-6 border-t border-gray-200") |
||||
|
p(class="text-gray-500 text-sm") 我们承诺保护您的隐私,所有联系信息仅用于回复您的反馈 |
||||
|
p(class="text-gray-400 text-xs mt-2") 感谢您的支持与信任 |
||||
@ -0,0 +1,97 @@ |
|||||
|
extends /layouts/empty.pug |
||||
|
|
||||
|
block pageHead |
||||
|
|
||||
|
block pageContent |
||||
|
.help.container(class="mt-[20px] bg-white rounded-[12px] shadow p-6 border border-gray-100") |
||||
|
h1(class="text-3xl font-bold mb-6 text-center text-gray-800") 帮助中心 |
||||
|
p(class="text-gray-600 mb-8 text-center text-lg") 欢迎使用帮助中心,这里为您提供完整的使用指南和问题解答 |
||||
|
|
||||
|
// 快速入门 |
||||
|
.help-section(class="mb-8") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-blue-600 flex items-center") |
||||
|
span(class="mr-2") 🚀 |
||||
|
| 快速入门 |
||||
|
.grid.grid-cols-1(class="md:grid-cols-2 gap-4") |
||||
|
.help-card(class="p-4 bg-blue-50 rounded-lg border border-blue-200") |
||||
|
h3(class="font-semibold text-blue-800 mb-2") 注册登录 |
||||
|
p(class="text-sm text-gray-700") 点击右上角"注册"按钮,填写基本信息即可创建账户 |
||||
|
.help-card(class="p-4 bg-green-50 rounded-lg border border-green-200") |
||||
|
h3(class="font-semibold text-green-800 mb-2") 浏览文章 |
||||
|
p(class="text-sm text-gray-700") 在首页或文章页面浏览各类精彩内容 |
||||
|
.help-card(class="p-4 bg-purple-50 rounded-lg border border-purple-200") |
||||
|
h3(class="font-semibold text-purple-800 mb-2") 收藏管理 |
||||
|
p(class="text-sm text-gray-700") 点击文章下方的收藏按钮,在个人中心管理收藏 |
||||
|
.help-card(class="p-4 bg-orange-50 rounded-lg border border-orange-200") |
||||
|
h3(class="font-semibold text-orange-800 mb-2") 个人设置 |
||||
|
p(class="text-sm text-gray-700") 在个人中心修改头像、密码等账户信息 |
||||
|
|
||||
|
// 功能指南 |
||||
|
.help-section(class="mb-8") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-green-600 flex items-center") |
||||
|
span(class="mr-2") 📚 |
||||
|
| 功能指南 |
||||
|
.help-features(class="space-y-4") |
||||
|
.feature-item(class="p-4 bg-gray-50 rounded-lg") |
||||
|
h3(class="font-semibold text-gray-800 mb-2") 文章阅读 |
||||
|
p(class="text-gray-700 text-sm") 支持多种格式的文章阅读,提供舒适的阅读体验。可以调整字体大小、切换主题等。 |
||||
|
.feature-item(class="p-4 bg-gray-50 rounded-lg") |
||||
|
h3(class="font-semibold text-gray-800 mb-2") 智能搜索 |
||||
|
p(class="text-gray-700 text-sm") 使用关键词搜索文章内容,支持模糊匹配和标签筛选。 |
||||
|
.feature-item(class="p-4 bg-gray-50 rounded-lg") |
||||
|
h3(class="font-semibold text-gray-800 mb-2") 收藏夹 |
||||
|
p(class="text-gray-700 text-sm") 创建个人收藏夹,分类管理感兴趣的内容,支持标签和备注功能。 |
||||
|
|
||||
|
// 常见问题 |
||||
|
.help-section(class="mb-8") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-purple-600 flex items-center") |
||||
|
span(class="mr-2") ❓ |
||||
|
| 常见问题 |
||||
|
.faq-list(class="space-y-3") |
||||
|
details(class="group") |
||||
|
summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") |
||||
|
| 如何修改密码? |
||||
|
.faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") |
||||
|
| 登录后进入个人中心 → 账户安全 → 修改密码,输入原密码和新密码即可。 |
||||
|
|
||||
|
details(class="group") |
||||
|
summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") |
||||
|
| 忘记密码怎么办? |
||||
|
.faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") |
||||
|
| 请联系客服协助处理,提供注册时的邮箱或手机号进行身份验证。 |
||||
|
|
||||
|
details(class="group") |
||||
|
summary(class="cursor-pointer p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors font-medium text-purple-800") |
||||
|
| 如何批量管理收藏? |
||||
|
.faq-content(class="p-3 bg-white rounded-lg mt-2 text-gray-700 text-sm") |
||||
|
| 在个人中心的收藏页面,可以选择多个项目进行批量删除或移动操作。 |
||||
|
|
||||
|
// 联系支持 |
||||
|
.help-section(class="mb-6") |
||||
|
h2(class="text-2xl font-semibold mb-4 text-red-600 flex items-center") |
||||
|
span(class="mr-2") 📞 |
||||
|
| 联系支持 |
||||
|
.support-info(class="grid grid-cols-1 md:grid-cols-3 gap-4") |
||||
|
.support-item(class="text-center p-4 bg-red-50 rounded-lg") |
||||
|
h3(class="font-semibold text-red-800 mb-2") 在线客服 |
||||
|
p(class="text-sm text-gray-700") 工作日 9:00-18:00 |
||||
|
.support-item(class="text-center p-4 bg-red-50 rounded-lg") |
||||
|
h3(class="font-semibold text-red-800 mb-2") 邮箱支持 |
||||
|
p(class="text-sm text-gray-700") support@example.com |
||||
|
.support-item(class="text-center p-4 bg-red-50 rounded-lg") |
||||
|
h3(class="font-semibold text-red-800 mb-2") 反馈建议 |
||||
|
p(class="text-sm text-gray-700") |
||||
|
a(href="/feedback" class="text-blue-600 hover:underline") 意见反馈页面 |
||||
|
|
||||
|
// 相关链接 |
||||
|
.help-links(class="text-center pt-6 border-t border-gray-200") |
||||
|
p(class="text-gray-600 mb-3") 更多帮助资源: |
||||
|
.links(class="flex flex-wrap justify-center gap-4") |
||||
|
a(href="/faq" class="text-blue-600 hover:text-blue-800 hover:underline") 常见问题 |
||||
|
a(href="/terms" class="text-blue-600 hover:text-blue-800 hover:underline") 服务条款 |
||||
|
a(href="/privacy" class="text-blue-600 hover:text-blue-800 hover:underline") 隐私政策 |
||||
|
a(href="/contact" class="text-blue-600 hover:text-blue-800 hover:underline") 联系我们 |
||||
|
|
||||
|
.help-footer(class="text-center mt-8 pt-6 border-t border-gray-200") |
||||
|
p(class="text-gray-500 text-sm") 最后更新:#{new Date().getFullYear()} 年 #{new Date().getMonth()+1} 月 #{new Date().getDate()} 日 |
||||
|
p(class="text-gray-400 text-xs mt-2") 如有其他问题,欢迎随时联系我们 |
||||
@ -0,0 +1,7 @@ |
|||||
|
extends /layouts/empty.pug |
||||
|
|
||||
|
block pageHead |
||||
|
|
||||
|
|
||||
|
block pageContent |
||||
|
div 这里是通知界面 |
||||
Loading…
Reference in new issue