Browse Source

更新数据库模型文档,新增文章和书签管理模型,优化文章服务和书签服务,添加联系表单处理逻辑,调整页面布局,增加通知和帮助中心页面,提升用户体验

re
谢亚昕 3 months ago
parent
commit
ce9effed42
  1. BIN
      database/development.sqlite3
  2. BIN
      database/development.sqlite3-shm
  3. BIN
      database/development.sqlite3-wal
  4. 14
      public/css/layouts/empty.css
  5. 49
      src/controllers/Page/PageController.js
  6. 190
      src/db/docs/ArticleModel.md
  7. 194
      src/db/docs/BookmarkModel.md
  8. 252
      src/db/docs/README.md
  9. 246
      src/db/docs/SiteConfigModel.md
  10. 158
      src/db/docs/UserModel.md
  11. 26
      src/db/migrations/20250830014825_create_articles_table.mjs
  12. 5
      src/db/migrations/20250830015422_create_bookmarks_table.mjs
  13. 60
      src/db/migrations/20250830020000_add_article_fields.mjs
  14. 298
      src/db/models/ArticleModel.js
  15. 77
      src/db/seeds/20250830020000_articles_seed.mjs
  16. 295
      src/services/ArticleService.js
  17. 312
      src/services/BookmarkService.js
  18. 222
      src/services/README.md
  19. 288
      src/services/SiteConfigService.js
  20. 36
      src/services/index.js
  21. 371
      src/services/userService.js
  22. 2
      src/views/htmx/footer.pug
  23. 3
      src/views/layouts/empty.pug
  24. 83
      src/views/page/extra/contact.pug
  25. 97
      src/views/page/extra/help.pug
  26. 16
      src/views/page/index/index.pug
  27. 7
      src/views/page/notice/index.pug

BIN
database/development.sqlite3

Binary file not shown.

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

14
public/css/layouts/empty.css

