From 71f7b00211c071436f244271f4184c589295d17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Mon, 29 Sep 2025 16:10:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=A4=9A=E4=B8=AA=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84=E6=96=87=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=96=87=E4=BB=B6=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=A8=A1=E5=9E=8B=E5=92=8C=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E3=80=81=E7=AB=99=E7=82=B9=E9=85=8D=E7=BD=AE=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=9A=84=E6=8E=A7=E5=88=B6=E5=99=A8=E4=B8=8E=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE=E5=BA=93=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=92=8C=E8=B7=AF=E7=94=B1=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=94=A8=E6=88=B7=E4=BF=A1=E6=81=AF=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=92=8C=E6=B3=A8=E5=86=8C=E9=A1=B5=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=B8=8E=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .qoder/quests/admin-backend-implementation.md | 355 ------------ .../db-module-check-and-optimization-1757490337.md | 617 --------------------- .qoder/quests/db-module-check-and-optimization.md | 0 database/.gitkeep | 0 database/db.sqlite3 | Bin 4096 -> 4096 bytes database/db.sqlite3-shm | Bin 32768 -> 32768 bytes database/db.sqlite3-wal | Bin 0 -> 477952 bytes database/development.sqlite3-shm | Bin 32768 -> 32768 bytes database/development.sqlite3-wal | Bin 1528552 -> 1528552 bytes src/base/BaseModel.js | 580 +++++++++++++++++++ src/db/docs/ArticleModel.md | 190 ------- src/db/docs/BookmarkModel.md | 194 ------- src/db/docs/README.md | 252 --------- src/db/docs/SiteConfigModel.md | 246 -------- src/db/docs/UserModel.md | 158 ------ .../20250616065041_create_users_table.mjs | 20 +- src/db/migrations/20250621013128_site_config.mjs | 8 +- .../20250830014825_create_articles_table.mjs | 26 - .../20250830015422_create_bookmarks_table.mjs | 25 - .../20250830020000_add_article_fields.mjs | 60 -- .../20250901000000_add_profile_fields.mjs | 25 - .../20250909000000_create_contacts_table.mjs | 31 -- .../20250910000001_add_performance_indexes.mjs | 146 ----- src/db/models/ArticleModel.js | 548 ------------------ src/db/models/BaseModel.js | 580 ------------------- src/db/models/BookmarkModel.js | 158 ------ src/db/models/ContactModel.js | 132 ----- src/db/models/SiteConfigModel.js | 81 --- src/db/models/UserModel.js | 105 ---- src/db/seeds/20250621013324_site_config_seed.mjs | 8 +- src/db/seeds/20250830020000_articles_seed.mjs | 77 --- src/middlewares/install.js | 1 - src/modules/Admin/controller/index.js | 43 ++ src/modules/Auth/controller/index.js | 75 --- src/modules/Auth/controller/login.js | 75 +++ src/modules/Auth/controller/register.js | 101 ++++ src/modules/Auth/controller/user.js | 0 src/modules/Auth/model/user.js | 107 ++++ src/modules/Auth/services/index.js | 12 +- src/modules/SiteConfig/model/site-config.js | 81 +++ src/modules/SiteConfig/services/index.js | 2 +- src/views/helper/utils.pug | 11 +- src/views/htmx/footer/index.pug | 3 +- src/views/layouts/admin.pug | 33 ++ src/views/page/admin/index/index.pug | 8 + src/views/page/admin/index/style.css | 0 src/views/page/admin/profile/index.pug | 398 +++++++++++++ src/views/page/admin/profile/style.css | 403 ++++++++++++++ src/views/page/auth/no-auth.pug | 8 +- src/views/page/index/index.pug | 2 +- src/views/page/register/_ui/confirmPassword.pug | 14 + src/views/page/register/_ui/nickname.pug | 6 + src/views/page/register/_ui/password.pug | 14 + src/views/page/register/_ui/username.pug | 14 + src/views/page/register/index.pug | 67 +++ src/views/page/register/style.css | 22 + 56 files changed, 2007 insertions(+), 4115 deletions(-) delete mode 100644 .qoder/quests/admin-backend-implementation.md delete mode 100644 .qoder/quests/db-module-check-and-optimization-1757490337.md delete mode 100644 .qoder/quests/db-module-check-and-optimization.md delete mode 100644 database/.gitkeep create mode 100644 src/base/BaseModel.js delete mode 100644 src/db/docs/ArticleModel.md delete mode 100644 src/db/docs/BookmarkModel.md delete mode 100644 src/db/docs/README.md delete mode 100644 src/db/docs/SiteConfigModel.md delete mode 100644 src/db/docs/UserModel.md delete mode 100644 src/db/migrations/20250830014825_create_articles_table.mjs delete mode 100644 src/db/migrations/20250830015422_create_bookmarks_table.mjs delete mode 100644 src/db/migrations/20250830020000_add_article_fields.mjs delete mode 100644 src/db/migrations/20250901000000_add_profile_fields.mjs delete mode 100644 src/db/migrations/20250909000000_create_contacts_table.mjs delete mode 100644 src/db/migrations/20250910000001_add_performance_indexes.mjs delete mode 100644 src/db/models/ArticleModel.js delete mode 100644 src/db/models/BaseModel.js delete mode 100644 src/db/models/BookmarkModel.js delete mode 100644 src/db/models/ContactModel.js delete mode 100644 src/db/models/SiteConfigModel.js delete mode 100644 src/db/models/UserModel.js delete mode 100644 src/db/seeds/20250830020000_articles_seed.mjs create mode 100644 src/modules/Admin/controller/index.js delete mode 100644 src/modules/Auth/controller/index.js create mode 100644 src/modules/Auth/controller/login.js create mode 100644 src/modules/Auth/controller/register.js create mode 100644 src/modules/Auth/controller/user.js create mode 100644 src/modules/Auth/model/user.js create mode 100644 src/modules/SiteConfig/model/site-config.js create mode 100644 src/views/layouts/admin.pug create mode 100644 src/views/page/admin/index/index.pug create mode 100644 src/views/page/admin/index/style.css create mode 100644 src/views/page/admin/profile/index.pug create mode 100644 src/views/page/admin/profile/style.css create mode 100644 src/views/page/register/_ui/confirmPassword.pug create mode 100644 src/views/page/register/_ui/nickname.pug create mode 100644 src/views/page/register/_ui/password.pug create mode 100644 src/views/page/register/_ui/username.pug create mode 100644 src/views/page/register/index.pug create mode 100644 src/views/page/register/style.css diff --git a/.qoder/quests/admin-backend-implementation.md b/.qoder/quests/admin-backend-implementation.md deleted file mode 100644 index 42e3811..0000000 --- a/.qoder/quests/admin-backend-implementation.md +++ /dev/null @@ -1,355 +0,0 @@ -# Admin后台管理系统设计文档 - -## 概述 - -为 koa3-demo 项目设计并实现一个完整的后台管理系统,允许注册用户管理自己的文章并查看联系我们的提交信息。系统采用传统的左侧导航栏布局,不继承现有页面样式,完全独立实现。 - -### 核心需求 -- 注册用户可以对自己的文章进行增删改查操作 -- 展示联系表单提交的信息 -- 采用Session认证,不使用API接口 -- 独立的管理界面,左侧导航栏+右侧内容区域 -- 不允许修改其他现有代码 - -## 架构设计 - -### 整体架构图 - -```mermaid -graph TB - A[用户访问 /admin] --> B[AdminController] - B --> C{Session验证} - C -->|未登录| D[跳转登录页] - C -->|已登录| E[后台主界面] - - E --> F[文章管理模块] - E --> G[联系信息模块] - - F --> H[ArticleService] - G --> I[ContactService] - - H --> J[ArticleModel] - I --> K[ContactModel] - - J --> L[(Articles表)] - K --> M[(Contacts表)] -``` - -### 模块架构 - -```mermaid -classDiagram - class AdminController { - +dashboard() - +articlesIndex() - +articleShow() - +articleCreate() - +articleEdit() - +articleUpdate() - +articleDelete() - +contactsIndex() - +contactShow() - +contactDelete() - } - - class ContactModel { - +findAll() - +findById() - +create() - +delete() - +findByDateRange() - } - - class ContactService { - +getAllContacts() - +getContactById() - +deleteContact() - +getContactsByDateRange() - } - - AdminController --> ContactService - AdminController --> ArticleService - ContactService --> ContactModel - ArticleService --> ArticleModel -``` - -## 数据模型设计 - -### 联系信息表 (contacts) - -| 字段名 | 类型 | 约束 | 描述 | -|--------|------|------|------| -| id | INTEGER | PRIMARY KEY | 主键ID | -| name | VARCHAR(100) | NOT NULL | 联系人姓名 | -| email | VARCHAR(255) | NOT NULL | 邮箱地址 | -| subject | VARCHAR(255) | NOT NULL | 主题 | -| message | TEXT | NOT NULL | 留言内容 | -| ip_address | VARCHAR(45) | NULL | IP地址 | -| user_agent | TEXT | NULL | 浏览器信息 | -| status | ENUM('unread','read','replied') | DEFAULT 'unread' | 处理状态 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 | - -### 数据库迁移设计 - -```sql --- 创建联系信息表 -CREATE TABLE contacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(100) NOT NULL, - email VARCHAR(255) NOT NULL, - subject VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - ip_address VARCHAR(45), - user_agent TEXT, - status VARCHAR(20) DEFAULT 'unread', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_contacts_status ON contacts(status); -CREATE INDEX idx_contacts_created_at ON contacts(created_at); -CREATE INDEX idx_contacts_email ON contacts(email); -``` - -## 后台界面设计 - -### 布局结构 - -``` -┌─────────────────────────────────────────────────────┐ -│ 顶部导航栏 │ -├──────────────┬──────────────────────────────────────┤ -│ │ │ -│ 左侧导航 │ 主内容区域 │ -│ │ │ -│ - 仪表盘 │ ┌────────────────────────────────┐ │ -│ - 文章管理 │ │ │ │ -│ - 所有文章 │ │ 页面内容 │ │ -│ - 新建文章 │ │ │ │ -│ - 联系信息 │ │ │ │ -│ │ └────────────────────────────────┘ │ -│ │ │ -└──────────────┴──────────────────────────────────────┘ -``` - -### 页面流程图 - -```mermaid -flowchart TD - A[访问 /admin] --> B{用户已登录?} - B -->|否| C[跳转到登录页] - B -->|是| D[后台仪表盘] - - D --> E[文章管理] - D --> F[联系信息管理] - - E --> G[文章列表] - E --> H[新建文章] - G --> I[编辑文章] - G --> J[删除文章] - - F --> K[联系信息列表] - K --> L[查看详情] - K --> M[删除信息] - - C --> N[登录成功] --> D -``` - -## 核心功能模块 - -### 1. 文章管理模块 - -#### 功能列表 -- **文章列表**: 显示当前用户的所有文章,支持状态筛选(草稿/已发布) -- **新建文章**: 创建新文章,支持Markdown编辑 -- **编辑文章**: 修改现有文章内容 -- **删除文章**: 删除指定文章 -- **发布/取消发布**: 切换文章发布状态 - -#### 权限控制 -- 用户只能操作自己创建的文章 -- 通过 `author` 字段进行权限过滤 - -#### 数据流程 - -```mermaid -sequenceDiagram - participant U as 用户 - participant AC as AdminController - participant AS as ArticleService - participant AM as ArticleModel - participant DB as 数据库 - - U->>AC: 访问文章列表 - AC->>AS: getUserArticles(userId) - AS->>AM: findByAuthor(userId) - AM->>DB: SELECT * FROM articles WHERE author = userId - DB-->>AM: 返回文章列表 - AM-->>AS: 文章数据 - AS-->>AC: 处理后的文章列表 - AC-->>U: 渲染文章管理页面 -``` - -### 2. 联系信息管理模块 - -#### 功能列表 -- **信息列表**: 显示所有联系表单提交的信息 -- **查看详情**: 查看完整的联系信息内容 -- **状态管理**: 标记为已读/未读/已回复 -- **删除信息**: 删除不需要的联系信息 -- **搜索筛选**: 按时间、状态、邮箱等条件筛选 - -#### 数据流程 - -```mermaid -sequenceDiagram - participant U as 用户 - participant AC as AdminController - participant CS as ContactService - participant CM as ContactModel - participant DB as 数据库 - - U->>AC: 访问联系信息列表 - AC->>CS: getAllContacts() - CS->>CM: findAll() - CM->>DB: SELECT * FROM contacts ORDER BY created_at DESC - DB-->>CM: 返回联系信息列表 - CM-->>CS: 联系信息数据 - CS-->>AC: 处理后的信息列表 - AC-->>U: 渲染联系信息页面 -``` - -## 技术实现规范 - -### 1. 控制器层设计 - -**AdminController.js** - 后台管理主控制器 -- 继承现有项目架构模式 -- 使用session进行用户认证 -- 所有路由需要登录权限 - -### 2. 服务层设计 - -**ContactService.js** - 联系信息业务逻辑 -- 提供联系信息的CRUD操作 -- 实现状态管理功能 -- 支持分页和搜索 - -### 3. 数据访问层设计 - -**ContactModel.js** - 联系信息数据模型 -- 实现基础CRUD操作 -- 支持条件查询和排序 -- 与现有模型保持一致的设计模式 - -### 4. 视图层设计 - -**布局文件**: `admin.pug` - 后台专用布局 -- 独立的CSS样式,不继承现有页面 -- 响应式左侧导航栏设计 -- 现代化的管理界面风格 - -**页面模板**: -- `admin/dashboard.pug` - 仪表盘首页 -- `admin/articles/index.pug` - 文章列表页 -- `admin/articles/create.pug` - 新建文章页 -- `admin/articles/edit.pug` - 编辑文章页 -- `admin/contacts/index.pug` - 联系信息列表页 -- `admin/contacts/show.pug` - 联系信息详情页 - -### 5. 路由设计 - -``` -/admin GET - 后台首页(仪表盘) -/admin/articles GET - 文章列表 -/admin/articles/create GET - 新建文章页面 -/admin/articles POST - 创建文章 -/admin/articles/:id GET - 查看文章详情 -/admin/articles/:id/edit GET - 编辑文章页面 -/admin/articles/:id PUT - 更新文章 -/admin/articles/:id DELETE - 删除文章 -/admin/contacts GET - 联系信息列表 -/admin/contacts/:id GET - 联系信息详情 -/admin/contacts/:id DELETE - 删除联系信息 -/admin/contacts/:id/status PUT - 更新联系信息状态 -``` - -## 安全考虑 - -### 1. 权限控制 -- 所有后台路由需要用户登录 -- 文章操作权限验证:用户只能操作自己的文章 -- 联系信息管理:所有登录用户都可查看 - -### 2. 数据验证 -- 服务端表单验证 -- XSS防护:模板自动转义 -- CSRF保护:利用现有session机制 - -### 3. 操作日志 -- 记录重要操作(删除文章、删除联系信息) -- 利用现有logger系统 - -## 集成方案 - -### 1. 现有系统集成 -- **联系表单增强**: 修改BasePageController中的contactPost方法,将数据存储到数据库 -- **用户认证复用**: 利用现有session认证机制 -- **数据库集成**: 使用现有Knex.js配置和迁移系统 - -### 2. 不影响现有功能 -- 新增模块独立部署在 `/admin` 路径下 -- 不修改现有控制器、服务和模型 -- 独立的样式文件,避免样式冲突 - -## 文件结构 - -``` -src/ -├── controllers/ -│ └── Page/ -│ └── AdminController.js # 后台管理控制器 -├── services/ -│ └── ContactService.js # 联系信息服务 -├── db/ -│ ├── models/ -│ │ └── ContactModel.js # 联系信息模型 -│ └── migrations/ -│ └── xxxx_create_contacts_table.mjs # 联系表迁移文件 -├── views/ -│ ├── layouts/ -│ │ └── admin.pug # 后台布局模板 -│ └── admin/ -│ ├── dashboard.pug # 仪表盘 -│ ├── articles/ -│ │ ├── index.pug # 文章列表 -│ │ ├── create.pug # 新建文章 -│ │ └── edit.pug # 编辑文章 -│ └── contacts/ -│ ├── index.pug # 联系信息列表 -│ └── show.pug # 联系信息详情 -└── public/ - ├── css/ - │ └── admin.css # 后台专用样式 - └── js/ - └── admin.js # 后台专用脚本 -``` - -## 测试策略 - -### 单元测试 -- ContactModel CRUD操作测试 -- ContactService业务逻辑测试 -- AdminController路由处理测试 - -### 集成测试 -- 用户权限验证测试 -- 文章管理完整流程测试 -- 联系信息管理流程测试 - -### 安全测试 -- 权限绕过测试 -- XSS攻击防护测试 -- 数据验证测试 \ No newline at end of file diff --git a/.qoder/quests/db-module-check-and-optimization-1757490337.md b/.qoder/quests/db-module-check-and-optimization-1757490337.md deleted file mode 100644 index d81e3f6..0000000 --- a/.qoder/quests/db-module-check-and-optimization-1757490337.md +++ /dev/null @@ -1,617 +0,0 @@ -# 数据库模块检查与优化设计文档 - -## 概述 - -本文档分析 Koa3 项目的数据库模块存在的问题,并提供优化方案。通过深入分析代码结构、模型设计、查询缓存和错误处理机制,识别潜在问题并提出改进建议。 - -## 技术栈分析 - -- **数据库**: SQLite3 -- **ORM框架**: Knex.js -- **缓存机制**: 内存缓存(自定义实现) -- **项目类型**: 后端应用(Node.js + Koa3) - -## 架构分析 - -### 当前架构结构 - -```mermaid -graph TB - A[应用层] --> B[模型层] - B --> C[数据库连接层] - C --> D[SQLite数据库] - - B --> E[查询缓存层] - E --> F[内存缓存] - - C --> G[Knex QueryBuilder] - G --> H[迁移系统] - G --> I[种子数据] -``` - -### 数据模型关系图 - -```mermaid -erDiagram - Users { - int id PK - string username - string email UK - string password - string role - string phone - int age - string name - text bio - string avatar - string status - timestamp created_at - timestamp updated_at - } - - Articles { - int id PK - string title - text content - string author - string category - string tags - string keywords - string description - string status - timestamp published_at - int view_count - string featured_image - text excerpt - int reading_time - string meta_title - text meta_description - string slug UK - timestamp created_at - timestamp updated_at - } - - Bookmarks { - int id PK - int user_id FK - string title - string url - text description - timestamp created_at - timestamp updated_at - } - - SiteConfig { - int id PK - string key UK - text value - timestamp created_at - timestamp updated_at - } - - Contacts { - int id PK - string name - string email - string subject - text message - string ip_address - text user_agent - string status - timestamp created_at - timestamp updated_at - } - - Users ||--o{ Bookmarks : "拥有" -``` - -## 问题识别与分析 - -### 1. 数据库连接问题 - -#### 问题描述 -- 连接池配置不合理,SQLite设置为最大1个连接,在高并发场景下可能成为瓶颈 -- 缺少连接重试机制和错误恢复策略 -- 没有健康检查机制 - -#### 影响评估 -- **性能影响**: 高并发场景下连接竞争导致性能下降 -- **稳定性风险**: 连接异常时缺少恢复机制 - -### 2. 模型设计问题 - -#### 问题描述 -- 模型方法返回值不一致,部分返回数组,部分返回对象 -- 缺少统一的错误处理机制 -- 模型之间缺少关联查询方法 -- 批量操作支持不足 - -#### 影响评估 -- **开发效率**: 不一致的API增加开发复杂度 -- **维护成本**: 缺少统一规范导致维护困难 - -### 3. 查询缓存问题 - -#### 问题描述 -- 缓存键生成策略不合理,可能产生冲突 -- 缺少缓存失效策略和一致性保证 -- 没有缓存命中率监控 - -#### 影响评估 -- **数据一致性**: 缓存与数据库数据不同步 -- **内存泄漏**: 缓存无限增长可能导致内存问题 - -### 4. 事务处理问题 - -#### 问题描述 -- 模型方法缺少事务支持 -- 没有原子操作保证 -- 复杂业务逻辑缺少事务包装 - -#### 影响评估 -- **数据完整性**: 并发操作可能导致数据不一致 -- **业务逻辑**: 复杂操作缺少原子性保证 - -### 5. 索引优化问题 - -#### 问题描述 -- 部分查询缺少合适的索引 -- 复合索引设计不合理 -- 缺少查询性能监控 - -#### 影响评估 -- **查询性能**: 缺少索引导致查询缓慢 -- **扩展性**: 数据量增长时性能急剧下降 - -## 优化方案设计 - -### 1. 数据库连接优化 - -#### 连接池配置改进 -```javascript -// knexfile.mjs 优化配置 -export default { - development: { - client: "sqlite3", - connection: { - filename: "./database/development.sqlite3", - }, - pool: { - min: 1, - max: 3, // 适当增加连接数 - acquireTimeoutMillis: 60000, - createTimeoutMillis: 30000, - destroyTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - reapIntervalMillis: 1000, - createRetryIntervalMillis: 200, - afterCreate: (conn, done) => { - conn.run("PRAGMA journal_mode = WAL", done) - conn.run("PRAGMA synchronous = NORMAL", done) - conn.run("PRAGMA cache_size = 1000", done) - conn.run("PRAGMA temp_store = MEMORY", done) - }, - }, - } -} -``` - -#### 健康检查机制 -```javascript -// db/index.js 添加健康检查 -export const checkHealth = async () => { - try { - await db.raw("SELECT 1") - return { status: "healthy", timestamp: new Date() } - } catch (error) { - return { status: "unhealthy", error: error.message, timestamp: new Date() } - } -} -``` - -### 2. 模型设计优化 - -#### 统一基础模型类 -```javascript -// db/models/BaseModel.js -class BaseModel { - static get tableName() { - throw new Error("tableName must be defined") - } - - static async findById(id) { - const result = await db(this.tableName).where("id", id).first() - return result || null - } - - static async findAll(options = {}) { - const { page = 1, limit = 10, orderBy = "id", order = "desc" } = options - const offset = (page - 1) * limit - - return db(this.tableName) - .orderBy(orderBy, order) - .limit(limit) - .offset(offset) - } - - static async create(data) { - const [result] = await db(this.tableName) - .insert({ - ...data, - created_at: db.fn.now(), - updated_at: db.fn.now(), - }) - .returning("*") - return result - } - - static async update(id, data) { - const [result] = await db(this.tableName) - .where("id", id) - .update({ - ...data, - updated_at: db.fn.now(), - }) - .returning("*") - return result - } - - static async delete(id) { - return db(this.tableName).where("id", id).del() - } - - static async count(conditions = {}) { - const result = await db(this.tableName).where(conditions).count("id as count").first() - return parseInt(result.count) - } -} -``` - -#### 关联查询方法 -```javascript -// 扩展模型关联查询 -class ArticleModel extends BaseModel { - static get tableName() { return "articles" } - - // 获取作者相关文章 - static async findByAuthorWithProfile(author) { - return db(this.tableName) - .select("articles.*", "users.name as author_name", "users.avatar as author_avatar") - .leftJoin("users", "articles.author", "users.username") - .where("articles.author", author) - .where("articles.status", "published") - } -} - -class BookmarkModel extends BaseModel { - static get tableName() { return "bookmarks" } - - // 获取用户书签(包含用户信息) - static async findByUserWithProfile(userId) { - return db(this.tableName) - .select("bookmarks.*", "users.username", "users.name") - .leftJoin("users", "bookmarks.user_id", "users.id") - .where("bookmarks.user_id", userId) - } -} -``` - -### 3. 查询缓存优化 - -#### 改进缓存键生成策略 -```javascript -// db/index.js 缓存优化 -const getCacheKeyForBuilder = (builder) => { - if (builder._customCacheKey) return String(builder._customCacheKey) - - // 改进键生成策略 - const sql = builder.toString() - const tableName = builder._single.table || 'unknown' - const hash = require('crypto').createHash('md5').update(sql).digest('hex') - - return `${tableName}:${hash}` -} - -// 添加缓存统计 -export const getCacheStats = () => { - let valid = 0 - let expired = 0 - let totalSize = 0 - - for (const [key, entry] of queryCache.entries()) { - if (isExpired(entry)) { - expired++ - } else { - valid++ - totalSize += JSON.stringify(entry.value).length - } - } - - return { - totalKeys: queryCache.size, - validKeys: valid, - expiredKeys: expired, - totalSize, - hitRate: valid / (valid + expired) || 0 - } -} -``` - -#### 缓存一致性策略 -```javascript -// 数据变更时自动清理相关缓存 -buildKnex.QueryBuilder.extend("invalidateCache", function() { - const tableName = this._single.table - if (tableName) { - DbQueryCache.clearByPrefix(`${tableName}:`) - } - return this -}) - -// 在模型的 CUD 操作后自动清理缓存 -class BaseModel { - static async create(data) { - const result = await db(this.tableName).insert(data).returning("*") - await db(this.tableName).invalidateCache() - return result[0] - } - - static async update(id, data) { - const result = await db(this.tableName) - .where("id", id) - .update(data) - .returning("*") - await db(this.tableName).invalidateCache() - return result[0] - } - - static async delete(id) { - const result = await db(this.tableName).where("id", id).del() - await db(this.tableName).invalidateCache() - return result - } -} -``` - -### 4. 事务处理优化 - -#### 事务工具函数 -```javascript -// db/transaction.js -export const withTransaction = async (callback) => { - const trx = await db.transaction() - try { - const result = await callback(trx) - await trx.commit() - return result - } catch (error) { - await trx.rollback() - throw error - } -} - -// 使用示例 -export const createUserWithProfile = async (userData, profileData) => { - return withTransaction(async (trx) => { - const [user] = await trx("users").insert(userData).returning("*") - const [profile] = await trx("user_profiles") - .insert({ ...profileData, user_id: user.id }) - .returning("*") - return { user, profile } - }) -} -``` - -#### 批量操作优化 -```javascript -// 批量插入优化 -class BaseModel { - static async createMany(dataArray, batchSize = 100) { - const results = [] - for (let i = 0; i < dataArray.length; i += batchSize) { - const batch = dataArray.slice(i, i + batchSize) - const batchResults = await db(this.tableName) - .insert(batch) - .returning("*") - results.push(...batchResults) - } - await db(this.tableName).invalidateCache() - return results - } - - static async updateMany(conditions, data) { - const result = await db(this.tableName) - .where(conditions) - .update({ ...data, updated_at: db.fn.now() }) - await db(this.tableName).invalidateCache() - return result - } -} -``` - -### 5. 索引优化建议 - -#### 添加必要索引 -```javascript -// 新增迁移文件:add_performance_indexes.mjs -export const up = async (knex) => { - // 用户表索引 - await knex.schema.alterTable("users", (table) => { - table.index(["email"]) - table.index(["username"]) - table.index(["status", "created_at"]) - }) - - // 文章表索引 - await knex.schema.alterTable("articles", (table) => { - table.index(["author", "status"]) - table.index(["category", "published_at"]) - table.index(["status", "view_count"]) - table.index(["tags"]) // 用于标签搜索 - }) - - // 书签表索引 - await knex.schema.alterTable("bookmarks", (table) => { - table.index(["user_id", "created_at"]) - table.index(["url"]) // 用于URL查重 - }) - - // 联系人表索引 - await knex.schema.alterTable("contacts", (table) => { - table.index(["email", "created_at"]) - table.index(["status", "created_at"]) - }) -} -``` - -### 6. 错误处理优化 - -#### 统一错误处理机制 -```javascript -// db/errors.js -export class DatabaseError extends Error { - constructor(message, code, originalError) { - super(message) - this.name = "DatabaseError" - this.code = code - this.originalError = originalError - } -} - -export const handleDatabaseError = (error) => { - if (error.code === "SQLITE_CONSTRAINT") { - return new DatabaseError("数据约束违反", "CONSTRAINT_VIOLATION", error) - } - if (error.code === "SQLITE_BUSY") { - return new DatabaseError("数据库忙,请稍后重试", "DATABASE_BUSY", error) - } - return new DatabaseError("数据库操作失败", "DATABASE_ERROR", error) -} - -// 在模型中使用 -class BaseModel { - static async findById(id) { - try { - return await db(this.tableName).where("id", id).first() - } catch (error) { - throw handleDatabaseError(error) - } - } -} -``` - -### 7. 性能监控优化 - -#### 查询性能监控 -```javascript -// db/monitor.js -const queryStats = new Map() - -export const logQuery = (sql, duration) => { - const key = sql.split(' ')[0].toUpperCase() // SELECT, INSERT, UPDATE, DELETE - if (!queryStats.has(key)) { - queryStats.set(key, { count: 0, totalTime: 0, avgTime: 0 }) - } - - const stats = queryStats.get(key) - stats.count++ - stats.totalTime += duration - stats.avgTime = stats.totalTime / stats.count -} - -export const getQueryStats = () => { - return Object.fromEntries(queryStats) -} - -// 在 knex 配置中添加查询日志 -export default { - development: { - // ... 其他配置 - log: { - warn(message) { - console.warn(message) - }, - error(message) { - console.error(message) - }, - deprecate(message) { - console.log(message) - }, - debug(message) { - if (message.sql) { - const duration = message.bindings ? message.duration : 0 - logQuery(message.sql, duration) - } - }, - }, - } -} -``` - -## 测试策略 - -### 单元测试框架 -```javascript -// tests/models/BaseModel.test.js -import { expect } from 'chai' -import { BaseModel } from '../src/db/models/BaseModel.js' - -describe('BaseModel', () => { - it('应该正确创建记录', async () => { - const data = { name: 'test' } - const result = await TestModel.create(data) - expect(result).to.have.property('id') - expect(result.name).to.equal('test') - }) - - it('应该正确处理事务', async () => { - await expect( - withTransaction(async (trx) => { - await trx('test_table').insert({ name: 'test' }) - throw new Error('回滚测试') - }) - ).to.be.rejected - - const count = await TestModel.count() - expect(count).to.equal(0) - }) -}) -``` - -### 性能测试 -```javascript -// tests/performance/cache.test.js -describe('缓存性能测试', () => { - it('缓存命中率应该大于80%', async () => { - // 执行大量查询 - for (let i = 0; i < 1000; i++) { - await ArticleModel.findById(1).cache(60000) - } - - const stats = getCacheStats() - expect(stats.hitRate).to.be.greaterThan(0.8) - }) -}) -``` - -## 迁移计划 - -### 阶段1: 基础优化(1-2周) -1. 修复数据库连接配置 -2. 统一模型返回值格式 -3. 添加基础错误处理 - -### 阶段2: 功能增强(2-3周) -1. 实现统一基础模型类 -2. 添加关联查询方法 -3. 优化查询缓存机制 - -### 阶段3: 性能优化(1-2周) -1. 添加必要索引 -2. 实现事务支持 -3. 添加性能监控 - -### 阶段4: 测试与验证(1周) -1. 编写单元测试 -2. 性能基准测试 -3. 生产环境验证 \ No newline at end of file diff --git a/.qoder/quests/db-module-check-and-optimization.md b/.qoder/quests/db-module-check-and-optimization.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/.gitkeep b/database/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/db.sqlite3 b/database/db.sqlite3 index 3f03792bc7042f2308a00071280fc50a710f3be0..f721384cbf711bf2dc09f1de8d9e2e27256656fa 100644 GIT binary patch delta 27 acmZorXi%77&dbPv0E`nAr8mYe7_w}9ui z-AN~sPHF5^)$h5<2Xu4!@2L-7xs~}o0IL+K0Mc*yd7Qjo{P^wN#MFoB$<*xJ=Py4x z#y@2j(qmsUf7s7Q?45k3{r#w9Kn7$$ z24p}6WIzUFKn7$$24p}6WIzUFKn7$$24p}6WIzUFKn7$$24p}6WIzUFKn7$$24p}6 zWIzUFKn7$$24p}6WIzUFKn7$$24p}6WIzUFKn7$$29jz(|5fh#o3;G}Di;G4+&59p zJrT9oj|G<>@YXZoZE|dbZ8L|;#Xu!Drqp54rPXuKF3)k!j574596pkal{NDWf#ukK>#_zk6@cW43AxQ8U8gURuf=_7y3wyDn2~0HHvJ4F15}qaNNM$-r2FGx-OwZP1 z{TLX;WjyyYIPhDS delta 98 zcmZo@U}|V!;+1%$%K!t6lM|VwMJ?DR*i0wi=ERUufXTqr|3?Fx6PY~iHWx5ife2P+ E0FO5xA^-pY diff --git a/database/db.sqlite3-wal b/database/db.sqlite3-wal index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..54a877e6f5bf32751aaddb762552072fee2d10e6 100644 GIT binary patch literal 477952 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 J#KlVV{{dvaJPQB- literal 0 HcmV?d00001 diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 95164b462e42fcf9becdc719cb7a92d5efcd75c4..10e6078d586b894043a8297dcd35bd77f21f45c4 100644 GIT binary patch literal 32768 zcmeI5b#N6|6vlra?gS53+zG+m-QC^Ysk?irm%3A@Zq!|=L#Yd;MqQ}4^xOh7Z!*BV zhLCLDJ7?z0+ueKj-0%GM=KYnNP2H46i2No&$_o3@%8u8qTDxWQxB{bAuI|w*t<1(X z(>ib1T6cYVo7Dd~KjaP(@@|WX?N?!tT@MjH@|g1OIL^1@IDE#Ah4bLGfT^IVkg2e# zh^eTln5np_gsG${#Z<~v+Em6=)>O_^-c-SqYN}|eWU6ecVybG|&s5D+-BiO=(^Sh; z+f>I?*Hq6`-_*d=(A3D(*c6C1F{PWDnwpuKn_8H5dJe~-*4Xn<$FBTm)>q&b*2wC- zEiGr*BeC8y))Lm<7whipnnE2r_t&oWOxQ1Buf*=dJ>6PQVE`0Vm)DoPZN>0#3jQH~}Z%1e|~q zZ~{)i2{-{K-~^n26L11fzzH}3C*TB}fD>>6PQVE`0Vm)DoPZN>0#3jQH~}Z%1e|~q zZ~_TWpn1Yqm{kQ<shszC#)sI@w)w+3spW<`Wj zn`D1fao1myzD(wD&gE&|i~Fu*oLHRI+{U;3$~FqBq$;SI>M31q)J1(XL}N5rv$ZG! zzX5HN@$qD;=34Y)3P*4r&+vYxG9=4QCFnpG7PFlDcremMw~Ms9D4DBGf2MLI=kqKd zM9Em>yp*IPU0K2k?&qP%xr;T_o=$XQDf_dQhhqo)I0Jz?3}6~ZaRJZqVTLh3jU|vm zcgAucXYeR*#tua8cp;~96 z%NYU<7{V-$<6_qFNzPcvC99UDC*wJoGkKi1a*1cRxn}|m8Om&q=Mr9G^PZy`HdLH98Ta;Ugpz8DQch8QJ&sRLx{v^WBP=aa>SCvduS zMzMfXxRTfTGJ)&d`_d|LJ$LXuzate^DOFSrHBd9PQ#bX~Fpbkx&D9d${diHD1#+PfLCG#{)AWbHaPXJ>nryg&Vnx gANiBKDylN7tXgWM7V4lL8lVxHpy`^gWxo6Q7k@G0LjV8( literal 32768 zcmeI51#px{7>1uWArK&hU?I3C1PJc#?he7-wQkhig?cG%sk?h=OIzyhLQB1+rKLud z?#|?&`6qKE5GH}#eLM5s?!Wqd&we|1b2GP>wl#|&2~t+OmsD2~zj@&L^z1p^w(P92 zZrk>a8Jl)wZpdDny(?`m`v-ITd3S|UxZamtGjUGwIpq>LWuJ%J{Bwd+NvBdyiB6@R z$~cvEN^&aal$DBk(E6^Yb}uzweuu zjeF`xBs&6oJnu*f+IN@xG2wah+l#z>@vIB#OJJYhCtvmo_AGb~a&L1`4(DOp!#)LZ zRM>ms{SOqjD=2$5!5Q+lR&uKBRK>}kY2VNLwzu}~E%)5(hFSS?q_Y(G6^sq?a9`i& zKz|PW$m=}%K6!D-?c<*KUC=&(y}5ILHhi78GuVqDj)Ldi2BVYuKfDtePM!*Od z0V7}pjDQg^0!F|H7y%<-1dMTY$=v#z2nbX5Ky!O*ET~Kp_!G zq8h#E!yslem-#GbC964@!#RqzT*9SX!IfOidamJGHgG*Ra3eQy3%9eGmwAO(d5zck zfIkb#LYvkI6c&MURHrw68O$8!v4DjvVlhit$}(24ii0?WLpg$@xtPnij$65d=lPJo z3dn;{J67>?sk?%^Z06xr&wkP+CQ1j!_=i#|tMaO(s;Z$nYM=}?RSUIOclFgE4by0iSGIjVip`*j8m}u; zpC*i93MX?85Aq~m@B{yns3fJRveHyjb=6Rf)l4nbK|Rz@gEd@ZG(l4=`lBWeV$+c- zG@vPCnaU}g%R@ZHm;A_olvX)aP!&~EE!9&aWhzUp)KNXvUqduPV>MAzE&5|KXrjjJ zR2tHZaZKY>&f{U8<}0?di%#lgu^lzLY>q@GP?bhxF`nt1#`!$LCcfq;_cv;t)!U*! zGBT@;8UoE}Nh?~@hPJe$Jss#sCpy!Gu5_b2J($1@F5q&mVjYk23_sIFbM0hAO$5ZU zBQ2Q8>0HROyugdR#5e3fic=XStD;g>UA0wT>1v|pYOOYEtFG#!0UD^G8mUnlr%9Tu zX_~GXnyFcutvQ-!XCszFsu=nH4!;tw1TD}KEz=6EvdAq)yJx2E8-aKEjS?!Ug<7iR YS}Fhk+m?(#Y!G;l-&tf4zXzNBKkyyYmH+?% diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal index 63682715947aee61bcc43367a4130dbe35459b61..8455649135d0cabad285e7e6ef8de31e034c63c2 100644 GIT binary patch delta 33335 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

FkdNb41skTHyK@o$If@$V$j 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 VO)LxfjoLnbP>UKgVP6YP{2x|qy&nJo diff --git a/src/base/BaseModel.js b/src/base/BaseModel.js new file mode 100644 index 0000000..e79743b --- /dev/null +++ b/src/base/BaseModel.js @@ -0,0 +1,580 @@ +import db from "@/db" +import { logger } from "@/logger.js" +import { BaseSingleton } from "@/utils/BaseSingleton" + +/** + * 数据库错误类 + */ +export class DatabaseError extends Error { + constructor(message, code, originalError) { + super(message) + this.name = "DatabaseError" + this.code = code + this.originalError = originalError + } +} + +/** + * 处理数据库错误的统一函数 + */ +export const handleDatabaseError = (error, operation = "数据库操作") => { + logger.error(`${operation}失败:`, error) + + if (error.code === "SQLITE_CONSTRAINT") { + return new DatabaseError("数据约束违反", "CONSTRAINT_VIOLATION", error) + } + if (error.code === "SQLITE_BUSY") { + return new DatabaseError("数据库忙,请稍后重试", "DATABASE_BUSY", error) + } + if (error.code === "SQLITE_LOCKED") { + return new DatabaseError("数据库被锁定", "DATABASE_LOCKED", error) + } + if (error.code === "SQLITE_NOTFOUND") { + return new DatabaseError("记录不存在", "NOT_FOUND", error) + } + + return new DatabaseError(`${operation}失败: ${error.message}`, "DATABASE_ERROR", error) +} + +/** + * 统一的数据库基础模型类 + * 提供标准化的CRUD操作和错误处理 + */ +export default class BaseModel extends BaseSingleton { + + /** + * @returns {BaseModel} + */ + static getInstance() { + return super.getInstance() + } + + constructor() { + super() + } + + /** + * 获取表名,必须由子类实现 + */ + get tableName() { + throw new Error("tableName must be defined in subclass") + } + + /** + * 获取默认排序字段 + */ + get defaultOrderBy() { + return "id" + } + + /** + * 获取默认排序方向 + */ + get defaultOrder() { + return "desc" + } + + /** + * 获取可搜索字段列表 + */ + get searchableFields() { + return [] + } + + /** + * 获取可过滤字段列表 + */ + get filterableFields() { + return [] + } + + /** + * 根据ID查找单条记录 + */ + async findById(id) { + try { + const result = await db(this.tableName).where("id", id).first() + return result || null + } catch (error) { + throw handleDatabaseError(error, `查找${this.tableName}记录(ID: ${id})`) + } + } + + /** + * 查找所有记录,支持分页和排序 + */ + async findAll(options = {}) { + try { + const { page = 1, limit = 10, orderBy = this.defaultOrderBy, order = this.defaultOrder, where = {}, select = "*" } = options + + const offset = (page - 1) * limit + + let query = db(this.tableName).select(select) + + // 添加where条件 + if (Object.keys(where).length > 0) { + query = query.where(where) + } + + // 添加排序和分页 + query = query.orderBy(orderBy, order).limit(limit).offset(offset) + + return await query + } catch (error) { + throw handleDatabaseError(error, `查找${this.tableName}记录列表`) + } + } + + /** + * 查找第一条记录 + */ + async findFirst(conditions = {}) { + try { + return (await db(this.tableName).where(conditions).first()) || null + } catch (error) { + throw handleDatabaseError(error, `查找${this.tableName}第一条记录`) + } + } + + /** + * 根据条件查找记录 + */ + async findWhere(conditions, options = {}) { + try { + const { orderBy = this.defaultOrderBy, order = this.defaultOrder, limit, select = "*" } = options + + let query = db(this.tableName).select(select).where(conditions) + + if (orderBy) { + query = query.orderBy(orderBy, order) + } + + if (limit) { + query = query.limit(limit) + } + + return await query + } catch (error) { + throw handleDatabaseError(error, `按条件查找${this.tableName}记录`) + } + } + + /** + * 创建新记录 + */ + async create(data) { + try { + const insertData = { + ...data, + created_at: db.fn.now(), + updated_at: db.fn.now(), + } + + const result = await db(this.tableName).insert(insertData).returning("*") + + // SQLite returning() 总是返回数组,这里统一返回第一个元素 + return Array.isArray(result) ? result[0] : result + } catch (error) { + throw handleDatabaseError(error, `创建${this.tableName}记录`) + } + } + + /** + * 更新记录 + */ + async update(id, data) { + try { + const updateData = { + ...data, + updated_at: db.fn.now(), + } + + const result = await db(this.tableName).where("id", id).update(updateData).returning("*") + + // SQLite returning() 总是返回数组,这里统一返回第一个元素 + return Array.isArray(result) ? result[0] : result + } catch (error) { + throw handleDatabaseError(error, `更新${this.tableName}记录(ID: ${id})`) + } + } + + /** + * 根据条件更新记录 + */ + async updateWhere(conditions, data) { + try { + const updateData = { + ...data, + updated_at: db.fn.now(), + } + + return await db(this.tableName).where(conditions).update(updateData) + } catch (error) { + throw handleDatabaseError(error, `按条件更新${this.tableName}记录`) + } + } + + /** + * 删除记录 + */ + async delete(id) { + try { + return await db(this.tableName).where("id", id).del() + } catch (error) { + throw handleDatabaseError(error, `删除${this.tableName}记录(ID: ${id})`) + } + } + + /** + * 根据条件删除记录 + */ + async deleteWhere(conditions) { + try { + return await db(this.tableName).where(conditions).del() + } catch (error) { + throw handleDatabaseError(error, `按条件删除${this.tableName}记录`) + } + } + + /** + * 统计记录数量 + */ + async count(conditions = {}) { + try { + const result = await db(this.tableName).where(conditions).count("id as count").first() + return parseInt(result.count) || 0 + } catch (error) { + throw handleDatabaseError(error, `统计${this.tableName}记录数量`) + } + } + + /** + * 检查记录是否存在 + */ + async exists(conditions) { + try { + const count = await this.count(conditions) + return count > 0 + } catch (error) { + throw handleDatabaseError(error, `检查${this.tableName}记录是否存在`) + } + } + + /** + * 分页查询 + */ + async paginate(options = {}) { + try { + const { + page = 1, + limit = 10, + orderBy = this.defaultOrderBy, + order = this.defaultOrder, + where = {}, + select = "*", + search = "", + searchFields = this.searchableFields, + } = options + + let query = db(this.tableName).select(select) + + // 添加where条件 + if (Object.keys(where).length > 0) { + query = query.where(where) + } + + // 添加搜索条件 + if (search && searchFields.length > 0) { + query = query.where(function () { + searchFields.forEach((field, index) => { + if (index === 0) { + this.where(field, "like", `%${search}%`) + } else { + this.orWhere(field, "like", `%${search}%`) + } + }) + }) + } + + // 获取总数 + const countQuery = query.clone() + const totalResult = await countQuery.count("id as count").first() + const total = parseInt(totalResult.count) || 0 + + // 分页查询 + const offset = (page - 1) * limit + const data = await query.orderBy(orderBy, order).limit(limit).offset(offset) + + return { + data, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1, + }, + } + } catch (error) { + throw handleDatabaseError(error, `分页查询${this.tableName}记录`) + } + } + + /** + * 批量创建记录 + */ + async createMany(dataArray, batchSize = 100) { + try { + const results = [] + + for (let i = 0; i < dataArray.length; i += batchSize) { + const batch = dataArray.slice(i, i + batchSize).map(data => ({ + ...data, + created_at: db.fn.now(), + updated_at: db.fn.now(), + })) + + const batchResults = await db(this.tableName).insert(batch).returning("*") + + results.push(...(Array.isArray(batchResults) ? batchResults : [batchResults])) + } + + return results + } catch (error) { + throw handleDatabaseError(error, `批量创建${this.tableName}记录`) + } + } + + /** + * 批量更新记录 + */ + async updateMany(conditions, data) { + try { + const updateData = { + ...data, + updated_at: db.fn.now(), + } + + return await db(this.tableName).where(conditions).update(updateData) + } catch (error) { + throw handleDatabaseError(error, `批量更新${this.tableName}记录`) + } + } + + /** + * 获取表结构信息 + */ + async getTableInfo() { + try { + return await db.raw(`PRAGMA table_info(${this.tableName})`) + } catch (error) { + throw handleDatabaseError(error, `获取${this.tableName}表结构信息`) + } + } + + /** + * 清空表数据 + */ + async truncate() { + try { + return await db(this.tableName).del() + } catch (error) { + throw handleDatabaseError(error, `清空${this.tableName}表数据`) + } + } + + /** + * 获取随机记录 + */ + async findRandom(limit = 1) { + try { + return await db(this.tableName).orderByRaw("RANDOM()").limit(limit) + } catch (error) { + throw handleDatabaseError(error, `获取${this.tableName}随机记录`) + } + } + + /** + * 关联查询基础方法 - 左连接 + */ + leftJoin(joinTable, leftKey, rightKey) { + return db(this.tableName).leftJoin(joinTable, leftKey, rightKey) + } + + /** + * 关联查询基础方法 - 内连接 + */ + innerJoin(joinTable, leftKey, rightKey) { + return db(this.tableName).innerJoin(joinTable, leftKey, rightKey) + } + + /** + * 关联查询基础方法 - 右连接 + */ + rightJoin(joinTable, leftKey, rightKey) { + return db(this.tableName).rightJoin(joinTable, leftKey, rightKey) + } + + /** + * 构建复杂关联查询 + */ + buildRelationQuery(relations = []) { + let query = db(this.tableName) + + relations.forEach(relation => { + const { type, table, on, select } = relation + + switch (type) { + case "left": + query = query.leftJoin(table, on[0], on[1]) + break + case "inner": + query = query.innerJoin(table, on[0], on[1]) + break + case "right": + query = query.rightJoin(table, on[0], on[1]) + break + } + + if (select) { + query = query.select(select) + } + }) + + return query + } + + /** + * 通用关联查询方法 + */ + async findWithRelations(conditions = {}, relations = [], options = {}) { + try { + const { orderBy = this.defaultOrderBy, order = this.defaultOrder, limit, select = [`${this.tableName}.*`] } = options + + let query = this.buildRelationQuery(relations) + + if (select && select.length > 0) { + query = query.select(...select) + } + + if (Object.keys(conditions).length > 0) { + query = query.where(conditions) + } + + if (orderBy) { + query = query.orderBy(orderBy, order) + } + + if (limit) { + query = query.limit(limit) + } + + return await query + } catch (error) { + throw handleDatabaseError(error, `关联查询${this.tableName}记录`) + } + } + + // ==================== 事务支持方法 ==================== + + /** + * 在事务中创建记录 + */ + async createInTransaction(trx, data) { + try { + const insertData = { + ...data, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + } + + const result = await trx(this.tableName).insert(insertData).returning("*") + + return Array.isArray(result) ? result[0] : result + } catch (error) { + throw handleDatabaseError(error, `在事务中创建${this.tableName}记录`) + } + } + + /** + * 在事务中更新记录 + */ + async updateInTransaction(trx, id, data) { + try { + const updateData = { + ...data, + updated_at: trx.fn.now(), + } + + const result = await trx(this.tableName).where("id", id).update(updateData).returning("*") + + return Array.isArray(result) ? result[0] : result + } catch (error) { + throw handleDatabaseError(error, `在事务中更新${this.tableName}记录(ID: ${id})`) + } + } + + /** + * 在事务中删除记录 + */ + async deleteInTransaction(trx, id) { + try { + return await trx(this.tableName).where("id", id).del() + } catch (error) { + throw handleDatabaseError(error, `在事务中删除${this.tableName}记录(ID: ${id})`) + } + } + + /** + * 在事务中批量创建记录 + */ + async createManyInTransaction(trx, dataArray, batchSize = 100) { + try { + const results = [] + + for (let i = 0; i < dataArray.length; i += batchSize) { + const batch = dataArray.slice(i, i + batchSize).map(data => ({ + ...data, + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + })) + + const batchResults = await trx(this.tableName).insert(batch).returning("*") + + results.push(...(Array.isArray(batchResults) ? batchResults : [batchResults])) + } + + return results + } catch (error) { + throw handleDatabaseError(error, `在事务中批量创建${this.tableName}记录`) + } + } + + /** + * 在事务中批量更新记录 + */ + async updateManyInTransaction(trx, conditions, data) { + try { + const updateData = { + ...data, + updated_at: trx.fn.now(), + } + + return await trx(this.tableName).where(conditions).update(updateData) + } catch (error) { + throw handleDatabaseError(error, `在事务中批量更新${this.tableName}记录`) + } + } + + /** + * 在事务中执行原生 SQL + */ + async rawInTransaction(trx, query, bindings = []) { + try { + return await trx.raw(query, bindings) + } catch (error) { + throw handleDatabaseError(error, `在事务中执行原生 SQL`) + } + } +} diff --git a/src/db/docs/ArticleModel.md b/src/db/docs/ArticleModel.md deleted file mode 100644 index c7e3d93..0000000 --- a/src/db/docs/ArticleModel.md +++ /dev/null @@ -1,190 +0,0 @@ -# 数据库模型文档 - -## 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 -``` diff --git a/src/db/docs/BookmarkModel.md b/src/db/docs/BookmarkModel.md deleted file mode 100644 index 273129b..0000000 --- a/src/db/docs/BookmarkModel.md +++ /dev/null @@ -1,194 +0,0 @@ -# 数据库模型文档 - -## 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. **权限控制**: 实现适当的访问控制机制 diff --git a/src/db/docs/README.md b/src/db/docs/README.md deleted file mode 100644 index 16a5aec..0000000 --- a/src/db/docs/README.md +++ /dev/null @@ -1,252 +0,0 @@ -# 数据库文档总览 - -本文档提供了整个数据库系统的概览,包括所有模型、表结构和关系。 - -## 数据库概览 - -这是一个基于 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 许可证。 diff --git a/src/db/docs/SiteConfigModel.md b/src/db/docs/SiteConfigModel.md deleted file mode 100644 index 64b03d5..0000000 --- a/src/db/docs/SiteConfigModel.md +++ /dev/null @@ -1,246 +0,0 @@ -# 数据库模型文档 - -## 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. **配置缓存**: 实现配置缓存机制,减少数据库查询 diff --git a/src/db/docs/UserModel.md b/src/db/docs/UserModel.md deleted file mode 100644 index c8bb373..0000000 --- a/src/db/docs/UserModel.md +++ /dev/null @@ -1,158 +0,0 @@ -# 数据库模型文档 - -## 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注入 - -### 扩展建议 - -可以考虑添加以下功能: -- 用户状态管理(激活/禁用) -- 密码重置功能 -- 用户头像管理 -- 用户偏好设置 -- 登录历史记录 -- 用户组管理 diff --git a/src/db/migrations/20250616065041_create_users_table.mjs b/src/db/migrations/20250616065041_create_users_table.mjs index a431899..a658d69 100644 --- a/src/db/migrations/20250616065041_create_users_table.mjs +++ b/src/db/migrations/20250616065041_create_users_table.mjs @@ -5,14 +5,18 @@ export const up = async knex => { return knex.schema.createTable("users", function (table) { table.increments("id").primary() // 自增主键 - table.string("username", 100).notNullable() // 字符串字段(最大长度100) - table.string("email", 100).unique() // 唯一邮箱 - table.string("password", 100).notNullable() // 密码 - table.string("role", 100).notNullable() - table.string("phone", 100) - table.integer("age").unsigned() // 无符号整数 - table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 - table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间 + table.string("username", 100).notNullable().unique().comment("用户名") // 字符串字段(最大长度100) + table.string("nickname", 100).comment("昵称") // 字符串字段(最大长度100) + table.string("bio").comment("个人简介") + table.string("avatar", 500).comment("头像") // 字符串字段(最大长度100) + table.string("email", 100).unique().comment("邮箱") // 唯一邮箱 + table.string("password", 100).notNullable().comment("密码") // 密码 + table.string("role", 100).notNullable().defaultTo("user").comment("角色 user, admin") // 角色 user, admin + table.string("phone", 100).comment("电话号码") + table.string("status", 20).defaultTo("inactive").comment("用户状态 inactive, active") // 用户状态 inactive, active + table.integer("age").unsigned().comment("年龄") // 无符号整数 + table.timestamp("created_at").defaultTo(knex.fn.now()).comment("创建时间") // 创建时间 + table.timestamp("updated_at").defaultTo(knex.fn.now()).comment("更新时间") // 更新时间 }) } diff --git a/src/db/migrations/20250621013128_site_config.mjs b/src/db/migrations/20250621013128_site_config.mjs index 87e998b..52d475f 100644 --- a/src/db/migrations/20250621013128_site_config.mjs +++ b/src/db/migrations/20250621013128_site_config.mjs @@ -5,10 +5,10 @@ export const up = async knex => { return knex.schema.createTable("site_config", function (table) { table.increments("id").primary() // 自增主键 - table.string("key", 100).notNullable().unique() // 配置项key,唯一 - table.text("value").notNullable() // 配置项value - table.timestamp("created_at").defaultTo(knex.fn.now()) // 创建时间 - table.timestamp("updated_at").defaultTo(knex.fn.now()) // 更新时间 + table.string("key", 100).notNullable().unique().comment("配置项key,唯一") // 配置项key,唯一 + table.text("value").notNullable().comment("配置项value") // 配置项value + table.timestamp("created_at").defaultTo(knex.fn.now()).comment("创建时间") // 创建时间 + table.timestamp("updated_at").defaultTo(knex.fn.now()).comment("更新时间") // 更新时间 }) } diff --git a/src/db/migrations/20250830014825_create_articles_table.mjs b/src/db/migrations/20250830014825_create_articles_table.mjs deleted file mode 100644 index 7dcf1b9..0000000 --- a/src/db/migrations/20250830014825_create_articles_table.mjs +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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 } - */ -export const down = async knex => { - return knex.schema.dropTable("articles") -} diff --git a/src/db/migrations/20250830015422_create_bookmarks_table.mjs b/src/db/migrations/20250830015422_create_bookmarks_table.mjs deleted file mode 100644 index 52ff3cc..0000000 --- a/src/db/migrations/20250830015422_create_bookmarks_table.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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.string("title", 200).notNullable() - table.string("url", 500) - table.text("description") - table.timestamp("created_at").defaultTo(knex.fn.now()) - table.timestamp("updated_at").defaultTo(knex.fn.now()) - - table.index(["user_id"]) // 常用查询索引 - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.dropTable("bookmarks") -} diff --git a/src/db/migrations/20250830020000_add_article_fields.mjs b/src/db/migrations/20250830020000_add_article_fields.mjs deleted file mode 100644 index 2775c57..0000000 --- a/src/db/migrations/20250830020000_add_article_fields.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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 } - */ -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"]) - }) -} diff --git a/src/db/migrations/20250901000000_add_profile_fields.mjs b/src/db/migrations/20250901000000_add_profile_fields.mjs deleted file mode 100644 index 3f27c22..0000000 --- a/src/db/migrations/20250901000000_add_profile_fields.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = async knex => { - return knex.schema.alterTable("users", function (table) { - table.string("name", 100) // 昵称 - table.text("bio") // 个人简介 - table.string("avatar", 500) // 头像URL - table.string("status", 20).defaultTo("active") // 用户状态 - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = async knex => { - return knex.schema.alterTable("users", function (table) { - table.dropColumn("name") - table.dropColumn("bio") - table.dropColumn("avatar") - table.dropColumn("status") - }) -} diff --git a/src/db/migrations/20250909000000_create_contacts_table.mjs b/src/db/migrations/20250909000000_create_contacts_table.mjs deleted file mode 100644 index a3a6198..0000000 --- a/src/db/migrations/20250909000000_create_contacts_table.mjs +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 联系信息表迁移文件 - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const up = function(knex) { - return knex.schema.createTable('contacts', function (table) { - table.increments('id').primary(); - table.string('name', 100).notNullable().comment('联系人姓名'); - table.string('email', 255).notNullable().comment('邮箱地址'); - table.string('subject', 255).notNullable().comment('主题'); - table.text('message').notNullable().comment('留言内容'); - table.string('ip_address', 45).nullable().comment('IP地址'); - table.text('user_agent').nullable().comment('浏览器信息'); - table.string('status', 20).defaultTo('unread').comment('处理状态: unread, read, replied'); - table.timestamps(true, true); // created_at, updated_at - - // 添加索引 - table.index('status', 'idx_contacts_status'); - table.index('created_at', 'idx_contacts_created_at'); - table.index('email', 'idx_contacts_email'); - }); -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export const down = function(knex) { - return knex.schema.dropTable('contacts'); -}; \ No newline at end of file diff --git a/src/db/migrations/20250910000001_add_performance_indexes.mjs b/src/db/migrations/20250910000001_add_performance_indexes.mjs deleted file mode 100644 index 0341d82..0000000 --- a/src/db/migrations/20250910000001_add_performance_indexes.mjs +++ /dev/null @@ -1,146 +0,0 @@ -/** - * 数据库性能优化索引迁移 - * 添加必要的复合索引以提升查询性能 - */ - -export const up = async (knex) => { - console.log('开始添加性能优化索引...') - - // 用户表索引优化 - await knex.schema.alterTable("users", (table) => { - // 单字段索引 - table.index(["email"], "idx_users_email") - table.index(["username"], "idx_users_username") - table.index(["status"], "idx_users_status") - table.index(["role"], "idx_users_role") - table.index(["created_at"], "idx_users_created_at") - - // 复合索引 - table.index(["status", "created_at"], "idx_users_status_created") - table.index(["role", "status"], "idx_users_role_status") - }) - console.log('✓ 用户表索引添加完成') - - // 文章表索引优化 - await knex.schema.alterTable("articles", (table) => { - // 单字段索引 - table.index(["author"], "idx_articles_author") - table.index(["category"], "idx_articles_category") - table.index(["status"], "idx_articles_status") - table.index(["slug"], "idx_articles_slug") - table.index(["published_at"], "idx_articles_published_at") - table.index(["view_count"], "idx_articles_view_count") - table.index(["created_at"], "idx_articles_created_at") - table.index(["updated_at"], "idx_articles_updated_at") - - // 复合索引 - 提升常用查询性能 - table.index(["status", "published_at"], "idx_articles_status_published") - table.index(["author", "status"], "idx_articles_author_status") - table.index(["category", "status"], "idx_articles_category_status") - table.index(["status", "view_count"], "idx_articles_status_views") - table.index(["author", "created_at"], "idx_articles_author_created") - table.index(["category", "published_at"], "idx_articles_category_published") - - // 全文搜索相关索引(SQLite不支持全文索引,但可以优化LIKE查询) - // 注意:SQLite的LIKE查询在列开头匹配时可以使用索引 - }) - console.log('✓ 文章表索引添加完成') - - // 书签表索引优化 - await knex.schema.alterTable("bookmarks", (table) => { - // 单字段索引 - table.index(["user_id"], "idx_bookmarks_user_id") - table.index(["url"], "idx_bookmarks_url") - table.index(["created_at"], "idx_bookmarks_created_at") - - // 复合索引 - table.index(["user_id", "created_at"], "idx_bookmarks_user_created") - table.index(["user_id", "url"], "idx_bookmarks_user_url") // 用于查重 - }) - console.log('✓ 书签表索引添加完成') - - // 联系人表索引优化 - await knex.schema.alterTable("contacts", (table) => { - // 单字段索引 - table.index(["email"], "idx_contacts_email") - table.index(["status"], "idx_contacts_status") - table.index(["created_at"], "idx_contacts_created_at") - - // 复合索引 - table.index(["status", "created_at"], "idx_contacts_status_created") - table.index(["email", "created_at"], "idx_contacts_email_created") - }) - console.log('✓ 联系人表索引添加完成') - - // 站点配置表索引优化 - await knex.schema.alterTable("site_config", (table) => { - // key字段应该已经有唯一索引,这里添加其他有用的索引 - table.index(["updated_at"], "idx_site_config_updated_at") - }) - console.log('✓ 站点配置表索引添加完成') - - console.log('所有性能优化索引添加完成!') -} - -export const down = async (knex) => { - console.log('开始移除性能优化索引...') - - // 用户表索引移除 - await knex.schema.alterTable("users", (table) => { - table.dropIndex(["email"], "idx_users_email") - table.dropIndex(["username"], "idx_users_username") - table.dropIndex(["status"], "idx_users_status") - table.dropIndex(["role"], "idx_users_role") - table.dropIndex(["created_at"], "idx_users_created_at") - table.dropIndex(["status", "created_at"], "idx_users_status_created") - table.dropIndex(["role", "status"], "idx_users_role_status") - }) - console.log('✓ 用户表索引移除完成') - - // 文章表索引移除 - await knex.schema.alterTable("articles", (table) => { - table.dropIndex(["author"], "idx_articles_author") - table.dropIndex(["category"], "idx_articles_category") - table.dropIndex(["status"], "idx_articles_status") - table.dropIndex(["slug"], "idx_articles_slug") - table.dropIndex(["published_at"], "idx_articles_published_at") - table.dropIndex(["view_count"], "idx_articles_view_count") - table.dropIndex(["created_at"], "idx_articles_created_at") - table.dropIndex(["updated_at"], "idx_articles_updated_at") - table.dropIndex(["status", "published_at"], "idx_articles_status_published") - table.dropIndex(["author", "status"], "idx_articles_author_status") - table.dropIndex(["category", "status"], "idx_articles_category_status") - table.dropIndex(["status", "view_count"], "idx_articles_status_views") - table.dropIndex(["author", "created_at"], "idx_articles_author_created") - table.dropIndex(["category", "published_at"], "idx_articles_category_published") - }) - console.log('✓ 文章表索引移除完成') - - // 书签表索引移除 - await knex.schema.alterTable("bookmarks", (table) => { - table.dropIndex(["user_id"], "idx_bookmarks_user_id") - table.dropIndex(["url"], "idx_bookmarks_url") - table.dropIndex(["created_at"], "idx_bookmarks_created_at") - table.dropIndex(["user_id", "created_at"], "idx_bookmarks_user_created") - table.dropIndex(["user_id", "url"], "idx_bookmarks_user_url") - }) - console.log('✓ 书签表索引移除完成') - - // 联系人表索引移除 - await knex.schema.alterTable("contacts", (table) => { - table.dropIndex(["email"], "idx_contacts_email") - table.dropIndex(["status"], "idx_contacts_status") - table.dropIndex(["created_at"], "idx_contacts_created_at") - table.dropIndex(["status", "created_at"], "idx_contacts_status_created") - table.dropIndex(["email", "created_at"], "idx_contacts_email_created") - }) - console.log('✓ 联系人表索引移除完成') - - // 站点配置表索引移除 - await knex.schema.alterTable("site_config", (table) => { - table.dropIndex(["updated_at"], "idx_site_config_updated_at") - }) - console.log('✓ 站点配置表索引移除完成') - - console.log('所有性能优化索引移除完成!') -} \ No newline at end of file diff --git a/src/db/models/ArticleModel.js b/src/db/models/ArticleModel.js deleted file mode 100644 index c034aca..0000000 --- a/src/db/models/ArticleModel.js +++ /dev/null @@ -1,548 +0,0 @@ -import BaseModel, { handleDatabaseError } from "./BaseModel.js" -import db from "../index.js" - -class ArticleModel extends BaseModel { - static get tableName() { - return "articles" - } - - static get searchableFields() { - return ["title", "content", "tags", "keywords", "description", "excerpt"] - } - - static get filterableFields() { - return ["author", "category", "status"] - } - - static get defaultOrderBy() { - return "created_at" - } - // ==================== 新增关联查询方法 ==================== - - /** - * 获取作者相关文章(包含作者信息) - */ - static async findByAuthorWithProfile(author) { - const relations = [{ - type: 'left', - table: 'users', - on: ['articles.author', 'users.username'], - select: [ - 'articles.*', - 'users.name as author_name', - 'users.avatar as author_avatar', - 'users.bio as author_bio' - ] - }] - - return this.findWithRelations( - { 'articles.author': author, 'articles.status': 'published' }, - relations, - { orderBy: 'articles.published_at', order: 'desc' } - ) - } - - /** - * 获取最受欢迎的文章(包含作者信息) - */ - static async getPopularArticlesWithAuthor(limit = 10) { - const relations = [{ - type: 'left', - table: 'users', - on: ['articles.author', 'users.username'], - select: [ - 'articles.*', - 'users.name as author_name', - 'users.avatar as author_avatar' - ] - }] - - return this.findWithRelations( - { 'articles.status': 'published' }, - relations, - { orderBy: 'articles.view_count', order: 'desc', limit } - ) - } - - /** - * 获取最新文章(包含作者信息) - */ - static async getRecentArticlesWithAuthor(limit = 10) { - const relations = [{ - type: 'left', - table: 'users', - on: ['articles.author', 'users.username'], - select: [ - 'articles.*', - 'users.name as author_name', - 'users.avatar as author_avatar' - ] - }] - - return this.findWithRelations( - { 'articles.status': 'published' }, - relations, - { orderBy: 'articles.published_at', order: 'desc', limit } - ) - } - - /** - * 获取精选文章(包含作者信息) - */ - static async getFeaturedArticlesWithAuthor(limit = 5) { - const relations = [{ - type: 'left', - table: 'users', - on: ['articles.author', 'users.username'], - select: [ - 'articles.*', - 'users.name as author_name', - 'users.avatar as author_avatar' - ] - }] - - return this.findWithRelations( - { - 'articles.status': 'published', - 'articles.featured_image': db.raw('NOT NULL') - }, - relations, - { orderBy: 'articles.published_at', order: 'desc', limit } - ) - } - - /** - * 按分类获取文章(包含作者信息) - */ - static async findByCategoryWithAuthor(category, limit = 20) { - const relations = [{ - type: 'left', - table: 'users', - on: ['articles.author', 'users.username'], - select: [ - 'articles.*', - 'users.name as author_name', - 'users.avatar as author_avatar' - ] - }] - - return this.findWithRelations( - { - 'articles.category': category, - 'articles.status': 'published' - }, - relations, - { orderBy: 'articles.published_at', order: 'desc', limit } - ) - } - - /** - * 搜索文章(包含作者信息) - */ - static async searchWithAuthor(keyword, limit = 20) { - try { - return await db(this.tableName) - .leftJoin('users', 'articles.author', 'users.username') - .select( - 'articles.*', - 'users.name as author_name', - 'users.avatar as author_avatar' - ) - .where('articles.status', 'published') - .where(function () { - this.where('articles.title', 'like', `%${keyword}%`) - .orWhere('articles.content', 'like', `%${keyword}%`) - .orWhere('articles.keywords', 'like', `%${keyword}%`) - .orWhere('articles.description', 'like', `%${keyword}%`) - .orWhere('articles.excerpt', 'like', `%${keyword}%`) - }) - .orderBy('articles.published_at', 'desc') - .limit(limit) - } catch (error) { - throw handleDatabaseError(error, `搜索文章`) - } - } - - // ==================== 原有方法保持不变 ==================== - return db("articles").orderBy("created_at", "desc") - } - - static async findPublished(offset, limit) { - let query = db("articles") - .where("status", "published") - .whereNotNull("published_at") - .orderBy("published_at", "desc") - if (typeof offset === "number") { - query = query.offset(offset) - } - if (typeof limit === "number") { - query = query.limit(limit) - } - return query - } - - 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 findByAuthorAll(author) { - return db("articles").where("author", author).orderBy("updated_at", "desc") - } - - static async findByAuthorWithPagination(author, options = {}) { - const { - page = 1, - limit = 10, - status = null, - keyword = null, - orderBy = 'updated_at', - order = 'desc' - } = options; - - let query = db("articles").where("author", author); - - // 状态筛选 - if (status) { - query = query.where("status", status); - } - - // 关键词搜索 - if (keyword && keyword.trim()) { - const searchKeyword = keyword.trim(); - query = query.where(function() { - this.where("title", "like", `%${searchKeyword}%`) - .orWhere("content", "like", `%${searchKeyword}%`) - .orWhere("tags", "like", `%${searchKeyword}%`) - .orWhere("description", "like", `%${searchKeyword}%`); - }); - } - - // 获取总数 - const countQuery = query.clone(); - const totalResult = await countQuery.count("id as count").first(); - const total = totalResult ? parseInt(totalResult.count) : 0; - - // 分页查询 - const offset = (page - 1) * limit; - const articles = await query - .orderBy(orderBy, order) - .limit(limit) - .offset(offset); - - return { - articles, - pagination: { - page: parseInt(page), - limit: parseInt(limit), - total, - totalPages: Math.ceil(total / limit), - hasNext: page * limit < total, - hasPrev: page > 1 - } - }; - } - - 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) - } - - // 只插入数据库表中存在的字段 - const insertData = { - title: data.title, - content: data.content, - author: data.author, - category: data.category || '', - tags, - keywords: data.keywords || '', - description: data.description || '', - slug, - reading_time: readingTime, - excerpt, - status: data.status || "draft", - view_count: 0, - featured_image: data.featured_image || '', - meta_title: data.meta_title || '', - meta_description: data.meta_description || '', - created_at: db.fn.now(), - updated_at: db.fn.now(), - }; - - const result = await db("articles") - .insert(insertData) - .returning("*"); - - return Array.isArray(result) ? result[0] : result // 确保返回单个对象 - } - - 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() - } - - // 只更新数据库表中存在的字段 - const updateData = { - updated_at: db.fn.now(), - }; - - // 有选择地更新字段 - if (data.title !== undefined) updateData.title = data.title; - if (data.content !== undefined) updateData.content = data.content; - if (data.category !== undefined) updateData.category = data.category; - if (data.keywords !== undefined) updateData.keywords = data.keywords; - if (data.description !== undefined) updateData.description = data.description; - if (data.featured_image !== undefined) updateData.featured_image = data.featured_image; - if (data.meta_title !== undefined) updateData.meta_title = data.meta_title; - if (data.meta_description !== undefined) updateData.meta_description = data.meta_description; - if (data.status !== undefined) updateData.status = data.status; - - // 处理计算字段 - updateData.tags = tags || current.tags; - updateData.slug = slug || current.slug; - updateData.reading_time = readingTime || current.reading_time; - updateData.excerpt = excerpt || current.excerpt; - updateData.published_at = publishedAt || current.published_at; - - const result = await db("articles") - .where("id", id) - .update(updateData) - .returning("*"); - - return Array.isArray(result) ? result[0] : result // 确保返回单个对象 - } - - 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) { - const result = await db("articles") - .where("id", id) - .update({ - status: "published", - published_at: db.fn.now(), - updated_at: db.fn.now(), - }) - .returning("*"); - - return Array.isArray(result) ? result[0] : result // 确保返回单个对象 - } - - static async unpublish(id) { - const result = await db("articles") - .where("id", id) - .update({ - status: "draft", - published_at: null, - updated_at: db.fn.now(), - }) - .returning("*"); - - return Array.isArray(result) ? result[0] : result // 确保返回单个对象 - } - - static async incrementViewCount(id) { - const result = await db("articles") - .where("id", id) - .increment("view_count", 1) - .returning("*"); - - return Array.isArray(result) ? result[0] : result // 确保返回单个对象 - } - - 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 } diff --git a/src/db/models/BaseModel.js b/src/db/models/BaseModel.js deleted file mode 100644 index c15e39f..0000000 --- a/src/db/models/BaseModel.js +++ /dev/null @@ -1,580 +0,0 @@ -import db from "../index.js" -import { logger } from "../../logger.js" -import { BaseSingleton } from "@/utils/BaseSingleton" - -/** - * 数据库错误类 - */ -export class DatabaseError extends Error { - constructor(message, code, originalError) { - super(message) - this.name = "DatabaseError" - this.code = code - this.originalError = originalError - } -} - -/** - * 处理数据库错误的统一函数 - */ -export const handleDatabaseError = (error, operation = "数据库操作") => { - logger.error(`${operation}失败:`, error) - - if (error.code === "SQLITE_CONSTRAINT") { - return new DatabaseError("数据约束违反", "CONSTRAINT_VIOLATION", error) - } - if (error.code === "SQLITE_BUSY") { - return new DatabaseError("数据库忙,请稍后重试", "DATABASE_BUSY", error) - } - if (error.code === "SQLITE_LOCKED") { - return new DatabaseError("数据库被锁定", "DATABASE_LOCKED", error) - } - if (error.code === "SQLITE_NOTFOUND") { - return new DatabaseError("记录不存在", "NOT_FOUND", error) - } - - return new DatabaseError(`${operation}失败: ${error.message}`, "DATABASE_ERROR", error) -} - -/** - * 统一的数据库基础模型类 - * 提供标准化的CRUD操作和错误处理 - */ -export default class BaseModel extends BaseSingleton { - - /** - * @returns {BaseModel} - */ - static getInstance() { - return super.getInstance() - } - - constructor() { - super() - } - - /** - * 获取表名,必须由子类实现 - */ - get tableName() { - throw new Error("tableName must be defined in subclass") - } - - /** - * 获取默认排序字段 - */ - get defaultOrderBy() { - return "id" - } - - /** - * 获取默认排序方向 - */ - get defaultOrder() { - return "desc" - } - - /** - * 获取可搜索字段列表 - */ - get searchableFields() { - return [] - } - - /** - * 获取可过滤字段列表 - */ - get filterableFields() { - return [] - } - - /** - * 根据ID查找单条记录 - */ - async findById(id) { - try { - const result = await db(this.tableName).where("id", id).first() - return result || null - } catch (error) { - throw handleDatabaseError(error, `查找${this.tableName}记录(ID: ${id})`) - } - } - - /** - * 查找所有记录,支持分页和排序 - */ - async findAll(options = {}) { - try { - const { page = 1, limit = 10, orderBy = this.defaultOrderBy, order = this.defaultOrder, where = {}, select = "*" } = options - - const offset = (page - 1) * limit - - let query = db(this.tableName).select(select) - - // 添加where条件 - if (Object.keys(where).length > 0) { - query = query.where(where) - } - - // 添加排序和分页 - query = query.orderBy(orderBy, order).limit(limit).offset(offset) - - return await query - } catch (error) { - throw handleDatabaseError(error, `查找${this.tableName}记录列表`) - } - } - - /** - * 查找第一条记录 - */ - async findFirst(conditions = {}) { - try { - return (await db(this.tableName).where(conditions).first()) || null - } catch (error) { - throw handleDatabaseError(error, `查找${this.tableName}第一条记录`) - } - } - - /** - * 根据条件查找记录 - */ - async findWhere(conditions, options = {}) { - try { - const { orderBy = this.defaultOrderBy, order = this.defaultOrder, limit, select = "*" } = options - - let query = db(this.tableName).select(select).where(conditions) - - if (orderBy) { - query = query.orderBy(orderBy, order) - } - - if (limit) { - query = query.limit(limit) - } - - return await query - } catch (error) { - throw handleDatabaseError(error, `按条件查找${this.tableName}记录`) - } - } - - /** - * 创建新记录 - */ - async create(data) { - try { - const insertData = { - ...data, - created_at: db.fn.now(), - updated_at: db.fn.now(), - } - - const result = await db(this.tableName).insert(insertData).returning("*") - - // SQLite returning() 总是返回数组,这里统一返回第一个元素 - return Array.isArray(result) ? result[0] : result - } catch (error) { - throw handleDatabaseError(error, `创建${this.tableName}记录`) - } - } - - /** - * 更新记录 - */ - async update(id, data) { - try { - const updateData = { - ...data, - updated_at: db.fn.now(), - } - - const result = await db(this.tableName).where("id", id).update(updateData).returning("*") - - // SQLite returning() 总是返回数组,这里统一返回第一个元素 - return Array.isArray(result) ? result[0] : result - } catch (error) { - throw handleDatabaseError(error, `更新${this.tableName}记录(ID: ${id})`) - } - } - - /** - * 根据条件更新记录 - */ - async updateWhere(conditions, data) { - try { - const updateData = { - ...data, - updated_at: db.fn.now(), - } - - return await db(this.tableName).where(conditions).update(updateData) - } catch (error) { - throw handleDatabaseError(error, `按条件更新${this.tableName}记录`) - } - } - - /** - * 删除记录 - */ - async delete(id) { - try { - return await db(this.tableName).where("id", id).del() - } catch (error) { - throw handleDatabaseError(error, `删除${this.tableName}记录(ID: ${id})`) - } - } - - /** - * 根据条件删除记录 - */ - async deleteWhere(conditions) { - try { - return await db(this.tableName).where(conditions).del() - } catch (error) { - throw handleDatabaseError(error, `按条件删除${this.tableName}记录`) - } - } - - /** - * 统计记录数量 - */ - async count(conditions = {}) { - try { - const result = await db(this.tableName).where(conditions).count("id as count").first() - return parseInt(result.count) || 0 - } catch (error) { - throw handleDatabaseError(error, `统计${this.tableName}记录数量`) - } - } - - /** - * 检查记录是否存在 - */ - async exists(conditions) { - try { - const count = await this.count(conditions) - return count > 0 - } catch (error) { - throw handleDatabaseError(error, `检查${this.tableName}记录是否存在`) - } - } - - /** - * 分页查询 - */ - async paginate(options = {}) { - try { - const { - page = 1, - limit = 10, - orderBy = this.defaultOrderBy, - order = this.defaultOrder, - where = {}, - select = "*", - search = "", - searchFields = this.searchableFields, - } = options - - let query = db(this.tableName).select(select) - - // 添加where条件 - if (Object.keys(where).length > 0) { - query = query.where(where) - } - - // 添加搜索条件 - if (search && searchFields.length > 0) { - query = query.where(function () { - searchFields.forEach((field, index) => { - if (index === 0) { - this.where(field, "like", `%${search}%`) - } else { - this.orWhere(field, "like", `%${search}%`) - } - }) - }) - } - - // 获取总数 - const countQuery = query.clone() - const totalResult = await countQuery.count("id as count").first() - const total = parseInt(totalResult.count) || 0 - - // 分页查询 - const offset = (page - 1) * limit - const data = await query.orderBy(orderBy, order).limit(limit).offset(offset) - - return { - data, - pagination: { - page: parseInt(page), - limit: parseInt(limit), - total, - totalPages: Math.ceil(total / limit), - hasNext: page * limit < total, - hasPrev: page > 1, - }, - } - } catch (error) { - throw handleDatabaseError(error, `分页查询${this.tableName}记录`) - } - } - - /** - * 批量创建记录 - */ - async createMany(dataArray, batchSize = 100) { - try { - const results = [] - - for (let i = 0; i < dataArray.length; i += batchSize) { - const batch = dataArray.slice(i, i + batchSize).map(data => ({ - ...data, - created_at: db.fn.now(), - updated_at: db.fn.now(), - })) - - const batchResults = await db(this.tableName).insert(batch).returning("*") - - results.push(...(Array.isArray(batchResults) ? batchResults : [batchResults])) - } - - return results - } catch (error) { - throw handleDatabaseError(error, `批量创建${this.tableName}记录`) - } - } - - /** - * 批量更新记录 - */ - async updateMany(conditions, data) { - try { - const updateData = { - ...data, - updated_at: db.fn.now(), - } - - return await db(this.tableName).where(conditions).update(updateData) - } catch (error) { - throw handleDatabaseError(error, `批量更新${this.tableName}记录`) - } - } - - /** - * 获取表结构信息 - */ - async getTableInfo() { - try { - return await db.raw(`PRAGMA table_info(${this.tableName})`) - } catch (error) { - throw handleDatabaseError(error, `获取${this.tableName}表结构信息`) - } - } - - /** - * 清空表数据 - */ - async truncate() { - try { - return await db(this.tableName).del() - } catch (error) { - throw handleDatabaseError(error, `清空${this.tableName}表数据`) - } - } - - /** - * 获取随机记录 - */ - async findRandom(limit = 1) { - try { - return await db(this.tableName).orderByRaw("RANDOM()").limit(limit) - } catch (error) { - throw handleDatabaseError(error, `获取${this.tableName}随机记录`) - } - } - - /** - * 关联查询基础方法 - 左连接 - */ - leftJoin(joinTable, leftKey, rightKey) { - return db(this.tableName).leftJoin(joinTable, leftKey, rightKey) - } - - /** - * 关联查询基础方法 - 内连接 - */ - innerJoin(joinTable, leftKey, rightKey) { - return db(this.tableName).innerJoin(joinTable, leftKey, rightKey) - } - - /** - * 关联查询基础方法 - 右连接 - */ - rightJoin(joinTable, leftKey, rightKey) { - return db(this.tableName).rightJoin(joinTable, leftKey, rightKey) - } - - /** - * 构建复杂关联查询 - */ - buildRelationQuery(relations = []) { - let query = db(this.tableName) - - relations.forEach(relation => { - const { type, table, on, select } = relation - - switch (type) { - case "left": - query = query.leftJoin(table, on[0], on[1]) - break - case "inner": - query = query.innerJoin(table, on[0], on[1]) - break - case "right": - query = query.rightJoin(table, on[0], on[1]) - break - } - - if (select) { - query = query.select(select) - } - }) - - return query - } - - /** - * 通用关联查询方法 - */ - async findWithRelations(conditions = {}, relations = [], options = {}) { - try { - const { orderBy = this.defaultOrderBy, order = this.defaultOrder, limit, select = [`${this.tableName}.*`] } = options - - let query = this.buildRelationQuery(relations) - - if (select && select.length > 0) { - query = query.select(...select) - } - - if (Object.keys(conditions).length > 0) { - query = query.where(conditions) - } - - if (orderBy) { - query = query.orderBy(orderBy, order) - } - - if (limit) { - query = query.limit(limit) - } - - return await query - } catch (error) { - throw handleDatabaseError(error, `关联查询${this.tableName}记录`) - } - } - - // ==================== 事务支持方法 ==================== - - /** - * 在事务中创建记录 - */ - async createInTransaction(trx, data) { - try { - const insertData = { - ...data, - created_at: trx.fn.now(), - updated_at: trx.fn.now(), - } - - const result = await trx(this.tableName).insert(insertData).returning("*") - - return Array.isArray(result) ? result[0] : result - } catch (error) { - throw handleDatabaseError(error, `在事务中创建${this.tableName}记录`) - } - } - - /** - * 在事务中更新记录 - */ - async updateInTransaction(trx, id, data) { - try { - const updateData = { - ...data, - updated_at: trx.fn.now(), - } - - const result = await trx(this.tableName).where("id", id).update(updateData).returning("*") - - return Array.isArray(result) ? result[0] : result - } catch (error) { - throw handleDatabaseError(error, `在事务中更新${this.tableName}记录(ID: ${id})`) - } - } - - /** - * 在事务中删除记录 - */ - async deleteInTransaction(trx, id) { - try { - return await trx(this.tableName).where("id", id).del() - } catch (error) { - throw handleDatabaseError(error, `在事务中删除${this.tableName}记录(ID: ${id})`) - } - } - - /** - * 在事务中批量创建记录 - */ - async createManyInTransaction(trx, dataArray, batchSize = 100) { - try { - const results = [] - - for (let i = 0; i < dataArray.length; i += batchSize) { - const batch = dataArray.slice(i, i + batchSize).map(data => ({ - ...data, - created_at: trx.fn.now(), - updated_at: trx.fn.now(), - })) - - const batchResults = await trx(this.tableName).insert(batch).returning("*") - - results.push(...(Array.isArray(batchResults) ? batchResults : [batchResults])) - } - - return results - } catch (error) { - throw handleDatabaseError(error, `在事务中批量创建${this.tableName}记录`) - } - } - - /** - * 在事务中批量更新记录 - */ - async updateManyInTransaction(trx, conditions, data) { - try { - const updateData = { - ...data, - updated_at: trx.fn.now(), - } - - return await trx(this.tableName).where(conditions).update(updateData) - } catch (error) { - throw handleDatabaseError(error, `在事务中批量更新${this.tableName}记录`) - } - } - - /** - * 在事务中执行原生 SQL - */ - async rawInTransaction(trx, query, bindings = []) { - try { - return await trx.raw(query, bindings) - } catch (error) { - throw handleDatabaseError(error, `在事务中执行原生 SQL`) - } - } -} diff --git a/src/db/models/BookmarkModel.js b/src/db/models/BookmarkModel.js deleted file mode 100644 index 9741418..0000000 --- a/src/db/models/BookmarkModel.js +++ /dev/null @@ -1,158 +0,0 @@ -import BaseModel, { handleDatabaseError } from "./BaseModel.js" - -class BookmarkModel extends BaseModel { - static get tableName() { - return "bookmarks" - } - - static get searchableFields() { - return ["title", "url", "description"] - } - - static get filterableFields() { - return ["user_id"] - } - - // 特定业务方法 - static async findAllByUser(userId) { - return this.findWhere({ user_id: userId }, { orderBy: "id", order: "desc" }) - } - - static async findByUserAndUrl(userId, url) { - return this.findFirst({ user_id: userId, url }) - } - - // 重写create方法添加验证 - static async create(data) { - const userId = data.user_id - const url = typeof data.url === "string" ? data.url.trim() : data.url - - if (userId != null && url) { - const exists = await this.findByUserAndUrl(userId, url) - if (exists) { - throw new Error("该用户下已存在相同 URL 的书签") - } - } - - return super.create({ ...data, url }) - } - - // 重写update方法添加验证 - static async update(id, data) { - const current = await this.findById(id) - if (!current) return null - - const nextUserId = data.user_id != null ? data.user_id : current.user_id - const nextUrlRaw = data.url != null ? data.url : current.url - const nextUrl = typeof nextUrlRaw === "string" ? nextUrlRaw.trim() : nextUrlRaw - - if (nextUserId != null && nextUrl) { - const exists = await this.findFirst({ - user_id: nextUserId, - url: nextUrl - }) - // 排除当前记录 - if (exists && exists.id !== parseInt(id)) { - throw new Error("该用户下已存在相同 URL 的书签") - } - } - - return super.update(id, { - ...data, - url: data.url != null ? nextUrl : data.url - }) - } - - // 获取用户书签统计 - static async getUserBookmarkStats(userId) { - const total = await this.count({ user_id: userId }) - return { total } - } - - // 按用户分页查询书签 - static async findByUserWithPagination(userId, options = {}) { - return this.paginate({ - ...options, - where: { user_id: userId } - }) - } - - // 标记为已回复 - static async markAsReplied(id) { - return this.update(id, { status: "replied" }) - } - - // ==================== 新增关联查询方法 ==================== - - /** - * 获取用户书签(包含用户信息) - */ - static async findByUserWithProfile(userId) { - const relations = [{ - type: 'left', - table: 'users', - on: ['bookmarks.user_id', 'users.id'], - select: [ - 'bookmarks.*', - 'users.username', - 'users.name as user_name', - 'users.avatar as user_avatar' - ] - }] - - return this.findWithRelations( - { 'bookmarks.user_id': userId }, - relations, - { orderBy: 'bookmarks.created_at', order: 'desc' } - ) - } - - /** - * 获取所有书签及其用户信息 - */ - static async findAllWithUsers(options = {}) { - const { limit = 50, orderBy = 'created_at', order = 'desc' } = options - - const relations = [{ - type: 'left', - table: 'users', - on: ['bookmarks.user_id', 'users.id'], - select: [ - 'bookmarks.*', - 'users.username', - 'users.name as user_name' - ] - }] - - return this.findWithRelations( - {}, - relations, - { orderBy: `bookmarks.${orderBy}`, order, limit } - ) - } - - /** - * 获取热门书签(按用户数量统计) - */ - static async getPopularBookmarks(limit = 10) { - try { - return await db(this.tableName) - .select( - 'url', - 'title', - db.raw('COUNT(*) as bookmark_count'), - db.raw('MAX(created_at) as latest_bookmark') - ) - .groupBy('url', 'title') - .orderBy('bookmark_count', 'desc') - .limit(limit) - } catch (error) { - throw handleDatabaseError(error, `获取热门书签`) - } - } -} - -export default BookmarkModel -export { BookmarkModel } - - diff --git a/src/db/models/ContactModel.js b/src/db/models/ContactModel.js deleted file mode 100644 index 326aec4..0000000 --- a/src/db/models/ContactModel.js +++ /dev/null @@ -1,132 +0,0 @@ -import BaseModel, { handleDatabaseError } from "./BaseModel.js" -import db from "../index.js" - -class ContactModel extends BaseModel { - static get tableName() { - return "contacts" - } - - static get searchableFields() { - return ["name", "email", "subject", "message"] - } - - static get filterableFields() { - return ["status"] - } - - static get defaultOrderBy() { - return "created_at" - } - - // 获取db实例 - static get db() { - return db - } - - // 特定业务方法 - static async findByEmail(email) { - return this.findWhere({ email }, { orderBy: "created_at", order: "desc" }) - } - - static async findByStatus(status) { - return this.findWhere({ status }, { orderBy: "created_at", order: "desc" }) - } - - static async findByDateRange(startDate, endDate) { - try { - const query = this.findWhere({}) - return await query.whereBetween('created_at', [startDate, endDate]) - } catch (error) { - throw handleDatabaseError(error, `按日期范围查找${this.tableName}记录`) - } - } - - // 获取联系信息统计 - static async getStats() { - const total = await this.count() - const unread = await this.count({ status: "unread" }) - const read = await this.count({ status: "read" }) - const replied = await this.count({ status: "replied" }) - - return { - total, - unread, - read, - replied - } - } - - // 批量更新状态 - static async updateStatusBatch(ids, status) { - return this.updateMany( - { id: ids }, // 这里需要使用whereIn,但BaseModel的updateMany不支持 - { status } - ) - } - - // 重写以支持whereIn操作 - static async updateStatusBatchByIds(ids, status) { - try { - return await db(this.tableName) - .whereIn("id", ids) - .update({ - status, - updated_at: db.fn.now() - }) - } catch (error) { - throw handleDatabaseError(error, `批量更新${this.tableName}状态`) - } - } - - // 分页查询重写,使用父类方法 - static async findAllWithPagination(options = {}) { - const { - page = 1, - limit = 20, - status = null, - orderBy = 'created_at', - order = 'desc' - } = options - - const where = status ? { status } : {} - - return this.paginate({ - page, - limit, - where, - orderBy, - order - }) - } - - // 获取今日新联系数量 - static async getTodayCount() { - const today = new Date() - today.setHours(0, 0, 0, 0) - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) - - try { - const result = await db(this.tableName) - .whereBetween('created_at', [today, tomorrow]) - .count('id as count') - .first() - return parseInt(result.count) || 0 - } catch (error) { - throw handleDatabaseError(error, `获取今日${this.tableName}数量`) - } - } - - // 标记为已读 - static async markAsRead(id) { - return this.update(id, { status: "read" }) - } - - // 标记为已回复 - static async markAsReplied(id) { - return this.update(id, { status: "replied" }) - } -} - -export default ContactModel -export { ContactModel } \ No newline at end of file diff --git a/src/db/models/SiteConfigModel.js b/src/db/models/SiteConfigModel.js deleted file mode 100644 index d8340ae..0000000 --- a/src/db/models/SiteConfigModel.js +++ /dev/null @@ -1,81 +0,0 @@ -import BaseModel from "./BaseModel.js" -import db from "../index.js" - -class SiteConfigModel extends BaseModel { - static get tableName() { - return "site_config" - } - - static get searchableFields() { - return ["key"] - } - - // 特定业务方法 - // 获取指定key的配置 - static async get(key) { - const row = await this.findFirst({ key }) - return row ? row.value : null - } - - // 设置指定key的配置(有则更新,无则插入) - static async set(key, value) { - const exists = await this.findFirst({ key }) - if (exists) { - return await this.update(exists.id, { value }) - } else { - return await this.create({ key, value }) - } - } - - // 批量获取多个key的配置 - static async getMany(keys) { - const rows = await db(this.tableName).whereIn("key", keys) - const result = {} - rows.forEach(row => { - result[row.key] = row.value - }) - return result - } - - // 获取所有配置 - static async getAll() { - const rows = await db(this.tableName).select("key", "value").cache() - const result = {} - rows.forEach(row => { - result[row.key] = row.value - }) - return result - } - - // 批量设置配置 - static async setMany(configs) { - const results = [] - for (const [key, value] of Object.entries(configs)) { - results.push(await this.set(key, value)) - } - return results - } - - // 删除配置 - static async deleteByKey(key) { - const config = await this.findFirst({ key }) - if (config) { - return await this.delete(config.id) - } - return 0 - } - - // 检查配置是否存在 - static async hasKey(key) { - return await this.exists({ key }) - } - - // 获取配置统计 - static async getConfigStats() { - const total = await this.count() - return { total } - } -} - -export default SiteConfigModel -export { SiteConfigModel } \ No newline at end of file diff --git a/src/db/models/UserModel.js b/src/db/models/UserModel.js deleted file mode 100644 index 5e467e0..0000000 --- a/src/db/models/UserModel.js +++ /dev/null @@ -1,105 +0,0 @@ -import BaseModel from "./BaseModel.js" - -class UserModel extends BaseModel { - /** - * @returns {UserModel} - */ - static getInstance() { - return super.getInstance() - } - - constructor() { - super() - } - - get tableName() { - return "users" - } - - get searchableFields() { - return ["username", "email", "name"] - } - - get filterableFields() { - return ["role", "status"] - } - - // 特定业务方法 - async findByUsername(username) { - return this.findFirst({ username }) - } - - async findByEmail(email) { - return this.findFirst({ email }) - } - - // 重写create方法添加验证 - async create(data) { - // 验证唯一性 - if (data.username) { - const existingUser = await this.findByUsername(data.username) - if (existingUser) { - throw new Error("用户名已存在") - } - } - - if (data.email) { - const existingEmail = await this.findByEmail(data.email) - if (existingEmail) { - throw new Error("邮箱已存在") - } - } - - return super.create(data) - } - - // 重写update方法添加验证 - async update(id, data) { - // 验证唯一性(排除当前用户) - if (data.username) { - const existingUser = await this.findFirst({ username: data.username }) - if (existingUser && existingUser.id !== parseInt(id)) { - throw new Error("用户名已存在") - } - } - - if (data.email) { - const existingEmail = await this.findFirst({ email: data.email }) - if (existingEmail && existingEmail.id !== parseInt(id)) { - throw new Error("邮箱已存在") - } - } - - return super.update(id, data) - } - - // 用户状态管理 - async activate(id) { - return this.update(id, { status: "active" }) - } - - async deactivate(id) { - return this.update(id, { status: "inactive" }) - } - - // 按角色查找用户 - async findByRole(role) { - return this.findWhere({ role }) - } - - // 获取用户统计 - async getUserStats() { - const total = await this.count() - const active = await this.count({ status: "active" }) - const inactive = await this.count({ status: "inactive" }) - - return { - total, - active, - inactive, - } - } -} - -export default UserModel -export { UserModel } diff --git a/src/db/seeds/20250621013324_site_config_seed.mjs b/src/db/seeds/20250621013324_site_config_seed.mjs index ec3c7c5..a1e9c3a 100644 --- a/src/db/seeds/20250621013324_site_config_seed.mjs +++ b/src/db/seeds/20250621013324_site_config_seed.mjs @@ -4,12 +4,14 @@ export const seed = async (knex) => { // 插入常用站点配置项 await knex('site_config').insert([ - { key: 'site_title', value: '罗非鱼的秘密' }, + { key: 'site_title', value: '烟霞渡' }, { key: 'site_author', value: '罗非鱼' }, { key: 'site_author_avatar', value: 'https://alist.xieyaxin.top/p/%E6%B8%B8%E5%AE%A2%E6%96%87%E4%BB%B6/%E5%85%AC%E5%85%B1%E4%BF%A1%E6%81%AF/avatar.jpg' }, - { key: 'site_description', value: '一屋很小,却也很大' }, + { key: 'site_description', value: '如梦如幻,如烟如霞,似真似幻,似梦似醒' }, { key: 'site_logo', value: '/static/logo.png' }, { key: 'site_bg', value: '/static/bg.jpg' }, - { key: 'keywords', value: 'blog' } + { key: 'site_favicon', value: '/static/bg.jpg' }, + { key: 'keywords', value: 'blog' }, + { key: 'site_base', value: '/' } ]); }; diff --git a/src/db/seeds/20250830020000_articles_seed.mjs b/src/db/seeds/20250830020000_articles_seed.mjs deleted file mode 100644 index 0dea864..0000000 --- a/src/db/seeds/20250830020000_articles_seed.mjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -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!") -} diff --git a/src/middlewares/install.js b/src/middlewares/install.js index 49e3d5b..cc29264 100644 --- a/src/middlewares/install.js +++ b/src/middlewares/install.js @@ -111,7 +111,6 @@ export default async app => { // 提供全局数据 app.use(async (ctx, next) => { ctx.state.siteConfig = await SiteConfigService.getAll() - ctx.state.$config = config return await next() }) // 错误处理,主要处理运行中抛出的错误 diff --git a/src/modules/Admin/controller/index.js b/src/modules/Admin/controller/index.js new file mode 100644 index 0000000..5e263df --- /dev/null +++ b/src/modules/Admin/controller/index.js @@ -0,0 +1,43 @@ +import Router from "utils/router.js" +import { logger } from "@/logger.js" +import BaseController from "@/base/BaseController.js" +import UserService from "@/modules/Auth/services" +import UserModel from "@/modules/Auth/model/user" + +export default class AuthController extends BaseController { + /** + * 创建基础页面相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new this() + const router = new Router({ auth: true, prefix: "/admin" }) + + router.get("/", controller.handleRequest(controller.indexGet)) + router.get("/profile", controller.handleRequest(controller.profileGet)) + router.get("", controller.handleRequest(controller.indexGet)) + router.post("/profile/update", controller.handleRequest(controller.profileUpdate)) + + return router + } + + async indexGet(ctx) { + return this.render(ctx, "page/admin/index/index", {}) + } + + async profileGet(ctx) { + return this.render(ctx, "page/admin/profile/index", { + user: ctx.state.user, + }) + } + + async profileUpdate(ctx) { + await UserService.update(ctx.state.user.id, ctx.request.body) + ctx.state.user = await UserService.findById(ctx.state.user.id) + if(ctx.session.user) { + ctx.session.user = ctx.state.user + } + ctx.status = 200 + ctx.set("HX-Redirect", "/admin/profile") + } +} diff --git a/src/modules/Auth/controller/index.js b/src/modules/Auth/controller/index.js deleted file mode 100644 index 172cda9..0000000 --- a/src/modules/Auth/controller/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import Router from "utils/router.js" -import { logger } from "@/logger.js" -import BaseController from "@/base/BaseController.js" -import AuthService from "../services" - -export default class AuthController extends BaseController { - /** - * 创建基础页面相关路由 - * @returns {Router} 路由实例 - */ - static createRoutes() { - const controller = new this() - const router = new Router({ auth: false }) - - router.get("/login", controller.handleRequest(controller.loginGet)) - router.post("/login", controller.handleRequest(controller.loginPost)) - - router.post("/login/validate/username", controller.handleRequest(controller.validateUsername)) - router.post("/login/validate/password", controller.handleRequest(controller.validatePassword)) - - router.post("/logout", controller.handleRequest(controller.logout), { auth: true }) - - return router - } - - constructor() { - super() - } - - // 首页 - async loginGet(ctx) { - return this.render(ctx, "page/login/index", {}) - } - - async loginPost(ctx) { - const res = await AuthService.login(ctx.request.body) - ctx.session.user = res.user - ctx.set("HX-Redirect", "/") - } - - async validateUsername(ctx) { - const { username } = ctx.request.body - const uiPath = "page/login/_ui/username" - if (username === "") { - return this.render(ctx, uiPath, { - value: username, - error: "用户名不能为空", - }) - } - return this.render(ctx, uiPath, { - value: username, - error: undefined, - }) - } - - async validatePassword(ctx) { - const { password } = ctx.request.body - const uiPath = "page/login/_ui/password" - if (password === "") { - return this.render(ctx, uiPath, { - value: password, - error: "密码不能为空", - }) - } - return this.render(ctx, uiPath, { - value: password, - error: undefined, - }) - } - - async logout(ctx) { - ctx.session.user = null - ctx.set("HX-Redirect", "/") - } -} diff --git a/src/modules/Auth/controller/login.js b/src/modules/Auth/controller/login.js new file mode 100644 index 0000000..172cda9 --- /dev/null +++ b/src/modules/Auth/controller/login.js @@ -0,0 +1,75 @@ +import Router from "utils/router.js" +import { logger } from "@/logger.js" +import BaseController from "@/base/BaseController.js" +import AuthService from "../services" + +export default class AuthController extends BaseController { + /** + * 创建基础页面相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new this() + const router = new Router({ auth: false }) + + router.get("/login", controller.handleRequest(controller.loginGet)) + router.post("/login", controller.handleRequest(controller.loginPost)) + + router.post("/login/validate/username", controller.handleRequest(controller.validateUsername)) + router.post("/login/validate/password", controller.handleRequest(controller.validatePassword)) + + router.post("/logout", controller.handleRequest(controller.logout), { auth: true }) + + return router + } + + constructor() { + super() + } + + // 首页 + async loginGet(ctx) { + return this.render(ctx, "page/login/index", {}) + } + + async loginPost(ctx) { + const res = await AuthService.login(ctx.request.body) + ctx.session.user = res.user + ctx.set("HX-Redirect", "/") + } + + async validateUsername(ctx) { + const { username } = ctx.request.body + const uiPath = "page/login/_ui/username" + if (username === "") { + return this.render(ctx, uiPath, { + value: username, + error: "用户名不能为空", + }) + } + return this.render(ctx, uiPath, { + value: username, + error: undefined, + }) + } + + async validatePassword(ctx) { + const { password } = ctx.request.body + const uiPath = "page/login/_ui/password" + if (password === "") { + return this.render(ctx, uiPath, { + value: password, + error: "密码不能为空", + }) + } + return this.render(ctx, uiPath, { + value: password, + error: undefined, + }) + } + + async logout(ctx) { + ctx.session.user = null + ctx.set("HX-Redirect", "/") + } +} diff --git a/src/modules/Auth/controller/register.js b/src/modules/Auth/controller/register.js new file mode 100644 index 0000000..7fc408f --- /dev/null +++ b/src/modules/Auth/controller/register.js @@ -0,0 +1,101 @@ +import Router from "utils/router.js" +import { logger } from "@/logger.js" +import BaseController from "@/base/BaseController.js" +import AuthService from "../services" + +export default class AuthController extends BaseController { + /** + * 创建基础页面相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new this() + const router = new Router({ auth: false }) + + router.get("/register", controller.handleRequest(controller.registerGet)) + router.post("/register", controller.handleRequest(controller.registerPost)) + + router.post("/register/validate/username", controller.handleRequest(controller.validateUsername)) + router.post("/register/validate/password", controller.handleRequest(controller.validatePassword)) + router.post("/register/validate/confirmPassword", controller.handleRequest(controller.validateConfirmPassword)) + + return router + } + + constructor() { + super() + } + + // 首页 + async registerGet(ctx) { + return this.render(ctx, "page/register/index", {}) + } + + async registerPost(ctx) { + const { username, password, confirmPassword, nickname } = ctx.request.body + if (password !== confirmPassword) { + return this.render(ctx, "page/register/_ui/confirmPassword", { + value: confirmPassword, + error: "确认密码与密码不一致", + }) + } + const res = await AuthService.register({ username, password, nickname, role: "user" }) + ctx.set("HX-Redirect", "/") + } + + async validateUsername(ctx) { + const { username } = ctx.request.body + const uiPath = "page/register/_ui/username" + if (username === "") { + return this.render(ctx, uiPath, { + value: username, + error: "用户名不能为空", + }) + } + return this.render(ctx, uiPath, { + value: username, + error: undefined, + }) + } + + async validateConfirmPassword(ctx) { + const { confirmPassword, password } = ctx.request.body + const uiPath = "page/register/_ui/confirmPassword" + if (confirmPassword === "") { + return this.render(ctx, uiPath, { + value: confirmPassword, + error: "确认密码不能为空", + }) + } + if (confirmPassword !== password) { + return this.render(ctx, uiPath, { + value: confirmPassword, + error: "确认密码与密码不一致", + }) + } + return this.render(ctx, uiPath, { + value: confirmPassword, + error: undefined, + }) + } + + async validatePassword(ctx) { + const { password } = ctx.request.body + const uiPath = "page/register/_ui/password" + if (password === "") { + return this.render(ctx, uiPath, { + value: password, + error: "密码不能为空", + }) + } + return this.render(ctx, uiPath, { + value: password, + error: undefined, + }) + } + + async logout(ctx) { + ctx.session.user = null + ctx.set("HX-Redirect", "/") + } +} diff --git a/src/modules/Auth/controller/user.js b/src/modules/Auth/controller/user.js new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/Auth/model/user.js b/src/modules/Auth/model/user.js new file mode 100644 index 0000000..3c6af04 --- /dev/null +++ b/src/modules/Auth/model/user.js @@ -0,0 +1,107 @@ +import BaseModel from "@/base/BaseModel.js" + +class UserModel extends BaseModel { + /** + * @returns {UserModel} + */ + static getInstance() { + return super.getInstance() + } + + constructor() { + super() + } + + get tableName() { + return "users" + } + + get searchableFields() { + return ["username", "email", "name"] + } + + get filterableFields() { + return ["role", "status"] + } + + // 特定业务方法 + async findByUsername(username) { + return this.findFirst({ username }) + } + + async findByEmail(email) { + return this.findFirst({ email }) + } + + // 重写create方法添加验证 + async create(data) { + // 验证唯一性 + if (data.username) { + const existingUser = await this.findByUsername(data.username) + if (existingUser) { + throw new Error("用户名已存在") + } + } + + if (data.email) { + const existingEmail = await this.findByEmail(data.email) + if (existingEmail) { + throw new Error("邮箱已存在") + } + } + + return super.create(data) + } + + // 重写update方法添加验证 + async update(id, data) { + console.log(id, data); + + // 验证唯一性(排除当前用户) + if (data.username) { + const existingUser = await this.findFirst({ username: data.username }) + if (existingUser && existingUser.id !== parseInt(id)) { + throw new Error("用户名已存在") + } + } + + if (data.email) { + const existingEmail = await this.findFirst({ email: data.email }) + if (existingEmail && existingEmail.id !== parseInt(id)) { + throw new Error("邮箱已存在") + } + } + + return super.update(id, data) + } + + // 用户状态管理 + async activate(id) { + return this.update(id, { status: "active" }) + } + + async deactivate(id) { + return this.update(id, { status: "inactive" }) + } + + // 按角色查找用户 + async findByRole(role) { + return this.findWhere({ role }) + } + + // 获取用户统计 + async getUserStats() { + const total = await this.count() + const active = await this.count({ status: "active" }) + const inactive = await this.count({ status: "inactive" }) + + return { + total, + active, + inactive, + } + } +} + +export default UserModel +export { UserModel } diff --git a/src/modules/Auth/services/index.js b/src/modules/Auth/services/index.js index 063d413..378d732 100644 --- a/src/modules/Auth/services/index.js +++ b/src/modules/Auth/services/index.js @@ -1,6 +1,6 @@ -import UserModel from "@/db/models/UserModel.js" +import UserModel from "../model/user" import CommonError from "@/utils/error/CommonError.js" -import { comparePassword } from "@/utils/bcrypt.js" +import { comparePassword, hashPassword } from "@/utils/bcrypt.js" import { JWT_SECRET } from "@/middlewares/Auth/index.js" import jwt from "jsonwebtoken" @@ -9,6 +9,10 @@ import jwt from "jsonwebtoken" * 提供认证相关的业务逻辑 */ export class AuthService { + static async findById(id) { + return UserModel.getInstance().findById(id) + } + // 注册新用户 static async register(data) { try { @@ -83,6 +87,10 @@ export class AuthService { throw new CommonError(`登录失败: ${error.message}`) } } + + static async update(id, data) { + return UserModel.getInstance().update(id, data) + } } export default AuthService diff --git a/src/modules/SiteConfig/model/site-config.js b/src/modules/SiteConfig/model/site-config.js new file mode 100644 index 0000000..289523c --- /dev/null +++ b/src/modules/SiteConfig/model/site-config.js @@ -0,0 +1,81 @@ +import BaseModel from "@/base/BaseModel.js" +import db from "@/db" + +class SiteConfigModel extends BaseModel { + static get tableName() { + return "site_config" + } + + static get searchableFields() { + return ["key"] + } + + // 特定业务方法 + // 获取指定key的配置 + static async get(key) { + const row = await this.findFirst({ key }) + return row ? row.value : null + } + + // 设置指定key的配置(有则更新,无则插入) + static async set(key, value) { + const exists = await this.findFirst({ key }) + if (exists) { + return await this.update(exists.id, { value }) + } else { + return await this.create({ key, value }) + } + } + + // 批量获取多个key的配置 + static async getMany(keys) { + const rows = await db(this.tableName).whereIn("key", keys) + const result = {} + rows.forEach(row => { + result[row.key] = row.value + }) + return result + } + + // 获取所有配置 + static async getAll() { + const rows = await db(this.tableName).select("key", "value").cache() + const result = {} + rows.forEach(row => { + result[row.key] = row.value + }) + return result + } + + // 批量设置配置 + static async setMany(configs) { + const results = [] + for (const [key, value] of Object.entries(configs)) { + results.push(await this.set(key, value)) + } + return results + } + + // 删除配置 + static async deleteByKey(key) { + const config = await this.findFirst({ key }) + if (config) { + return await this.delete(config.id) + } + return 0 + } + + // 检查配置是否存在 + static async hasKey(key) { + return await this.exists({ key }) + } + + // 获取配置统计 + static async getConfigStats() { + const total = await this.count() + return { total } + } +} + +export default SiteConfigModel +export { SiteConfigModel } \ No newline at end of file diff --git a/src/modules/SiteConfig/services/index.js b/src/modules/SiteConfig/services/index.js index 0d414a2..ea402f9 100644 --- a/src/modules/SiteConfig/services/index.js +++ b/src/modules/SiteConfig/services/index.js @@ -1,4 +1,4 @@ -import SiteConfigModel from "@/db/models/SiteConfigModel.js" +import SiteConfigModel from "../model/site-config" import { logger } from "@/logger.js" /** diff --git a/src/views/helper/utils.pug b/src/views/helper/utils.pug index 28cebef..be3fe52 100644 --- a/src/views/helper/utils.pug +++ b/src/views/helper/utils.pug @@ -1,22 +1,15 @@ -mixin include() - if block - block -//- include的使用方法 -//- +include() -//- - var edit = false -//- include /htmx/footer.pug mixin css(url, extranl = false) if extranl || url.startsWith('http') || url.startsWith('//') link(rel="stylesheet" type="text/css" href=url) else - link(rel="stylesheet", href=($config && $config.base || "") + "public/"+ (url.startsWith('/') ? url.slice(1) : url)) + link(rel="stylesheet", href=(siteConfig && siteConfig.site_base || "") + "public/"+ (url.startsWith('/') ? url.slice(1) : url)) mixin js(url, extranl = false) if extranl || url.startsWith('http') || url.startsWith('//') script(type="text/javascript" src=url) else - script(src=($config && $config.base || "") + "public/" + (url.startsWith('/') ? url.slice(1) : url)) + script(src=(siteConfig && siteConfig.site_base || "") + "public/" + (url.startsWith('/') ? url.slice(1) : url)) mixin link(href, name) //- attributes == {class: "btn"} diff --git a/src/views/htmx/footer/index.pug b/src/views/htmx/footer/index.pug index a1ad3bd..59d59b9 100644 --- a/src/views/htmx/footer/index.pug +++ b/src/views/htmx/footer/index.pug @@ -6,7 +6,8 @@ footer.footer.shadow .footer-main(class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8") .footer-section h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") #{siteConfig.site_title} - p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 + //- p.footer-desc(class="text-gray-600 text-sm leading-relaxed") 明月照佳人,用真心对待世界。
岁月催人老,用真情对待自己。 + p.footer-desc(class="text-gray-600 text-sm leading-relaxed" style="text-wrap: balance;") #{siteConfig.site_description} .footer-section h3.footer-title(class="text-lg font-semibold text-gray-900 mb-4") 快速链接 diff --git a/src/views/layouts/admin.pug b/src/views/layouts/admin.pug new file mode 100644 index 0000000..97c9e01 --- /dev/null +++ b/src/views/layouts/admin.pug @@ -0,0 +1,33 @@ +extends /layouts/root.pug + +block $$head + style. + .page-layout { + flex: 1; + display: flex; + flex-direction: column; + width: 100%; + position: relative; + } + block Head + +block $$content + .page-layout.bg-gray-50 + canvas#background.absolute.block.top-0.left-0.z-0 + .h-full.relative.flex + .left-sidebar.border.border-r.border-gray-200(class="w-[250px]") + a.text-center.text-2xl.font-bold.mb-4(class="h-[75px] block leading-[75px]" href="/") 烟霞渡 + a(href="/admin" class=`cursor-pointer block h-[75px] leading-[75px] text-center hover:bg-gray-100 ${(currentPath === "/admin" || currentPath === "/admin/") ? "bg-gray-100" : ""}`) 仪表板 + a(href="/admin/profile" class=`cursor-pointer block h-[75px] leading-[75px] text-center hover:bg-gray-100 ${(currentPath === "/admin/profile" || currentPath === "/admin/profile/") ? "bg-gray-100" : ""}`) 用户信息 + .right-content(class="flex-1") + block Content + +block $$scripts + +js("https://cdnjs.cloudflare.com/ajax/libs/particlesjs/2.2.2/particles.min.js") + script. + Particles.init({ + selector: '#background', + maxParticles: 350, + }); + block Scripts + \ No newline at end of file diff --git a/src/views/page/admin/index/index.pug b/src/views/page/admin/index/index.pug new file mode 100644 index 0000000..e15bbce --- /dev/null +++ b/src/views/page/admin/index/index.pug @@ -0,0 +1,8 @@ +extends /layouts/admin.pug + +block Head + style + include ./style.css + +block Content + div \ No newline at end of file diff --git a/src/views/page/admin/index/style.css b/src/views/page/admin/index/style.css new file mode 100644 index 0000000..e69de29 diff --git a/src/views/page/admin/profile/index.pug b/src/views/page/admin/profile/index.pug new file mode 100644 index 0000000..917a531 --- /dev/null +++ b/src/views/page/admin/profile/index.pug @@ -0,0 +1,398 @@ +extends /layouts/admin.pug + +block Head + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css") + style + include ./style.css + +block Content + .profile-container + .profile-content + .profile-card + .card-header + .avatar-section + if user.avatar + img.avatar-preview(src=user.avatar alt="用户头像") + else + .avatar-placeholder + i.fas.fa-user + .avatar-info + h3 头像预览 + p 点击下方输入框更新头像链接 + + form.profile-form(hx-post="/admin/profile/update" hx-target="#profile-result") + .form-grid + .form-group + label.form-label(for="username") + i.fas.fa-user + span 用户名 + input.form-input( + type="text" + id="username" + name="username" + placeholder="请输入用户名" + value=user.username + required + ) + .form-hint 用于登录的唯一标识符 + + .form-group + label.form-label(for="email") + i.fas.fa-envelope + span 邮箱地址 + input.form-input( + type="email" + id="email" + name="email" + placeholder="请输入邮箱地址" + value=user.email + required + ) + .form-hint 用于接收系统通知和重置密码 + + .form-group + label.form-label(for="nickname") + i.fas.fa-id-card + span 昵称 + input.form-input( + type="text" + id="nickname" + name="nickname" + placeholder="请输入昵称" + value=user.nickname + ) + .form-hint 显示给其他用户的友好名称 + + .form-group + label.form-label(for="phone") + i.fas.fa-phone + span 联系电话 + input.form-input( + type="tel" + id="phone" + name="phone" + placeholder="请输入联系电话" + value=user.phone + ) + .form-hint 用于紧急联系和重要通知 + + .form-group + label.form-label(for="age") + i.fas.fa-birthday-cake + span 年龄 + input.form-input( + type="number" + id="age" + name="age" + placeholder="请输入年龄" + value=user.age + min="1" + max="120" + ) + .form-hint 用于个性化推荐和统计分析 + + .form-group.full-width + label.form-label(for="bio") + i.fas.fa-file-text + span 个人简介 + textarea.form-textarea( + id="bio" + name="bio" + placeholder="请介绍一下自己..." + rows="4" + )= user.bio + .form-hint 让其他用户了解您的背景和兴趣 + + .form-group + label.form-label(for="avatar") + i.fas.fa-image + span 头像链接 + input.form-input( + type="url" + id="avatar" + name="avatar" + placeholder="请输入头像图片链接" + value=user.avatar + ) + .form-hint 支持 JPG、PNG、GIF 格式的图片链接 + + .form-group + label.form-label(for="status") + i.fas.fa-toggle-on + span 账户状态 + select.form-select( + id="status" + name="status" + ) + option(value="active" selected=user.status === 'active') 活跃 + option(value="inactive" selected=user.status === 'inactive') 非活跃 + option(value="suspended" selected=user.status === 'suspended') 已暂停 + .form-hint 控制账户的可用状态 + + .form-group + label.form-label(for="role") + i.fas.fa-user-tag + span 用户角色 + select.form-select( + id="role" + name="role" + ) + option(value="user" selected=user.role === 'user') 普通用户 + option(value="admin" selected=user.role === 'admin') 管理员 + option(value="moderator" selected=user.role === 'moderator') 版主 + .form-hint 决定用户的权限级别 + + .form-actions + button.btn.btn-primary(type="submit") + i.fas.fa-save + span 保存更改 + button.btn.btn-secondary(type="button" onclick="history.back()") + i.fas.fa-arrow-left + span 返回 + + #profile-result.profile-result + +block Scripts + script. + document.addEventListener('DOMContentLoaded', function() { + // 头像预览功能 + const avatarInput = document.getElementById('avatar'); + const avatarPreview = document.querySelector('.avatar-preview'); + const avatarPlaceholder = document.querySelector('.avatar-placeholder'); + + if (avatarInput) { + avatarInput.addEventListener('input', function() { + const avatarUrl = this.value.trim(); + if (avatarUrl) { + if (avatarPreview) { + avatarPreview.src = avatarUrl; + avatarPreview.style.display = 'block'; + if (avatarPlaceholder) { + avatarPlaceholder.style.display = 'none'; + } + } else { + // 创建新的头像预览 + const newPreview = document.createElement('img'); + newPreview.className = 'avatar-preview'; + newPreview.src = avatarUrl; + newPreview.alt = '用户头像'; + avatarPlaceholder.parentNode.insertBefore(newPreview, avatarPlaceholder); + avatarPlaceholder.style.display = 'none'; + } + } else { + if (avatarPreview) { + avatarPreview.style.display = 'none'; + } + if (avatarPlaceholder) { + avatarPlaceholder.style.display = 'flex'; + } + } + }); + } + + // 表单验证 + const form = document.querySelector('.profile-form'); + if (form) { + form.addEventListener('submit', function(e) { + e.preventDefault(); + + // 清除之前的验证状态 + clearValidationStates(); + + // 验证必填字段 + const username = document.getElementById('username'); + const email = document.getElementById('email'); + + let isValid = true; + + if (username && !username.value.trim()) { + showFieldError(username, '用户名不能为空'); + isValid = false; + } + + if (email && !email.value.trim()) { + showFieldError(email, '邮箱不能为空'); + isValid = false; + } else if (email && !isValidEmail(email.value)) { + showFieldError(email, '请输入有效的邮箱地址'); + isValid = false; + } + + // 验证年龄 + const age = document.getElementById('age'); + if (age && age.value && (isNaN(age.value) || age.value < 1 || age.value > 120)) { + showFieldError(age, '年龄必须在1-120之间'); + isValid = false; + } + + // 验证头像URL + const avatar = document.getElementById('avatar'); + if (avatar && avatar.value && !isValidUrl(avatar.value)) { + showFieldError(avatar, '请输入有效的图片链接'); + isValid = false; + } + + if (isValid) { + // 显示加载状态 + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) { + submitBtn.classList.add('loading'); + submitBtn.disabled = true; + } + + // 提交表单 + htmx.ajax('POST', '/admin/profile/update', { + values: new FormData(form), + target: '#profile-result', + swap: 'innerHTML' + }).then(() => { + // 恢复按钮状态 + if (submitBtn) { + submitBtn.classList.remove('loading'); + submitBtn.disabled = false; + } + }); + } + }); + } + + // 实时验证 + const inputs = document.querySelectorAll('.form-input, .form-textarea, .form-select'); + inputs.forEach(input => { + input.addEventListener('blur', function() { + validateField(this); + }); + + input.addEventListener('input', function() { + if (this.classList.contains('error')) { + validateField(this); + } + }); + }); + + // 字段验证函数 + function validateField(field) { + const value = field.value.trim(); + const fieldName = field.name; + + clearFieldError(field); + + switch (fieldName) { + case 'username': + if (!value) { + showFieldError(field, '用户名不能为空'); + } else if (value.length < 3) { + showFieldError(field, '用户名至少需要3个字符'); + } else { + showFieldSuccess(field); + } + break; + + case 'email': + if (!value) { + showFieldError(field, '邮箱不能为空'); + } else if (!isValidEmail(value)) { + showFieldError(field, '请输入有效的邮箱地址'); + } else { + showFieldSuccess(field); + } + break; + + case 'age': + if (value && (isNaN(value) || value < 1 || value > 120)) { + showFieldError(field, '年龄必须在1-120之间'); + } else if (value) { + showFieldSuccess(field); + } + break; + + case 'avatar': + if (value && !isValidUrl(value)) { + showFieldError(field, '请输入有效的图片链接'); + } else if (value) { + showFieldSuccess(field); + } + break; + } + } + + // 显示字段错误 + function showFieldError(field, message) { + field.classList.add('error'); + field.classList.remove('success'); + + // 移除之前的错误提示 + const existingError = field.parentNode.querySelector('.field-error'); + if (existingError) { + existingError.remove(); + } + + // 添加新的错误提示 + const errorDiv = document.createElement('div'); + errorDiv.className = 'field-error'; + errorDiv.style.color = '#ef4444'; + errorDiv.style.fontSize = '0.8rem'; + errorDiv.style.marginTop = '0.25rem'; + errorDiv.textContent = message; + field.parentNode.appendChild(errorDiv); + } + + // 显示字段成功 + function showFieldSuccess(field) { + field.classList.remove('error'); + field.classList.add('success'); + + // 移除错误提示 + const existingError = field.parentNode.querySelector('.field-error'); + if (existingError) { + existingError.remove(); + } + } + + // 清除字段错误 + function clearFieldError(field) { + field.classList.remove('error', 'success'); + const existingError = field.parentNode.querySelector('.field-error'); + if (existingError) { + existingError.remove(); + } + } + + // 清除所有验证状态 + function clearValidationStates() { + inputs.forEach(input => { + clearFieldError(input); + }); + } + + // 验证邮箱格式 + function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + // 验证URL格式 + function isValidUrl(url) { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + // 处理HTMX响应 + document.body.addEventListener('htmx:afterRequest', function(event) { + const resultDiv = document.getElementById('profile-result'); + if (resultDiv && resultDiv.innerHTML.trim()) { + resultDiv.classList.add('show'); + + // 3秒后自动隐藏成功消息 + if (resultDiv.classList.contains('success')) { + setTimeout(() => { + resultDiv.classList.remove('show'); + }, 3000); + } + } + }); + }); \ No newline at end of file diff --git a/src/views/page/admin/profile/style.css b/src/views/page/admin/profile/style.css new file mode 100644 index 0000000..13629ef --- /dev/null +++ b/src/views/page/admin/profile/style.css @@ -0,0 +1,403 @@ +/* 用户信息页面样式 */ +.profile-container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + min-height: 100vh; +} + +/* 页面头部 */ +.profile-header { + text-align: center; + margin-bottom: 3rem; + animation: fadeInDown 0.6s ease-out; +} + +.profile-title { + font-size: 2.5rem; + font-weight: 700; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.profile-subtitle { + font-size: 1.1rem; + color: #6b7280; + font-weight: 400; + margin: 0; +} + +/* 主要内容区域 */ +.profile-content { + display: flex; + justify-content: center; + animation: fadeInUp 0.8s ease-out; +} + +.profile-card { + background: #ffffff; + border-radius: 20px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + overflow: hidden; + width: 100%; + max-width: 800px; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.profile-card:hover { + transform: translateY(-5px); + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); +} + +/* 卡片头部 */ +.card-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 2rem; + color: white; + text-align: center; +} + +.avatar-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.avatar-preview { + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid rgba(255, 255, 255, 0.3); + object-fit: cover; + transition: transform 0.3s ease; +} + +.avatar-preview:hover { + transform: scale(1.05); +} + +.avatar-placeholder { + width: 120px; + height: 120px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + border: 4px solid rgba(255, 255, 255, 0.3); + font-size: 3rem; + color: rgba(255, 255, 255, 0.8); +} + +.avatar-info h3 { + font-size: 1.5rem; + font-weight: 600; + margin: 0.5rem 0; +} + +.avatar-info p { + font-size: 0.9rem; + opacity: 0.9; + margin: 0; +} + +/* 表单样式 */ +.profile-form { + padding: 2rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.form-group { + display: flex; + flex-direction: column; + position: relative; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-label i { + color: #667eea; + width: 16px; + text-align: center; +} + +.form-input, +.form-textarea, +.form-select { + padding: 0.875rem 1rem; + border: 2px solid #e5e7eb; + border-radius: 12px; + font-size: 1rem; + transition: all 0.3s ease; + background: #ffffff; + color: #374151; + font-family: inherit; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + transform: translateY(-1px); +} + +.form-input:hover, +.form-textarea:hover, +.form-select:hover { + border-color: #d1d5db; +} + +.form-textarea { + resize: vertical; + min-height: 100px; + font-family: inherit; +} + +.form-hint { + font-size: 0.8rem; + color: #6b7280; + margin-top: 0.25rem; + line-height: 1.4; +} + +/* 按钮样式 */ +.form-actions { + display: flex; + gap: 1rem; + justify-content: center; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.875rem 2rem; + border: none; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + font-family: inherit; + min-width: 140px; + justify-content: center; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); +} + +.btn-secondary { + background: #f3f4f6; + color: #374151; + border: 2px solid #e5e7eb; +} + +.btn-secondary:hover { + background: #e5e7eb; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +/* 结果提示 */ +.profile-result { + margin-top: 1rem; + padding: 1rem; + border-radius: 12px; + text-align: center; + font-weight: 500; + opacity: 0; + transform: translateY(-10px); + transition: all 0.3s ease; +} + +.profile-result.show { + opacity: 1; + transform: translateY(0); +} + +.profile-result.success { + background: #d1fae5; + color: #065f46; + border: 1px solid #a7f3d0; +} + +.profile-result.error { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fca5a5; +} + +/* 动画效果 */ +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .profile-container { + padding: 1rem; + } + + .profile-title { + font-size: 2rem; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .form-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } + + .card-header { + padding: 1.5rem; + } + + .profile-form { + padding: 1.5rem; + } +} + +@media (max-width: 480px) { + .profile-container { + padding: 0.5rem; + } + + .profile-title { + font-size: 1.75rem; + } + + .avatar-preview, + .avatar-placeholder { + width: 80px; + height: 80px; + } + + .avatar-placeholder { + font-size: 2rem; + } +} + +/* 加载状态 */ +.btn.loading { + position: relative; + color: transparent; +} + +.btn.loading::after { + content: ''; + position: absolute; + width: 20px; + height: 20px; + top: 50%; + left: 50%; + margin-left: -10px; + margin-top: -10px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 输入验证状态 */ +.form-input.error, +.form-textarea.error, +.form-select.error { + border-color: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + +.form-input.success, +.form-textarea.success, +.form-select.success { + border-color: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); +} + +/* 头像预览更新动画 */ +.avatar-preview.updated { + animation: pulse 0.6s ease-in-out; +} + +/* 表单组聚焦效果 */ +.form-group:focus-within .form-label { + color: #667eea; +} + +.form-group:focus-within .form-label i { + color: #764ba2; +} diff --git a/src/views/page/auth/no-auth.pug b/src/views/page/auth/no-auth.pug index d578636..28b776a 100644 --- a/src/views/page/auth/no-auth.pug +++ b/src/views/page/auth/no-auth.pug @@ -1,6 +1,6 @@ -extends /layouts/empty.pug +extends /layouts/root.pug -block pageContent +block $$content .no-auth-container .no-auth-icon i.fa.fa-lock @@ -8,7 +8,7 @@ block pageContent p 您没有权限访问此页面,请先登录或联系管理员。 a.btn(href='/login') 去登录 -block pageHead +block $$head style. .no-auth-container { display: flex; @@ -48,7 +48,7 @@ block pageHead color: #fff200; } -//- block pageScripts +//- block $$scripts //- script. //- const curUrl = URL.parse(location.href).searchParams.get("from") //- fetch(curUrl,{redirect: 'error'}).then(res=>location.href=curUrl).catch(e=>console.log(e)) \ No newline at end of file diff --git a/src/views/page/index/index.pug b/src/views/page/index/index.pug index 221f557..9fb4993 100644 --- a/src/views/page/index/index.pug +++ b/src/views/page/index/index.pug @@ -28,7 +28,7 @@ block $$content a.flex.items-center.px-5(href="/login") 登录 else a.flex.items-center.px-5.cursor-pointer(hx-post="/logout") 退出 - a.flex.items-center.px-5.cursor-pointer 后台 + a.flex.items-center.px-5.cursor-pointer(href="/admin") 后台 a.flex.items-center.px-5(href="/profile") 欢迎您,#{user.username} canvas#background.absolute.block.top-0.left-0.z-0 .min-h-screen.relative diff --git a/src/views/page/register/_ui/confirmPassword.pug b/src/views/page/register/_ui/confirmPassword.pug new file mode 100644 index 0000000..e882373 --- /dev/null +++ b/src/views/page/register/_ui/confirmPassword.pug @@ -0,0 +1,14 @@ +- let confirmPasswordLabel = "确认密码" +- let confirmPasswordPlaceholder = "请输入确认密码" +- let confirmPasswordUrl = "/register/validate/confirmPassword" +div(hx-target="this" hx-swap="outerHTML") + div(class="relative") + label.block.text-sm.font-medium.text-gray-700.mb-2(for="confirmPassword") #{confirmPasswordLabel} + input(type="password" id="confirmPassword" value=value name="confirmPassword" placeholder=confirmPasswordPlaceholder hx-indicator="#ind" hx-post=confirmPasswordUrl hx-trigger="blur delay:500ms" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out" + (error ? ' border-red-500 focus:ring-red-500' : '')) + div(id="ind" class="htmx-indicator absolute right-3 top-9 transform -translate-y-1/2") + div(class="animate-spin h-5 w-5 border-2 border-gray-300 border-t-blue-500 rounded-full") + if error + div(class="error-message text-red-500 text-sm mt-2 flex items-center") + svg.w-4.h-4.mr-1(xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor") + path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z") + | #{error} \ No newline at end of file diff --git a/src/views/page/register/_ui/nickname.pug b/src/views/page/register/_ui/nickname.pug new file mode 100644 index 0000000..1b1abed --- /dev/null +++ b/src/views/page/register/_ui/nickname.pug @@ -0,0 +1,6 @@ +- let nicknameLabel = "昵称" +- let nicknamePlaceholder = "请输入昵称(可选,默认与用户名相同)" +div(hx-target="this" hx-swap="outerHTML") + div(class="relative") + label.block.text-sm.font-medium.text-gray-700.mb-2(for="nickname") #{nicknameLabel} + input(type="text" id="nickname" value=value name="nickname" placeholder=nicknamePlaceholder class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out") diff --git a/src/views/page/register/_ui/password.pug b/src/views/page/register/_ui/password.pug new file mode 100644 index 0000000..2af11ba --- /dev/null +++ b/src/views/page/register/_ui/password.pug @@ -0,0 +1,14 @@ +- let pwdLabel = "密码" +- let pwdPlaceholder = "请输入密码" +- let pwdUrl = "/register/validate/password" +div(hx-target="this" hx-swap="outerHTML") + div(class="relative") + label.block.text-sm.font-medium.text-gray-700.mb-2(for="password") #{pwdLabel} + input(type="password" id="password" value=value name="password" placeholder=pwdPlaceholder hx-indicator="#ind" hx-post=pwdUrl hx-trigger="blur delay:500ms" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out" + (error ? ' border-red-500 focus:ring-red-500' : '')) + div(id="ind" class="htmx-indicator absolute right-3 top-9 transform -translate-y-1/2") + div(class="animate-spin h-5 w-5 border-2 border-gray-300 border-t-blue-500 rounded-full") + if error + div(class="error-message text-red-500 text-sm mt-2 flex items-center") + svg.w-4.h-4.mr-1(xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor") + path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z") + | #{error} \ No newline at end of file diff --git a/src/views/page/register/_ui/username.pug b/src/views/page/register/_ui/username.pug new file mode 100644 index 0000000..bd909b7 --- /dev/null +++ b/src/views/page/register/_ui/username.pug @@ -0,0 +1,14 @@ +- let label = "用户名" +- let placeholder = "请输入用户名" +- let url = "/register/validate/username" +div(hx-target="this" hx-swap="outerHTML") + div(class="relative") + label.block.text-sm.font-medium.text-gray-700.mb-2(for="username") #{label} + input(type="text" id="username" value=value name="username" placeholder=placeholder hx-indicator="#ind" hx-post=url hx-trigger="blur delay:500ms" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200 ease-in-out" + (error ? ' border-red-500 focus:ring-red-500' : '')) + div(id="ind" class="htmx-indicator absolute right-3 top-9 transform -translate-y-1/2") + div(class="animate-spin h-5 w-5 border-2 border-gray-300 border-t-blue-500 rounded-full") + if error + div(class="error-message text-red-500 text-sm mt-2 flex items-center") + svg.w-4.h-4.mr-1(xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor") + path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z") + | #{error} \ No newline at end of file diff --git a/src/views/page/register/index.pug b/src/views/page/register/index.pug new file mode 100644 index 0000000..bfeea42 --- /dev/null +++ b/src/views/page/register/index.pug @@ -0,0 +1,67 @@ +extends /layouts/root.pug + +block $$head + style + include ./style.css + + +block $$content + .page-layout.bg-gray-50 + navbar + //- .placeholder(class="h-[75px] w-full opacity-0") + .fixed.top-0.left-0.right-0.z-10(class="h-[75px]") + .container.h-full + a.h-full.flex.items-center.float-left.text-2xl.font-bold(href="/") 烟霞渡 + canvas#background.absolute.block.top-0.left-0.z-0 + .h-full.relative(class="sm:px-6 lg:px-8") + .container.h-full.flex.items-center + .flex-1.h-full.flex.items-center.justify-center + h1.text-4xl.font-bold#chars + div 烟霞渡! + .mt-5 欢迎您的到来 + .flex-1.px-4.max-w-md + .w-full.space-y-8 + .py-8.px-4(class="sm:px-10") + .text-center.mb-8 + form.space-y-6(hx-post="/register") + include _ui/username.pug + include _ui/nickname.pug + include _ui/password.pug + include _ui/confirmPassword.pug + div + button.group.relative.w-full.flex.justify-center.py-3.px-4.border.border-transparent.text-sm.font-medium.rounded-md.text-white.bg-blue-600(type="submit" class="hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-150 ease-in-out") + span.absolute.left-0.inset-y-0.flex.items-center.pl-3 + span 注册 + .text-center + p.text-sm.text-gray-600 + | 已有账户? + a.font-medium.text-blue-600(href="/login" class="hover:text-blue-500") 立即登录 + +block $$scripts + +js("https://cdnjs.cloudflare.com/ajax/libs/particlesjs/2.2.2/particles.min.js") + +js("https://unpkg.co/gsap@3/dist/gsap.min.js") + +js("https://assets.codepen.io/16327/SplitText3-beta.min.js?b=43") + script. + Particles.init({ + selector: '#background', + maxParticles: 350, + }); + gsap.registerPlugin(SplitText); + let split, animation; + split = SplitText.create("#chars", {type:"chars"}); + animation && animation.revert(); + animation = gsap.from(split.chars, { + x: 150, + opacity: 0, + duration: 1.7, + ease: "power4", + stagger: 0.04 + }) + + document.addEventListener('htmx:error', function(evt) { + if(evt.detail.elt instanceof HTMLElement) { + if(evt.detail.elt.tagName === 'FORM' && evt.detail.xhr) { + window.alert(evt.detail.xhr.response || '请求失败') + } + } + }); \ No newline at end of file diff --git a/src/views/page/register/style.css b/src/views/page/register/style.css new file mode 100644 index 0000000..68dd56e --- /dev/null +++ b/src/views/page/register/style.css @@ -0,0 +1,22 @@ +.page-layout { + flex: 1; + display: flex; + flex-direction: column; + width: 100%; + position: relative; +} + +.container { + max-width: 1226px; + margin-right: auto; + margin-left: auto; + /* padding-left: 20px; + padding-right: 20px; */ +} + +@media (max-width: 640px) { + .container { + padding-left: 10px; + padding-right: 10px; + } +} \ No newline at end of file