@ -430,3 +430,17 @@ body {
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.fe--notice-active {
display: inline-block;
width: 24px;
height: 24px;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M15.085 4.853a2.501 2.501 0 1 1 2.572 3.142A6 6 0 0 1 18 10v6h1c.55 0 1 .45 1 1s-.45 1-1 1h-4v1a3 3 0 0 1-6 0v-1H5c-.55 0-1-.45-1-1s.45-1 1-1h1v-6a6 6 0 0 1 5-5.917V3a1 1 0 0 1 2 0v1.083a6 6 0 0 1 2.085.77M12 20a1 1 0 0 0 1-1v-1h-2v1a1 1 0 0 0 1 1m-4-4h8v-6a4 4 0 1 0-8 0z'/%3E%3C/svg%3E");
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}

49
src/controllers/Page/PageController.js

@ -1,17 +1,22 @@
import Router from "utils/router.js"
import UserService from "services/UserService.js"
import SiteConfigService from "services/SiteConfigService.js"
import ArticleService from "services/ArticleService.js"
import svgCaptcha from "svg-captcha"
import CommonError from "@/utils/error/CommonError"
import { logger } from "@/logger.js"
class PageController {
constructor() {
this.userService = new UserService()
this.siteConfigService = new SiteConfigService()
this.siteConfigService = new SiteConfigService()
this.articleService = new ArticleService()
}
// 首页
async indexGet(ctx) {
const blogs = await this.articleService.getAllArticles()
return await ctx.render(
"page/index/index",
{
@ -22,24 +27,7 @@ class PageController {
url: "https://pic.xieyaxin.top/random.php",
},
],
blogs: [
{
title: "以梦为马,不负韶华",
content: "生活如诗,岁月如歌。让我们在时光的长河中,保持热爱,奔赴山海,书写属于自己的精彩篇章。",
},
{
title: "风雨过后是彩虹",
content: "以前,我以为自己很坚强,直到有一天,我发现自己连哭都哭不出来。",
},
{
title: "岁月如歌",
content: "未来,我希望能有更多的时间陪伴家人,有更多的时间去旅行,有更多的时间去实现自己的梦想。",
},
{
title: "明月如水",
content: "啊啊"
}
].slice(0, 4),
blogs: blogs.slice(0, 4)
},
{ includeSite: true, includeUser: true }
)
@ -146,6 +134,27 @@ class PageController {
ctx.set("hx-redirect", "/")
}
// 处理联系表单提交
async contactPost(ctx) {
const { name, email, subject, message } = ctx.request.body
// 简单的表单验证
if (!name || !email || !subject || !message) {
ctx.status = 400
ctx.body = { success: false, message: "请填写所有必填字段" }
return
}
// 这里可以添加邮件发送逻辑或数据库存储逻辑
// 目前只是简单的成功响应
logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`)
ctx.body = {
success: true,
message: "感谢您的留言,我们会尽快回复您!"
}
}
// 渲染页面
pageGet(name, data) {
return async ctx => {
@ -176,6 +185,10 @@ class PageController {
router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false })
router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false })
router.get("/profile", controller.pageGet("page/profile/index"), { auth: true })
router.get("/notice", controller.pageGet("page/notice/index"), { auth: true })
router.get("/help", controller.pageGet("page/extra/help"), { auth: false })
router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false })
router.post("/contact", controller.contactPost.bind(controller), { auth: false })
router.get("/login", controller.loginGet.bind(controller), { auth: "try" })
router.post("/login", controller.loginPost.bind(controller), { auth: false })
router.get("/captcha", controller.captchaGet.bind(controller), { auth: false })

190
src/db/docs/ArticleModel.md

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

194
src/db/docs/BookmarkModel.md

@ -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. **权限控制**: 实现适当的访问控制机制

252
src/db/docs/README.md

@ -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 许可证。

246
src/db/docs/SiteConfigModel.md

@ -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. **配置缓存**: 实现配置缓存机制,减少数据库查询

158
src/db/docs/UserModel.md

@ -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注入
### 扩展建议
可以考虑添加以下功能:
- 用户状态管理(激活/禁用)
- 密码重置功能
- 用户头像管理
- 用户偏好设置
- 登录历史记录
- 用户组管理

26
src/db/migrations/20250830014825_create_articles_table.mjs

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

5
src/db/migrations/20250827090000_create_bookmarks_table.mjs → src/db/migrations/20250830015422_create_bookmarks_table.mjs

@ -5,8 +5,7 @@
export const up = async knex => {
return knex.schema.createTable("bookmarks", function (table) {
table.increments("id").primary()
table.integer("user_id").unsigned()
.references("id").inTable("users").onDelete("CASCADE")
table.integer("user_id").unsigned().references("id").inTable("users").onDelete("CASCADE")
table.string("title", 200).notNullable()
table.string("url", 500)
table.text("description")
@ -24,5 +23,3 @@ export const up = async knex => {
export const down = async knex => {
return knex.schema.dropTable("bookmarks")
}

60
src/db/migrations/20250830020000_add_article_fields.mjs

@ -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"])
})
}

298
src/db/models/ArticleModel.js

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

77
src/db/seeds/20250830020000_articles_seed.mjs

@ -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![图片描述](图片URL)\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!")
}

295
src/services/ArticleService.js

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

312
src/services/BookmarkService.js

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

222
src/services/README.md

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

288
src/services/SiteConfigService.js

@ -1,25 +1,299 @@
import SiteConfigModel from "../db/models/SiteConfigModel.js"
import CommonError from "utils/error/CommonError.js"
class SiteConfigService {
// 获取单个配置
// 获取指定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) {
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) {
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() {
try {
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 { SiteConfigService }

36
src/services/index.js

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

371
src/services/userService.js

@ -1,72 +1,413 @@
import UserModel from "db/models/UserModel.js"
import { hashPassword, comparePassword } from "utils/bcrypt.js"
import CommonError from "utils/error/CommonError"
import CommonError from "utils/error/CommonError.js"
import { JWT_SECRET } from "@/middlewares/Auth/auth.js"
import jwt from "@/middlewares/Auth/jwt.js"
class UserService {
// 根据ID获取用户
async getUserById(id) {
// 这里可以调用数据库模型
// 示例返回
return { id, name: `User_${id}` }
try {
if (!id) {
throw new CommonError("用户ID不能为空")
}
const user = await UserModel.findById(id)
if (!user) {
throw new CommonError("用户不存在")
}
// 返回脱敏信息
const { password, ...userInfo } = user
return userInfo
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`获取用户失败: ${error.message}`)
}
}
// 获取所有用户
async getAllUsers() {
return await UserModel.findAll()
try {
const users = await UserModel.findAll()
// 返回脱敏信息
return users.map(user => {
const { password, ...userInfo } = user
return userInfo
})
} catch (error) {
throw new CommonError(`获取用户列表失败: ${error.message}`)
}
}
// 创建新用户
async createUser(data) {
if (!data.name) throw new Error("用户名不能为空")
return await UserModel.create(data)
try {
if (!data.username || !data.password) {
throw new CommonError("用户名和密码为必填字段")
}
// 检查用户名是否已存在
const existUser = await UserModel.findByUsername(data.username)
if (existUser) {
throw new CommonError(`用户名${data.username}已存在`)
}
// 检查邮箱是否已存在
if (data.email) {
const existEmail = await UserModel.findByEmail(data.email)
if (existEmail) {
throw new CommonError(`邮箱${data.email}已被使用`)
}
}
// 密码加密
const hashedPassword = await hashPassword(data.password)
const user = await UserModel.create({
...data,
password: hashedPassword
})
// 返回脱敏信息
const { password, ...userInfo } = Array.isArray(user) ? user[0] : user
return userInfo
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`创建用户失败: ${error.message}`)
}
}
// 更新用户
async updateUser(id, data) {
try {
if (!id) {
throw new CommonError("用户ID不能为空")
}
const user = await UserModel.findById(id)
if (!user) throw new Error("用户不存在")
return await UserModel.update(id, data)
if (!user) {
throw new CommonError("用户不存在")
}
// 如果要更新用户名,检查是否重复
if (data.username && data.username !== user.username) {
const existUser = await UserModel.findByUsername(data.username)
if (existUser) {
throw new CommonError(`用户名${data.username}已存在`)
}
}
// 如果要更新邮箱,检查是否重复
if (data.email && data.email !== user.email) {
const existEmail = await UserModel.findByEmail(data.email)
if (existEmail) {
throw new CommonError(`邮箱${data.email}已被使用`)
}
}
// 如果要更新密码,需要加密
if (data.password) {
data.password = await hashPassword(data.password)
}
const updatedUser = await UserModel.update(id, data)
// 返回脱敏信息
const { password, ...userInfo } = Array.isArray(updatedUser) ? updatedUser[0] : updatedUser
return userInfo
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`更新用户失败: ${error.message}`)
}
}
// 删除用户
async deleteUser(id) {
try {
if (!id) {
throw new CommonError("用户ID不能为空")
}
const user = await UserModel.findById(id)
if (!user) throw new Error("用户不存在")
if (!user) {
throw new CommonError("用户不存在")
}
return await UserModel.delete(id)
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`删除用户失败: ${error.message}`)
}
}
// 注册新用户
async register(data) {
if (!data.username || !data.password) throw new CommonError("用户名、邮箱和密码不能为空")
try {
if (!data.username || !data.password) {
throw new CommonError("用户名和密码不能为空")
}
// 检查用户名是否已存在
const existUser = await UserModel.findByUsername(data.username)
if (existUser) throw new CommonError(`用户名${data.username}已存在`)
if (existUser) {
throw new CommonError(`用户名${data.username}已存在`)
}
// 检查邮箱是否已存在
if (data.email) {
const existEmail = await UserModel.findByEmail(data.email)
if (existEmail) {
throw new CommonError(`邮箱${data.email}已被使用`)
}
}
// 密码加密
const hashed = await hashPassword(data.password)
const user = await UserModel.create({ ...data, password: hashed })
// 返回脱敏信息
const { password, ...userInfo } = Array.isArray(user) ? user[0] : user
return userInfo
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`注册失败: ${error.message}`)
}
}
// 登录
async login({ username, email, password }) {
try {
if (!password) {
throw new CommonError("密码不能为空")
}
if (!username && !email) {
throw new CommonError("用户名或邮箱不能为空")
}
let user
if (username) {
user = await UserModel.findByUsername(username)
} else if (email) {
user = await UserModel.findByEmail(email)
}
if (!user) throw new Error("用户不存在")
if (!user) {
throw new CommonError("用户不存在")
}
// 校验密码
const ok = await comparePassword(password, user.password)
if (!ok) throw new Error("密码错误")
if (!ok) {
throw new CommonError("密码错误")
}
// 生成token
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: "2h" })
const token = jwt.sign(
{ id: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: "2h" }
)
// 返回token和用户信息
const { password: pwd, ...userInfo } = user
return { token, user: userInfo }
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`登录失败: ${error.message}`)
}
}
// 根据用户名查找用户
async getUserByUsername(username) {
try {
if (!username) {
throw new CommonError("用户名不能为空")
}
const user = await UserModel.findByUsername(username)
if (!user) {
throw new CommonError("用户不存在")
}
// 返回脱敏信息
const { password, ...userInfo } = user
return userInfo
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`获取用户失败: ${error.message}`)
}
}
// 根据邮箱查找用户
async getUserByEmail(email) {
try {
if (!email) {
throw new CommonError("邮箱不能为空")
}
const user = await UserModel.findByEmail(email)
if (!user) {
throw new CommonError("用户不存在")
}
// 返回脱敏信息
const { password, ...userInfo } = user
return userInfo
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`获取用户失败: ${error.message}`)
}
}
// 修改密码
async changePassword(userId, oldPassword, newPassword) {
try {
if (!userId || !oldPassword || !newPassword) {
throw new CommonError("用户ID、旧密码和新密码不能为空")
}
const user = await UserModel.findById(userId)
if (!user) {
throw new CommonError("用户不存在")
}
// 验证旧密码
const isOldPasswordCorrect = await comparePassword(oldPassword, user.password)
if (!isOldPasswordCorrect) {
throw new CommonError("旧密码错误")
}
// 加密新密码
const hashedNewPassword = await hashPassword(newPassword)
// 更新密码
await UserModel.update(userId, { password: hashedNewPassword })
return { message: "密码修改成功" }
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`修改密码失败: ${error.message}`)
}
}
// 重置密码
async resetPassword(email, newPassword) {
try {
if (!email || !newPassword) {
throw new CommonError("邮箱和新密码不能为空")
}
const user = await UserModel.findByEmail(email)
if (!user) {
throw new CommonError("用户不存在")
}
// 加密新密码
const hashedPassword = await hashPassword(newPassword)
// 更新密码
await UserModel.update(user.id, { password: hashedPassword })
return { message: "密码重置成功" }
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`重置密码失败: ${error.message}`)
}
}
// 获取用户统计信息
async getUserStats() {
try {
const users = await UserModel.findAll()
const stats = {
total: users.length,
active: users.filter(user => user.status === 'active').length,
inactive: users.filter(user => user.status === 'inactive').length,
byRole: {},
byDate: {}
}
// 按角色分组统计
users.forEach(user => {
const role = user.role || 'user'
stats.byRole[role] = (stats.byRole[role] || 0) + 1
})
// 按创建时间分组统计
users.forEach(user => {
const date = new Date(user.created_at).toISOString().split('T')[0]
stats.byDate[date] = (stats.byDate[date] || 0) + 1
})
return stats
} catch (error) {
throw new CommonError(`获取用户统计失败: ${error.message}`)
}
}
// 搜索用户
async searchUsers(keyword) {
try {
if (!keyword || keyword.trim() === '') {
return await this.getAllUsers()
}
const users = await UserModel.findAll()
const searchTerm = keyword.toLowerCase().trim()
const filteredUsers = users.filter(user => {
return (
user.username?.toLowerCase().includes(searchTerm) ||
user.email?.toLowerCase().includes(searchTerm) ||
user.name?.toLowerCase().includes(searchTerm)
)
})
// 返回脱敏信息
return filteredUsers.map(user => {
const { password, ...userInfo } = user
return userInfo
})
} catch (error) {
throw new CommonError(`搜索用户失败: ${error.message}`)
}
}
// 批量删除用户
async deleteUsers(userIds) {
try {
if (!Array.isArray(userIds) || userIds.length === 0) {
throw new CommonError("用户ID列表不能为空")
}
const results = []
const errors = []
for (const id of userIds) {
try {
await this.deleteUser(id)
results.push(id)
} catch (error) {
errors.push({
id,
error: error.message
})
}
}
return {
success: results,
errors,
total: userIds.length,
successCount: results.length,
errorCount: errors.length
}
} catch (error) {
if (error instanceof CommonError) throw error
throw new CommonError(`批量删除用户失败: ${error.message}`)
}
}
}

2
src/views/htmx/footer.pug

@ -7,6 +7,8 @@
a(href="/") 首页
li
a(href="/about") 关于我们
li
a(href="/contact") 联系我们
style.
.footer-panel {
background: rgba(34,34,34,.25);

3
src/views/layouts/empty.pug

@ -25,6 +25,8 @@ block $$content
.right.menu.desktop-only
a.menu-item(hx-post="/logout") 退出
a.menu-item(href="/profile") 欢迎您 , #{$user.username}
a.menu-item(href="/notice")
.fe--notice-active
// 移动端:汉堡按钮
button.menu-toggle(type="button" aria-label="打开菜单")
span.bar
@ -43,6 +45,7 @@ block $$content
.right.menu
a.menu-item(hx-post="/logout") 退出
a.menu-item() 欢迎您 , #{$user.username}
a.menu-item(href="/notice" class="fe--notice-active") 公告
.page-layout
.page.container
block pageContent

83
src/views/page/extra/contact.pug

@ -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") 感谢您的支持与信任

97
src/views/page/extra/help.pug

@ -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") 如有其他问题,欢迎随时联系我们

16
src/views/page/index/index.pug

@ -15,13 +15,21 @@ mixin card(blog)
.article-card(class="bg-white rounded-[12px] shadow p-6 transition hover:shadow-lg border border-gray-100")
h3.article-title(class="text-lg font-semibold text-gray-900 mb-2")
a(href="/article/1" class="hover:text-blue-600 transition-colors duration-200") #{blog.title}
p.article-meta(class="text-sm text-gray-400 mb-3")
| 作者:明月 &nbsp;|&nbsp; 2024-06-01 &nbsp;|&nbsp; 分类:生活感悟
p.article-meta(class="text-sm text-gray-400 mb-3 flex")
span(class="mr-2 line-clamp-1" title=blog.author)
span 作者:
a(href=blog.author class="hover:text-blue-600 transition-colors duration-200") #{blog.author}
span(class="mr-2 whitespace-nowrap")
span |
a(href=blog.updated_at.slice(0, 10) class="hover:text-blue-600 transition-colors duration-200") #{blog.updated_at.slice(0, 10)}
span(class="mr-2 whitespace-nowrap")
span | 分类:
a(href=blog.category class="hover:text-blue-600 transition-colors duration-200") #{blog.category}
p.article-desc(
class="text-gray-600 text-base mb-4 line-clamp-2"
style="height: 2.8em; overflow: hidden;"
)
| #{blog.content}
| #{blog.description}
a(href="/article/1" class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 →
mixin empty()
@ -44,7 +52,7 @@ block pageContent
each blog in blogs
+card(blog)
else
+empty() 空
+empty() 文章数据为
div(class="mt-[20px]")
h2(class="text-[20px] font-bold mb-[10px]") 收藏列表
if collections && collections.length > 0

7
src/views/page/notice/index.pug

@ -0,0 +1,7 @@
extends /layouts/empty.pug
block pageHead
block pageContent
div 这里是通知界面
Loading…
Cancel
Save