feat: 完成项目初始化并重构部署流程
主要变更: - 添加完整的项目结构和模块(admin、articles、comments、users、session、oauth2、email、moderation、analytics、jobs 等) - 实现系统初始化 API(/init/status 和 /init/run) - 重写部署流程:迁移到 package.json scripts,删除 Makefile - 优化部署脚本:deploy.sh、healthcheck.sh、backup.sh、restore.sh、verify-env.sh - 更新 README.md:简化文档,整合部署指南 - 优化 AGENTS.md:精简到约 150 行,包含完整的代码规范和命令速查 - 配置 Docker Compose 自动化部署(prisma migrate deploy + seed) - 生成 OAuth2 RSA 密钥对支持 - 添加环境变量验证和数据库备份恢复功能
This commit is contained in:
parent
97f81fd010
commit
37742571ae
66
.env.example
Normal file
66
.env.example
Normal file
@ -0,0 +1,66 @@
|
||||
# ===== Server =====
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
APP_BASE_URL=http://localhost:3001
|
||||
|
||||
# ===== DB =====
|
||||
DATABASE_URL=postgresql://blog:blog@postgres:5432/linkshare?schema=public
|
||||
|
||||
# ===== Redis (BullMQ) =====
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# ===== Session (后端登录态 cookie) =====
|
||||
SESSION_COOKIE_SECRET=change_me
|
||||
SESSION_COOKIE_NAME=linkshare_session
|
||||
SESSION_COOKIE_SECURE=false
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
|
||||
# ===== OAuth2 Provider =====
|
||||
OAUTH2_ISSUER=http://localhost:3001
|
||||
|
||||
OAUTH2_ACCESS_TOKEN_TTL_SECONDS=3600
|
||||
OAUTH2_REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||
|
||||
OAUTH2_TOKEN_SIGNING_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----
|
||||
...
|
||||
-----END PRIVATE KEY-----
|
||||
OAUTH2_TOKEN_SIGNING_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----
|
||||
...
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
# Provider default client(给 web/admin 使用)
|
||||
OAUTH2_DEFAULT_CLIENT_ID=web-client
|
||||
OAUTH2_DEFAULT_CLIENT_SECRET=change_me
|
||||
OAUTH2_DEFAULT_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# ===== AI (BYOK via OpenRouter/AiMixHub) =====
|
||||
AI_PROVIDER=openrouter
|
||||
AI_API_KEY=change_me
|
||||
AI_BASE_URL=https://openrouter.ai/api/v1
|
||||
AI_MODEL_NAME=meta-llama/llama-3.1-70b-instruct
|
||||
|
||||
AI_REVIEW_ENABLED=true
|
||||
AI_REVIEW_TEMPERATURE=0.2
|
||||
|
||||
# ===== SMTP =====
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=user@example.com
|
||||
SMTP_PASS=change_me
|
||||
SMTP_FROM="LinkShare Blog <no-reply@example.com>"
|
||||
|
||||
# ===== Visitor analytics cookie =====
|
||||
VISITOR_COOKIE_NAME=linkshare_visitor
|
||||
VISITOR_COOKIE_MAX_AGE_DAYS=365
|
||||
|
||||
# ===== Link access =====
|
||||
LINK_URL_MODE_DEFAULT=slug_token
|
||||
|
||||
# ===== Security =====
|
||||
PASSWORD_SALT_ROUNDS=12
|
||||
|
||||
# ===== Rate Limiting =====
|
||||
THROTTLE_TTL=60000
|
||||
THROTTLE_LIMIT=100
|
||||
837
.opencode/plans/PLAN.md
Normal file
837
.opencode/plans/PLAN.md
Normal file
@ -0,0 +1,837 @@
|
||||
# Nuxt4 + Nuxt-UI 博客前端开发提示词
|
||||
|
||||
## 项目概述
|
||||
|
||||
```markdown
|
||||
# 任务:构建 LinkShare Blog 前端应用
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: Nuxt 4 (Vue 3 + Vite)
|
||||
- **UI 库**: Nuxt UI (基于 Tailwind CSS + Headless UI)
|
||||
- **状态管理**: Pinia
|
||||
- **HTTP 客户端**: fetch / ofetch
|
||||
- **Markdown 渲染**: @nuxt/content 或 markdown-it
|
||||
- **代码规范**: ESLint + Prettier
|
||||
|
||||
## 后端 API 信息
|
||||
|
||||
- **地址**: http://localhost:3001 (开发) / https://api.example.com (生产)
|
||||
- **认证**: Session Cookie (HttpOnly)
|
||||
- **文档**: http://localhost:3001/api-docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目初始化
|
||||
|
||||
```markdown
|
||||
请创建一个 Nuxt 4 项目,要求:
|
||||
|
||||
1. 安装 Nuxt UI: `npx nuxi@latest module add ui`
|
||||
2. 配置 Tailwind CSS
|
||||
3. 配置 Pinia 状态管理
|
||||
4. 设置 API 请求拦截器(携带 Cookie)
|
||||
5. 配置路由中间件(认证守卫)
|
||||
6. 创建布局文件:default.vue、admin.vue
|
||||
|
||||
目录结构:
|
||||
```
|
||||
|
||||
├── components/
|
||||
│ ├── ui/ # Nuxt UI 组件封装
|
||||
│ ├── layout/ # 布局组件
|
||||
│ ├── article/ # 文章相关组件
|
||||
│ ├── comment/ # 评论相关组件
|
||||
│ └── admin/ # 管理后台组件
|
||||
├── composables/
|
||||
│ ├── useAuth.ts # 认证逻辑
|
||||
│ ├── useApi.ts # API 请求封装
|
||||
│ └── useArticles.ts # 文章数据获取
|
||||
├── layouts/
|
||||
│ ├── default.vue # 默认布局
|
||||
│ └── admin.vue # 管理后台布局
|
||||
├── middleware/
|
||||
│ ├── auth.global.ts # 全局认证检查
|
||||
│ └── admin.ts # 管理员权限检查
|
||||
├── pages/
|
||||
│ ├── index.vue # 首页(文章列表)
|
||||
│ ├── article/
|
||||
│ │ └── [slug].vue # 文章详情
|
||||
│ ├── login.vue # 登录页
|
||||
│ ├── create.vue # 创建文章
|
||||
│ └── admin/
|
||||
│ ├── dashboard.vue # 管理后台首页
|
||||
│ ├── articles.vue # 文章管理
|
||||
│ ├── comments.vue # 评论管理
|
||||
│ └── users.vue # 用户管理
|
||||
├── stores/
|
||||
│ ├── auth.ts # 认证状态
|
||||
│ └── article.ts # 文章状态
|
||||
├── types/
|
||||
│ ├── api.ts # API 响应类型
|
||||
│ └── models.ts # 数据模型
|
||||
└── utils/
|
||||
├── constants.ts # 常量配置
|
||||
└── validators.ts # 表单验证
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. API 类型定义
|
||||
|
||||
```markdown
|
||||
请根据后端 API 创建 TypeScript 类型定义:
|
||||
|
||||
### 用户模型 (User)
|
||||
|
||||
- id: number
|
||||
- email: string
|
||||
- username: string
|
||||
- displayName: string | null
|
||||
- avatarUrl: string | null
|
||||
- role: 'admin' | 'moderator' | 'user'
|
||||
- isActive: boolean
|
||||
- createdAt: DateTime
|
||||
- updatedAt: DateTime
|
||||
|
||||
### 文章模型 (Article)
|
||||
|
||||
- id: number
|
||||
- slug: string
|
||||
- title: string
|
||||
- content: string | null (Markdown)
|
||||
- excerpt: string | null
|
||||
- coverImageUrl: string | null
|
||||
- status: 'draft' | 'published' | 'archived'
|
||||
- visibility: 'public' | 'unlisted' | 'private'
|
||||
- viewCount: number
|
||||
- publishedAt: DateTime | null
|
||||
- createdAt: DateTime
|
||||
- updatedAt: DateTime
|
||||
- author: User (仅 id, username, displayName, avatarUrl)
|
||||
- comments: Comment[] (已审核)
|
||||
- reactions: Reaction[]
|
||||
|
||||
### 评论模型 (Comment)
|
||||
|
||||
- id: number
|
||||
- articleId: number
|
||||
- parentId: number | null
|
||||
- content: string
|
||||
- status: 'pending' | 'approved' | 'rejected' | 'suspicious'
|
||||
- authorName: string | null
|
||||
- authorEmail: string | null
|
||||
- authorId: number | null
|
||||
- createdAt: DateTime
|
||||
- updatedAt: DateTime
|
||||
- author: User | null
|
||||
- replies: Comment[]
|
||||
|
||||
### 分页响应
|
||||
|
||||
{
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
### API 端点
|
||||
|
||||
#### 认证
|
||||
|
||||
- POST /login - { emailOrUsername, password } → { user }
|
||||
- POST /logout - → { ok: true }
|
||||
- GET /me - → { user }
|
||||
|
||||
#### 文章
|
||||
|
||||
- GET /articles?page=1&limit=20&status=&visibility=&authorId=
|
||||
- GET /articles/:id
|
||||
- GET /articles/slug/:slug
|
||||
- POST /articles (需登录) - { title, content, excerpt?, coverImageUrl?, status, visibility }
|
||||
- PUT /articles/:id (作者/管理员)
|
||||
- DELETE /articles/:id (作者/管理员)
|
||||
|
||||
#### 评论
|
||||
|
||||
- GET /articles/:id/comments?status=approved
|
||||
- POST /articles/:id/comments (需登录) - { content }
|
||||
|
||||
#### 管理后台 (需 admin 角色)
|
||||
|
||||
- GET /admin/comments?page=&limit=&articleId=&status=&authorId=
|
||||
- PUT /admin/comments/:id/status?status=approved|rejected
|
||||
- DELETE /admin/comments/:id
|
||||
- GET /admin/comments/:id/audits
|
||||
- GET /admin/articles?page=&limit=&status=
|
||||
- GET /analytics-summary?days=30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 认证 Composable
|
||||
|
||||
```markdown
|
||||
创建 useAuth composable,实现:
|
||||
|
||||
### 状态管理
|
||||
|
||||
- user: Ref<User | null>
|
||||
- isAuthenticated: ComputedRef<boolean>
|
||||
- isAdmin: ComputedRef<boolean>
|
||||
- isLoading: Ref<boolean>
|
||||
|
||||
### 方法
|
||||
|
||||
- login(credentials): Promise<User>
|
||||
- logout(): Promise<void>
|
||||
- fetchUser(): Promise<User | null>
|
||||
- checkAuth(): Promise<boolean>
|
||||
|
||||
### 要求
|
||||
|
||||
- 自动携带 Cookie (credentials: 'include')
|
||||
- 错误处理(401 清除用户状态)
|
||||
- SSR 兼容(useFetch + useNuxtApp)
|
||||
- 登录成功后自动跳转回上一页
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 文章列表页
|
||||
|
||||
```markdown
|
||||
创建首页文章列表,要求:
|
||||
|
||||
### 布局
|
||||
|
||||
- 顶部导航栏(Logo、菜单、登录/用户头像)
|
||||
- 响应式网格布局(移动端 1 列,桌面 3 列)
|
||||
- 分页组件(使用 Nuxt UI 的 UPagination)
|
||||
|
||||
### 文章卡片 (UCard 组件)
|
||||
|
||||
- 封面图(可选,使用 UImage)
|
||||
- 标题(UButton 样式,链接到详情页)
|
||||
- 摘要(最多 150 字)
|
||||
- 作者信息(头像 + 名称)
|
||||
- 发布时间(相对时间,如"3 天前")
|
||||
- 查看数(UIcon + 数字)
|
||||
- 状态标签(UBadge:已发布/草稿/归档)
|
||||
|
||||
### 筛选功能
|
||||
|
||||
- 搜索框(按标题)
|
||||
- 状态下拉框(全部/已发布/草稿/归档)
|
||||
- 作者筛选(可选)
|
||||
- URL 同步筛选参数(使用 useRouteQuery)
|
||||
|
||||
### 加载状态
|
||||
|
||||
- 骨架屏(USkeleton)
|
||||
- 空状态(UEmpty:无文章时显示)
|
||||
- 错误状态(UAlert:加载失败时)
|
||||
|
||||
### SEO
|
||||
|
||||
- useSeoMeta 设置 title、description
|
||||
- Open Graph 标签
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 文章详情页
|
||||
|
||||
```markdown
|
||||
创建文章详情页(/article/[slug]),要求:
|
||||
|
||||
### 页面结构
|
||||
|
||||
- 文章头部:标题、作者信息、发布时间、查看数
|
||||
- 封面图(如果有)
|
||||
- Markdown 内容渲染区
|
||||
- 文章底部:标签、分享按钮
|
||||
|
||||
### Markdown 渲染
|
||||
|
||||
- 使用 @nuxt/content 或 markdown-it
|
||||
- 支持代码高亮(Prism.js / Shiki)
|
||||
- 支持表格、引用、列表
|
||||
- 响应式图片(懒加载)
|
||||
|
||||
### 评论系统
|
||||
|
||||
- 评论列表(嵌套回复,最多 2 层)
|
||||
- 评论表单(登录后可评论)
|
||||
- 评论状态提示(待审核/已通过/已拒绝)
|
||||
- 实时刷新(提交后刷新评论列表)
|
||||
|
||||
### 侧边栏(桌面端)
|
||||
|
||||
- 目录(TOC,基于文章标题自动生成)
|
||||
- 相关文章推荐
|
||||
- 作者信息卡片
|
||||
|
||||
### 功能
|
||||
|
||||
- 文章版本历史(如果有多版本)
|
||||
- 密码保护文章(输入密码后查看)
|
||||
- 私密文章验证(通过 token 访问)
|
||||
- 阅读进度条(顶部固定)
|
||||
|
||||
### 交互
|
||||
|
||||
- 点赞/表情反应(ULikeButton 风格)
|
||||
- 分享功能(复制链接、Twitter、微信)
|
||||
- 返回目录(滚动监听)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 创建/编辑文章页
|
||||
|
||||
```markdown
|
||||
创建文章编辑器(/create 和 /article/[id]/edit),要求:
|
||||
|
||||
### 编辑器布局
|
||||
|
||||
- 左右分栏(左侧编辑,右侧预览)
|
||||
- 可切换全屏编辑模式
|
||||
- 实时预览(防抖,500ms)
|
||||
|
||||
### 表单字段 (使用 UForm + UInput)
|
||||
|
||||
- 标题(UInput,最大 200 字,必填)
|
||||
- Slug(自动生成,可手动修改)
|
||||
- 内容(UTextarea,支持 Markdown 语法提示)
|
||||
- 摘要(UTextarea,最大 500 字,可选)
|
||||
- 封面图 URL(UInput,可选)
|
||||
- 状态(URadio:草稿/已发布/归档)
|
||||
- 可见性(URadio:公开/未列出/私密)
|
||||
- 访问令牌(私密文章时显示)
|
||||
|
||||
### 验证规则
|
||||
|
||||
- 标题:必填,1-200 字
|
||||
- 内容:必填
|
||||
- 摘要:可选,0-500 字
|
||||
- 自动保存草稿(每 60 秒)
|
||||
|
||||
### 功能
|
||||
|
||||
- Markdown 工具栏(粗体、斜体、链接、图片、代码块)
|
||||
- 图片上传(拖拽上传,返回 URL)
|
||||
- 发布确认对话框
|
||||
- 版本历史(显示编辑记录)
|
||||
|
||||
### 权限
|
||||
|
||||
- 仅作者和管理员可编辑
|
||||
- 未授权时显示 403 页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 登录页
|
||||
|
||||
```markdown
|
||||
创建登录页(/login),要求:
|
||||
|
||||
### 表单设计 (UCard 居中布局)
|
||||
|
||||
- 邮箱/用户名输入框(UInput)
|
||||
- 密码输入框(UInput type="password")
|
||||
- 记住我复选框(UCheckbox)
|
||||
- 登录按钮(UButton,带加载状态)
|
||||
- 忘记密码链接(可选)
|
||||
|
||||
### 验证
|
||||
|
||||
- 邮箱/用户名:必填
|
||||
- 密码:必填,最少 6 位
|
||||
- 错误提示(UAlert 显示在表单顶部)
|
||||
|
||||
### 功能
|
||||
|
||||
- 限流处理(5 次/分钟,显示重试时间)
|
||||
- 登录成功后跳转到来源页面
|
||||
- 已登录用户自动跳转到首页
|
||||
- Social 登录按钮(预留位置)
|
||||
|
||||
### 安全
|
||||
|
||||
- CSRF Token(如后端需要)
|
||||
- HTTPS 强制(生产环境)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 管理后台
|
||||
|
||||
```markdown
|
||||
创建管理后台(/admin/\*),要求:
|
||||
|
||||
### 布局 (admin.vue)
|
||||
|
||||
- 侧边栏导航(文章管理、评论管理、用户管理、数据分析)
|
||||
- 顶部栏(返回首页、用户信息、退出登录)
|
||||
- 权限检查(仅 admin 角色可访问)
|
||||
|
||||
### 评论管理页 (/admin/comments)
|
||||
|
||||
- 表格展示(UTable)
|
||||
- 评论内容(截断)
|
||||
- 文章标题(链接)
|
||||
- 作者(名称/邮箱)
|
||||
- 状态(UBadge:待审核/通过/拒绝/可疑)
|
||||
- 创建时间
|
||||
- 操作(审核通过/拒绝/删除/查看日志)
|
||||
- 筛选:文章 ID、状态、作者、分页
|
||||
- 批量操作(批量通过/拒绝/删除)
|
||||
- 审核日志弹窗(显示 AI 审核记录)
|
||||
|
||||
### 文章管理页 (/admin/articles)
|
||||
|
||||
- 表格展示
|
||||
- 标题 + 封面图缩略图
|
||||
- 作者
|
||||
- 状态(UBadge)
|
||||
- 可见性(UIcon)
|
||||
- 查看数
|
||||
- 发布时间
|
||||
- 操作(编辑/删除/强制发布/归档)
|
||||
- 筛选:状态、可见性、作者、分页
|
||||
- 强制操作(管理员可编辑/删除任何文章)
|
||||
|
||||
### 用户管理页 (/admin/users)
|
||||
|
||||
- 表格展示
|
||||
- 用户信息(头像 + 名称 + 邮箱)
|
||||
- 角色(UBadge:admin/moderator/user)
|
||||
- 状态(激活/禁用)
|
||||
- 创建时间
|
||||
- 操作(编辑角色/禁用/删除)
|
||||
- 创建用户按钮(弹窗表单)
|
||||
|
||||
### 数据分析页 (/admin/analytics)
|
||||
|
||||
- 数据卡片(UStat)
|
||||
- 总文章数
|
||||
- 总评论数
|
||||
- 总用户数
|
||||
- 近 30 天查看数
|
||||
- 图表(使用 Chart.js 或 ECharts)
|
||||
- 每日查看趋势
|
||||
- 热门文章 Top 10
|
||||
- 评论审核统计
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 评论组件
|
||||
|
||||
```markdown
|
||||
创建评论系统组件,要求:
|
||||
|
||||
### 评论列表 (CommentList.vue)
|
||||
|
||||
- 嵌套结构(父评论 + 回复列表)
|
||||
- 头像 + 用户名 + 时间
|
||||
- 内容(支持简单 Markdown)
|
||||
- 状态标签(待审核/已审核)
|
||||
- 回复按钮(登录后可用)
|
||||
- 点赞数(可选)
|
||||
|
||||
### 评论表单 (CommentForm.vue)
|
||||
|
||||
- 文本域(最小 3 行,支持 Markdown)
|
||||
- 字符计数器(最大 2000 字)
|
||||
- 提交按钮(带加载状态)
|
||||
- 登录提示(未登录时显示)
|
||||
- 预览功能(可选)
|
||||
|
||||
### 回复表单 (ReplyForm.vue)
|
||||
|
||||
- 简化版评论表单
|
||||
- 内联显示(点击回复后展开)
|
||||
- 取消按钮
|
||||
|
||||
### 审核提示
|
||||
|
||||
- 提交成功:显示"评论待审核"
|
||||
- 审核通过:自动刷新显示评论
|
||||
- 审核拒绝:显示拒绝原因(如果公开)
|
||||
|
||||
### 权限
|
||||
|
||||
- 仅登录用户可评论
|
||||
- 作者/管理员可删除评论
|
||||
- 管理员可在后台审核
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 全局组件
|
||||
|
||||
```markdown
|
||||
创建以下全局通用组件:
|
||||
|
||||
### 导航栏 (AppHeader.vue)
|
||||
|
||||
- Logo(左侧)
|
||||
- 菜单(首页、关于、管理后台)
|
||||
- 搜索框(可选)
|
||||
- 用户菜单(头像下拉:个人中心、退出登录)
|
||||
- 登录/注册按钮(未登录时)
|
||||
- 移动端汉堡菜单
|
||||
|
||||
### 页脚 (AppFooter.vue)
|
||||
|
||||
- 版权信息
|
||||
- 友情链接
|
||||
- Social 图标
|
||||
- ICP 备案(如果需要)
|
||||
|
||||
### 加载器 (AppLoading.vue)
|
||||
|
||||
- 全局加载中(USpinner)
|
||||
- 页面切换进度条(NuxtPageTransition)
|
||||
|
||||
### 错误页 (AppError.vue)
|
||||
|
||||
- 404 页面(UEmpty + 返回首页按钮)
|
||||
- 403 页面(无权限提示)
|
||||
- 500 页面(服务器错误)
|
||||
|
||||
### SEO 组件 (SeoMeta.vue)
|
||||
|
||||
- 动态设置 title、description
|
||||
- Open Graph 标签
|
||||
- Twitter Card 标签
|
||||
- JSON-LD 结构化数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Pinia Store
|
||||
|
||||
```markdown
|
||||
创建以下 Pinia stores:
|
||||
|
||||
### auth store (stores/auth.ts)
|
||||
|
||||
- State: user, isLoading, error
|
||||
- Getters: isAuthenticated, isAdmin, userRole
|
||||
- Actions: login, logout, fetchUser, checkAuth, refreshUser
|
||||
|
||||
### article store (stores/article.ts)
|
||||
|
||||
- State: articles, currentArticle, loading, pagination
|
||||
- Getters: publishedArticles, draftArticles, totalArticles
|
||||
- Actions: fetchArticles, fetchArticle, createArticle, updateArticle, deleteArticle
|
||||
|
||||
### comment store (stores/comment.ts)
|
||||
|
||||
- State: comments, loading
|
||||
- Getters: approvedComments, pendingComments
|
||||
- Actions: fetchComments, createComment, approveComment, rejectComment, deleteComment
|
||||
|
||||
### admin store (stores/admin.ts)
|
||||
|
||||
- State: stats, loading
|
||||
- Getters: commentStats, articleStats, userStats
|
||||
- Actions: fetchStats, updateCommentStatus, deleteUser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 样式与主题
|
||||
|
||||
````markdown
|
||||
配置 Nuxt UI 主题:
|
||||
|
||||
### 颜色方案 (nuxt.config.ts)
|
||||
|
||||
```ts
|
||||
export default defineNuxtConfig({
|
||||
ui: {
|
||||
primary: 'indigo',
|
||||
gray: 'slate',
|
||||
theme: {
|
||||
dark: true, // 支持暗色模式
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
````
|
||||
|
||||
### 自定义样式 (assets/css/main.css)
|
||||
|
||||
- 全局字体(Inter / Noto Sans SC)
|
||||
- 文章排版样式(prose 类)
|
||||
- 代码块主题(One Dark / GitHub)
|
||||
- 响应式断点调整
|
||||
|
||||
### 暗色模式切换
|
||||
|
||||
- 使用 useDark() 和 useToggle()
|
||||
- 切换按钮(UIcon:sun/moon)
|
||||
- 持久化到 localStorage
|
||||
- 系统偏好自动检测
|
||||
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 13. SEO 优化
|
||||
|
||||
```markdown
|
||||
实现 SEO 优化:
|
||||
|
||||
### 动态 Meta 标签 (useSeoMeta)
|
||||
- title / titleTemplate
|
||||
- description
|
||||
- canonical URL
|
||||
- robots
|
||||
|
||||
### Open Graph
|
||||
- og:title
|
||||
- og:description
|
||||
- og:image(文章封面)
|
||||
- og:url
|
||||
- og:type
|
||||
|
||||
### Twitter Card
|
||||
- twitter:card
|
||||
- twitter:title
|
||||
- twitter:description
|
||||
- twitter:image
|
||||
|
||||
### 结构化数据 (JSON-LD)
|
||||
- Article Schema(文章页)
|
||||
- BreadcrumbList(面包屑)
|
||||
- Organization(网站信息)
|
||||
|
||||
### Sitemap
|
||||
- @nuxtjs/sitemap 模块
|
||||
- 自动生成文章路由
|
||||
- 静态路由配置
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 14. 性能优化
|
||||
|
||||
```markdown
|
||||
实现性能优化:
|
||||
|
||||
### 图片优化
|
||||
|
||||
- Nuxt Image 模块(自动 WebP)
|
||||
- 懒加载(loading="lazy")
|
||||
- 响应式图片(srcset)
|
||||
- LQIP(低质量占位图)
|
||||
|
||||
### 代码分割
|
||||
|
||||
- 路由懒加载(默认启用)
|
||||
- 组件异步加载(defineAsyncComponent)
|
||||
- 第三方库按需引入
|
||||
|
||||
### 缓存策略
|
||||
|
||||
- API 响应缓存(useFetch + cache)
|
||||
- 静态资源 CDN
|
||||
- Service Worker(可选)
|
||||
|
||||
### 渲染优化
|
||||
|
||||
- 骨架屏(Skeleton)
|
||||
- 虚拟滚动(长列表)
|
||||
- 防抖/节流(搜索、滚动)
|
||||
- 预加载(hover 时预加载文章)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 测试
|
||||
|
||||
```markdown
|
||||
编写测试:
|
||||
|
||||
### 单元测试 (Vitest)
|
||||
|
||||
- Composables 测试
|
||||
- Utils 函数测试
|
||||
- Stores 测试
|
||||
|
||||
### 组件测试 (Vue Test Utils)
|
||||
|
||||
- 按钮点击
|
||||
- 表单验证
|
||||
- 条件渲染
|
||||
|
||||
### E2E 测试 (Playwright)
|
||||
|
||||
- 登录流程
|
||||
- 文章创建/编辑
|
||||
- 评论提交
|
||||
- 管理后台操作
|
||||
|
||||
### 测试覆盖率
|
||||
|
||||
- 目标:80%+
|
||||
- 关键路径:认证、文章 CRUD、评论审核
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 环境变量配置
|
||||
|
||||
````markdown
|
||||
配置环境变量:
|
||||
|
||||
### .env 文件
|
||||
|
||||
```env
|
||||
NUXT_PUBLIC_API_BASE=http://localhost:3001
|
||||
NUXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NUXT_PUBLIC_SITE_NAME=LinkShare Blog
|
||||
```
|
||||
````
|
||||
|
||||
### nuxt.config.ts
|
||||
|
||||
```ts
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 类型安全
|
||||
|
||||
```ts
|
||||
declare module '@nuxt/schema' {
|
||||
interface RuntimeConfig {
|
||||
public: {
|
||||
apiBase: string;
|
||||
siteUrl: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 17. 部署配置
|
||||
|
||||
```markdown
|
||||
部署配置:
|
||||
|
||||
### Dockerfile
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
````
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NUXT_PUBLIC_API_BASE=http://api:3001
|
||||
```
|
||||
|
||||
### Nginx 配置
|
||||
|
||||
- 反向代理到 Nuxt
|
||||
- 静态资源缓存
|
||||
- Gzip 压缩
|
||||
- HTTPS 配置
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发优先级
|
||||
|
||||
1. **第一阶段 - 核心功能**
|
||||
- 项目初始化
|
||||
- 认证系统(登录/登出)
|
||||
- 文章列表页
|
||||
- 文章详情页
|
||||
- 评论系统
|
||||
|
||||
2. **第二阶段 - 内容创作**
|
||||
- 文章创建/编辑页
|
||||
- Markdown 编辑器
|
||||
- 图片上传
|
||||
|
||||
3. **第三阶段 - 管理后台**
|
||||
- 管理后台布局
|
||||
- 评论管理
|
||||
- 文章管理
|
||||
- 用户管理
|
||||
|
||||
4. **第四阶段 - 优化与完善**
|
||||
- SEO 优化
|
||||
- 性能优化
|
||||
- 测试覆盖
|
||||
- 部署配置
|
||||
|
||||
---
|
||||
|
||||
## 后端 API 参考
|
||||
|
||||
### 数据库模型 (Prisma Schema)
|
||||
|
||||
详见 `prisma/schema.prisma`,主要模型:
|
||||
- User(用户)
|
||||
- Article(文章)
|
||||
- Comment(评论)
|
||||
- ArticleVersion(文章版本)
|
||||
- CommentAudit(评论审核日志)
|
||||
- OAuth2Token(OAuth2 令牌)
|
||||
- AnalyticsEvent(分析事件)
|
||||
|
||||
### 认证机制
|
||||
|
||||
- **Web 端**: Session Cookie(HttpOnly,Secure)
|
||||
- **API 端**: OAuth2 Bearer Token
|
||||
- **角色**: admin、moderator、user
|
||||
- **限流**: /login 和 /oauth2/token 接口 5 次/分钟
|
||||
|
||||
### 内容审核
|
||||
|
||||
- **规则审核**: 敏感词、长度限制
|
||||
- **AI 审核**: OpenRouter API(异步)
|
||||
- **状态**: pending → approved/rejected/suspicious
|
||||
- **审计**: 所有审核操作记录到 CommentAudit
|
||||
|
||||
---
|
||||
|
||||
**创建日期**: 2026-03-28
|
||||
**后端版本**: 1.0.0
|
||||
**适用框架**: Nuxt 4 + Nuxt UI
|
||||
```
|
||||
234
AGENTS.md
Normal file
234
AGENTS.md
Normal file
@ -0,0 +1,234 @@
|
||||
# AGENTS.md - 智能编码助手指南
|
||||
|
||||
## 偏好设置
|
||||
|
||||
- 使用中文回答问题
|
||||
- 代码变更前先阅读相关文件
|
||||
- 遵循项目现有架构和模式
|
||||
|
||||
---
|
||||
|
||||
## 命令速查
|
||||
|
||||
### 基础命令
|
||||
|
||||
```bash
|
||||
pnpm install # 安装依赖
|
||||
pnpm build # 构建应用
|
||||
pnpm start:dev # 开发服务器(热重载)
|
||||
pnpm lint # 代码检查(自动修复)
|
||||
pnpm format # 代码格式化
|
||||
```
|
||||
|
||||
### 测试命令
|
||||
|
||||
```bash
|
||||
pnpm test # 所有测试
|
||||
pnpm test:watch # 监听模式
|
||||
pnpm test:cov # 覆盖率报告
|
||||
pnpm test -- path/to/testFile.spec.ts # 单个测试文件
|
||||
pnpm test -- -t "pattern" # 匹配名称的测试
|
||||
pnpm test:e2e # E2E 测试
|
||||
```
|
||||
|
||||
### 数据库
|
||||
|
||||
```bash
|
||||
pnpm prisma generate # 生成 Prisma Client
|
||||
pnpm prisma migrate dev # 开发环境迁移
|
||||
pnpm prisma:deploy # 生产环境迁移
|
||||
pnpm prisma:seed # 运行种子数据
|
||||
```
|
||||
|
||||
### 部署运维
|
||||
|
||||
```bash
|
||||
pnpm run verify # 验证环境配置
|
||||
pnpm run deploy # 生产环境一键部署
|
||||
pnpm run deploy:dev # 开发环境部署
|
||||
pnpm run health # 健康检查
|
||||
pnpm run init:status # 检查初始化状态(需 token)
|
||||
pnpm run backup # 备份数据库
|
||||
pnpm run logs:api # 查看 API 日志
|
||||
pnpm run shell # 进入 API 容器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码规范
|
||||
|
||||
### TypeScript
|
||||
|
||||
- 严格模式已启用(`strict: true`)
|
||||
- 关键选项:`noImplicitAny`、`strictNullChecks`
|
||||
- 目标:ES2023,模块:NodeNext,装饰器已启用
|
||||
|
||||
### 导入顺序
|
||||
|
||||
1. Node 内置模块
|
||||
2. 第三方库
|
||||
3. 内部模块(相对路径)
|
||||
|
||||
### 文件结构(NestJS 模块)
|
||||
|
||||
```
|
||||
src/modules/<module-name>/
|
||||
├── <module>.module.ts
|
||||
├── <module>.service.ts
|
||||
├── <module>.controller.ts
|
||||
├── dto/
|
||||
│ ├── <entity>.dto.ts
|
||||
│ └── ...
|
||||
└── guards/、decorators/、processors/(可选)
|
||||
```
|
||||
|
||||
### 命名约定
|
||||
|
||||
- **文件**:kebab-case(如 `admin-comments.controller.ts`)
|
||||
- **类**:PascalCase(如 `ArticlesService`)
|
||||
- **方法/变量**:camelCase
|
||||
- **枚举**:PascalCase 名称,UPPER_SNAKE 值
|
||||
- **常量**:UPPER_SNAKE
|
||||
- **接口**:PascalCase
|
||||
|
||||
### DTO 与验证
|
||||
|
||||
- 必须使用 `class-validator` 装饰器
|
||||
- 必填字段必填,可选字段加 `@IsOptional()`
|
||||
- 使用 `@IsString()`、`@IsInt()`、`@IsEnum()`、`@IsEmail()` 等
|
||||
- 添加限制:`@Length()`、`@Min()`、`@Max()`
|
||||
- Create DTO:必填字段;Update DTO:所有字段可选
|
||||
|
||||
### 服务层
|
||||
|
||||
- `@Injectable()`,通过构造函数注入依赖
|
||||
- 异步操作使用 `async/await`
|
||||
- 记录日志但不吞噬异常
|
||||
- 返回 Plain Objects,避免循环引用
|
||||
|
||||
### 控制器层
|
||||
|
||||
- 使用 NestJS 装饰器:`@Controller()`、`@Get()`、`@Post()`、`@Put()`、`@Delete()`
|
||||
- 验证输入:`@Body()`、`@Query()`、`@Param()` 配合 DTO
|
||||
- 访问请求:`@Req()` 获取 session、用户、IP 等
|
||||
- 应用守卫:`@UseGuards(RequireSessionUser)`、`@RequireRole('admin')`
|
||||
- 错误处理:未找到用 `HttpStatus.NOT_FOUND`,禁止访问用 `HttpStatus.FORBIDDEN`
|
||||
|
||||
### 守卫与装饰器
|
||||
|
||||
- `RequireSessionUser`:从 session 加载用户到 `req.user`
|
||||
- `RequireRole`:RBAC 角色验证,配合 `RequireRoleGuard` 使用
|
||||
- `@Throttler()`:对 `/login` 和 `/oauth2/token` 限流
|
||||
|
||||
### 错误处理
|
||||
|
||||
- 使用 `HttpException` 配合适当的 HTTP 状态码
|
||||
- 服务层用 `Logger.error()` 记录异常
|
||||
- 外部 API 调用必须用 try-catch 包裹,提供降级行为
|
||||
- 不暴露内部错误详情给客户端
|
||||
|
||||
### 日志规范
|
||||
|
||||
- 使用 `Logger` from `@nestjs/common`
|
||||
- 实例化:`new Logger(ClassName.name)`
|
||||
- 日志级别:`log`、`debug`、`warn`、`error`
|
||||
- 禁止记录敏感数据,记录操作审计
|
||||
|
||||
### 数据库(Prisma)
|
||||
|
||||
- Schema:`prisma/schema.prisma`
|
||||
- 通过 `PrismaService` 访问,生产用真实客户端,开发用内存 mock
|
||||
- Mock 必须实现完整 Prisma API:`findUnique`、`findMany`、`create`、`update`、`delete`、`count`、`aggregate`
|
||||
- `findMany` 支持:`where`、`include`、`orderBy`、`skip`、`take`
|
||||
- `update` 支持嵌套操作:`{ viewCount: { increment: 1 } }`
|
||||
|
||||
### AI 内容审核(OpenRouter)
|
||||
|
||||
- 使用 OpenAI 兼容的 chat completions 端点
|
||||
- 配置:`AI_REVIEW_PROMPT_TEMPLATE` 环境变量
|
||||
- 响应必须是 JSON:`{ decision: 'approved'|'rejected'|'suspicious', reason, score: 0-1 }`
|
||||
- API 错误时降级到基于规则的审核
|
||||
|
||||
### 邮件服务(Nodemailer + BullMQ)
|
||||
|
||||
- 仅使用 HTML 模板(可选纯文本备用),内联 CSS
|
||||
- 邮件任务通过 BullMQ 队列 `email` 排队
|
||||
- `EmailProcessor` 处理 `send-email` 任务
|
||||
- 所有邮件记录到 `EmailMessage` 表(状态:pending、sent、failed)
|
||||
- 重试机制:3 次尝试,指数退避
|
||||
|
||||
### 安全规范
|
||||
|
||||
- Helmet 中间件已启用(安全头)
|
||||
- CORS:生产环境仅允许 `APP_BASE_URL`
|
||||
- Session Cookie:生产环境 `SESSION_COOKIE_SECURE=true`、`HttpOnly=true`
|
||||
- 限流:`@Throttler()` 应用于 `/login` 和 `/oauth2/token`
|
||||
- 全局验证管道:自动转换、白名单、开发环境拒绝额外属性
|
||||
- 绝不记录敏感数据,所有凭据通过环境变量管理
|
||||
|
||||
### 管理后台 API
|
||||
|
||||
- 所有 `/admin/*` 路由需要 `@RequireRole('admin')`
|
||||
- 参考 `AdminCommentsController` 模式
|
||||
- 分页:查询参数 `page`、`limit`,响应包含 `{ items, total, page, limit, totalPages }`
|
||||
|
||||
### Swagger 文档
|
||||
|
||||
- 非生产环境可访问 `/api-docs`
|
||||
- 使用 `@nestjs/swagger` 装饰器:`@ApiProperty()`、`@ApiBody()`、`@ApiParam()`、`@ApiQuery()`、`@ApiBearerAuth()`
|
||||
- 保持 DTO 简单,示例避免复杂嵌套对象
|
||||
|
||||
### Docker 与部署
|
||||
|
||||
- 镜像使用 Bun 运行时(`oven/bun:1.1`)
|
||||
- 多阶段构建:`builder` + `runner`
|
||||
- Docker Compose 服务:`postgres`、`redis`、`api`
|
||||
- 健康检查端点验证数据库和 Redis 连接
|
||||
- 启动时自动运行:`prisma migrate deploy` 和 `prisma db seed`
|
||||
|
||||
### 环境变量
|
||||
|
||||
必需配置(见 `.env.example`):
|
||||
|
||||
```env
|
||||
DATABASE_URL # PostgreSQL 连接
|
||||
SESSION_COOKIE_SECRET # Session 签名密钥(至少 32 位)
|
||||
AI_API_KEY # AI 服务 API Key
|
||||
SMTP_HOST、SMTP_PORT、SMTP_USER、SMTP_PASS # 邮件服务
|
||||
REDIS_HOST # Redis 地址
|
||||
OAUTH2_TOKEN_SIGNING_PRIVATE_KEY、OAUTH2_TOKEN_SIGNING_PUBLIC_KEY # RSA 密钥对(生产必填)
|
||||
```
|
||||
|
||||
### 测试策略
|
||||
|
||||
- **单元测试**:隔离测试服务层,mock PrismaService
|
||||
- **E2E 测试**:使用 `supertest` 测试 HTTP 端点
|
||||
- **文件命名**:`*.spec.ts` 或 `*.test.ts`
|
||||
- **位置**:与源文件并列或 `test/` 目录
|
||||
- **运行**:`pnpm test`、`pnpm test:watch`、`pnpm test:cov`
|
||||
|
||||
### Git 与提交
|
||||
|
||||
- 无强制提交规范,但请写清晰的提交信息
|
||||
- 禁止提交密钥和 `.env` 文件
|
||||
- 提交前运行 `pnpm lint` 和 `pnpm test`
|
||||
|
||||
### 已知模式
|
||||
|
||||
- 基于 session 的 Web 认证 + OAuth2 API 客户端
|
||||
- 评论审核:规则优先,可疑内容走 AI 异步审核
|
||||
- 文章查看数:`{ viewCount: { increment: 1 } }`
|
||||
- RBAC 角色:`admin`、`moderator`、`user`
|
||||
- PrismaService mock:确保所有模型实现完整 CRUD 方法
|
||||
|
||||
---
|
||||
|
||||
## Cursor / Copilot 说明
|
||||
|
||||
本项目无特殊的 Cursor 或 Copilot 配置,请遵循上述指南。所有编码助手应:
|
||||
|
||||
1. 遵守 TypeScript 严格模式
|
||||
2. 遵循 NestJS 最佳实践
|
||||
3. 使用 DTO 进行输入验证
|
||||
4. 正确使用 PrismaService
|
||||
5. 记录日志但不泄露敏感信息
|
||||
55
Dockerfile.api
Normal file
55
Dockerfile.api
Normal file
@ -0,0 +1,55 @@
|
||||
# Build stage
|
||||
FROM oven/bun:1.1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件
|
||||
COPY apps/api/package.json .
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY package.json .
|
||||
|
||||
# 安装 pnpm(如果未预装)
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 复制源码
|
||||
COPY apps/api/src ./src
|
||||
COPY apps/api/prisma ./prisma
|
||||
COPY apps/api/.env.example .env.example
|
||||
|
||||
# 生成 Prisma Client
|
||||
RUN pnpm prisma generate
|
||||
|
||||
# 构建
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1.1-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖(仅生产)
|
||||
COPY apps/api/package.json .
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY package.json .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# 复制构建结果
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/.env.example .env.example
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3001
|
||||
|
||||
# 健康检查(使用 Bun 运行 node 兼容代码)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD bun -e "require('http').get('http://localhost:3001/health', (r) => {if(r.statusCode!==200)throw new Error(r.statusCode)})"
|
||||
|
||||
# 启动命令(使用 Bun)
|
||||
CMD ["bun", "dist/main"]
|
||||
50
Dockerfile.web
Normal file
50
Dockerfile.web
Normal file
@ -0,0 +1,50 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件
|
||||
COPY apps/web/package.json .
|
||||
COPY apps/web/pnpm-lock.yaml . # 如果存在
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY package.json .
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 复制源码
|
||||
COPY apps/web .
|
||||
|
||||
# 构建
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖(仅生产)
|
||||
COPY apps/web/package.json .
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY package.json .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# 复制构建结果
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/nuxt.config.ts ./
|
||||
COPY --from=builder /app/.env.example ./.env.example
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000', (r) => {if(r.statusCode!==200)throw new Error(r.statusCode)})"
|
||||
|
||||
# 启动命令
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
701
README.md
701
README.md
@ -1,98 +1,671 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# LinkShare Blog - 后端 API
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
基于 **NestJS + Bun + PostgreSQL + Redis** 的现代化博客后端系统。
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
[](https://nestjs.com)
|
||||
[](https://bun.sh)
|
||||
[](https://www.postgresql.org)
|
||||
[](https://redis.io)
|
||||
[](LICENSE)
|
||||
|
||||
## Description
|
||||
---
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
## 🚀 快速开始
|
||||
|
||||
## Project setup
|
||||
### 一键部署(推荐)
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
# 1. 克隆代码
|
||||
git clone <your-repo>
|
||||
cd BingLogyBlog-Backend
|
||||
|
||||
# 2. 验证环境
|
||||
pnpm run verify
|
||||
|
||||
# 3. 一键部署
|
||||
pnpm run deploy
|
||||
|
||||
# 4. 验证服务
|
||||
pnpm run health
|
||||
|
||||
# 5. 检查初始化状态(可选)
|
||||
pnpm run init:status
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
### 开发环境快速启动
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
# 1. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
# 2. 启动依赖服务(PostgreSQL + Redis)
|
||||
docker-compose up -d postgres redis
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
# 3. 环境验证
|
||||
cp .env.example .env
|
||||
# 编辑 .env,至少设置 DATABASE_URL 和 SESSION_COOKIE_SECRET
|
||||
|
||||
# 4. 数据库迁移与种子
|
||||
pnpm run prisma:deploy
|
||||
pnpm run prisma:seed
|
||||
|
||||
# 5. 启动开发服务器
|
||||
pnpm run start:dev
|
||||
```
|
||||
|
||||
## Run tests
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- ✅ **文章管理** - 完整的 CRUD + 分页 + 权限验证 + 查看计数 + 分析追踪
|
||||
- ✅ **用户系统** - 用户增删改查(仅管理员)+ RBAC 角色权限 + 版主种子用户
|
||||
- ✅ **评论系统** - 规则审核 + AI 智能审核 + 完整的审计日志
|
||||
- ✅ **邮件服务** - Nodemailer + HTML 模板 + BullMQ 队列 + 重试机制
|
||||
- ✅ **AI 审核** - OpenRouter 集成 + 自定义提示词 + 容错机制
|
||||
- ✅ **访客分析** - 事件追踪 + 文章查看统计 + 管理员汇总接口
|
||||
- ✅ **OAuth2** - 完整的 OAuth2 授权服务器实现
|
||||
- ✅ **管理后台** - 评论管理 + 文章管理 + 用户管理
|
||||
- ✅ **Swagger 文档** - 自动生成 API 文档(开发环境)
|
||||
- ✅ **Docker 部署** - 生产就绪的容器化部署方案
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ Client App │────│ API (Nest) │────│ PostgreSQL │
|
||||
│ (Web/Mobile) │ │ :3001 │ │ :5432 │
|
||||
└─────────────────┘ └──────────────┘ └─────────────┘
|
||||
│
|
||||
├─────────► Redis :6379 (Cache + Queue)
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ BullMQ │
|
||||
│ Workers │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**核心技术栈:**
|
||||
|
||||
- **框架**: NestJS 11.x + TypeScript 5.x
|
||||
- **运行时**: Bun 1.1+ (生产优化) / Node.js 20+
|
||||
- **数据库**: PostgreSQL 16+ + Prisma ORM 6.x
|
||||
- **缓存/队列**: Redis 7+ + BullMQ 11.x
|
||||
- **文档**: Swagger/OpenAPI 3.0
|
||||
- **安全**: Helmet, CORS, Rate Limiting, Session + OAuth2
|
||||
- **邮件**: Nodemailer + HTML 模板
|
||||
- **AI**: OpenRouter 集成 (BYOK)
|
||||
|
||||
---
|
||||
|
||||
## 📦 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── src/ # 源代码
|
||||
│ ├── app.module.ts # 根模块
|
||||
│ ├── main.ts # 入口文件
|
||||
│ ├── config/ # 配置模块
|
||||
│ │ ├── config.module.ts
|
||||
│ │ └── env.ts
|
||||
│ ├── modules/ # 功能模块
|
||||
│ │ ├── init/ # 系统初始化
|
||||
│ │ ├── admin/ # 管理后台
|
||||
│ │ │ ├── admin-articles/
|
||||
│ │ │ └── admin-comments/
|
||||
│ │ ├── articles/ # 文章模块
|
||||
│ │ ├── users/ # 用户模块
|
||||
│ │ ├── comments/ # 评论模块
|
||||
│ │ ├── session/ # 会话管理
|
||||
│ │ ├── oauth2/ # OAuth2 授权
|
||||
│ │ ├── email/ # 邮件服务
|
||||
│ │ ├── moderation/ # 内容审核
|
||||
│ │ ├── analytics/ # 数据分析
|
||||
│ │ ├── jobs/ # 任务队列
|
||||
│ │ ├── prisma/ # 数据库服务
|
||||
│ │ └── health/ # 健康检查
|
||||
│ └── types/ # 类型声明
|
||||
├── scripts/ # 部署脚本
|
||||
│ ├── verify-env.sh # 环境验证
|
||||
│ ├── deploy.sh # 一键部署
|
||||
│ ├── backup.sh # 数据库备份
|
||||
│ ├── restore.sh # 数据库恢复
|
||||
│ ├── healthcheck.sh # 健康检查
|
||||
│ └── init-db.sql # 数据库初始化
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── Dockerfile.api # API 镜像构建
|
||||
├── prisma/ # Prisma Schema
|
||||
│ ├── schema.prisma
|
||||
│ ├── seed.ts
|
||||
│ └── migrations/
|
||||
├── AGENTS.md # 开发规范
|
||||
└── .env.example # 环境变量模板
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 快速命令
|
||||
|
||||
### 使用 Package.json Scripts (推荐)
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
# 查看所有可用命令
|
||||
pnpm run --help
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
# 部署相关
|
||||
pnpm run verify # 验证环境配置
|
||||
pnpm run deploy # 完整生产部署
|
||||
pnpm run deploy:dev # 开发环境部署
|
||||
pnpm run deploy:quick # 快速部署(跳过构建)
|
||||
pnpm run deploy:check # 部署并检查初始化状态
|
||||
pnpm run migrate # 仅运行数据库迁移
|
||||
pnpm run seed # 仅运行数据库种子
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
# 服务管理
|
||||
pnpm run start:all # 启动所有服务
|
||||
pnpm run stop # 停止所有服务
|
||||
pnpm run restart # 重启所有服务
|
||||
pnpm run recreate # 重新创建服务(会丢失数据!)
|
||||
pnpm run status # 查看服务状态
|
||||
pnpm run stats # 查看资源使用情况
|
||||
|
||||
# 日志查看
|
||||
pnpm run logs # 查看所有服务日志
|
||||
pnpm run logs:api # 查看 API 日志
|
||||
pnpm run logs:db # 查看数据库日志
|
||||
pnpm run logs:redis # 查看 Redis 日志
|
||||
|
||||
# 健康与初始化
|
||||
pnpm run health # 运行健康检查
|
||||
pnpm run health:init # 完整健康检查(含初始化)
|
||||
pnpm run init:status # 检查初始化状态(需要管理员 Token)
|
||||
pnpm run init:run # 手动触发初始化检查
|
||||
pnpm run init:generate-keys # 生成 OAuth2 RSA 密钥对
|
||||
|
||||
# 数据库管理
|
||||
pnpm run prisma:generate # 生成 Prisma Client
|
||||
pnpm run prisma:deploy # 部署迁移到数据库
|
||||
pnpm run prisma:seed # 运行数据库种子
|
||||
pnpm run prisma:studio # 打开 Prisma Studio
|
||||
pnpm run backup # 备份数据库
|
||||
pnpm run restore # 恢复数据库(需要 BACKUP_FILE 参数)
|
||||
|
||||
# 开发相关
|
||||
pnpm run start:dev # 开发模式(热重载)
|
||||
pnpm run start:debug # 调试模式
|
||||
pnpm run dev # 同 start:dev
|
||||
pnpm run debug # 同 start:debug
|
||||
|
||||
# 代码质量
|
||||
pnpm run build # 构建应用
|
||||
pnpm run lint # 代码检查
|
||||
pnpm run format # 代码格式化
|
||||
pnpm run test # 运行测试
|
||||
pnpm run test:watch # 测试(watch 模式)
|
||||
pnpm run test:cov # 测试(覆盖率)
|
||||
|
||||
# 安全与配置
|
||||
pnpm run secrets # 生成随机密钥
|
||||
pnpm run cert # 生成自签名证书(仅开发)
|
||||
|
||||
# 容器访问
|
||||
pnpm run shell # 进入 API 容器
|
||||
pnpm run shell:db # 进入数据库容器
|
||||
pnpm run shell:redis # 进入 Redis 容器
|
||||
|
||||
# 清理
|
||||
pnpm run clean # 清理(停止服务并删除未使用的资源)
|
||||
pnpm run clean:all # 彻底清理(包括镜像)
|
||||
|
||||
# 文档
|
||||
pnpm run docs # 显示文档链接
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
### 使用脚本(底层)
|
||||
|
||||
```bash
|
||||
$ pnpm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
# 环境验证
|
||||
./scripts/verify-env.sh
|
||||
|
||||
# 一键部署(内部调用 deploy.sh)
|
||||
./scripts/deploy.sh --production
|
||||
./scripts/deploy.sh --development
|
||||
|
||||
# 健康检查(包含初始化检查)
|
||||
./scripts/healthcheck.sh
|
||||
./scripts/healthcheck.sh --admin-token <your-token>
|
||||
|
||||
# 数据库操作
|
||||
./scripts/backup.sh [name]
|
||||
./scripts/restore.sh <backup_file>
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
### Docker Compose(直接操作)
|
||||
|
||||
## Resources
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker-compose build
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
# 查看日志
|
||||
docker-compose logs -f api
|
||||
|
||||
## Support
|
||||
# 重启服务
|
||||
docker-compose restart
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
## Stay in touch
|
||||
# 查看状态
|
||||
docker-compose ps
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
# 进入容器
|
||||
docker-compose exec api sh
|
||||
```
|
||||
|
||||
## License
|
||||
---
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
## 📊 默认账户
|
||||
|
||||
首次部署后,种子脚本自动创建:
|
||||
|
||||
| 角色 | 邮箱 | 密码 | 权限 |
|
||||
| ------ | --------------------- | ------------ | -------- |
|
||||
| 管理员 | admin@example.com | password123 | 完全权限 |
|
||||
| 版主 | moderator@example.com | moderator123 | 评论审核 |
|
||||
|
||||
**⚠️ 重要**: 首次登录后请立即修改密码!
|
||||
|
||||
---
|
||||
|
||||
## 🔐 安全配置
|
||||
|
||||
### 1. 生成随机密钥
|
||||
|
||||
```bash
|
||||
pnpm run secrets
|
||||
# 或手动
|
||||
openssl rand -base64 32 # SESSION_COOKIE_SECRET
|
||||
openssl rand -hex 32 # OAUTH2_CLIENT_SECRET
|
||||
```
|
||||
|
||||
### 2. 生成 OAuth2 RSA 密钥对
|
||||
|
||||
```bash
|
||||
pnpm run init:generate-keys
|
||||
# 或手动
|
||||
openssl genrsa -out oauth2-private.pem 2048
|
||||
openssl rsa -pubout -in oauth2-private.pem -out oauth2-public.pem
|
||||
```
|
||||
|
||||
将 PEM 内容复制到 `.env` 文件。
|
||||
|
||||
### 3. 生产环境必需配置
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
SESSION_COOKIE_SECURE=true
|
||||
APP_BASE_URL=https://api.yourdomain.com
|
||||
OAUTH2_TOKEN_SIGNING_PRIVATE_KEY=<your-private-key>
|
||||
OAUTH2_TOKEN_SIGNING_PUBLIC_KEY=<your-public-key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 API 端点概览
|
||||
|
||||
### 认证
|
||||
|
||||
- `POST /login` - JSON 登录
|
||||
- `POST /logout` - 登出
|
||||
- `GET /oauth2/authorize` - OAuth2 授权
|
||||
- `POST /oauth2/token` - 获取 Token
|
||||
- `GET /me` - 当前用户信息
|
||||
|
||||
### 文章
|
||||
|
||||
- `GET /articles` - 列表(支持分页、筛选)
|
||||
- `POST /articles` - 创建(需登录)
|
||||
- `GET /articles/:id` - 详情
|
||||
- `GET /articles/slug/:slug` - 根据 slug 获取
|
||||
- `PUT /articles/:id` - 更新(作者/管理员)
|
||||
- `DELETE /articles/:id` - 删除(作者/管理员)
|
||||
|
||||
### 评论
|
||||
|
||||
- `POST /articles/:id/comments` - 提交评论
|
||||
- `GET /articles/:id/comments` - 获取评论
|
||||
|
||||
### 管理后台 (需管理员)
|
||||
|
||||
- `GET /admin/articles` - 文章管理(强制操作)
|
||||
- `GET /admin/comments` - 评论管理
|
||||
- `PUT /admin/comments/:id/status` - 更新评论状态
|
||||
- `DELETE /admin/comments/:id` - 删除评论
|
||||
- `GET /admin/comments/:id/audits` - 查看审核日志
|
||||
|
||||
### 系统
|
||||
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /init/status` - 初始化状态(管理员)
|
||||
- `POST /init/run` - 运行初始化检查(管理员)
|
||||
|
||||
完整 API 文档访问 `/api-docs` (开发环境)。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 所有测试
|
||||
pnpm test
|
||||
|
||||
# 覆盖率
|
||||
pnpm run test:cov
|
||||
|
||||
# Watch 模式
|
||||
pnpm run test:watch
|
||||
|
||||
# E2E 测试
|
||||
pnpm run test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 20+ 或 Bun 1.1+
|
||||
- PostgreSQL 16+
|
||||
- Redis 7+
|
||||
- pnpm 8+
|
||||
|
||||
### 开发流程
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 2. 复制环境配置
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件
|
||||
|
||||
# 3. 启动依赖服务
|
||||
pnpm run start:all
|
||||
|
||||
# 4. 运行迁移
|
||||
pnpm run prisma:deploy
|
||||
|
||||
# 5. 启动开发服务器
|
||||
pnpm run start:dev
|
||||
```
|
||||
|
||||
### 常用命令速查
|
||||
|
||||
```bash
|
||||
pnpm run build # 构建
|
||||
pnpm run lint # 代码检查
|
||||
pnpm run format # 格式化
|
||||
pnpm run test # 测试
|
||||
pnpm run verify # 环境验证
|
||||
pnpm run health # 健康检查
|
||||
pnpm run logs:api # 查看 API 日志
|
||||
pnpm run shell # 进入容器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 构建和启动
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker-compose build
|
||||
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f api
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 资源限制
|
||||
|
||||
Docker Compose 已配置:
|
||||
|
||||
- CPU: 限制 2 核心,预留 0.5 核心
|
||||
- 内存: 限制 2GB,预留 512MB
|
||||
- 日志: 最大 10MB × 3 文件,自动压缩
|
||||
|
||||
---
|
||||
|
||||
## 📈 监控和维护
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
# API 健康检查
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# 综合健康检查脚本
|
||||
./scripts/healthcheck.sh
|
||||
```
|
||||
|
||||
### 日志管理
|
||||
|
||||
```bash
|
||||
# 实时日志
|
||||
pnpm run logs:api
|
||||
|
||||
# 导出日志 (自动归档)
|
||||
docker-compose logs api > logs/$(date +%Y%m%d_%H%M%S).log
|
||||
|
||||
# 清理旧日志
|
||||
find logs/ -name "*.log" -mtime +30 -delete
|
||||
```
|
||||
|
||||
### 备份数据库
|
||||
|
||||
```bash
|
||||
# 手动备份
|
||||
pnpm run backup
|
||||
|
||||
# 自动备份 (添加到 crontab)
|
||||
0 2 * * * cd /path/to/project && pnpm run backup
|
||||
```
|
||||
|
||||
### 恢复数据库
|
||||
|
||||
```bash
|
||||
# 查看备份列表
|
||||
ls -lh backups/
|
||||
|
||||
# 恢复指定备份
|
||||
BACKUP_FILE=backups/daily_20260328.sql.gz pnpm run restore
|
||||
# 或
|
||||
./scripts/restore.sh backups/daily_20260328.sql.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **容器无法启动**
|
||||
|
||||
```bash
|
||||
docker-compose logs api # 查看错误日志
|
||||
docker-compose down && docker-compose up -d # 重启
|
||||
```
|
||||
|
||||
2. **数据库连接失败**
|
||||
|
||||
```bash
|
||||
docker-compose ps postgres # 检查数据库状态
|
||||
docker-compose logs postgres # 查看数据库日志
|
||||
```
|
||||
|
||||
3. **Redis 连接超时**
|
||||
|
||||
```bash
|
||||
docker-compose restart redis
|
||||
docker-compose logs redis
|
||||
```
|
||||
|
||||
4. **邮件发送失败**
|
||||
|
||||
```bash
|
||||
# 检查 SMTP 配置
|
||||
docker-compose exec api env | grep SMTP
|
||||
# 查看邮件队列
|
||||
docker-compose exec api pnpm prisma emailMessage.findMany()
|
||||
```
|
||||
|
||||
5. **AI 审核不工作**
|
||||
```bash
|
||||
# 检查 API Key
|
||||
docker-compose exec api env | grep AI_API_KEY
|
||||
# 测试 OpenRouter
|
||||
curl -H "Authorization: Bearer $AI_API_KEY" https://openrouter.ai/api/v1/models
|
||||
```
|
||||
|
||||
详细故障排查见 README 的"监控和维护"部分。
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全建议
|
||||
|
||||
1. ✅ **修改所有默认密码** (admin/moderator)
|
||||
2. ✅ **使用强随机密钥** (至少 32 位): `pnpm run secrets`
|
||||
3. ✅ **启用 HTTPS** (Nginx/Traefik + SSL)
|
||||
4. ✅ **配置防火墙** (仅开放 80, 443, 3001)
|
||||
5. ✅ **定期更新依赖** (`pnpm update`)
|
||||
6. ✅ **设置自动备份** (每日备份 + 异地存储)
|
||||
7. ✅ **启用审计日志** (监控 CommentAudit 表)
|
||||
8. ✅ **限制数据库访问** (仅允许 API 容器访问)
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **部署指南**: 见本文档的"快速开始"和"快速命令"部分
|
||||
- [AGENTS.md](./AGENTS.md) - 开发规范
|
||||
- [prisma/schema.prisma](./prisma/schema.prisma) - 数据库 Schema
|
||||
- [.env.example](./.env.example) - 环境变量说明
|
||||
- [scripts/](./scripts/) - 部署脚本集
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
请确保:
|
||||
|
||||
- 代码通过 `pnpm run lint`
|
||||
- 添加了适当的测试
|
||||
- 更新了相关文档
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-03-28
|
||||
**版本**: 1.0.0
|
||||
**维护者**: [Your Team]
|
||||
|
||||
---
|
||||
|
||||
## 🚀 完整部署指南
|
||||
|
||||
本文档的"快速开始"部分提供了基本的部署流程。如需详细的环境配置说明、故障排查、性能优化建议等,请参考以下扩展内容。
|
||||
|
||||
### 环境要求
|
||||
|
||||
| 组件 | 版本要求 | 说明 |
|
||||
| ----------------------- | ---------- | ---------------------------------- |
|
||||
| Node.js | ≥ 20.0.0 | 建议使用 Bun 1.0+ 作为替代(更快) |
|
||||
| pnpm | ≥ 8.0.0 | 包管理 |
|
||||
| PostgreSQL | ≥ 15.0 | 数据库 |
|
||||
| Redis | ≥ 7.0 | 队列与缓存 |
|
||||
| Docker & Docker Compose | 最新稳定版 | 可选,用于容器化部署 |
|
||||
| OpenSSL | 任意 | 生成 RSA 密钥对(OAuth2) |
|
||||
|
||||
### 环境变量配置详解
|
||||
|
||||
核心变量(`.env` 文件):
|
||||
|
||||
| 变量名 | 必填 | 默认值 | 说明 |
|
||||
| ---------------------------------- | ------------ | --------------------- | ----------------------------------------------------- |
|
||||
| `NODE_ENV` | 否 | production | 运行环境 |
|
||||
| `PORT` | 否 | 3001 | API 监听端口 |
|
||||
| `DATABASE_URL` | **是** | - | PostgreSQL 连接字符串 |
|
||||
| `REDIS_HOST` | 否 | redis | Redis 主机名 |
|
||||
| `REDIS_PORT` | 否 | 6379 | Redis 端口 |
|
||||
| `SESSION_COOKIE_SECRET` | **是** | change_me | Session cookie 签名密钥,生产环境必须改为随机长字符串 |
|
||||
| `SESSION_COOKIE_NAME` | 否 | linkshare_session | Cookie 名称 |
|
||||
| `SESSION_COOKIE_SECURE` | 否 | false | HTTPS 时设为 true |
|
||||
| `SESSION_COOKIE_HTTPONLY` | 否 | true | HttpOnly 防止 XSS |
|
||||
| `OAUTH2_ISSUER` | 否 | http://localhost:3001 | OAuth2 签发者 |
|
||||
| `OAUTH2_ACCESS_TOKEN_TTL_SECONDS` | 否 | 3600 | Access token 有效期(秒) |
|
||||
| `OAUTH2_REFRESH_TOKEN_TTL_SECONDS` | 否 | 2592000 | Refresh token 有效期(秒) |
|
||||
| `OAUTH2_TOKEN_SIGNING_PRIVATE_KEY` | **生产必填** | - | RSA 私钥(PEM 格式) |
|
||||
| `OAUTH2_TOKEN_SIGNING_PUBLIC_KEY` | **生产必填** | - | RSA 公钥(PEM 格式) |
|
||||
| `AI_PROVIDER` | 否 | openrouter | AI 提供商 |
|
||||
| `AI_API_KEY` | 否 | - | AI API 密钥(BYOK) |
|
||||
| `SMTP_HOST` | 否 | - | SMTP 服务器 |
|
||||
| `SMTP_PORT` | 否 | 587 | SMTP 端口 |
|
||||
| `SMTP_USER` | 否 | - | SMTP 用户名 |
|
||||
| `SMTP_PASS` | 否 | - | SMTP 密码 |
|
||||
|
||||
完整列表见 [.env.example](./.env.example)。
|
||||
|
||||
### 生产环境注意事项
|
||||
|
||||
1. **修改所有默认密码** (admin/moderator)
|
||||
2. **使用强随机密钥** (至少 32 位): `pnpm run secrets`
|
||||
3. **启用 HTTPS** (Nginx/Traefik + SSL)
|
||||
4. **配置防火墙** (仅开放 80, 443, 3001)
|
||||
5. **定期更新依赖** (`pnpm update`)
|
||||
6. **设置自动备份** (每日备份 + 异地存储)
|
||||
7. **启用审计日志** (监控 CommentAudit 表)
|
||||
8. **限制数据库访问** (仅允许 API 容器访问)
|
||||
|
||||
### 故障排查
|
||||
|
||||
**API 无法启动**
|
||||
|
||||
```bash
|
||||
docker-compose logs api # 查看错误日志
|
||||
docker-compose down && docker-compose up -d # 重启
|
||||
```
|
||||
|
||||
**数据库连接失败**
|
||||
|
||||
```bash
|
||||
docker-compose ps postgres # 检查数据库状态
|
||||
docker-compose logs postgres # 查看数据库日志
|
||||
```
|
||||
|
||||
**初始化检查失败**
|
||||
|
||||
```bash
|
||||
# 获取管理员 token 后运行
|
||||
pnpm run init:status
|
||||
# 或
|
||||
./scripts/healthcheck.sh --admin-token <token>
|
||||
```
|
||||
|
||||
120
docker-compose.yml
Normal file
120
docker-compose.yml
Normal file
@ -0,0 +1,120 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: blog-postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: blog
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-blog}
|
||||
POSTGRES_DB: linkshare
|
||||
POSTGRES_INITDB_ARGS: '--encoding=UTF-8'
|
||||
volumes:
|
||||
- ./volumes/postgres:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
ports:
|
||||
- '${POSTGRES_PORT:-5432}:5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U blog -d linkshare']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
networks:
|
||||
- blog-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: blog-redis
|
||||
restart: always
|
||||
ports:
|
||||
- '${REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
- ./volumes/redis:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
networks:
|
||||
- blog-network
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
args:
|
||||
NODE_ENV: production
|
||||
container_name: blog-api
|
||||
restart: always
|
||||
ports:
|
||||
- '${API_PORT:-3001}:3001'
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://blog:${POSTGRES_PASSWORD:-blog}@postgres:5432/linkshare?schema=public
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
|
||||
APP_BASE_URL: ${APP_BASE_URL:-https://api.yourdomain.com}
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "
|
||||
echo '⏳ Waiting for database...' &&
|
||||
sleep 15 &&
|
||||
echo '🔧 Running database migrations...' &&
|
||||
bunx prisma migrate deploy &&
|
||||
echo '🌱 Seeding database...' &&
|
||||
bunx prisma db seed ||
|
||||
echo '⚠️ Seed skipped (data may already exist)' &&
|
||||
echo '🚀 Starting application...' &&
|
||||
node dist/main
|
||||
"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'-e',
|
||||
"require('http').get('http://localhost:3001/health', (r) => {if(r.statusCode!==200)process.exit(1)})",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- blog-network
|
||||
# 资源限制
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
# 日志配置
|
||||
logging:
|
||||
driver: 'json-file'
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
compress: 'true'
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
redis:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
blog-network:
|
||||
driver: bridge
|
||||
20
ecosystem.config.js
Normal file
20
ecosystem.config.js
Normal file
@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'binglogyblog-api',
|
||||
cwd: './apps/api',
|
||||
script: 'dist/main',
|
||||
interpreter: 'bun', // Use Bun runtime
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
13
jest.config.js
Normal file
13
jest.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: '.',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s', '!**/*.spec.ts', '!**/node_modules/**'],
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
};
|
||||
80
package.json
80
package.json
@ -1,10 +1,7 @@
|
||||
{
|
||||
"name": "bing-logy-blog-backend",
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@ -17,14 +14,65 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma": "prisma",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"postinstall": "prisma generate",
|
||||
"verify": "bash scripts/verify-env.sh",
|
||||
"deploy": "bash scripts/deploy.sh --production",
|
||||
"deploy:dev": "bash scripts/deploy.sh --development",
|
||||
"deploy:quick": "bash scripts/deploy.sh --production --skip-build",
|
||||
"deploy:check": "bash scripts/deploy.sh --production --init-check",
|
||||
"migrate": "bash scripts/deploy.sh --production --skip-build --skip-seed",
|
||||
"seed": "bash scripts/deploy.sh --production --skip-build --skip-migrate",
|
||||
"health": "bash scripts/healthcheck.sh",
|
||||
"health:init": "bash -c 'read -p \"管理员 Token: \" token; ADMIN_TOKEN=$$token bash scripts/healthcheck.sh'",
|
||||
"init:status": "bash -c 'read -p \"管理员 Token: \" token; curl -s -H \"Authorization: Bearer $$token\" http://localhost:3001/init/status | python3 -m json.tool 2>/dev/null || curl -s -H \"Authorization: Bearer $$token\" http://localhost:3001/init/status'",
|
||||
"init:run": "bash -c 'read -p \"管理员 Token: \" token; curl -s -X POST -H \"Authorization: Bearer $$token\" http://localhost:3001/init/run | python3 -m json.tool 2>/dev/null || curl -s -X POST -H \"Authorization: Bearer $$token\" http://localhost:3001/init/run'",
|
||||
"init:generate-keys": "bash -c 'openssl genrsa -out oauth2-private.pem 2048 && openssl rsa -pubout -in oauth2-private.pem -out oauth2-public.pem && echo \"✅ 密钥已生成:\" && echo \" 私钥: oauth2-private.pem\" && echo \" 公钥: oauth2-public.pem\" && echo \"\" && echo \"请将 PEM 内容复制到 .env 文件:\" && echo \"OAUTH2_TOKEN_SIGNING_PRIVATE_KEY=\\\"$$(cat oauth2-private.pem)\\\"\" && echo \"OAUTH2_TOKEN_SIGNING_PUBLIC_KEY=\\\"$$(cat oauth2-public.pem)\\\"\"'",
|
||||
"logs": "docker-compose logs -f",
|
||||
"logs:api": "docker-compose logs -f api",
|
||||
"logs:db": "docker-compose logs -f postgres",
|
||||
"logs:redis": "docker-compose logs -f redis",
|
||||
"backup": "bash scripts/backup.sh manual_$(date +%Y%m%d_%H%M%S)",
|
||||
"restore": "bash -c 'if [ -z \"$(BACKUP_FILE)\" ]; then echo \"❌ 请指定备份文件: pnpm run restore --BACKUP_FILE=backups/xxx.sql.gz\"; exit 1; fi; bash scripts/restore.sh \"$(BACKUP_FILE)\"'",
|
||||
"status": "docker-compose ps",
|
||||
"stats": "docker stats --no-stream",
|
||||
"shell": "docker-compose exec api sh",
|
||||
"shell:db": "docker-compose exec postgres psql -U blog linkshare",
|
||||
"shell:redis": "docker-compose exec redis redis-cli",
|
||||
"start:all": "docker-compose up -d",
|
||||
"stop": "docker-compose down",
|
||||
"restart": "docker-compose restart",
|
||||
"recreate": "docker-compose down -v && docker-compose up -d",
|
||||
"clean": "docker-compose down -v && docker system prune -f",
|
||||
"clean:all": "docker-compose down -v && docker rmi binglogyblog-api -f 2>/dev/null || true && docker system prune -af --volumes",
|
||||
"secrets": "bash -c 'echo \"SESSION_COOKIE_SECRET=$(openssl rand -base64 32)\" && echo \"OAUTH2_CLIENT_SECRET=$(openssl rand -hex 32)\" && echo \"\" && echo \"请将上述值添加到 .env 文件\"'",
|
||||
"cert": "bash -c 'mkdir -p ssl && openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ssl/selfsigned.key -out ssl/selfsigned.crt -subj \"/C=US/ST=State/L=City/O=Org/CN=localhost\" && echo \"✅ 证书已生成: ssl/selfsigned.crt / ssl/selfsigned.key\"'",
|
||||
"docs": "bash -c 'echo \"📚 文档:\" && echo \" README: ./README.md\" && echo \" 开发规范: ./AGENTS.md\" && echo \" 数据库 Schema: ./prisma/schema.prisma\" && echo \" 环境变量: ./.env.example\" && echo \" 脚本集: ./scripts/\" && echo \" API 文档: http://localhost:3001/api-docs (开发环境)\"'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"express-session": "^1.17.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.9.16",
|
||||
"prisma": "^6.1.0",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"helmet": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@ -32,7 +80,10 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@ -50,22 +101,5 @@
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
||||
1286
pnpm-lock.yaml
generated
1286
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
11
pnpm-workspace.yaml
Normal file
11
pnpm-workspace.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
|
||||
packages:
|
||||
- 'apps/api'
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
bcrypt: true
|
||||
msgpackr-extract: true
|
||||
prisma: true
|
||||
unrs-resolver: true
|
||||
267
prisma/schema.prisma
Normal file
267
prisma/schema.prisma
Normal file
@ -0,0 +1,267 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
username String @unique
|
||||
passwordHash String
|
||||
displayName String?
|
||||
avatarUrl String?
|
||||
role String @default("user") // admin, user, moderator
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
oauth2Tokens OAuth2Token[]
|
||||
oauth2AuthCodes OAuth2AuthorizationCode[]
|
||||
articles Article[]
|
||||
comments Comment[]
|
||||
reactions Reaction[] @relation("UserReactions")
|
||||
follows Follow[] @relation("FollowFollower")
|
||||
followees Follow[] @relation("FollowFollowee")
|
||||
subscriptions Subscription[] @relation("SubscriptionSubscriber")
|
||||
targets Subscription[] @relation("SubscriptionTarget")
|
||||
notifications Notification[] @relation("UserNotifications")
|
||||
editedVersions ArticleVersion[] @relation("ArticleVersionEditor")
|
||||
reviewedCommentAudits CommentAudit[] @relation("CommentAuditReviewer")
|
||||
}
|
||||
|
||||
model OAuth2Client {
|
||||
id Int @id @default(autoincrement())
|
||||
clientId String @unique
|
||||
clientSecret String
|
||||
redirectUris String[] // JSON array of URIs
|
||||
scopes String @default("read write")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tokens OAuth2Token[]
|
||||
authCodes OAuth2AuthorizationCode[]
|
||||
}
|
||||
|
||||
model OAuth2AuthorizationCode {
|
||||
id Int @id @default(autoincrement())
|
||||
codeHash String @unique
|
||||
clientId String
|
||||
userId Int?
|
||||
redirectUri String
|
||||
scopes String
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
client OAuth2Client @relation(fields: [clientId], references: [clientId])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model OAuth2Token {
|
||||
id Int @id @default(autoincrement())
|
||||
accessTokenHash String @unique
|
||||
refreshTokenHash String? @unique
|
||||
clientId String
|
||||
userId Int?
|
||||
scopes String
|
||||
expiresAt DateTime
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
client OAuth2Client @relation(fields: [clientId], references: [clientId])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([clientId])
|
||||
}
|
||||
|
||||
model Article {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
title String
|
||||
content String? // Markdown content
|
||||
excerpt String?
|
||||
coverImageUrl String?
|
||||
status String @default("draft") // draft, published, archived
|
||||
visibility String @default("public") // public, unlisted, private
|
||||
passwordHash String? // for password-protected articles
|
||||
token String? @unique // for token-based access
|
||||
viewCount Int @default(0) // number of views
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
authorId Int
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
versions ArticleVersion[] @relation("ArticleVersions")
|
||||
comments Comment[]
|
||||
reactions Reaction[] @relation("ArticleReactions")
|
||||
linkAccesses LinkAccess[] @relation("ArticleLinkAccess")
|
||||
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
model ArticleVersion {
|
||||
id Int @id @default(autoincrement())
|
||||
articleId Int
|
||||
version Int
|
||||
title String
|
||||
content String?
|
||||
excerpt String?
|
||||
editorId Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
article Article @relation("ArticleVersions", fields: [articleId], references: [id])
|
||||
editor User? @relation("ArticleVersionEditor", fields: [editorId], references: [id])
|
||||
|
||||
@@unique([articleId, version])
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id Int @id @default(autoincrement())
|
||||
articleId Int
|
||||
parentId Int?
|
||||
content String
|
||||
status String @default("pending") // pending, approved, rejected, suspicious
|
||||
authorName String?
|
||||
authorEmail String?
|
||||
authorId Int? // registered user
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
article Article @relation(fields: [articleId], references: [id])
|
||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
|
||||
replies Comment[] @relation("CommentReplies")
|
||||
audits CommentAudit[] @relation("CommentAudits")
|
||||
author User? @relation(fields: [authorId], references: [id])
|
||||
|
||||
@@index([articleId, status, parentId])
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
model CommentAudit {
|
||||
id Int @id @default(autoincrement())
|
||||
commentId Int
|
||||
status String // approved, rejected, suspicious
|
||||
reason String? // rule reason or AI decision
|
||||
score Float? // AI confidence score
|
||||
reviewerId Int? // null for AI
|
||||
reviewedAt DateTime @default(now())
|
||||
|
||||
comment Comment @relation("CommentAudits", fields: [commentId], references: [id])
|
||||
reviewer User? @relation("CommentAuditReviewer", fields: [reviewerId], references: [id])
|
||||
|
||||
@@unique([commentId])
|
||||
}
|
||||
|
||||
model LinkAccess {
|
||||
id Int @id @default(autoincrement())
|
||||
token String @unique
|
||||
articleId Int
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
viewCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
article Article @relation("ArticleLinkAccess", fields: [articleId], references: [id])
|
||||
}
|
||||
|
||||
model Reaction {
|
||||
id Int @id @default(autoincrement())
|
||||
articleId Int
|
||||
userId Int
|
||||
type String // like, love, wow, etc.
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
article Article @relation("ArticleReactions", fields: [articleId], references: [id])
|
||||
user User @relation("UserReactions", fields: [userId], references: [id])
|
||||
|
||||
@@unique([userId, articleId, type])
|
||||
}
|
||||
|
||||
model Follow {
|
||||
id Int @id @default(autoincrement())
|
||||
followerId Int
|
||||
followeeId Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
follower User @relation("FollowFollower", fields: [followerId], references: [id])
|
||||
followee User @relation("FollowFollowee", fields: [followeeId], references: [id])
|
||||
|
||||
@@unique([followerId, followeeId])
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id Int @id @default(autoincrement())
|
||||
subscriberId Int
|
||||
targetId Int?
|
||||
type String // article, user, comment
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
subscriber User @relation("SubscriptionSubscriber", fields: [subscriberId], references: [id])
|
||||
target User? @relation("SubscriptionTarget", fields: [targetId], references: [id])
|
||||
|
||||
@@unique([subscriberId, targetId, type])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
type String // comment_reply, reaction, follow, system
|
||||
title String
|
||||
message String
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("UserNotifications", fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model EmailMessage {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
subject String
|
||||
body String
|
||||
status String @default("pending") // pending, sent, failed
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
scheduledAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(cuid())
|
||||
type String // page_view, article_view, comment_submit, etc.
|
||||
userId Int?
|
||||
sessionId String?
|
||||
ip String?
|
||||
userAgent String?
|
||||
data Json? // additional event data
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([type, createdAt])
|
||||
@@index([userId])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model AnalyticsAggregate {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime // truncated to day
|
||||
type String // page_views, unique_visitors, etc.
|
||||
value Int
|
||||
metadata Json?
|
||||
|
||||
@@unique([date, type])
|
||||
}
|
||||
116
prisma/seed.ts
Normal file
116
prisma/seed.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// 创建默认 OAuth2 客户端
|
||||
const defaultClient = await prisma.oAuth2Client.upsert({
|
||||
where: { clientId: 'web-client' },
|
||||
update: {},
|
||||
create: {
|
||||
clientId: 'web-client',
|
||||
clientSecret: 'change_me',
|
||||
redirectUris: ['http://localhost:3000/auth/callback'],
|
||||
scopes: 'read write',
|
||||
},
|
||||
});
|
||||
console.log('Default OAuth2 client:', defaultClient.clientId);
|
||||
|
||||
// 检查是否已有管理员用户
|
||||
const existingAdmin = await prisma.user.findFirst({
|
||||
where: { email: 'admin@example.com' },
|
||||
});
|
||||
|
||||
let admin;
|
||||
if (!existingAdmin) {
|
||||
const passwordHash = await bcrypt.hash('password123', 12);
|
||||
admin = await prisma.user.create({
|
||||
data: {
|
||||
email: 'admin@example.com',
|
||||
username: 'admin',
|
||||
passwordHash,
|
||||
displayName: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
console.log('Created admin user:', admin.email);
|
||||
} else {
|
||||
admin = existingAdmin;
|
||||
console.log('Admin user already exists:', admin.email);
|
||||
}
|
||||
|
||||
// 检查是否已有版主用户
|
||||
const existingModerator = await prisma.user.findFirst({
|
||||
where: { email: 'moderator@example.com' },
|
||||
});
|
||||
|
||||
if (!existingModerator) {
|
||||
const modPasswordHash = await bcrypt.hash('moderator123', 12);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: 'moderator@example.com',
|
||||
username: 'moderator',
|
||||
passwordHash: modPasswordHash,
|
||||
displayName: 'Moderator',
|
||||
role: 'moderator',
|
||||
},
|
||||
});
|
||||
console.log('Created moderator user: moderator@example.com');
|
||||
} else {
|
||||
console.log('Moderator user already exists:', existingModerator.email);
|
||||
}
|
||||
|
||||
// 创建示例文章
|
||||
const existingArticle = await prisma.article.findFirst({
|
||||
where: { slug: 'welcome' },
|
||||
});
|
||||
|
||||
if (!existingArticle) {
|
||||
const article = await prisma.article.create({
|
||||
data: {
|
||||
slug: 'welcome',
|
||||
title: 'Welcome to LinkShare Blog',
|
||||
content: 'This is a sample article. You can edit or delete it.',
|
||||
excerpt: 'Short excerpt',
|
||||
status: 'published',
|
||||
visibility: 'public',
|
||||
publishedAt: new Date(),
|
||||
authorId: admin.id,
|
||||
},
|
||||
});
|
||||
console.log('Created sample article:', article.slug);
|
||||
} else {
|
||||
console.log('Sample article already exists:', existingArticle.slug);
|
||||
}
|
||||
|
||||
// 创建 link-access token 示例
|
||||
const article = await prisma.article.findFirst({
|
||||
where: { slug: 'welcome' },
|
||||
});
|
||||
if (article) {
|
||||
const existingLink = await prisma.linkAccess.findFirst({
|
||||
where: { articleId: article.id },
|
||||
});
|
||||
if (!existingLink) {
|
||||
await prisma.linkAccess.create({
|
||||
data: {
|
||||
token: 'sample-token-123',
|
||||
articleId: article.id,
|
||||
maxViews: 100,
|
||||
},
|
||||
});
|
||||
console.log('Created link-access token for article:', article.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
76
scripts/backup.sh
Normal file
76
scripts/backup.sh
Normal file
@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# LinkShare Blog - 数据库备份脚本
|
||||
# ============================================
|
||||
# 用途: 自动备份 PostgreSQL 数据库
|
||||
# 用法: ./scripts/backup.sh [backup_name]
|
||||
# 示例: ./scripts/backup.sh daily
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
BACKUP_DIR="/backups"
|
||||
DB_CONTAINER="blog-postgres"
|
||||
DB_NAME="linkshare"
|
||||
DB_USER="blog"
|
||||
RETENTION_DAYS=7
|
||||
|
||||
# 参数处理
|
||||
BACKUP_NAME=${1:-"auto_$(date +%Y%m%d_%H%M%S)"}
|
||||
BACKUP_FILE="$BACKUP_DIR/${BACKUP_NAME}.sql.gz"
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 创建备份目录
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 检查数据库容器
|
||||
if ! docker ps | grep -q "$DB_CONTAINER"; then
|
||||
log_error "数据库容器 '$DB_CONTAINER' 未运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行备份
|
||||
log_info "开始备份数据库: $DB_NAME"
|
||||
log_info "备份文件: $BACKUP_FILE"
|
||||
|
||||
if docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" 2>/dev/null | gzip > "$BACKUP_FILE"; then
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
log_info "备份成功: $BACKUP_SIZE"
|
||||
|
||||
# 记录备份信息
|
||||
echo "Backup: $BACKUP_NAME" >> "$BACKUP_DIR/backup.log"
|
||||
echo "Date: $(date '+%Y-%m-%d %H:%M:%S')" >> "$BACKUP_DIR/backup.log"
|
||||
echo "Size: $BACKUP_SIZE" >> "$BACKUP_DIR/backup.log"
|
||||
echo "File: $BACKUP_FILE" >> "$BACKUP_DIR/backup.log"
|
||||
echo "---" >> "$BACKUP_DIR/backup.log"
|
||||
|
||||
# 清理旧备份
|
||||
log_info "清理 $RETENTION_DAYS 天前的旧备份..."
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +$RETENTION_DAYS -delete 2>/dev/null || true
|
||||
|
||||
log_info "备份完成!"
|
||||
|
||||
# 输出备份文件列表
|
||||
echo ""
|
||||
echo "可用的备份:"
|
||||
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null || echo "无备份文件"
|
||||
|
||||
exit 0
|
||||
else
|
||||
log_error "备份失败"
|
||||
rm -f "$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
312
scripts/deploy.sh
Normal file
312
scripts/deploy.sh
Normal file
@ -0,0 +1,312 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# LinkShare Blog - 现代化一键部署脚本
|
||||
# ============================================
|
||||
# 用途: 自动化部署流程,支持初始化检查
|
||||
# 用法: ./scripts/deploy.sh [options]
|
||||
# 示例: ./scripts/deploy.sh
|
||||
# ./scripts/deploy.sh --dev
|
||||
# ./scripts/deploy.sh --init-check
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[→]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
|
||||
print_banner() {
|
||||
echo "=========================================="
|
||||
echo " LinkShare Blog 一键部署"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
show_usage() {
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --dev, --development 开发环境部署"
|
||||
echo " --prod, --production 生产环境部署 (默认)"
|
||||
echo " --skip-build 跳过镜像构建"
|
||||
echo " --skip-migrate 跳过数据库迁移"
|
||||
echo " --skip-seed 跳过数据库种子"
|
||||
echo " --init-check 部署后检查初始化状态"
|
||||
echo " --admin-token TOKEN 管理员 token(用于初始化检查)"
|
||||
echo " --no-deps 不启动依赖服务"
|
||||
echo " --backup 部署前自动备份数据库"
|
||||
echo " -h, --help 显示此帮助"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 # 完整生产部署"
|
||||
echo " $0 --dev # 开发环境部署"
|
||||
echo " $0 --skip-build --init-check # 跳过构建并检查初始化"
|
||||
echo ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 参数解析
|
||||
ENV="production"
|
||||
SKIP_BUILD=false
|
||||
SKIP_MIGRATE=false
|
||||
SKIP_SEED=false
|
||||
INIT_CHECK=false
|
||||
ADMIN_TOKEN=""
|
||||
INCLUDE_DEPS=true
|
||||
BACKUP_BEFORE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dev|--development)
|
||||
ENV="development"
|
||||
shift
|
||||
;;
|
||||
--prod|--production)
|
||||
ENV="production"
|
||||
shift
|
||||
;;
|
||||
--skip-build)
|
||||
SKIP_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--skip-migrate)
|
||||
SKIP_MIGRATE=true
|
||||
shift
|
||||
;;
|
||||
--skip-seed)
|
||||
SKIP_SEED=true
|
||||
shift
|
||||
;;
|
||||
--init-check)
|
||||
INIT_CHECK=true
|
||||
shift
|
||||
;;
|
||||
--admin-token)
|
||||
ADMIN_TOKEN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-deps)
|
||||
INCLUDE_DEPS=false
|
||||
shift
|
||||
;;
|
||||
--backup)
|
||||
BACKUP_BEFORE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_usage
|
||||
;;
|
||||
*)
|
||||
log_error "未知选项: $1"
|
||||
show_usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 工具函数
|
||||
wait_for_service() {
|
||||
local url=$1
|
||||
local name=$2
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
log_step "等待 $name 就绪..."
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if curl -sf "$url" >/dev/null 2>&1; then
|
||||
log_info "$name 已就绪"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
((attempt++))
|
||||
done
|
||||
echo ""
|
||||
log_error "$name 未能在预期时间内就绪"
|
||||
return 1
|
||||
}
|
||||
|
||||
check_deployment_success() {
|
||||
log_step "验证部署结果..."
|
||||
|
||||
# 基本健康检查
|
||||
if ! curl -sf http://localhost:3001/health >/dev/null; then
|
||||
log_error "API 健康检查失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "API 健康检查通过"
|
||||
|
||||
# 检查初始化状态(如果要求)
|
||||
if [ "$INIT_CHECK" = true ] && [ -n "$ADMIN_TOKEN" ]; then
|
||||
local init_response=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
http://localhost:3001/init/status)
|
||||
|
||||
if [ "$init_response" = "200" ]; then
|
||||
log_info "初始化状态检查通过"
|
||||
else
|
||||
log_warn "初始化状态检查失败 (HTTP $init_response)"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# 主部署流程
|
||||
main() {
|
||||
print_banner
|
||||
log_info "目标环境: $ENV"
|
||||
echo ""
|
||||
|
||||
# 1. 环境验证
|
||||
log_step "1/5 验证环境配置..."
|
||||
if [ ! -f ".env" ]; then
|
||||
log_warn ".env 文件不存在,从模板创建..."
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example .env
|
||||
log_warn "请编辑 .env 文件设置必要的配置!"
|
||||
else
|
||||
log_error "未找到 .env.example 模板"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "scripts/verify-env.sh" ]; then
|
||||
log_info "运行环境验证..."
|
||||
if ! bash scripts/verify-env.sh; then
|
||||
log_error "环境验证失败,请修复上述问题"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. 备份数据库(可选)
|
||||
if [ "$BACKUP_BEFORE" = true ]; then
|
||||
log_step "2/5 备份数据库..."
|
||||
if [ -f "scripts/backup.sh" ]; then
|
||||
bash scripts/backup.sh "pre-deploy-$(date +%Y%m%d_%H%M%S)" || {
|
||||
log_warn "备份失败,但继续部署..."
|
||||
}
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 3. 构建镜像
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
log_step "3/5 构建 Docker 镜像..."
|
||||
if ! docker-compose build api; then
|
||||
log_error "镜像构建失败"
|
||||
exit 1
|
||||
fi
|
||||
log_info "镜像构建成功"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 4. 启动服务
|
||||
log_step "4/5 启动服务..."
|
||||
if [ "$INCLUDE_DEPS" = false ]; then
|
||||
docker-compose up -d api || {
|
||||
log_error "API 服务启动失败"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
docker-compose up -d || {
|
||||
log_error "服务启动失败"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
log_info "服务已启动"
|
||||
echo ""
|
||||
|
||||
# 5. 等待服务就绪
|
||||
if ! wait_for_service "http://localhost:3001/health" "API"; then
|
||||
log_error "API 服务未就绪"
|
||||
docker-compose logs api --tail=50
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 6. 数据库迁移和 seed
|
||||
if [ "$SKIP_MIGRATE" = false ]; then
|
||||
log_step "6/5 运行数据库迁移..."
|
||||
if docker-compose exec -T api bunx prisma migrate deploy 2>/dev/null; then
|
||||
log_info "数据库迁移完成"
|
||||
else
|
||||
log_warn "迁移失败或无需迁移"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$SKIP_SEED" = false ] && [ "$SKIP_MIGRATE" = false ]; then
|
||||
log_step "7/5 运行数据库种子..."
|
||||
if docker-compose exec -T api bunx prisma db seed 2>/dev/null; then
|
||||
log_info "数据库种子完成"
|
||||
else
|
||||
log_warn "种子执行失败或数据已存在"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 7. 验证部署
|
||||
if ! check_deployment_success; then
|
||||
log_error "部署验证失败"
|
||||
echo ""
|
||||
log_error "========== 部署失败 =========="
|
||||
log_error "请检查以下内容:"
|
||||
log_error " 1. 查看日志: docker-compose logs -f api"
|
||||
log_error " 2. 检查配置: cat .env"
|
||||
log_error " 3. 验证依赖: docker-compose ps"
|
||||
log_error " 4. 手动健康检查: curl http://localhost:3001/health"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 8. 显示部署信息
|
||||
echo ""
|
||||
log_step "部署完成!"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "服务状态:"
|
||||
echo "=========================================="
|
||||
docker-compose ps
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "访问地址:"
|
||||
echo "=========================================="
|
||||
echo "API: http://localhost:3001"
|
||||
echo "健康检查: http://localhost:3001/health"
|
||||
echo "Swagger: http://localhost:3001/api-docs (开发环境)"
|
||||
if [ "$INIT_CHECK" = true ]; then
|
||||
echo "初始化状态: http://localhost:3001/init/status (需要管理员 Token)"
|
||||
fi
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "常用命令:"
|
||||
echo "=========================================="
|
||||
echo "查看日志: docker-compose logs -f api"
|
||||
echo "重启服务: docker-compose restart"
|
||||
echo "停止服务: docker-compose down"
|
||||
echo "备份数据库: ./scripts/backup.sh"
|
||||
echo "健康检查: ./scripts/healthcheck.sh"
|
||||
if [ "$INIT_CHECK" = true ]; then
|
||||
echo "初始化检查: ./scripts/healthcheck.sh --admin-token <token>"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ -f ".env" ]; then
|
||||
log_warn "请确保 .env 文件中的敏感配置已正确设置!"
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
246
scripts/healthcheck.sh
Normal file
246
scripts/healthcheck.sh
Normal file
@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# LinkShare Blog - 增强健康检查脚本
|
||||
# ============================================
|
||||
# 用途: 全面检查系统状态,包括初始化状态
|
||||
# 用法: ./scripts/healthcheck.sh [--admin-token TOKEN]
|
||||
# 示例: ./scripts/healthcheck.sh
|
||||
# ./scripts/healthcheck.sh --admin-token xxx
|
||||
|
||||
set -e
|
||||
|
||||
# API 地址
|
||||
API_URL=${HEALTH_CHECK_URL:-http://localhost:3001}
|
||||
ADMIN_TOKEN=${ADMIN_TOKEN:-}
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# 检查 API 健康
|
||||
check_api() {
|
||||
log_step "检查 API 健康状态..."
|
||||
local response=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/health" 2>/dev/null)
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
log_info "✓ API 健康检查通过 (HTTP $response)"
|
||||
return 0
|
||||
else
|
||||
log_error "✗ API 健康检查失败 (HTTP $response)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查数据库连接
|
||||
check_database() {
|
||||
log_step "检查数据库连接..."
|
||||
local health_json=$(curl -s "$API_URL/health" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$health_json" | grep -q '"database":"ok"'; then
|
||||
log_info "✓ 数据库连接正常"
|
||||
return 0
|
||||
else
|
||||
log_error "✗ 数据库连接异常"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查 Redis 连接
|
||||
check_redis() {
|
||||
log_step "检查 Redis 连接..."
|
||||
local health_json=$(curl -s "$API_URL/health" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$health_json" | grep -q '"redis":"ok"'; then
|
||||
log_info "✓ Redis 连接正常"
|
||||
return 0
|
||||
else
|
||||
log_error "✗ Redis 连接异常"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查初始化状态(需要管理员 token)
|
||||
check_initialization() {
|
||||
log_step "检查系统初始化状态..."
|
||||
|
||||
if [ -z "$ADMIN_TOKEN" ]; then
|
||||
# 尝试从环境变量或 .env 文件获取管理员 token(简化版)
|
||||
log_warn "未提供管理员 token,跳过初始化状态检查"
|
||||
log_info "提示: 使用 --admin-token 参数或设置 ADMIN_TOKEN 环境变量"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local response=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$API_URL/init/status" 2>/dev/null)
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
log_info "✓ 初始化状态检查成功"
|
||||
# 可选:显示详细状态
|
||||
# local status_json=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" "$API_URL/init/status")
|
||||
# echo "$status_json" | python3 -m json.tool 2>/dev/null || echo "$status_json"
|
||||
return 0
|
||||
else
|
||||
log_error "✗ 初始化状态检查失败 (HTTP $response)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查关键 API 端点
|
||||
check_endpoints() {
|
||||
log_step "检查关键 API 端点..."
|
||||
local all_ok=true
|
||||
|
||||
# 健康检查端点
|
||||
if curl -sf "$API_URL/health" >/dev/null 2>/dev/null; then
|
||||
log_info " ✓ GET /health"
|
||||
else
|
||||
log_error " ✗ GET /health 失败"
|
||||
all_ok=false
|
||||
fi
|
||||
|
||||
# 登录端点(不验证,只检查是否存在)
|
||||
if curl -sf -X POST "$API_URL/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"emailOrUsername":"test","password":"test"}"' \
|
||||
>/dev/null 2>&1; then
|
||||
# 即使登录失败(401),端点也是可用的
|
||||
log_info " ✓ POST /login (可用)"
|
||||
else
|
||||
log_error " ✗ POST /login 不可用"
|
||||
all_ok=false
|
||||
fi
|
||||
|
||||
# 初始化端点(需要管理员 token)
|
||||
if [ -n "$ADMIN_TOKEN" ]; then
|
||||
if curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API_URL/init/status" \
|
||||
>/dev/null 2>&1; then
|
||||
log_info " ✓ GET /init/status (管理员)"
|
||||
else
|
||||
log_warn " ⚠ GET /init/status 失败 (可能需要管理员权限)"
|
||||
fi
|
||||
fi
|
||||
|
||||
return $([ "$all_ok" = true ] && echo 0 || echo 1)
|
||||
}
|
||||
|
||||
# 检查磁盘空间
|
||||
check_disk() {
|
||||
log_step "检查磁盘空间..."
|
||||
local available=$(df -BG /app 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
|
||||
|
||||
if [ "$available" -ge 5 ]; then
|
||||
log_info "✓ 可用磁盘空间: ${available}GB"
|
||||
return 0
|
||||
else
|
||||
log_warn "⚠ 可用磁盘空间: ${available}GB (建议至少 5GB)"
|
||||
return 0 # 仅警告,不失败
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查 Docker 容器状态
|
||||
check_containers() {
|
||||
log_step "检查 Docker 容器状态..."
|
||||
local all_running=true
|
||||
|
||||
# 检查 API 容器
|
||||
if docker ps --format '{{.Names}}' | grep -q 'blog-api'; then
|
||||
log_info " ✓ API 容器运行中"
|
||||
else
|
||||
log_error " ✗ API 容器未运行"
|
||||
all_running=false
|
||||
fi
|
||||
|
||||
# 检查数据库容器
|
||||
if docker ps --format '{{.Names}}' | grep -q 'blog-postgres'; then
|
||||
log_info " ✓ PostgreSQL 容器运行中"
|
||||
else
|
||||
log_error " ✗ PostgreSQL 容器未运行"
|
||||
all_running=false
|
||||
fi
|
||||
|
||||
# 检查 Redis 容器
|
||||
if docker ps --format '{{.Names}}' | grep -q 'blog-redis'; then
|
||||
log_info " ✓ Redis 容器运行中"
|
||||
else
|
||||
log_error " ✗ Redis 容器未运行"
|
||||
all_running=false
|
||||
fi
|
||||
|
||||
return $([ "$all_running" = true ] && echo 0 || echo 1)
|
||||
}
|
||||
|
||||
# 主检查流程
|
||||
main() {
|
||||
echo "=========================================="
|
||||
echo "LinkShare Blog 系统健康检查"
|
||||
echo "检查时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "API URL: $API_URL"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
local all_passed=true
|
||||
|
||||
# 1. 容器状态检查
|
||||
if command -v docker &>/dev/null; then
|
||||
check_containers || all_passed=false
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 2. API 健康检查
|
||||
check_api || all_passed=false
|
||||
echo ""
|
||||
|
||||
# 3. 数据库检查
|
||||
check_database || all_passed=false
|
||||
echo ""
|
||||
|
||||
# 4. Redis 检查
|
||||
check_redis || all_passed=false
|
||||
echo ""
|
||||
|
||||
# 5. 初始化状态检查
|
||||
check_initialization || all_passed=false
|
||||
echo ""
|
||||
|
||||
# 6. 关键端点检查
|
||||
check_endpoints || all_passed=false
|
||||
echo ""
|
||||
|
||||
# 7. 磁盘空间检查
|
||||
check_disk
|
||||
echo ""
|
||||
|
||||
# 总结
|
||||
echo "=========================================="
|
||||
if [ "$all_passed" = true ]; then
|
||||
log_info "所有关键检查通过!系统运行正常。"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " - 访问 API: $API_URL"
|
||||
echo " - Swagger 文档: $API_URL/api-docs (开发环境)"
|
||||
echo " - 初始化状态: $API_URL/init/status (需要管理员 Token)"
|
||||
exit 0
|
||||
else
|
||||
log_error "部分检查失败,请排查上述问题。"
|
||||
echo ""
|
||||
echo "故障排查:"
|
||||
echo " - 查看日志: docker-compose logs -f api"
|
||||
echo " - 重启服务: make restart"
|
||||
echo " - 重新部署: make deploy"
|
||||
echo " - 查看文档: DEPLOYMENT.md"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 运行主函数
|
||||
main
|
||||
29
scripts/init-db.sql
Normal file
29
scripts/init-db.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- ============================================
|
||||
-- LinkShare Blog - 数据库初始化脚本
|
||||
-- ============================================
|
||||
-- 用途: 在 PostgreSQL 首次启动时创建必要扩展和配置
|
||||
-- 自动由 docker-compose 执行
|
||||
|
||||
-- 创建 pgcrypto 扩展 (用于密码哈希等)
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- 创建 uuid-ossp 扩展 (如果使用 UUID)
|
||||
-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- 设置时区
|
||||
SET timezone = 'UTC';
|
||||
|
||||
-- 创建性能优化索引 (将在 Prisma 迁移后创建)
|
||||
-- 这些索引可以在迁移文件中定义,这里作为补充
|
||||
|
||||
-- 示例: 为常用查询字段创建索引
|
||||
-- CREATE INDEX IF NOT EXISTS idx_user_email ON "User"(email);
|
||||
-- CREATE INDEX IF NOT EXISTS idx_article_slug ON "Article"(slug);
|
||||
-- CREATE INDEX IF NOT EXISTS idx_comment_article_status ON "Comment"("articleId", "status");
|
||||
|
||||
-- 输出完成信息
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Database initialization completed successfully!';
|
||||
RAISE NOTICE 'Extensions: pgcrypto';
|
||||
END $$;
|
||||
165
scripts/restore.sh
Normal file
165
scripts/restore.sh
Normal file
@ -0,0 +1,165 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# LinkShare Blog - 数据库恢复脚本
|
||||
# ============================================
|
||||
# 用途: 从备份恢复 PostgreSQL 数据库
|
||||
# 用法: ./scripts/restore.sh <backup_file> [options]
|
||||
# 示例: ./scripts/restore.sh daily_20260328.sql.gz
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
DB_CONTAINER="blog-postgres"
|
||||
DB_NAME="linkshare"
|
||||
DB_USER="blog"
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
# 显示用法
|
||||
show_usage() {
|
||||
echo "用法: $0 <backup_file> [options]"
|
||||
echo ""
|
||||
echo "参数:"
|
||||
echo " backup_file 备份文件路径 (必需)"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --drop 恢复前删除现有数据库 (危险!)"
|
||||
echo " --dry-run 仅验证备份文件,不执行恢复"
|
||||
echo " --help 显示此帮助信息"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 daily_20260328.sql.gz # 恢复备份"
|
||||
echo " $0 daily_20260328.sql.gz --drop # 删除并恢复"
|
||||
echo " $0 daily_20260328.sql.gz --dry-run # 验证备份"
|
||||
echo ""
|
||||
echo "可用备份:"
|
||||
ls -1 backups/*.sql.gz 2>/dev/null || echo " (无备份文件)"
|
||||
}
|
||||
|
||||
# 检查参数
|
||||
if [ $# -lt 1 ] || [ "$1" = "--help" ]; then
|
||||
show_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE=$1
|
||||
shift
|
||||
|
||||
# 解析选项
|
||||
DROP_DB=false
|
||||
DRY_RUN=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--drop)
|
||||
DROP_DB=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log_error "未知选项: $1"
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 检查备份文件
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
log_error "备份文件不存在: $BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证备份文件
|
||||
log_info "验证备份文件..."
|
||||
if gzip -t "$BACKUP_FILE" 2>/dev/null; then
|
||||
log_info "✓ 备份文件完整性检查通过"
|
||||
else
|
||||
log_error "备份文件损坏或不是有效的 gzip 文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# dry-run 模式
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
log_info "Dry-run 模式: 不执行恢复"
|
||||
log_info "备份信息:"
|
||||
ls -lh "$BACKUP_FILE"
|
||||
gzip -dc "$BACKUP_FILE" | head -n 20
|
||||
log_info "备份文件前20行预览完成"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查数据库容器
|
||||
if ! docker ps | grep -q "$DB_CONTAINER"; then
|
||||
log_error "数据库容器 '$DB_CONTAINER' 未运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 警告确认
|
||||
echo ""
|
||||
log_warn "即将执行数据库恢复!"
|
||||
log_warn "备份文件: $BACKUP_FILE"
|
||||
log_warn "数据库: $DB_NAME"
|
||||
if [ "$DROP_DB" = true ]; then
|
||||
log_warn "警告: 将删除现有数据库!"
|
||||
fi
|
||||
echo ""
|
||||
read -p "确认继续? (yes/no): " CONFIRM
|
||||
if [ "$CONFIRM" != "yes" ]; then
|
||||
log_info "操作已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行恢复
|
||||
log_info "开始恢复数据库..."
|
||||
|
||||
if [ "$DROP_DB" = true ]; then
|
||||
log_info "删除现有数据库..."
|
||||
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -c "CREATE DATABASE $DB_NAME;"
|
||||
log_info "数据库已重建"
|
||||
fi
|
||||
|
||||
# 恢复数据
|
||||
if gzip -dc "$BACKUP_FILE" | docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME"; then
|
||||
log_info "✓ 数据库恢复成功!"
|
||||
|
||||
# 验证恢复
|
||||
log_info "验证恢复结果..."
|
||||
TABLE_COUNT=$(gzip -dc "$BACKUP_FILE" | grep -c "^CREATE TABLE" || echo "0")
|
||||
log_info "备份中发现的表数量: $TABLE_COUNT"
|
||||
|
||||
ACTUAL_COUNT=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';" 2>/dev/null | tr -d '[:space:]')
|
||||
log_info "恢复后的表数量: $ACTUAL_COUNT"
|
||||
|
||||
if [ "$TABLE_COUNT" -eq "$ACTUAL_COUNT" ] || [ "$ACTUAL_COUNT" -gt 0 ]; then
|
||||
log_info "✓ 验证通过"
|
||||
exit 0
|
||||
else
|
||||
log_warn "表数量不匹配,请手动验证数据完整性"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
log_error "数据库恢复失败"
|
||||
exit 1
|
||||
fi
|
||||
257
scripts/verify-env.sh
Normal file
257
scripts/verify-env.sh
Normal file
@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# LinkShare Blog - 环境验证脚本
|
||||
# ============================================
|
||||
# 用途: 在生产环境部署前验证所有必需配置
|
||||
# 用法: ./scripts/verify-env.sh
|
||||
|
||||
set -e # 遇到错误退出
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 检查项计数
|
||||
CHECKS_PASSED=0
|
||||
CHECKS_FAILED=0
|
||||
CHECKS_WARNING=0
|
||||
|
||||
# 检查函数
|
||||
check_file_exists() {
|
||||
local file=$1
|
||||
local description=$2
|
||||
if [ -f "$file" ]; then
|
||||
log_info "$description: 存在 ($file)"
|
||||
((CHECKS_PASSED++))
|
||||
return 0
|
||||
else
|
||||
log_error "$description: 缺失 ($file)"
|
||||
((CHECKS_FAILED++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_env_var() {
|
||||
local var=$1
|
||||
local description=$2
|
||||
local required=${3:-true}
|
||||
|
||||
local value=$(eval echo "\$$var")
|
||||
if [ -n "$value" ] && [ "$value" != "your-secret-here" ] && [ "$value" != "change_me" ]; then
|
||||
log_info "$description ($var): 已设置"
|
||||
((CHECKS_PASSED++))
|
||||
return 0
|
||||
else
|
||||
if [ "$required" = "true" ]; then
|
||||
log_error "$description ($var): 未设置或为默认值"
|
||||
((CHECKS_FAILED++))
|
||||
return 1
|
||||
else
|
||||
log_warn "$description ($var): 未设置或为默认值 (可选)"
|
||||
((CHECKS_WARNING++))
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
check_command() {
|
||||
local cmd=$1
|
||||
local description=$2
|
||||
if command -v "$cmd" &> /dev/null; then
|
||||
local version=$($cmd --version 2>&1 | head -n1)
|
||||
log_info "$description: 已安装 ($version)"
|
||||
((CHECKS_PASSED++))
|
||||
return 0
|
||||
else
|
||||
log_error "$description: 未安装"
|
||||
((CHECKS_FAILED++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_port() {
|
||||
local port=$1
|
||||
local description=$2
|
||||
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||
log_info "$description (端口 $port): 已占用"
|
||||
((CHECKS_WARNING++))
|
||||
return 0
|
||||
else
|
||||
log_info "$description (端口 $port): 可用"
|
||||
((CHECKS_PASSED++))
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
check_docker() {
|
||||
if docker info >/dev/null 2>&1; then
|
||||
log_info "Docker: 运行中"
|
||||
((CHECKS_PASSED++))
|
||||
return 0
|
||||
else
|
||||
log_error "Docker: 未运行或无权限"
|
||||
((CHECKS_FAILED++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 开始检查
|
||||
# ============================================
|
||||
|
||||
echo "=========================================="
|
||||
echo "LinkShare Blog 环境验证"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 1. 检查必需文件
|
||||
log_info "检查文件..."
|
||||
check_file_exists ".env" "环境变量文件"
|
||||
check_file_exists "docker-compose.yml" "Docker Compose 配置"
|
||||
check_file_exists "Dockerfile.api" "Dockerfile"
|
||||
check_file_exists "ecosystem.config.js" "PM2 配置"
|
||||
check_file_exists "package.json" "Package.json"
|
||||
echo ""
|
||||
|
||||
# 2. 检查环境变量
|
||||
log_info "检查环境变量..."
|
||||
check_env_var "DATABASE_URL" "数据库连接"
|
||||
check_env_var "SESSION_COOKIE_SECRET" "Session 密钥"
|
||||
check_env_var "AI_API_KEY" "AI API 密钥"
|
||||
check_env_var "SMTP_HOST" "SMTP 主机"
|
||||
check_env_var "SMTP_PORT" "SMTP 端口"
|
||||
check_env_var "SMTP_USER" "SMTP 用户名"
|
||||
check_env_var "SMTP_PASS" "SMTP 密码"
|
||||
check_env_var "OAUTH2_TOKEN_SIGNING_PRIVATE_KEY" "OAuth2 私钥"
|
||||
check_env_var "OAUTH2_TOKEN_SIGNING_PUBLIC_KEY" "OAuth2 公钥"
|
||||
echo ""
|
||||
|
||||
# 3. 检查可选环境变量
|
||||
log_info "检查可选环境变量..."
|
||||
check_env_var "REDIS_HOST" "Redis 主机" "false"
|
||||
check_env_var "THROTTLE_TTL" "限流配置" "false"
|
||||
check_env_var "APP_BASE_URL" "应用基础 URL" "false"
|
||||
echo ""
|
||||
|
||||
# 4. 检查命令行工具
|
||||
log_info "检查命令行工具..."
|
||||
check_command "docker" "Docker CLI"
|
||||
check_command "docker-compose" "Docker Compose"
|
||||
check_command "node" "Node.js"
|
||||
check_command "pnpm" "PNPM"
|
||||
check_command "git" "Git"
|
||||
echo ""
|
||||
|
||||
# 5. 检查端口占用
|
||||
log_info "检查端口占用..."
|
||||
check_port "3001" "API 端口"
|
||||
check_port "5432" "PostgreSQL 端口"
|
||||
check_port "6379" "Redis 端口"
|
||||
echo ""
|
||||
|
||||
# 6. 检查 Docker 状态
|
||||
log_info "检查 Docker 状态..."
|
||||
check_docker
|
||||
echo ""
|
||||
|
||||
# 7. 检查 Docker 镜像
|
||||
log_info "检查 Docker 镜像..."
|
||||
if docker image inspect binglogyblog-api:latest >/dev/null 2>&1; then
|
||||
log_info "API 镜像: 存在"
|
||||
((CHECKS_PASSED++))
|
||||
else
|
||||
log_warn "API 镜像: 不存在 (需要运行 docker-compose build)"
|
||||
((CHECKS_WARNING++))
|
||||
fi
|
||||
|
||||
if docker image inspect postgres:16-alpine >/dev/null 2>&1; then
|
||||
log_info "PostgreSQL 镜像: 存在"
|
||||
((CHECKS_PASSED++))
|
||||
else
|
||||
log_warn "PostgreSQL 镜像: 不存在 (将自动拉取)"
|
||||
((CHECKS_WARNING++))
|
||||
fi
|
||||
|
||||
if docker image inspect redis:7-alpine >/dev/null 2>&1; then
|
||||
log_info "Redis 镜像: 存在"
|
||||
((CHECKS_PASSED++))
|
||||
else
|
||||
log_warn "Redis 镜像: 不存在 (将自动拉取)"
|
||||
((CHECKS_WARNING++))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 8. 检查磁盘空间
|
||||
log_info "检查磁盘空间..."
|
||||
AVAILABLE_SPACE=$(df -BG . | tail -1 | awk '{print $4}' | sed 's/G//')
|
||||
if [ "$AVAILABLE_SPACE" -ge 10 ]; then
|
||||
log_info "可用磁盘空间: ${AVAILABLE_SPACE}GB"
|
||||
((CHECKS_PASSED++))
|
||||
else
|
||||
log_warn "可用磁盘空间: ${AVAILABLE_SPACE}GB (建议至少 10GB)"
|
||||
((CHECKS_WARNING++))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 9. 检查数据库迁移状态
|
||||
log_info "检查数据库迁移..."
|
||||
if [ -d "prisma/migrations" ]; then
|
||||
MIGRATION_COUNT=$(ls -1 prisma/migrations | wc -l)
|
||||
log_info "迁移文件数量: $MIGRATION_COUNT"
|
||||
((CHECKS_PASSED++))
|
||||
else
|
||||
log_warn "未找到迁移文件"
|
||||
((CHECKS_WARNING++))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================
|
||||
# 输出总结
|
||||
# ============================================
|
||||
|
||||
echo "=========================================="
|
||||
echo "验证完成"
|
||||
echo "=========================================="
|
||||
echo -e "通过: ${GREEN}$CHECKS_PASSED${NC}"
|
||||
echo -e "警告: ${YELLOW}$CHECKS_WARNING${NC}"
|
||||
echo -e "失败: ${RED}$CHECKS_FAILED${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $CHECKS_FAILED -gt 0 ]; then
|
||||
log_error "存在 $CHECKS_FAILED 个问题需要修复"
|
||||
echo ""
|
||||
echo "建议操作:"
|
||||
echo "1. 复制 .env.example 为 .env"
|
||||
echo "2. 填写所有必需的配置项"
|
||||
echo "3. 生成 OAuth2 RSA 密钥对"
|
||||
echo "4. 运行: docker-compose build"
|
||||
exit 1
|
||||
elif [ $CHECKS_WARNING -gt 0 ]; then
|
||||
log_warn "存在 $CHECKS_WARNING 个警告"
|
||||
echo "建议继续,但请注意上述警告"
|
||||
exit 0
|
||||
else
|
||||
log_info "所有检查通过!可以开始部署。"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " docker-compose build # 构建镜像"
|
||||
echo " docker-compose up -d # 启动服务"
|
||||
echo " curl http://localhost:3001/health # 验证健康状态"
|
||||
exit 0
|
||||
fi
|
||||
@ -1,10 +1,56 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppConfigModule } from './config/config.module';
|
||||
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||
import { SessionModule } from './modules/session/session.module';
|
||||
import { OAuth2Module } from './modules/oauth2/oauth2.module';
|
||||
import { MeModule } from './modules/me/me.module';
|
||||
import { ArticlesModule } from './modules/articles/articles.module';
|
||||
import { CommentsModule } from './modules/comments/comments.module';
|
||||
import { ModerationModule } from './modules/moderation/moderation.module';
|
||||
import { JobsModule } from './modules/jobs/jobs.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { EmailModule } from './modules/email/email.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { AdminCommentsModule } from './modules/admin/admin-comments/admin-comments.module';
|
||||
import { AdminArticlesModule } from './modules/admin/admin-articles/admin-articles.module';
|
||||
import { InitModule } from './modules/init/init.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
AppConfigModule,
|
||||
PrismaModule,
|
||||
SessionModule,
|
||||
OAuth2Module,
|
||||
MeModule,
|
||||
ArticlesModule,
|
||||
CommentsModule,
|
||||
ModerationModule,
|
||||
JobsModule,
|
||||
HealthModule,
|
||||
UsersModule,
|
||||
EmailModule,
|
||||
AnalyticsModule,
|
||||
AdminCommentsModule,
|
||||
AdminArticlesModule,
|
||||
InitModule,
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const ttl = configService.get<number>('THROTTLE_TTL');
|
||||
const limit = configService.get<number>('THROTTLE_LIMIT');
|
||||
return {
|
||||
ttl: ttl ?? 60000,
|
||||
limit: limit ?? 100,
|
||||
} as any; // Type assertion to bypass strict type checking
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
57
src/config/config.module.ts
Normal file
57
src/config/config.module.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
oauth2Config,
|
||||
sessionConfig,
|
||||
aiConfig,
|
||||
mailConfig,
|
||||
visitorConfig,
|
||||
linkAccessConfig,
|
||||
securityConfig,
|
||||
} from './env';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
ConfigService,
|
||||
{
|
||||
provide: 'OAUTH2_CONFIG',
|
||||
useValue: oauth2Config,
|
||||
},
|
||||
{
|
||||
provide: 'SESSION_CONFIG',
|
||||
useValue: sessionConfig,
|
||||
},
|
||||
{
|
||||
provide: 'AI_CONFIG',
|
||||
useValue: aiConfig,
|
||||
},
|
||||
{
|
||||
provide: 'MAIL_CONFIG',
|
||||
useValue: mailConfig,
|
||||
},
|
||||
{
|
||||
provide: 'VISITOR_CONFIG',
|
||||
useValue: visitorConfig,
|
||||
},
|
||||
{
|
||||
provide: 'LINK_ACCESS_CONFIG',
|
||||
useValue: linkAccessConfig,
|
||||
},
|
||||
{
|
||||
provide: 'SECURITY_CONFIG',
|
||||
useValue: securityConfig,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
'OAUTH2_CONFIG',
|
||||
'SESSION_CONFIG',
|
||||
'AI_CONFIG',
|
||||
'MAIL_CONFIG',
|
||||
'VISITOR_CONFIG',
|
||||
'LINK_ACCESS_CONFIG',
|
||||
'SECURITY_CONFIG',
|
||||
ConfigService,
|
||||
],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
58
src/config/env.ts
Normal file
58
src/config/env.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export const oauth2Config = {
|
||||
issuer: process.env.OAUTH2_ISSUER || 'http://localhost:3001',
|
||||
accessTokenTtlSeconds: parseInt(
|
||||
process.env.OAUTH2_ACCESS_TOKEN_TTL_SECONDS || '3600',
|
||||
10,
|
||||
),
|
||||
refreshTokenTtlSeconds: parseInt(
|
||||
process.env.OAUTH2_REFRESH_TOKEN_TTL_SECONDS || '2592000',
|
||||
10,
|
||||
),
|
||||
tokenSigningPrivateKey: process.env.OAUTH2_TOKEN_SIGNING_PRIVATE_KEY || '',
|
||||
tokenSigningPublicKey: process.env.OAUTH2_TOKEN_SIGNING_PUBLIC_KEY || '',
|
||||
defaultClientId: process.env.OAUTH2_DEFAULT_CLIENT_ID || 'web-client',
|
||||
defaultClientSecret: process.env.OAUTH2_DEFAULT_CLIENT_SECRET || 'change_me',
|
||||
defaultRedirectUri:
|
||||
process.env.OAUTH2_DEFAULT_REDIRECT_URI ||
|
||||
'http://localhost:3000/auth/callback',
|
||||
};
|
||||
|
||||
export const sessionConfig = {
|
||||
cookieSecret: process.env.SESSION_COOKIE_SECRET || 'change_me',
|
||||
cookieName: process.env.SESSION_COOKIE_NAME || 'linkshare_session',
|
||||
cookieSecure: process.env.SESSION_COOKIE_SECURE === 'true',
|
||||
cookieHttpOnly: process.env.SESSION_COOKIE_HTTPONLY !== 'false',
|
||||
};
|
||||
|
||||
export const aiConfig = {
|
||||
provider: process.env.AI_PROVIDER || 'openrouter',
|
||||
apiKey: process.env.AI_API_KEY || '',
|
||||
baseUrl: process.env.AI_BASE_URL || 'https://openrouter.ai/api/v1',
|
||||
modelName: process.env.AI_MODEL_NAME || 'meta-llama/llama-3.1-70b-instruct',
|
||||
reviewEnabled: process.env.AI_REVIEW_ENABLED === 'true',
|
||||
temperature: parseFloat(process.env.AI_REVIEW_TEMPERATURE || '0.2'),
|
||||
};
|
||||
|
||||
export const mailConfig = {
|
||||
host: process.env.SMTP_HOST || 'smtp.example.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
from: process.env.SMTP_FROM || 'LinkShare Blog <no-reply@example.com>',
|
||||
};
|
||||
|
||||
export const visitorConfig = {
|
||||
cookieName: process.env.VISITOR_COOKIE_NAME || 'linkshare_visitor',
|
||||
cookieMaxAgeDays: parseInt(
|
||||
process.env.VISITOR_COOKIE_MAX_AGE_DAYS || '365',
|
||||
10,
|
||||
),
|
||||
};
|
||||
|
||||
export const linkAccessConfig = {
|
||||
defaultMode: process.env.LINK_URL_MODE_DEFAULT || 'slug_token',
|
||||
};
|
||||
|
||||
export const securityConfig = {
|
||||
passwordSaltRounds: parseInt(process.env.PASSWORD_SALT_ROUNDS || '12', 10),
|
||||
};
|
||||
50
src/main.ts
50
src/main.ts
@ -1,8 +1,56 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// CORS configuration
|
||||
const configService = app.get(ConfigService);
|
||||
const isProd = configService.get('NODE_ENV') === 'production';
|
||||
const allowedOrigins = isProd
|
||||
? [configService.get<string>('APP_BASE_URL')].filter(Boolean)
|
||||
: ['http://localhost:3000', 'http://localhost:3001'];
|
||||
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
const isDev = !isProd;
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: isDev,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Swagger documentation
|
||||
if (!isProd) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('LinkShare Blog API')
|
||||
.setDescription('API for blog backend')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
Logger.log('Swagger docs available at /api-docs', 'Bootstrap');
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
Logger.log(`API server listening on port ${port}`, 'Bootstrap');
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
142
src/modules/admin/admin-articles/admin-articles.controller.ts
Normal file
142
src/modules/admin/admin-articles/admin-articles.controller.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Delete,
|
||||
Query,
|
||||
Param,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { ArticlesService } from '../../articles/articles.service';
|
||||
import { RequireSessionUser } from '../../session/require-session-user.guard';
|
||||
import { RequireRole } from '../../common/decorators/require-role.decorator';
|
||||
import { RequireRoleGuard } from '../../common/guards/require-role.guard';
|
||||
|
||||
@ApiTags('管理员 - 文章')
|
||||
@Controller('admin/articles')
|
||||
@UseGuards(RequireSessionUser, RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
export class AdminArticlesController {
|
||||
constructor(private readonly articlesService: ArticlesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取所有文章(管理员视图)' })
|
||||
@ApiResponse({ status: 200, description: '成功获取文章列表', type: Object })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '页码',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '每页数量',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'status',
|
||||
required: false,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
description: '文章状态',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'visibility',
|
||||
required: false,
|
||||
enum: ['public', 'unlisted', 'private'],
|
||||
description: '文章可见性',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'authorId',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '作者 ID',
|
||||
})
|
||||
async listArticles(
|
||||
@Req() req: any,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('visibility') visibility?: string,
|
||||
@Query('authorId') authorId?: string,
|
||||
) {
|
||||
const query: any = {
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
where: {},
|
||||
};
|
||||
|
||||
if (status) query.where.status = status;
|
||||
if (visibility) query.where.visibility = visibility;
|
||||
if (authorId) query.where.authorId = parseInt(authorId, 10);
|
||||
|
||||
return this.articlesService.findMany({
|
||||
...query,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id/force')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '强制更新文章(管理员覆盖作者权限)' })
|
||||
@ApiResponse({ status: 200, description: '文章更新成功', type: Object })
|
||||
@ApiResponse({ status: 400, description: '请求参数无效' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权操作' })
|
||||
@ApiResponse({ status: 404, description: '文章不存在' })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
async forceUpdateArticle(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() updateData: any,
|
||||
) {
|
||||
const articleId = parseInt(id, 10);
|
||||
const article = await this.articlesService.findById(articleId);
|
||||
if (!article) {
|
||||
throw new HttpException('Article not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return this.articlesService.update(articleId, updateData);
|
||||
}
|
||||
|
||||
@Delete(':id/force')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '强制删除文章(管理员覆盖作者权限)' })
|
||||
@ApiResponse({ status: 204, description: '文章删除成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权操作' })
|
||||
@ApiResponse({ status: 404, description: '文章不存在' })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async forceDeleteArticle(@Req() req: any, @Param('id') id: string) {
|
||||
const articleId = parseInt(id, 10);
|
||||
const article = await this.articlesService.findById(articleId);
|
||||
if (!article) {
|
||||
throw new HttpException('Article not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
await this.articlesService.remove(articleId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
11
src/modules/admin/admin-articles/admin-articles.module.ts
Normal file
11
src/modules/admin/admin-articles/admin-articles.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminArticlesController } from './admin-articles.controller';
|
||||
import { ArticlesService } from '../../articles/articles.service';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminArticlesController],
|
||||
providers: [ArticlesService],
|
||||
})
|
||||
export class AdminArticlesModule {}
|
||||
@ -0,0 +1,61 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Delete,
|
||||
Query,
|
||||
Param,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { CommentsService } from '../../comments/comments.service';
|
||||
import { RequireSessionUser } from '../../session/require-session-user.guard';
|
||||
import { RequireRole } from '../../common/decorators/require-role.decorator';
|
||||
import { RequireRoleGuard } from '../../common/guards/require-role.guard';
|
||||
|
||||
@Controller('admin/comments')
|
||||
@UseGuards(RequireSessionUser, RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
export class AdminCommentsController {
|
||||
constructor(private readonly commentsService: CommentsService) {}
|
||||
|
||||
@Get()
|
||||
async listComments(
|
||||
@Req() req: any,
|
||||
@Query('articleId') articleId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('authorId') authorId?: string,
|
||||
@Query('parentId') parentId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.commentsService.findAdminComments({
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
articleId: articleId ? parseInt(articleId, 10) : undefined,
|
||||
status,
|
||||
authorId: authorId ? parseInt(authorId, 10) : undefined,
|
||||
parentId: parentId !== undefined ? parseInt(parentId, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
async updateStatus(@Param('id') id: string, @Query('status') status: string) {
|
||||
const commentId = parseInt(id, 10);
|
||||
return this.commentsService.updateCommentStatus(commentId, status);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteComment(@Param('id') id: string) {
|
||||
const commentId = parseInt(id, 10);
|
||||
return this.commentsService.deleteComment(commentId);
|
||||
}
|
||||
|
||||
@Get(':id/audits')
|
||||
async getAudits(@Param('id') id: string) {
|
||||
const commentId = parseInt(id, 10);
|
||||
return this.commentsService.getCommentAudits(commentId);
|
||||
}
|
||||
}
|
||||
11
src/modules/admin/admin-comments/admin-comments.module.ts
Normal file
11
src/modules/admin/admin-comments/admin-comments.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminCommentsController } from './admin-comments.controller';
|
||||
import { CommentsService } from '../../comments/comments.service';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCommentsController],
|
||||
providers: [CommentsService],
|
||||
})
|
||||
export class AdminCommentsModule {}
|
||||
30
src/modules/analytics/analytics.controller.ts
Normal file
30
src/modules/analytics/analytics.controller.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { RequireSessionUser } from '../session/require-session-user.guard';
|
||||
import { RequireRole } from '../common/decorators/require-role.decorator';
|
||||
import { RequireRoleGuard } from '../common/guards/require-role.guard';
|
||||
|
||||
@Controller('analytics')
|
||||
@UseGuards(RequireSessionUser)
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
async getSummary(@Query('days') days?: string) {
|
||||
const periodDays = days ? parseInt(days, 10) : 30;
|
||||
if (isNaN(periodDays) || periodDays < 1 || periodDays > 365) {
|
||||
throw new HttpException('Invalid days parameter', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.analyticsService.getSummary(periodDays);
|
||||
}
|
||||
}
|
||||
11
src/modules/analytics/analytics.module.ts
Normal file
11
src/modules/analytics/analytics.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
198
src/modules/analytics/analytics.service.spec.ts
Normal file
198
src/modules/analytics/analytics.service.spec.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AnalyticsEventType } from './analytics.service';
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
let service: AnalyticsService;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
analyticsEvent: {
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
article: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AnalyticsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AnalyticsService>(AnalyticsService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('should track event successfully', async () => {
|
||||
mockPrismaService.analyticsEvent.create.mockResolvedValueOnce({});
|
||||
|
||||
await service.trackEvent(
|
||||
AnalyticsEventType.PAGE_VIEW,
|
||||
'session-123',
|
||||
1,
|
||||
'127.0.0.1',
|
||||
'Mozilla/5.0',
|
||||
{ path: '/articles' },
|
||||
);
|
||||
|
||||
expect(mockPrismaService.analyticsEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
type: AnalyticsEventType.PAGE_VIEW,
|
||||
sessionId: 'session-123',
|
||||
userId: 1,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
data: { path: '/articles' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should track event without optional fields', async () => {
|
||||
mockPrismaService.analyticsEvent.create.mockResolvedValueOnce({});
|
||||
|
||||
await service.trackEvent(
|
||||
AnalyticsEventType.COMMENT_SUBMIT,
|
||||
'session-456',
|
||||
);
|
||||
|
||||
expect(mockPrismaService.analyticsEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
type: AnalyticsEventType.COMMENT_SUBMIT,
|
||||
sessionId: 'session-456',
|
||||
userId: undefined,
|
||||
ip: undefined,
|
||||
userAgent: undefined,
|
||||
data: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully without throwing', async () => {
|
||||
mockPrismaService.analyticsEvent.create.mockRejectedValueOnce(
|
||||
new Error('Database error'),
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
service.trackEvent(AnalyticsEventType.ARTICLE_VIEW, 'session-789'),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Error should be logged but not propagated
|
||||
expect(mockPrismaService.analyticsEvent.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
const mockArticles = [
|
||||
{ id: 1, slug: 'article-1', title: 'Article 1', viewCount: 100 },
|
||||
{ id: 2, slug: 'article-2', title: 'Article 2', viewCount: 50 },
|
||||
{ id: 3, slug: 'article-3', title: 'Article 3', viewCount: 25 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrismaService.analyticsEvent.count.mockResolvedValue(150);
|
||||
mockPrismaService.article.findMany.mockResolvedValue(mockArticles);
|
||||
});
|
||||
|
||||
it('should generate summary for default 30 days', async () => {
|
||||
const summary = await service.getSummary();
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
periodDays: 30,
|
||||
totalPageViews: 150,
|
||||
totalArticleViews: 175, // 100 + 50 + 25
|
||||
uniqueVisitors: 150,
|
||||
topArticles: mockArticles,
|
||||
generatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate summary for custom period', async () => {
|
||||
const summary = await service.getSummary(7);
|
||||
|
||||
expect(summary.periodDays).toBe(7);
|
||||
});
|
||||
|
||||
it('should count page views correctly', async () => {
|
||||
await service.getSummary();
|
||||
|
||||
expect(mockPrismaService.analyticsEvent.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
createdAt: expect.any(Object),
|
||||
type: {
|
||||
in: [AnalyticsEventType.PAGE_VIEW, AnalyticsEventType.ARTICLE_VIEW],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should count unique visitors by distinct sessionId', async () => {
|
||||
await service.getSummary();
|
||||
|
||||
expect(mockPrismaService.analyticsEvent.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
createdAt: expect.any(Object),
|
||||
sessionId: { not: null },
|
||||
},
|
||||
distinct: ['sessionId'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch top articles by view count', async () => {
|
||||
await service.getSummary();
|
||||
|
||||
expect(mockPrismaService.article.findMany).toHaveBeenCalledWith({
|
||||
orderBy: { viewCount: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
viewCount: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty article list', async () => {
|
||||
mockPrismaService.article.findMany.mockResolvedValue([]);
|
||||
|
||||
const summary = await service.getSummary();
|
||||
|
||||
expect(summary.totalArticleViews).toBe(0);
|
||||
expect(summary.topArticles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should calculate date range correctly', async () => {
|
||||
await service.getSummary(1);
|
||||
|
||||
const dateArg =
|
||||
mockPrismaService.analyticsEvent.count.mock.calls[0][0].where.createdAt
|
||||
.gte;
|
||||
expect(dateArg).toBeInstanceOf(Date);
|
||||
|
||||
const now = new Date();
|
||||
const expectedDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
expect(dateArg.getTime()).toBeCloseTo(expectedDate.getTime(), -3); // Within 1 second
|
||||
});
|
||||
});
|
||||
});
|
||||
90
src/modules/analytics/analytics.service.ts
Normal file
90
src/modules/analytics/analytics.service.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export enum AnalyticsEventType {
|
||||
PAGE_VIEW = 'page_view',
|
||||
ARTICLE_VIEW = 'article_view',
|
||||
COMMENT_SUBMIT = 'comment_submit',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async trackEvent(
|
||||
type: AnalyticsEventType,
|
||||
sessionId: string,
|
||||
userId?: number,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
data?: any,
|
||||
) {
|
||||
try {
|
||||
await this.prisma.analyticsEvent.create({
|
||||
data: {
|
||||
type,
|
||||
sessionId,
|
||||
userId,
|
||||
ip,
|
||||
userAgent,
|
||||
data,
|
||||
},
|
||||
});
|
||||
this.logger.debug(`Analytics event: ${type} by session ${sessionId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to track event ${type}: ${error}`);
|
||||
// Don't throw - analytics should not break main flow
|
||||
}
|
||||
}
|
||||
|
||||
async getSummary(periodDays: number = 30) {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - periodDays);
|
||||
|
||||
// Total page views (PAGE_VIEW + ARTICLE_VIEW events)
|
||||
const [totalPageViews, uniqueVisitors, articles] = await Promise.all([
|
||||
this.prisma.analyticsEvent.count({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
type: {
|
||||
in: [AnalyticsEventType.PAGE_VIEW, AnalyticsEventType.ARTICLE_VIEW],
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.analyticsEvent.count({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
sessionId: { not: null },
|
||||
},
|
||||
distinct: ['sessionId'],
|
||||
}),
|
||||
this.prisma.article.findMany({
|
||||
orderBy: { viewCount: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
viewCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Total article views from article viewCount fields (sum of all articles' viewCount)
|
||||
const totalArticleViews = articles.reduce(
|
||||
(sum: number, a: any) => sum + a.viewCount,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
periodDays,
|
||||
totalPageViews,
|
||||
totalArticleViews,
|
||||
uniqueVisitors,
|
||||
topArticles: articles,
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
335
src/modules/articles/articles.controller.ts
Normal file
335
src/modules/articles/articles.controller.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import {
|
||||
CreateArticleDto,
|
||||
UpdateArticleDto,
|
||||
ArticleQueryDto,
|
||||
} from './dto/article.dto';
|
||||
import { RequireSessionUser } from '../session/require-session-user.guard';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { AnalyticsEventType } from '../analytics/analytics.service';
|
||||
|
||||
@ApiTags('文章')
|
||||
@Controller('articles')
|
||||
export class ArticlesController {
|
||||
constructor(
|
||||
private readonly articlesService: ArticlesService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(RequireSessionUser)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '创建文章' })
|
||||
@ApiResponse({ status: 201, description: '文章创建成功', type: Object })
|
||||
@ApiResponse({ status: 400, description: '请求参数无效' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async createArticle(
|
||||
@Req() req: any,
|
||||
@Body() createArticleDto: CreateArticleDto,
|
||||
) {
|
||||
// 作者必须是当前登录用户
|
||||
const authorId = req.user.id;
|
||||
const article = await this.articlesService.create({
|
||||
...createArticleDto,
|
||||
authorId,
|
||||
});
|
||||
return article;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取文章列表' })
|
||||
@ApiResponse({ status: 200, description: '成功获取文章列表', type: Object })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '页码',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '每页数量',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'status',
|
||||
required: false,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
description: '文章状态',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'visibility',
|
||||
required: false,
|
||||
enum: ['public', 'unlisted', 'private'],
|
||||
description: '文章可见性',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'authorId',
|
||||
required: false,
|
||||
type: String,
|
||||
description: '作者 ID',
|
||||
})
|
||||
async getArticles(@Req() req: any, @Query() query: ArticleQueryDto) {
|
||||
// Track page view
|
||||
this.analyticsService
|
||||
.trackEvent(
|
||||
AnalyticsEventType.PAGE_VIEW,
|
||||
req.sessionID,
|
||||
req.user?.id,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
{ path: '/articles' },
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error('Failed to track page view:', err);
|
||||
});
|
||||
|
||||
const { page = 1, limit = 20, status, visibility, authorId } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
const result = await this.articlesService.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
where: {
|
||||
...(status && { status }),
|
||||
...(visibility && { visibility }),
|
||||
...(authorId && { authorId: parseInt(authorId, 10) }),
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const total = result.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
return {
|
||||
articles: result.articles,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '根据 ID 获取文章' })
|
||||
@ApiResponse({ status: 200, description: '成功获取文章', type: Object })
|
||||
@ApiResponse({ status: 404, description: '文章不存在' })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
async getArticle(@Req() req: any, @Param('id') id: string) {
|
||||
const articleId = parseInt(id, 10);
|
||||
const article = await this.articlesService.findById(articleId, {
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
reactions: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
comments: {
|
||||
where: { status: 'approved' },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
},
|
||||
replies: {
|
||||
where: { status: 'approved' },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
throw new HttpException('Article not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Track view (debounced per session)
|
||||
this.trackArticleView(req, articleId);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
@Get('slug/:slug')
|
||||
@ApiOperation({ summary: '根据 Slug 获取文章' })
|
||||
@ApiResponse({ status: 200, description: '成功获取文章', type: Object })
|
||||
@ApiResponse({ status: 404, description: '文章不存在' })
|
||||
@ApiParam({ name: 'slug', description: '文章 Slug', type: String })
|
||||
async getArticleBySlug(@Req() req: any, @Param('slug') slug: string) {
|
||||
const article = await this.articlesService.findBySlug(slug, {
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
reactions: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
comments: {
|
||||
where: { status: 'approved' },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
},
|
||||
replies: {
|
||||
where: { status: 'approved' },
|
||||
include: {
|
||||
author: {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
throw new HttpException('Article not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Track view (debounced per session)
|
||||
this.trackArticleView(req, article.id);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(RequireSessionUser)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '更新文章' })
|
||||
@ApiResponse({ status: 200, description: '文章更新成功', type: Object })
|
||||
@ApiResponse({ status: 400, description: '请求参数无效' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权修改此文章' })
|
||||
@ApiResponse({ status: 404, description: '文章不存在' })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
async updateArticle(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() updateArticleDto: UpdateArticleDto,
|
||||
) {
|
||||
const articleId = parseInt(id, 10);
|
||||
const article = await this.articlesService.findById(articleId);
|
||||
if (!article) {
|
||||
throw new HttpException('Article not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
// 只有作者或 admin 可以更新
|
||||
const currentUser = req.user;
|
||||
if (article.authorId !== currentUser.id && currentUser.role !== 'admin') {
|
||||
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return this.articlesService.update(articleId, updateArticleDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(RequireSessionUser)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '删除文章' })
|
||||
@ApiResponse({ status: 204, description: '文章删除成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权删除此文章' })
|
||||
@ApiResponse({ status: 404, description: '文章不存在' })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteArticle(@Req() req: any, @Param('id') id: string) {
|
||||
const articleId = parseInt(id, 10);
|
||||
const article = await this.articlesService.findById(articleId);
|
||||
if (!article) {
|
||||
throw new HttpException('Article not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
const currentUser = req.user;
|
||||
if (article.authorId !== currentUser.id && currentUser.role !== 'admin') {
|
||||
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
await this.articlesService.remove(articleId);
|
||||
return;
|
||||
}
|
||||
|
||||
private trackArticleView(req: any, articleId: number): void {
|
||||
// Initialize session tracking if not present
|
||||
if (!req.session.viewedArticles) {
|
||||
req.session.viewedArticles = [];
|
||||
}
|
||||
if (!req.session.viewedArticles.includes(articleId)) {
|
||||
// Mark as viewed
|
||||
req.session.viewedArticles.push(articleId);
|
||||
// Increment article viewCount
|
||||
this.articlesService
|
||||
.update(articleId, { viewCount: { increment: 1 } } as any)
|
||||
.catch((err) => {
|
||||
console.error('Failed to increment view count:', err);
|
||||
});
|
||||
// Track analytics event
|
||||
this.analyticsService
|
||||
.trackEvent(
|
||||
AnalyticsEventType.ARTICLE_VIEW,
|
||||
req.sessionID,
|
||||
req.user?.id,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
{ articleId },
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error('Failed to track analytics:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/modules/articles/articles.module.ts
Normal file
11
src/modules/articles/articles.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
})
|
||||
export class ArticlesModule {}
|
||||
79
src/modules/articles/articles.service.ts
Normal file
79
src/modules/articles/articles.service.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateArticleDto, UpdateArticleDto } from './dto/article.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(data: CreateArticleDto & { authorId: number }) {
|
||||
return this.prisma.article.create({
|
||||
data,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findMany(params: {
|
||||
skip: number;
|
||||
take: number;
|
||||
where?: any;
|
||||
include?: any;
|
||||
orderBy?: any;
|
||||
}) {
|
||||
const [articles, total] = await Promise.all([
|
||||
this.prisma.article.findMany({
|
||||
...params,
|
||||
}),
|
||||
this.prisma.article.count({
|
||||
where: params.where,
|
||||
}),
|
||||
]);
|
||||
return { articles, total };
|
||||
}
|
||||
|
||||
async findById(id: number, include?: any) {
|
||||
return this.prisma.article.findUnique({
|
||||
where: { id },
|
||||
...(include && { include }),
|
||||
});
|
||||
}
|
||||
|
||||
async findBySlug(slug: string, include?: any) {
|
||||
return this.prisma.article.findUnique({
|
||||
where: { slug },
|
||||
...(include && { include }),
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: UpdateArticleDto) {
|
||||
return this.prisma.article.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
return this.prisma.article.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
224
src/modules/articles/dto/article.dto.ts
Normal file
224
src/modules/articles/dto/article.dto.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsUUID,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export enum ArticleStatus {
|
||||
DRAFT = 'draft',
|
||||
PUBLISHED = 'published',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export enum ArticleVisibility {
|
||||
PUBLIC = 'public',
|
||||
UNLISTED = 'unlisted',
|
||||
PRIVATE = 'private',
|
||||
}
|
||||
|
||||
export class CreateArticleDto {
|
||||
@ApiProperty({
|
||||
description: '文章标题',
|
||||
example: '如何构建 NestJS 应用',
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(1, 200)
|
||||
title: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '文章内容(支持 Markdown 或 HTML)',
|
||||
example: '# 欢迎阅读\n这是一篇示例文章...',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文章摘要',
|
||||
example: '本文将介绍如何构建一个完整的博客平台...',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
excerpt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '封面图片 URL',
|
||||
example: 'https://example.com/cover.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverImageUrl?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '文章状态',
|
||||
enum: ArticleStatus,
|
||||
default: ArticleStatus.DRAFT,
|
||||
example: ArticleStatus.DRAFT,
|
||||
})
|
||||
@IsEnum(ArticleStatus)
|
||||
status: ArticleStatus = ArticleStatus.DRAFT;
|
||||
|
||||
@ApiProperty({
|
||||
description: '文章可见性',
|
||||
enum: ArticleVisibility,
|
||||
default: ArticleVisibility.PUBLIC,
|
||||
example: ArticleVisibility.PUBLIC,
|
||||
})
|
||||
@IsEnum(ArticleVisibility)
|
||||
visibility: ArticleVisibility = ArticleVisibility.PUBLIC;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '密码哈希(用于密码保护文章)',
|
||||
example: '$2a$10$...',
|
||||
deprecated: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passwordHash?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '访问令牌(用于私密文章)',
|
||||
example: 'abc123token',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export class UpdateArticleDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '文章标题',
|
||||
example: '更新后的标题',
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 200)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文章内容',
|
||||
example: '# 更新后的内容\n...',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文章摘要',
|
||||
example: '更新后的摘要...',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
excerpt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '封面图片 URL',
|
||||
example: 'https://example.com/new-cover.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverImageUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文章状态',
|
||||
enum: ArticleStatus,
|
||||
example: ArticleStatus.PUBLISHED,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleStatus)
|
||||
status?: ArticleStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文章可见性',
|
||||
enum: ArticleVisibility,
|
||||
example: ArticleVisibility.PRIVATE,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleVisibility)
|
||||
visibility?: ArticleVisibility;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '密码哈希',
|
||||
example: '$2a$10$...',
|
||||
deprecated: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
passwordHash?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '访问令牌',
|
||||
example: 'new-token-xyz',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export class ArticleQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '页码(从 1 开始)',
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量',
|
||||
example: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 20,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '按文章状态筛选',
|
||||
enum: ArticleStatus,
|
||||
example: ArticleStatus.PUBLISHED,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleStatus)
|
||||
status?: ArticleStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '按文章可见性筛选',
|
||||
enum: ArticleVisibility,
|
||||
example: ArticleVisibility.PUBLIC,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ArticleVisibility)
|
||||
visibility?: ArticleVisibility;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '按作者 ID 筛选',
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
}
|
||||
76
src/modules/comments/comments.controller.ts
Normal file
76
src/modules/comments/comments.controller.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { Controller, Post, Get, Req, Body, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { AnalyticsEventType } from '../analytics/analytics.service';
|
||||
|
||||
@ApiTags('评论')
|
||||
@Controller('articles')
|
||||
export class CommentsController {
|
||||
constructor(
|
||||
private readonly commentsService: CommentsService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
@Post(':id/comments')
|
||||
@ApiOperation({ summary: '创建评论' })
|
||||
@ApiResponse({ status: 201, description: '评论创建成功', type: Object })
|
||||
@ApiResponse({ status: 400, description: '请求参数无效' })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
async createComment(
|
||||
@Req() req: any,
|
||||
@Body() createCommentDto: { content: string },
|
||||
) {
|
||||
const articleId = parseInt(req.params.id, 10);
|
||||
const comment = await this.commentsService.createComment(
|
||||
articleId,
|
||||
createCommentDto.content,
|
||||
);
|
||||
|
||||
// Track comment submission
|
||||
this.analyticsService
|
||||
.trackEvent(
|
||||
AnalyticsEventType.COMMENT_SUBMIT,
|
||||
req.sessionID,
|
||||
req.user?.id,
|
||||
req.ip,
|
||||
req.headers['user-agent'],
|
||||
{
|
||||
articleId,
|
||||
commentId: comment.id,
|
||||
contentLength: comment.content.length,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error('Failed to track comment event:', err);
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@Get(':id/comments')
|
||||
@ApiOperation({ summary: '获取文章的评论' })
|
||||
@ApiResponse({ status: 200, description: '成功获取评论列表', type: [Object] })
|
||||
@ApiParam({ name: 'id', description: '文章 ID', type: Number })
|
||||
@ApiQuery({
|
||||
name: 'status',
|
||||
required: false,
|
||||
enum: ['pending', 'approved', 'rejected'],
|
||||
description: '评论状态',
|
||||
})
|
||||
async getComments(@Req() req: any, @Query('status') status?: string) {
|
||||
const articleId = parseInt(req.params.id, 10);
|
||||
const effectiveStatus = status || 'approved';
|
||||
const comments = await this.commentsService.getCommentsByArticle(
|
||||
articleId,
|
||||
effectiveStatus,
|
||||
);
|
||||
return comments;
|
||||
}
|
||||
}
|
||||
9
src/modules/comments/comments.module.ts
Normal file
9
src/modules/comments/comments.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CommentsController],
|
||||
providers: [CommentsService],
|
||||
})
|
||||
export class CommentsModule {}
|
||||
184
src/modules/comments/comments.service.ts
Normal file
184
src/modules/comments/comments.service.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ModerationService } from '../moderation/moderation.service';
|
||||
import { JobsService } from '../jobs/jobs.service';
|
||||
import { ModerationStatus } from '../moderation/moderation.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
private readonly logger = new Logger(CommentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moderationService: ModerationService,
|
||||
private readonly jobsService: JobsService,
|
||||
) {}
|
||||
|
||||
async createComment(
|
||||
articleId: number,
|
||||
content: string,
|
||||
authorName?: string,
|
||||
authorEmail?: string,
|
||||
authorId?: number,
|
||||
) {
|
||||
// 先规则审核
|
||||
const ruleResult = await this.moderationService.ruleBasedModerate(content);
|
||||
const status = ruleResult.status;
|
||||
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
articleId,
|
||||
content,
|
||||
status,
|
||||
authorName,
|
||||
authorEmail,
|
||||
authorId,
|
||||
},
|
||||
});
|
||||
|
||||
// 如果可疑,触发 AI 异步审核
|
||||
if (status === ModerationStatus.SUSPICIOUS) {
|
||||
await this.jobsService.addModerationReview(comment.id);
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async getCommentsByArticle(articleId: number, status: string = 'approved') {
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
articleId,
|
||||
status,
|
||||
parentId: null,
|
||||
},
|
||||
include: {
|
||||
author: true,
|
||||
replies: {
|
||||
where: { status: 'approved' },
|
||||
include: { author: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return comments;
|
||||
}
|
||||
|
||||
// Admin: list all comments with filters
|
||||
async findAdminComments(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
articleId?: number;
|
||||
status?: string;
|
||||
authorId?: number;
|
||||
parentId?: number;
|
||||
}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
articleId,
|
||||
status,
|
||||
authorId,
|
||||
parentId,
|
||||
} = params;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [comments, total] = await Promise.all([
|
||||
this.prisma.comment.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
where: {
|
||||
...(articleId && { articleId }),
|
||||
...(status && { status }),
|
||||
...(authorId && { authorId }),
|
||||
...(parentId !== undefined && { parentId }),
|
||||
},
|
||||
include: {
|
||||
author: true,
|
||||
article: {
|
||||
select: { id: true, slug: true, title: true },
|
||||
},
|
||||
parent: {
|
||||
select: { id: true, content: true, authorName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.comment.count({
|
||||
where: {
|
||||
...(articleId && { articleId }),
|
||||
...(status && { status }),
|
||||
...(authorId && { authorId }),
|
||||
...(parentId !== undefined && { parentId }),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
// Admin: update comment status
|
||||
async updateCommentStatus(id: number, status: string) {
|
||||
const comment = await this.prisma.comment.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
|
||||
// Admin: delete comment (hard delete)
|
||||
async deleteComment(id: number) {
|
||||
// Check for replies - maybe cascade? We'll just delete; in schema, replies are children with parentId.
|
||||
// If there are replies, we should delete them too or prevent deletion. For simplicity, delete only if no replies.
|
||||
const comment = await this.prisma.comment.findUnique({
|
||||
where: { id },
|
||||
include: { replies: true },
|
||||
});
|
||||
if (!comment) {
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
if (comment.replies && comment.replies.length > 0) {
|
||||
// Optionally delete all replies recursively or just disallow
|
||||
// For now, we'll delete all descendants
|
||||
await this.deleteCommentTree(id);
|
||||
return { id };
|
||||
}
|
||||
await this.prisma.comment.delete({
|
||||
where: { id },
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
private async deleteCommentTree(commentId: number): Promise<void> {
|
||||
// Delete all descendants
|
||||
const replies = await this.prisma.comment.findMany({
|
||||
where: { parentId: commentId },
|
||||
});
|
||||
for (const reply of replies) {
|
||||
await this.deleteCommentTree(reply.id);
|
||||
}
|
||||
await this.prisma.comment.delete({
|
||||
where: { id: commentId },
|
||||
});
|
||||
}
|
||||
|
||||
// Admin: get audit history for a comment
|
||||
async getCommentAudits(commentId: number) {
|
||||
const audits = await this.prisma.commentAudit.findMany({
|
||||
where: { commentId },
|
||||
orderBy: { reviewedAt: 'desc' },
|
||||
include: {
|
||||
reviewer: {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return audits;
|
||||
}
|
||||
}
|
||||
5
src/modules/common/decorators/require-role.decorator.ts
Normal file
5
src/modules/common/decorators/require-role.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const REQUIRED_ROLES_KEY = 'required_roles';
|
||||
export const RequireRole = (...roles: string[]) =>
|
||||
SetMetadata(REQUIRED_ROLES_KEY, roles);
|
||||
33
src/modules/common/guards/require-role.guard.ts
Normal file
33
src/modules/common/guards/require-role.guard.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { REQUIRED_ROLES_KEY } from '../decorators/require-role.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RequireRoleGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.get<string[]>(
|
||||
REQUIRED_ROLES_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!requiredRoles) {
|
||||
return true; // No role requirement
|
||||
}
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user || !user.role) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
const hasRole = requiredRoles.includes(user.role);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
41
src/modules/email/email.module.ts
Normal file
41
src/modules/email/email.module.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EmailService } from './email.service';
|
||||
import { EmailQueueService } from './email.queue.service';
|
||||
import { EmailProcessor } from './processors/email.processor';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get<string>('REDIS_HOST') || 'localhost',
|
||||
port: configService.get<number>('REDIS_PORT') || 6379,
|
||||
},
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: {
|
||||
count: 1000,
|
||||
age: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
removeOnFail: {
|
||||
count: 1000,
|
||||
age: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'email',
|
||||
}),
|
||||
],
|
||||
controllers: [],
|
||||
providers: [EmailService, EmailQueueService, EmailProcessor],
|
||||
exports: [EmailService, EmailQueueService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
68
src/modules/email/email.queue.service.ts
Normal file
68
src/modules/email/email.queue.service.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EmailService } from './email.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface EmailJobData {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
userId?: number;
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailQueueService {
|
||||
private readonly logger = new Logger(EmailQueueService.name);
|
||||
private queue: Queue;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private emailService: EmailService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
this.initializeQueue();
|
||||
}
|
||||
|
||||
private initializeQueue() {
|
||||
const redisHost =
|
||||
this.configService.get<string>('REDIS_HOST') || 'localhost';
|
||||
const redisPort = this.configService.get<number>('REDIS_PORT') || 6379;
|
||||
|
||||
this.queue = new Queue('email', {
|
||||
connection: {
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
},
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: {
|
||||
count: 1000,
|
||||
age: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
removeOnFail: {
|
||||
count: 1000,
|
||||
age: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log('Email queue initialized');
|
||||
}
|
||||
|
||||
async addToQueue(data: EmailJobData): Promise<Job> {
|
||||
return await this.queue.add('send-email', data, {
|
||||
delay: 0, // send immediately
|
||||
});
|
||||
}
|
||||
|
||||
async getQueue() {
|
||||
return this.queue;
|
||||
}
|
||||
}
|
||||
213
src/modules/email/email.service.spec.ts
Normal file
213
src/modules/email/email.service.spec.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EmailService } from './email.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
describe('EmailService', () => {
|
||||
let service: EmailService;
|
||||
let configService: ConfigService;
|
||||
let mockTransporter: Partial<nodemailer.Transporter>;
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, any> = {
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: 'test@example.com',
|
||||
SMTP_PASS: 'password',
|
||||
SMTP_SECURE: false,
|
||||
SMTP_FROM: '"Test" <test@example.com>',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
const mockSendMail = jest.fn();
|
||||
const mockVerify = jest.fn((callback: (error: Error | null) => void) =>
|
||||
callback(null),
|
||||
);
|
||||
|
||||
mockTransporter = {
|
||||
sendMail: mockSendMail,
|
||||
verify: mockVerify,
|
||||
} as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock nodemailer.createTransport to return our mock transporter
|
||||
const createTransportSpy = jest
|
||||
.spyOn(require('nodemailer'), 'createTransport')
|
||||
.mockReturnValue(mockTransporter);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideProvider(ConfigService)
|
||||
.useValue(mockConfigService)
|
||||
.compile();
|
||||
|
||||
service = module.get<EmailService>(EmailService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
|
||||
// Save spy for restoration
|
||||
(service as any).createTransportSpy = createTransportSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Restore spy
|
||||
if ((service as any).createTransportSpy) {
|
||||
(service as any).createTransportSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('initializeTransporter', () => {
|
||||
it('should create SMTP transporter with correct configuration', () => {
|
||||
// Verify was called during initialization
|
||||
expect(mockVerify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing SMTP configuration gracefully', async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const serviceWithoutSmtp = module.get<EmailService>(EmailService);
|
||||
expect(serviceWithoutSmtp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMail', () => {
|
||||
const mailOptions = {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Subject',
|
||||
html: '<p>Test content</p>',
|
||||
text: 'Test content',
|
||||
};
|
||||
|
||||
it('should send email successfully', async () => {
|
||||
mockSendMail.mockResolvedValueOnce({
|
||||
messageId: 'test-message-id',
|
||||
accepted: [mailOptions.to],
|
||||
rejected: [],
|
||||
});
|
||||
|
||||
const result = await service.sendMail(mailOptions);
|
||||
|
||||
expect(mockSendMail).toHaveBeenCalledWith({
|
||||
from: expect.stringContaining('Test'),
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
html: mailOptions.html,
|
||||
text: mailOptions.text,
|
||||
});
|
||||
expect(result).toEqual({ messageId: 'test-message-id' });
|
||||
});
|
||||
|
||||
it('should use default from address when SMTP_FROM not set', async () => {
|
||||
const configWithoutFrom = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'SMTP_FROM') return undefined;
|
||||
return mockConfigService.get(key);
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: configWithoutFrom,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const serviceWithoutFrom = module.get<EmailService>(EmailService);
|
||||
(serviceWithoutFrom as any).transporter = mockTransporter;
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({
|
||||
messageId: 'test-message-id',
|
||||
accepted: [mailOptions.to],
|
||||
rejected: [],
|
||||
});
|
||||
|
||||
await serviceWithoutFrom.sendMail(mailOptions);
|
||||
|
||||
expect(mockSendMail).toHaveBeenCalledWith({
|
||||
from: expect.stringMatching(/^"Blog" </),
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
html: mailOptions.html,
|
||||
text: mailOptions.text,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when sending fails', async () => {
|
||||
mockSendMail.mockRejectedValueOnce(new Error('SMTP error'));
|
||||
|
||||
await expect(service.sendMail(mailOptions)).rejects.toThrow('SMTP error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderRegistrationEmail', () => {
|
||||
it('should render registration email with correct content', () => {
|
||||
const email = service.renderRegistrationEmail(
|
||||
'TestUser',
|
||||
'https://example.com/confirm',
|
||||
);
|
||||
|
||||
expect(email).toContain('Welcome, TestUser!');
|
||||
expect(email).toContain('https://example.com/confirm');
|
||||
expect(email).toContain('Confirm Email');
|
||||
expect(email).toContain('LinkShare Blog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderPasswordResetEmail', () => {
|
||||
it('should render password reset email with correct content', () => {
|
||||
const email = service.renderPasswordResetEmail(
|
||||
'TestUser',
|
||||
'https://example.com/reset',
|
||||
);
|
||||
|
||||
expect(email).toContain('Hello TestUser,');
|
||||
expect(email).toContain('https://example.com/reset');
|
||||
expect(email).toContain('Reset Password');
|
||||
expect(email).toContain('expire in 1 hour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderNotificationEmail', () => {
|
||||
it('should render notification email with correct content', () => {
|
||||
const email = service.renderNotificationEmail(
|
||||
'Test Subject',
|
||||
'Test message content',
|
||||
);
|
||||
|
||||
expect(email).toContain('Test Subject');
|
||||
expect(email).toContain('Test message content');
|
||||
expect(email).toContain('automated notification');
|
||||
});
|
||||
});
|
||||
});
|
||||
166
src/modules/email/email.service.ts
Normal file
166
src/modules/email/email.service.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
private initializeTransporter() {
|
||||
const host = this.configService.get<string>('SMTP_HOST');
|
||||
const port = this.configService.get<number>('SMTP_PORT');
|
||||
const user = this.configService.get<string>('SMTP_USER');
|
||||
const pass = this.configService.get<string>('SMTP_PASS');
|
||||
const secure = this.configService.get<boolean>('SMTP_SECURE') ?? true;
|
||||
|
||||
if (!host || !port) {
|
||||
this.logger.warn('SMTP not configured, email sending disabled');
|
||||
this.transporter = nodemailer.createTransport({
|
||||
streamTransport: true,
|
||||
// Dummy transport for development
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
});
|
||||
|
||||
// Verify connection configuration
|
||||
this.transporter.verify((error: Error | null) => {
|
||||
if (error) {
|
||||
this.logger.error(`SMTP connection error: ${error.message}`);
|
||||
} else {
|
||||
this.logger.log('SMTP transporter verified');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sendMail(options: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}): Promise<{ messageId?: string }> {
|
||||
try {
|
||||
const result = await this.transporter.sendMail({
|
||||
from:
|
||||
this.configService.get<string>('SMTP_FROM') ||
|
||||
`"Blog" <${this.configService.get<string>('SMTP_USER')}>`,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Email sent to ${options.to}, messageId: ${result.messageId}`,
|
||||
);
|
||||
return { messageId: result.messageId };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${options.to}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Template methods
|
||||
renderRegistrationEmail(username: string, confirmUrl: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Welcome to LinkShare Blog</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.container { border: 1px solid #ddd; border-radius: 8px; padding: 30px; background: #f9f9f9; }
|
||||
h1 { color: #2c3e50; }
|
||||
.button { display: inline-block; padding: 12px 24px; background: #3498db; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { margin-top: 30px; font-size: 12px; color: #777; border-top: 1px solid #eee; padding-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Welcome, ${username}!</h1>
|
||||
<p>Thank you for registering at LinkShare Blog. To activate your account, please click the button below:</p>
|
||||
<p><a href="${confirmUrl}" class="button">Confirm Email</a></p>
|
||||
<p>If the button doesn't work, copy and paste this URL into your browser:</p>
|
||||
<p>${confirmUrl}</p>
|
||||
<div class="footer">
|
||||
<p>This email was sent to you because you registered at LinkShare Blog. If you didn't register, please ignore this email.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
renderPasswordResetEmail(username: string, resetUrl: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Password Reset - LinkShare Blog</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.container { border: 1px solid #ddd; border-radius: 8px; padding: 30px; background: #f9f9f9; }
|
||||
h1 { color: #2c3e50; }
|
||||
.button { display: inline-block; padding: 12px 24px; background: #e74c3c; color: white; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.warning { color: #e74c3c; font-weight: bold; }
|
||||
.footer { margin-top: 30px; font-size: 12px; color: #777; border-top: 1px solid #eee; padding-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Password Reset</h1>
|
||||
<p>Hello ${username},</p>
|
||||
<p>We received a request to reset your password. Click the button below to set a new password:</p>
|
||||
<p><a href="${resetUrl}" class="button">Reset Password</a></p>
|
||||
<p class="warning">This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request a password reset, please ignore this email. Your password will remain unchanged.</p>
|
||||
<div class="footer">
|
||||
<p>LinkShare Blog Security Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
renderNotificationEmail(subject: string, message: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${subject}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.container { border: 1px solid #ddd; border-radius: 8px; padding: 30px; background: #f9f9f9; }
|
||||
h1 { color: #2c3e50; }
|
||||
.content { margin: 20px 0; padding: 15px; background: #fff; border-left: 4px solid #3498db; }
|
||||
.footer { margin-top: 30px; font-size: 12px; color: #777; border-top: 1px solid #eee; padding-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${subject}</h1>
|
||||
<div class="content">${message}</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated notification from LinkShare Blog.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
39
src/modules/email/processors/email.processor.ts
Normal file
39
src/modules/email/processors/email.processor.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Processor, Process } from '@nestjs/bullmq';
|
||||
import type { Job } from 'bullmq';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EmailQueueService } from '../email.queue.service';
|
||||
import { EmailService } from '../email.service';
|
||||
|
||||
@Injectable()
|
||||
@Processor('email')
|
||||
export class EmailProcessor {
|
||||
private readonly logger = new Logger(EmailProcessor.name);
|
||||
|
||||
constructor(
|
||||
private emailQueueService: EmailQueueService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
@Process('send-email')
|
||||
async processSendEmail(
|
||||
job: Job<{ to: string; subject: string; html: string; text?: string }>,
|
||||
) {
|
||||
const { to, subject, html, text } = job.data;
|
||||
this.logger.debug(`Processing email to ${to}, subject: ${subject}`);
|
||||
|
||||
try {
|
||||
const result = await this.emailService.sendMail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
this.logger.log(`Email sent to ${to}, messageId: ${result.messageId}`);
|
||||
return { success: true, messageId: result.messageId };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${to}: ${error}`);
|
||||
throw error; // BullMQ will retry based on job options
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/modules/health/health.controller.ts
Normal file
55
src/modules/health/health.controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Controller, Get, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobsModule } from '../jobs/jobs.module';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
private readonly logger = new Logger(HealthController.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get('health')
|
||||
async getHealth() {
|
||||
const health = {
|
||||
ok: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
database: 'unknown',
|
||||
redis: 'unknown',
|
||||
},
|
||||
};
|
||||
|
||||
// Check database
|
||||
try {
|
||||
// Simple query to check connection
|
||||
await this.prisma.user.count({ where: {} });
|
||||
health.checks.database = 'ok';
|
||||
} catch (error) {
|
||||
this.logger.error(`Database health check failed: ${error}`);
|
||||
health.checks.database = 'error';
|
||||
health.ok = false;
|
||||
}
|
||||
|
||||
// Check Redis (via BullMQ)
|
||||
try {
|
||||
// We can get the email queue and check connection
|
||||
const queue = new Queue('email', {
|
||||
connection: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
},
|
||||
} as any);
|
||||
const connected = await (queue as any).getConnection().ping();
|
||||
if (connected) health.checks.redis = 'ok';
|
||||
else health.checks.redis = 'error';
|
||||
await (queue as any).close();
|
||||
} catch (error) {
|
||||
this.logger.error(`Redis health check failed: ${error}`);
|
||||
health.checks.redis = 'error';
|
||||
health.ok = false;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
}
|
||||
7
src/modules/health/health.module.ts
Normal file
7
src/modules/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
86
src/modules/init/init.controller.ts
Normal file
86
src/modules/init/init.controller.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { InitService, InitStatus } from './init.service';
|
||||
import { RequireSessionUser } from '../session/require-session-user.guard';
|
||||
import { RequireRole } from '../common/decorators/require-role.decorator';
|
||||
import { RequireRoleGuard } from '../common/guards/require-role.guard';
|
||||
|
||||
@ApiTags('系统初始化')
|
||||
@Controller('init')
|
||||
@UseGuards(RequireSessionUser, RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
export class InitController {
|
||||
constructor(private readonly initService: InitService) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取系统初始化状态' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '成功获取初始化状态',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
initialized: { type: 'boolean' },
|
||||
checks: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
environment: { type: 'string', enum: ['ok', 'missing', 'error'] },
|
||||
database: { type: 'string', enum: ['ok', 'error', 'not_migrated'] },
|
||||
seedData: { type: 'string', enum: ['ok', 'error', 'not_seeded'] },
|
||||
oauth2: { type: 'string', enum: ['ok', 'error', 'not_found'] },
|
||||
adminUser: { type: 'string', enum: ['ok', 'error', 'not_found'] },
|
||||
},
|
||||
},
|
||||
details: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
missingEnvVars: { type: 'array', items: { type: 'string' } },
|
||||
dbError: { type: 'string' },
|
||||
seedError: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权访问' })
|
||||
async getInitStatus(@Req() req: any) {
|
||||
return this.initService.checkInitializationStatus();
|
||||
}
|
||||
|
||||
@Post('run')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '手动运行系统初始化(仅开发环境)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '初始化完成',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权访问' })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async runInit(@Req() req: any) {
|
||||
const result = await this.initService.runInitialization();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
11
src/modules/init/init.module.ts
Normal file
11
src/modules/init/init.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InitController } from './init.controller';
|
||||
import { InitService } from './init.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [InitController],
|
||||
providers: [InitService],
|
||||
})
|
||||
export class InitModule {}
|
||||
195
src/modules/init/init.service.ts
Normal file
195
src/modules/init/init.service.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type CheckStatus =
|
||||
| 'ok'
|
||||
| 'missing'
|
||||
| 'error'
|
||||
| 'not_migrated'
|
||||
| 'not_seeded'
|
||||
| 'not_found';
|
||||
|
||||
export interface InitStatus {
|
||||
initialized: boolean;
|
||||
checks: {
|
||||
environment: CheckStatus;
|
||||
database: CheckStatus;
|
||||
seedData: CheckStatus;
|
||||
oauth2: CheckStatus;
|
||||
adminUser: CheckStatus;
|
||||
};
|
||||
details?: {
|
||||
missingEnvVars?: string[];
|
||||
dbError?: string;
|
||||
seedError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InitService {
|
||||
private readonly logger = new Logger(InitService.name);
|
||||
|
||||
// 必需的环境变量列表
|
||||
private readonly requiredEnvVars = [
|
||||
'DATABASE_URL',
|
||||
'SESSION_COOKIE_SECRET',
|
||||
'AI_API_KEY',
|
||||
'SMTP_HOST',
|
||||
'SMTP_PORT',
|
||||
'SMTP_USER',
|
||||
'SMTP_PASS',
|
||||
];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async checkInitializationStatus(): Promise<InitStatus> {
|
||||
const checks: InitStatus['checks'] = {
|
||||
environment: 'ok',
|
||||
database: 'ok',
|
||||
seedData: 'ok',
|
||||
oauth2: 'ok',
|
||||
adminUser: 'ok',
|
||||
};
|
||||
const details: InitStatus['details'] = {};
|
||||
|
||||
// 1. 检查环境变量
|
||||
const missingEnvVars = this.requiredEnvVars.filter(
|
||||
(key) => !this.configService.get(key),
|
||||
);
|
||||
if (missingEnvVars.length > 0) {
|
||||
checks.environment = 'missing';
|
||||
details.missingEnvVars = missingEnvVars;
|
||||
}
|
||||
|
||||
// 2. 检查数据库连接和迁移
|
||||
try {
|
||||
// 检查数据库连接
|
||||
await this.prisma.user.count();
|
||||
|
||||
// 检查是否已迁移(检查关键表是否存在)
|
||||
const tablesExist = await this.checkDatabaseTables();
|
||||
if (!tablesExist) {
|
||||
checks.database = 'not_migrated';
|
||||
checks.seedData = 'not_seeded';
|
||||
return { initialized: false, checks, details };
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Database check failed: ${error}`);
|
||||
checks.database = 'error';
|
||||
details.dbError = error instanceof Error ? error.message : String(error);
|
||||
return { initialized: false, checks, details };
|
||||
}
|
||||
|
||||
// 3. 检查种子数据
|
||||
try {
|
||||
const adminCount = await this.prisma.user.count({
|
||||
where: { role: 'admin' },
|
||||
});
|
||||
if (adminCount === 0) {
|
||||
checks.adminUser = 'not_found';
|
||||
checks.seedData = 'not_seeded';
|
||||
}
|
||||
|
||||
const oauth2Client = await this.prisma.oAuth2Client.findFirst({
|
||||
where: { clientId: 'web-client' },
|
||||
});
|
||||
if (!oauth2Client) {
|
||||
checks.oauth2 = 'not_found';
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Seed data check failed: ${error}`);
|
||||
checks.seedData = 'error';
|
||||
details.seedError =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const isInitialized =
|
||||
checks.environment === 'ok' &&
|
||||
checks.database === 'ok' &&
|
||||
checks.seedData === 'ok' &&
|
||||
checks.oauth2 === 'ok' &&
|
||||
checks.adminUser === 'ok';
|
||||
|
||||
return {
|
||||
initialized: isInitialized,
|
||||
checks,
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabaseTables(): Promise<boolean> {
|
||||
try {
|
||||
// 检查关键表是否存在且可访问
|
||||
const [userCount, articleCount, oauth2ClientCount] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.article.count(),
|
||||
this.prisma.oAuth2Client.count(),
|
||||
]);
|
||||
|
||||
this.logger.debug(
|
||||
`Database tables check: users=${userCount}, articles=${articleCount}, oauth2Clients=${oauth2ClientCount}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Database tables check error: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async runInitialization(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
this.logger.log('Starting system initialization...');
|
||||
|
||||
// 1. 检查环境变量
|
||||
const missingEnvVars = this.requiredEnvVars.filter(
|
||||
(key) => !this.configService.get(key),
|
||||
);
|
||||
if (missingEnvVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missingEnvVars.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 检查数据库连接
|
||||
try {
|
||||
await this.prisma.user.count();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Database connection failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 检查是否已初始化
|
||||
const status = await this.checkInitializationStatus();
|
||||
if (status.initialized) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'System is already initialized',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 运行种子脚本(通过 Prisma CLI)
|
||||
this.logger.log('Running database seed...');
|
||||
// 注意:这里不直接运行 prisma db seed,因为那是 CLI 命令
|
||||
// 实际种子数据应该在 docker-compose 启动时通过 prisma db seed 执行
|
||||
// 这里只是检查状态
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
'Initialization check completed. Please ensure prisma db seed has been executed.',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Initialization failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/modules/jobs/job-names.constants.ts
Normal file
3
src/modules/jobs/job-names.constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const JOB_NAMES = {
|
||||
MODERATION_LLM_REVIEW: 'moderation:llm_review',
|
||||
};
|
||||
33
src/modules/jobs/jobs.module.ts
Normal file
33
src/modules/jobs/jobs.module.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JobsService } from './jobs.service';
|
||||
import { ModerationProcessor } from './processors/moderation.processor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.forRootAsync({
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get<string>('REDIS_HOST') || 'redis',
|
||||
port: parseInt(configService.get<string>('REDIS_PORT') || '6379', 10),
|
||||
password: configService.get<string>('REDIS_PASSWORD') || undefined,
|
||||
},
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'moderation',
|
||||
}),
|
||||
],
|
||||
providers: [JobsService, ModerationProcessor],
|
||||
exports: [JobsService],
|
||||
})
|
||||
export class JobsModule {}
|
||||
37
src/modules/jobs/jobs.service.ts
Normal file
37
src/modules/jobs/jobs.service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { ModerationService } from '../moderation/moderation.service';
|
||||
|
||||
export const MODERATION_QUEUE_NAME = 'moderation';
|
||||
|
||||
@Injectable()
|
||||
export class JobsService implements OnModuleInit {
|
||||
private worker: Worker;
|
||||
|
||||
constructor(
|
||||
@Inject(MODERATION_QUEUE_NAME) private moderationQueue: Queue,
|
||||
private readonly moderationService: ModerationService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.worker = new Worker(
|
||||
MODERATION_QUEUE_NAME,
|
||||
async (job: Job) => {
|
||||
if ((job as any).name === 'moderation:llm_review') {
|
||||
const { commentId } = (job as any).data;
|
||||
await this.moderationService.aiReview(commentId);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: (this.moderationQueue as any).client,
|
||||
},
|
||||
);
|
||||
(this.worker as any).on('failed', (job: Job, error: Error) => {
|
||||
console.error(`Job ${(job as any).id} failed:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
async addModerationReview(commentId: number) {
|
||||
await this.moderationQueue.add('moderation:llm_review', { commentId });
|
||||
}
|
||||
}
|
||||
16
src/modules/jobs/processors/moderation.processor.ts
Normal file
16
src/modules/jobs/processors/moderation.processor.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Processor, Process } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModerationService } from '../../moderation/moderation.service';
|
||||
import type { Job } from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
@Processor('moderation')
|
||||
export class ModerationProcessor {
|
||||
constructor(private readonly moderationService: ModerationService) {}
|
||||
|
||||
@Process('moderation:llm_review')
|
||||
async asyncProcess(job: Job) {
|
||||
const { commentId } = job.data;
|
||||
await this.moderationService.aiReview(commentId);
|
||||
}
|
||||
}
|
||||
18
src/modules/me/me.controller.ts
Normal file
18
src/modules/me/me.controller.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Req, UnauthorizedException } from '@nestjs/common';
|
||||
import { OAuth2Guard } from '../oauth2/oauth2.guard';
|
||||
import { RequireSessionUser } from '../session/require-session-user.guard';
|
||||
|
||||
@Controller()
|
||||
export class MeController {
|
||||
@Get('me')
|
||||
async me(@Req() req: any) {
|
||||
// 优先使用 OAuth2 token,否则使用 session
|
||||
if (req.user && req.user.userId) {
|
||||
return { ok: true, user: req.user };
|
||||
}
|
||||
if (req.user && req.user.id) {
|
||||
return { ok: true, user: req.user };
|
||||
}
|
||||
throw new UnauthorizedException('Not authenticated');
|
||||
}
|
||||
}
|
||||
7
src/modules/me/me.module.ts
Normal file
7
src/modules/me/me.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeController } from './me.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [MeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
8
src/modules/moderation/moderation.module.ts
Normal file
8
src/modules/moderation/moderation.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModerationService } from './moderation.service';
|
||||
|
||||
@Module({
|
||||
providers: [ModerationService],
|
||||
exports: [ModerationService],
|
||||
})
|
||||
export class ModerationModule {}
|
||||
311
src/modules/moderation/moderation.service.spec.ts
Normal file
311
src/modules/moderation/moderation.service.spec.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ModerationService } from './moderation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ModerationStatus } from './moderation.service';
|
||||
|
||||
describe('ModerationService', () => {
|
||||
let service: ModerationService;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
comment: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
commentAudit: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockAiConfig = {
|
||||
reviewEnabled: true,
|
||||
baseUrl: 'https://api.openrouter.ai/v1',
|
||||
apiKey: 'test-api-key',
|
||||
modelName: 'openai/gpt-3.5-turbo',
|
||||
promptTemplate: undefined,
|
||||
temperature: 0.2,
|
||||
siteUrl: 'http://localhost:3001',
|
||||
title: 'Test Blog',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ModerationService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
{
|
||||
provide: 'AI_CONFIG',
|
||||
useValue: mockAiConfig,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ModerationService>(ModerationService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('ruleBasedModerate', () => {
|
||||
it('should reject content with blocked keywords', async () => {
|
||||
const result = await service.ruleBasedModerate(
|
||||
'This contains badword1 in it',
|
||||
);
|
||||
expect(result.status).toBe(ModerationStatus.REJECTED);
|
||||
expect(result.reason).toContain('badword1');
|
||||
});
|
||||
|
||||
it('should approve clean content', async () => {
|
||||
const result = await service.ruleBasedModerate('This is a clean comment');
|
||||
expect(result.status).toBe(ModerationStatus.APPROVED);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should mark suspicious content that is too long', async () => {
|
||||
const longContent = 'a'.repeat(1001);
|
||||
const result = await service.ruleBasedModerate(longContent);
|
||||
expect(result.status).toBe(ModerationStatus.SUSPICIOUS);
|
||||
expect(result.reason).toBe('Too long');
|
||||
});
|
||||
|
||||
it('should be case insensitive for keyword matching', async () => {
|
||||
const result = await service.ruleBasedModerate('This contains BADWORD2');
|
||||
expect(result.status).toBe(ModerationStatus.REJECTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aiReview', () => {
|
||||
it('should skip AI review when disabled', async () => {
|
||||
const disabledConfig = { ...mockAiConfig, reviewEnabled: false };
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ModerationService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
{
|
||||
provide: 'AI_CONFIG',
|
||||
useValue: disabledConfig,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const serviceWithDisabledAi =
|
||||
module.get<ModerationService>(ModerationService);
|
||||
|
||||
await serviceWithDisabledAi.aiReview(1);
|
||||
|
||||
expect(mockPrismaService.comment.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle non-existent comment gracefully', async () => {
|
||||
mockPrismaService.comment.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await service.aiReview(999);
|
||||
|
||||
expect(mockPrismaService.comment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 999 },
|
||||
include: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should process AI review successfully', async () => {
|
||||
const mockComment = {
|
||||
id: 1,
|
||||
content: 'Test comment',
|
||||
article: {
|
||||
title: 'Test Article',
|
||||
content: 'Test content',
|
||||
},
|
||||
};
|
||||
|
||||
mockPrismaService.comment.findUnique.mockResolvedValueOnce(mockComment);
|
||||
|
||||
// Mock successful AI response
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
decision: 'approved',
|
||||
reason: 'Good comment',
|
||||
score: 0.95,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await service.aiReview(1);
|
||||
|
||||
expect(mockPrismaService.commentAudit.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
commentId: 1,
|
||||
status: ModerationStatus.APPROVED,
|
||||
reason: 'Good comment',
|
||||
score: 0.95,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mockPrismaService.comment.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { status: ModerationStatus.APPROVED },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle AI API errors with fallback to rule-based', async () => {
|
||||
const mockComment = {
|
||||
id: 2,
|
||||
content: 'test comment',
|
||||
article: {
|
||||
title: 'Test Article',
|
||||
content: 'Test content',
|
||||
},
|
||||
};
|
||||
|
||||
mockPrismaService.comment.findUnique.mockResolvedValueOnce(mockComment);
|
||||
|
||||
// Mock fetch to throw error
|
||||
global.fetch = jest.fn().mockRejectedValueOnce(new Error('API error'));
|
||||
|
||||
await service.aiReview(2);
|
||||
|
||||
// Should fall back to rule-based moderation
|
||||
expect(mockPrismaService.commentAudit.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
commentId: 2,
|
||||
reason: expect.stringContaining('AI error'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callOpenRouter', () => {
|
||||
it('should call OpenRouter API with correct parameters', async () => {
|
||||
const mockResponse = {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
decision: 'approved',
|
||||
reason: 'Clean content',
|
||||
score: 0.9,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
// Access private method via type assertion (for testing only)
|
||||
const result = await (service as any).callOpenRouter('Test content');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/chat/completions'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-api-key',
|
||||
}),
|
||||
body: expect.stringContaining('Test content'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
decision: 'approved',
|
||||
reason: 'Clean content',
|
||||
score: 0.9,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-OK HTTP responses', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
text: async () => 'Invalid request',
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).callOpenRouter('Test content'),
|
||||
).rejects.toThrow('OpenRouter API error');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON response', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'invalid json' } }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).callOpenRouter('Test content'),
|
||||
).rejects.toThrow('Failed to parse AI response');
|
||||
});
|
||||
|
||||
it('should validate decision values', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
decision: 'invalid',
|
||||
reason: 'Test',
|
||||
score: 0.5,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).callOpenRouter('Test content'),
|
||||
).rejects.toThrow('Invalid decision');
|
||||
});
|
||||
|
||||
it('should validate score range', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
decision: 'approved',
|
||||
reason: 'Test',
|
||||
score: 1.5,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).callOpenRouter('Test content'),
|
||||
).rejects.toThrow('Invalid score');
|
||||
});
|
||||
});
|
||||
});
|
||||
191
src/modules/moderation/moderation.service.ts
Normal file
191
src/modules/moderation/moderation.service.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export enum ModerationStatus {
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
SUSPICIOUS = 'suspicious',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ModerationService {
|
||||
private readonly logger = new Logger(ModerationService.name);
|
||||
private defaultPromptTemplate = `
|
||||
You are a content moderator for a blog platform. Review the following comment and classify it as one of:
|
||||
- "approved": clean, respectful, relevant
|
||||
- "rejected": contains profanity, harassment, spam, or off-topic
|
||||
- "suspicious": borderline, needs human review
|
||||
|
||||
Consider:
|
||||
- Tone: respectful vs aggressive
|
||||
- Content: relevant to the article vs off-topic
|
||||
- Spam indicators: excessive links, promotional content
|
||||
|
||||
Comment content: "{{content}}"
|
||||
|
||||
Respond ONLY with a JSON object containing:
|
||||
{
|
||||
"decision": "approved|rejected|suspicious",
|
||||
"reason": "Brief explanation (1-2 sentences)",
|
||||
"score": 0.0-1.0 (confidence level)
|
||||
}
|
||||
`.trim();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject('AI_CONFIG') private readonly aiConfig: any,
|
||||
) {}
|
||||
|
||||
async ruleBasedModerate(
|
||||
content: string,
|
||||
): Promise<{ status: ModerationStatus; reason?: string }> {
|
||||
const blockedKeywords = ['badword1', 'badword2', 'spam'];
|
||||
const lower = content.toLowerCase();
|
||||
for (const word of blockedKeywords) {
|
||||
if (lower.includes(word)) {
|
||||
return {
|
||||
status: ModerationStatus.REJECTED,
|
||||
reason: `Blocked keyword: ${word}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (content.length > 1000) {
|
||||
return { status: ModerationStatus.SUSPICIOUS, reason: 'Too long' };
|
||||
}
|
||||
return { status: ModerationStatus.APPROVED };
|
||||
}
|
||||
|
||||
async aiReview(commentId: number) {
|
||||
// Skip if AI review disabled
|
||||
if (!this.aiConfig.reviewEnabled) {
|
||||
this.logger.debug(`AI review disabled, skipping comment ${commentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = await this.prisma.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
include: {
|
||||
article: {
|
||||
select: { title: true, content: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!comment) return;
|
||||
|
||||
try {
|
||||
const decision = await this.callOpenRouter(comment.content);
|
||||
this.logger.log(
|
||||
`AI review for comment ${commentId}: ${decision.decision} (score: ${decision.score})`,
|
||||
);
|
||||
|
||||
const status =
|
||||
decision.decision === 'approved'
|
||||
? ModerationStatus.APPROVED
|
||||
: decision.decision === 'rejected'
|
||||
? ModerationStatus.REJECTED
|
||||
: ModerationStatus.SUSPICIOUS;
|
||||
|
||||
await this.prisma.commentAudit.create({
|
||||
data: {
|
||||
commentId,
|
||||
status,
|
||||
reason: decision.reason,
|
||||
score: decision.score,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: { status },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`AI review failed for comment ${commentId}: ${error}`);
|
||||
// Fallback: if rule-based says suspicious, keep suspicious; otherwise approve
|
||||
const ruleBased = await this.ruleBasedModerate(comment.content);
|
||||
const fallbackStatus =
|
||||
ruleBased.status === ModerationStatus.SUSPICIOUS
|
||||
? ModerationStatus.SUSPICIOUS
|
||||
: ModerationStatus.APPROVED;
|
||||
|
||||
await this.prisma.commentAudit.create({
|
||||
data: {
|
||||
commentId,
|
||||
status: fallbackStatus,
|
||||
reason: `AI error: ${error instanceof Error ? error.message : 'Unknown error'}. Fallback to ${fallbackStatus}.`,
|
||||
score: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: { status: fallbackStatus },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async callOpenRouter(content: string): Promise<{
|
||||
decision: 'approved' | 'rejected' | 'suspicious';
|
||||
reason: string;
|
||||
score: number;
|
||||
}> {
|
||||
const prompt = this.aiConfig.promptTemplate || this.defaultPromptTemplate;
|
||||
const renderedPrompt = prompt.replace(/\{\{content\}\}/g, content);
|
||||
|
||||
const response = await fetch(this.aiConfig.baseUrl + '/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.aiConfig.apiKey}`,
|
||||
'HTTP-Referer': this.aiConfig.siteUrl || 'http://localhost:3001',
|
||||
'X-Title': this.aiConfig.title || 'LinkShare Blog',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.aiConfig.modelName,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a content moderation assistant that responds in JSON.',
|
||||
},
|
||||
{ role: 'user', content: renderedPrompt },
|
||||
],
|
||||
temperature: this.aiConfig.temperature || 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`OpenRouter API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const contentStr = data.choices?.[0]?.message?.content;
|
||||
if (!contentStr) {
|
||||
throw new Error('Invalid response from OpenRouter: no content');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(contentStr);
|
||||
const decision = parsed.decision?.toLowerCase();
|
||||
if (!['approved', 'rejected', 'suspicious'].includes(decision)) {
|
||||
throw new Error(`Invalid decision: ${decision}`);
|
||||
}
|
||||
const score = parseFloat(parsed.score);
|
||||
if (isNaN(score) || score < 0 || score > 1) {
|
||||
throw new Error(`Invalid score: ${score}`);
|
||||
}
|
||||
return {
|
||||
decision: decision as 'approved' | 'rejected' | 'suspicious',
|
||||
reason: parsed.reason || 'No reason provided',
|
||||
score,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse AI response: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/modules/oauth2/dto/oauth2.dto.ts
Normal file
23
src/modules/oauth2/dto/oauth2.dto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export class AuthorizationCodeDto {
|
||||
response_type!: 'code';
|
||||
client_id!: string;
|
||||
redirect_uri!: string;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export class TokenRequestDto {
|
||||
grant_type!: 'authorization_code' | 'client_credentials';
|
||||
code?: string;
|
||||
redirect_uri?: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
export class TokenResponseDto {
|
||||
access_token!: string;
|
||||
token_type!: string;
|
||||
expires_in!: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
71
src/modules/oauth2/oauth2.controller.ts
Normal file
71
src/modules/oauth2/oauth2.controller.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Req,
|
||||
Query,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { OAuth2Service } from './oauth2.service';
|
||||
import { RequireSessionUser } from '../session/require-session-user.guard';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Controller('oauth2')
|
||||
export class OAuth2Controller {
|
||||
constructor(private readonly oauth2Service: OAuth2Service) {}
|
||||
|
||||
@Get('authorize')
|
||||
async authorize(@Req() req: Request, @Query() query: any) {
|
||||
const { response_type, client_id, redirect_uri, scope, state } = query;
|
||||
|
||||
const result = await this.oauth2Service.authorize(
|
||||
response_type,
|
||||
client_id,
|
||||
redirect_uri,
|
||||
scope,
|
||||
state,
|
||||
(req.session as any)?.userId,
|
||||
);
|
||||
|
||||
if (result.redirectUrl.startsWith('/')) {
|
||||
// 重定向到前端
|
||||
return req.res!.redirect(303, result.redirectUrl);
|
||||
}
|
||||
return req.res!.redirect(303, result.redirectUrl);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@Throttle({
|
||||
limit: 10,
|
||||
ttl: 60000,
|
||||
} as any)
|
||||
async token(@Req() req: Request) {
|
||||
const grantType = req.body.grant_type;
|
||||
const { code, redirect_uri, client_id, client_secret } = req.body;
|
||||
|
||||
try {
|
||||
const tokenResponse = await this.oauth2Service.token(
|
||||
grantType,
|
||||
code,
|
||||
redirect_uri,
|
||||
client_id,
|
||||
client_secret,
|
||||
);
|
||||
return tokenResponse;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException('invalid_grant');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
async logout(@Req() req: Request) {
|
||||
// 可选:清理 session 或 token
|
||||
// 这里只返回成功,实际登出由前端 session 处理
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
24
src/modules/oauth2/oauth2.guard.ts
Normal file
24
src/modules/oauth2/oauth2.guard.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { OAuth2Service } from './oauth2.service';
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Guard implements CanActivate {
|
||||
constructor(private readonly oauth2Service: OAuth2Service) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('missing_token');
|
||||
}
|
||||
const token = authHeader.substring(7);
|
||||
const validation = await this.oauth2Service.validateAccessToken(token);
|
||||
if (!validation) {
|
||||
throw new UnauthorizedException('invalid_token');
|
||||
}
|
||||
// 将 token 信息注入 request
|
||||
request.user = validation;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
src/modules/oauth2/oauth2.module.ts
Normal file
12
src/modules/oauth2/oauth2.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OAuth2Controller } from './oauth2.controller';
|
||||
import { OAuth2Service } from './oauth2.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [OAuth2Controller],
|
||||
providers: [OAuth2Service],
|
||||
exports: [OAuth2Service],
|
||||
})
|
||||
export class OAuth2Module {}
|
||||
256
src/modules/oauth2/oauth2.service.ts
Normal file
256
src/modules/oauth2/oauth2.service.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as crypto from 'crypto';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { oauth2Config } from '../../config/env';
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Service {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject('OAUTH2_CONFIG') private readonly oauth2Config: any,
|
||||
) {}
|
||||
|
||||
async authorize(
|
||||
responseType: string,
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
scope?: string,
|
||||
state?: string,
|
||||
userId?: number,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
if (responseType !== 'code') {
|
||||
throw new BadRequestException('unsupported_response_type');
|
||||
}
|
||||
|
||||
const client = await this.prisma.oAuth2Client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
if (!client) {
|
||||
throw new BadRequestException('invalid_client');
|
||||
}
|
||||
|
||||
// 验证 redirect_uri 是否在客户端注册的列表中
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('invalid_redirect_uri');
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
// 未登录,重定向到前端登录页,带上 next=当前URL(编码后)
|
||||
const next = encodeURIComponent(
|
||||
`/oauth2/authorize?response_type=${responseType}&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scope || ''}&state=${state || ''}`,
|
||||
);
|
||||
const loginUrl = `/login?next=${next}`;
|
||||
return { redirectUrl: loginUrl };
|
||||
}
|
||||
|
||||
// 已登录,生成 authorization code
|
||||
const code = crypto.randomBytes(32).toString('hex');
|
||||
const codeHash = crypto.createHash('sha256').update(code).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||
|
||||
await this.prisma.oAuth2AuthorizationCode.create({
|
||||
data: {
|
||||
codeHash,
|
||||
clientId,
|
||||
userId,
|
||||
redirectUri,
|
||||
scopes: scope || '',
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 重定向回 redirect_uri,携带 code 和 state
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('code', code);
|
||||
if (state) {
|
||||
url.searchParams.set('state', state);
|
||||
}
|
||||
return { redirectUrl: url.toString() };
|
||||
}
|
||||
|
||||
async token(
|
||||
grantType: string,
|
||||
code?: string,
|
||||
redirectUri?: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
): Promise<any> {
|
||||
if (grantType === 'authorization_code') {
|
||||
if (!code || !redirectUri || !clientId || !clientSecret) {
|
||||
throw new BadRequestException('invalid_request');
|
||||
}
|
||||
|
||||
const client = await this.prisma.oAuth2Client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
if (!client || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('invalid_client');
|
||||
}
|
||||
|
||||
const codeHash = crypto.createHash('sha256').update(code).digest('hex');
|
||||
const authCode = await this.prisma.oAuth2AuthorizationCode.findFirst({
|
||||
where: { codeHash },
|
||||
});
|
||||
|
||||
if (!authCode) {
|
||||
throw new BadRequestException('invalid_grant');
|
||||
}
|
||||
if (authCode.used) {
|
||||
throw new BadRequestException('invalid_grant');
|
||||
}
|
||||
if (authCode.expiresAt < new Date()) {
|
||||
throw new BadRequestException('invalid_grant');
|
||||
}
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
throw new BadRequestException('invalid_grant');
|
||||
}
|
||||
|
||||
// 标记为已使用
|
||||
await this.prisma.oAuth2AuthorizationCode.update({
|
||||
where: { id: authCode.id },
|
||||
data: { used: true },
|
||||
});
|
||||
|
||||
// 生成 access token 和 refresh token
|
||||
const accessToken = this.signToken({
|
||||
sub: authCode.userId?.toString() || '',
|
||||
clientId: authCode.clientId,
|
||||
scope: authCode.scopes,
|
||||
});
|
||||
const refreshToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + this.oauth2Config.accessTokenTtlSeconds * 1000,
|
||||
);
|
||||
const refreshExpiresAt = new Date(
|
||||
Date.now() + this.oauth2Config.refreshTokenTtlSeconds * 1000,
|
||||
);
|
||||
|
||||
await this.prisma.oAuth2Token.create({
|
||||
data: {
|
||||
accessTokenHash: crypto
|
||||
.createHash('sha256')
|
||||
.update(accessToken)
|
||||
.digest('hex'),
|
||||
refreshTokenHash: crypto
|
||||
.createHash('sha256')
|
||||
.update(refreshToken)
|
||||
.digest('hex'),
|
||||
clientId: authCode.clientId,
|
||||
userId: authCode.userId,
|
||||
scopes: authCode.scopes,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: this.oauth2Config.accessTokenTtlSeconds,
|
||||
refresh_token: refreshToken,
|
||||
scope: authCode.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new BadRequestException('invalid_request');
|
||||
}
|
||||
|
||||
const client = await this.prisma.oAuth2Client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
if (!client || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('invalid_client');
|
||||
}
|
||||
|
||||
const accessToken = this.signToken({
|
||||
sub: '',
|
||||
clientId,
|
||||
scope: client.scopes,
|
||||
});
|
||||
const expiresAt = new Date(
|
||||
Date.now() + this.oauth2Config.accessTokenTtlSeconds * 1000,
|
||||
);
|
||||
|
||||
await this.prisma.oAuth2Token.create({
|
||||
data: {
|
||||
accessTokenHash: crypto
|
||||
.createHash('sha256')
|
||||
.update(accessToken)
|
||||
.digest('hex'),
|
||||
clientId,
|
||||
userId: null,
|
||||
scopes: client.scopes,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: this.oauth2Config.accessTokenTtlSeconds,
|
||||
scope: client.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestException('unsupported_grant_type');
|
||||
}
|
||||
|
||||
async validateAccessToken(
|
||||
accessToken: string,
|
||||
): Promise<{ userId?: number; clientId: string; scope: string } | null> {
|
||||
try {
|
||||
const payload = jwt.verify(
|
||||
accessToken,
|
||||
this.oauth2Config.tokenSigningPublicKey,
|
||||
{ algorithms: ['RS256'] },
|
||||
) as any;
|
||||
|
||||
const tokenHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(accessToken)
|
||||
.digest('hex');
|
||||
const tokenRecord = await this.prisma.oAuth2Token.findFirst({
|
||||
where: { accessTokenHash: tokenHash },
|
||||
});
|
||||
|
||||
if (
|
||||
!tokenRecord ||
|
||||
tokenRecord.revoked ||
|
||||
tokenRecord.expiresAt < new Date()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: tokenRecord.userId,
|
||||
clientId: tokenRecord.clientId,
|
||||
scope: tokenRecord.scopes,
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private signToken(payload: {
|
||||
sub: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
}): string {
|
||||
const privateKey = this.oauth2Config.tokenSigningPrivateKey;
|
||||
if (!privateKey) {
|
||||
throw new Error('OAuth2 token signing private key not configured');
|
||||
}
|
||||
return jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: `${this.oauth2Config.accessTokenTtlSeconds}s`,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/modules/prisma/prisma.module.ts
Normal file
8
src/modules/prisma/prisma.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
651
src/modules/prisma/prisma.service.ts
Normal file
651
src/modules/prisma/prisma.service.ts
Normal file
@ -0,0 +1,651 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// 定义 Prisma 客户端的精简接口
|
||||
interface PrismaClientLike extends PrismaClient {
|
||||
user: any;
|
||||
article: any;
|
||||
comment: any;
|
||||
reaction: any;
|
||||
emailMessage: any;
|
||||
commentAudit: any;
|
||||
analyticsEvent: any;
|
||||
oAuth2Client: any;
|
||||
oAuth2AuthorizationCode: any;
|
||||
oAuth2Token: any;
|
||||
}
|
||||
|
||||
// Mock 数据接口类型
|
||||
interface MockModel<T, TManyResult = T[]> {
|
||||
findUnique: (params: any) => Promise<T | null>;
|
||||
findMany: (params: any) => Promise<TManyResult>;
|
||||
findFirst: (params: any) => Promise<T | null>;
|
||||
count: (params: any) => Promise<number>;
|
||||
create: (data: any) => Promise<T>;
|
||||
update: (params: any) => Promise<T | null>;
|
||||
delete: (params: any) => Promise<{ id: any } | null>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService implements OnModuleInit, OnModuleDestroy {
|
||||
// 声明属性以满足 TypeScript 类型检查
|
||||
user: any;
|
||||
article: any;
|
||||
comment: any;
|
||||
reaction: any;
|
||||
emailMessage: any;
|
||||
commentAudit: any;
|
||||
analyticsEvent: any;
|
||||
oAuth2Client: any;
|
||||
oAuth2AuthorizationCode: any;
|
||||
oAuth2Token: any;
|
||||
private readonly isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
private prisma?: PrismaClient;
|
||||
|
||||
// 开发环境内存存储
|
||||
private devUsers: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
username: 'admin',
|
||||
passwordHash: 'password123',
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
private devArticles: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
slug: 'welcome',
|
||||
title: 'Welcome',
|
||||
content: 'Sample',
|
||||
excerpt: null,
|
||||
coverImageUrl: null,
|
||||
status: 'published',
|
||||
visibility: 'public',
|
||||
authorId: 1,
|
||||
viewCount: 0,
|
||||
publishedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
private devComments: any[] = [];
|
||||
private devReactions: any[] = [];
|
||||
private devEmailMessages: any[] = [];
|
||||
private devCommentAudits: any[] = [];
|
||||
private devAnalyticsEvents: any[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.isDevelopment) {
|
||||
this.prisma = new PrismaClient();
|
||||
} else {
|
||||
console.log('PrismaService: Using in-memory mock for development');
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.prisma) {
|
||||
this.prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前客户端(生产环境返回 PrismaClient,开发环境返回 mock)
|
||||
private getClient(): any {
|
||||
if (!this.isDevelopment || !this.prisma) {
|
||||
return this.prisma!;
|
||||
}
|
||||
|
||||
// 返回开发环境的 mock 实现
|
||||
return {
|
||||
user: this.createUserMock(),
|
||||
article: this.createArticleMock(),
|
||||
comment: this.createCommentMock(),
|
||||
reaction: this.createReactionMock(),
|
||||
emailMessage: this.createEmailMessageMock(),
|
||||
commentAudit: this.createCommentAuditMock(),
|
||||
analyticsEvent: this.createAnalyticsEventMock(),
|
||||
oAuth2Client: this.createOAuth2ClientMock(),
|
||||
oAuth2AuthorizationCode: this.createOAuth2AuthorizationCodeMock(),
|
||||
oAuth2Token: this.createOAuth2TokenMock(),
|
||||
$connect: async () => {},
|
||||
$disconnect: async () => {},
|
||||
$on: async () => {},
|
||||
$off: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// 创建 mock 实现
|
||||
private createUserMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any) => {
|
||||
if (params.where?.email)
|
||||
return (
|
||||
this.devUsers.find((u) => u.email === params.where.email) || null
|
||||
);
|
||||
if (params.where?.username)
|
||||
return (
|
||||
this.devUsers.find((u) => u.username === params.where.username) ||
|
||||
null
|
||||
);
|
||||
if (params.where?.id)
|
||||
return this.devUsers.find((u) => u.id === params.where.id) || null;
|
||||
return null;
|
||||
},
|
||||
findUnique: async (params: any) =>
|
||||
this.devUsers.find((u) => u.id === params.where.id) || null,
|
||||
findMany: async (params: any) => {
|
||||
let result = [...this.devUsers];
|
||||
if (params.where) {
|
||||
if (params.where.role)
|
||||
result = result.filter((u) => u.role === params.where.role);
|
||||
if (params.where.isActive !== undefined)
|
||||
result = result.filter((u) => u.isActive === params.where.isActive);
|
||||
}
|
||||
if (params.orderBy) {
|
||||
const key = Object.keys(params.orderBy)[0];
|
||||
const dir = params.orderBy[key];
|
||||
result.sort((u, v) =>
|
||||
u[key] < v[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1,
|
||||
);
|
||||
}
|
||||
if (params.skip) result = result.slice(params.skip);
|
||||
if (params.take) result = result.slice(0, params.take);
|
||||
return result;
|
||||
},
|
||||
count: async (params: any) => {
|
||||
let result = [...this.devUsers];
|
||||
if (params.where) {
|
||||
if (params.where.role)
|
||||
result = result.filter((u) => u.role === params.where.role);
|
||||
if (params.where.isActive !== undefined)
|
||||
result = result.filter((u) => u.isActive === params.where.isActive);
|
||||
}
|
||||
return result.length;
|
||||
},
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devUsers.map((u) => u.id), 0) + 1;
|
||||
const user = {
|
||||
id,
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.devUsers.push(user);
|
||||
return user;
|
||||
},
|
||||
update: async (params: any) => {
|
||||
const idx = this.devUsers.findIndex((u) => u.id === params.where.id);
|
||||
if (idx === -1) return null;
|
||||
this.devUsers[idx] = {
|
||||
...this.devUsers[idx],
|
||||
...params.data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return this.devUsers[idx];
|
||||
},
|
||||
delete: async (params: any) => {
|
||||
const idx = this.devUsers.findIndex((u) => u.id === params.where.id);
|
||||
if (idx === -1) return null;
|
||||
this.devUsers.splice(idx, 1);
|
||||
return { id: params.where.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createArticleMock(): MockModel<
|
||||
any,
|
||||
{ articles: any[]; total: number }
|
||||
> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any) =>
|
||||
this.devArticles.find((a) => a.id === params.where.id) || null,
|
||||
findUnique: async (params: any) =>
|
||||
this.devArticles.find((a) => a.id === params.where.id) || null,
|
||||
findMany: async (params: any) => {
|
||||
let result = [...this.devArticles];
|
||||
if (params.where) {
|
||||
if (params.where.authorId)
|
||||
result = result.filter((a) => a.authorId === params.where.authorId);
|
||||
if (params.where.status)
|
||||
result = result.filter((a) => a.status === params.where.status);
|
||||
if (params.where.visibility)
|
||||
result = result.filter(
|
||||
(a) => a.visibility === params.where.visibility,
|
||||
);
|
||||
if (params.where.slug)
|
||||
result = result.filter((a) => a.slug === params.where.slug);
|
||||
}
|
||||
if (params.orderBy) {
|
||||
const key = Object.keys(params.orderBy)[0];
|
||||
const dir = params.orderBy[key];
|
||||
result.sort((a, b) =>
|
||||
a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1,
|
||||
);
|
||||
}
|
||||
if (params.skip) result = result.slice(params.skip);
|
||||
if (params.take) result = result.slice(0, params.take);
|
||||
if (params.include) {
|
||||
result = result.map((a) => ({
|
||||
...a,
|
||||
author: this.devUsers.find((u) => u.id === a.authorId) || null,
|
||||
}));
|
||||
}
|
||||
return { articles: result, total: result.length };
|
||||
},
|
||||
count: async (params: any) => {
|
||||
let result = [...this.devArticles];
|
||||
if (params.where) {
|
||||
if (params.where.authorId)
|
||||
result = result.filter((a) => a.authorId === params.where.authorId);
|
||||
if (params.where.status)
|
||||
result = result.filter((a) => a.status === params.where.status);
|
||||
if (params.where.visibility)
|
||||
result = result.filter(
|
||||
(a) => a.visibility === params.where.visibility,
|
||||
);
|
||||
}
|
||||
return result.length;
|
||||
},
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devArticles.map((a) => a.id), 0) + 1;
|
||||
const article = {
|
||||
id,
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.devArticles.push(article);
|
||||
return article;
|
||||
},
|
||||
update: async (params: any) => {
|
||||
const idx = this.devArticles.findIndex((a) => a.id === params.where.id);
|
||||
if (idx === -1) return null;
|
||||
const current = this.devArticles[idx];
|
||||
const updated = { ...current };
|
||||
// 处理增量更新
|
||||
const newData = { ...params.data };
|
||||
if (
|
||||
newData.viewCount &&
|
||||
typeof newData.viewCount === 'object' &&
|
||||
newData.viewCount.increment
|
||||
) {
|
||||
updated.viewCount =
|
||||
(current.viewCount || 0) + newData.viewCount.increment;
|
||||
delete newData.viewCount;
|
||||
}
|
||||
this.devArticles[idx] = {
|
||||
...updated,
|
||||
...newData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return this.devArticles[idx];
|
||||
},
|
||||
delete: async (params: any) => {
|
||||
const idx = this.devArticles.findIndex((a) => a.id === params.where.id);
|
||||
if (idx === -1) return null;
|
||||
this.devArticles.splice(idx, 1);
|
||||
return { id: params.where.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createCommentMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any) =>
|
||||
this.devComments.find((c) => c.id === params.where.id) || null,
|
||||
findUnique: async (params: any) =>
|
||||
this.devComments.find((c) => c.id === params.where.id) || null,
|
||||
findMany: async (params: any) => {
|
||||
let result = [...this.devComments];
|
||||
if (params.where) {
|
||||
if (params.where.articleId)
|
||||
result = result.filter(
|
||||
(c) => c.articleId === params.where.articleId,
|
||||
);
|
||||
if (params.where.status)
|
||||
result = result.filter((c) => c.status === params.where.status);
|
||||
if (params.where.parentId !== undefined)
|
||||
result = result.filter((c) => c.parentId === params.where.parentId);
|
||||
}
|
||||
if (params.orderBy) {
|
||||
const key = Object.keys(params.orderBy)[0];
|
||||
const dir = params.orderBy[key];
|
||||
result.sort((a, b) =>
|
||||
a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1,
|
||||
);
|
||||
}
|
||||
if (params.include) {
|
||||
result = result.map((c) => ({
|
||||
...c,
|
||||
author: c.authorId
|
||||
? this.devUsers.find((u) => u.id === c.authorId) || null
|
||||
: null,
|
||||
replies: c.parentId
|
||||
? []
|
||||
: this.devComments
|
||||
.filter((r) => r.parentId === c.id)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
author: r.authorId
|
||||
? this.devUsers.find((u) => u.id === r.authorId) || null
|
||||
: null,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
count: async (params: any) => {
|
||||
let result = [...this.devComments];
|
||||
if (params.where) {
|
||||
if (params.where.articleId)
|
||||
result = result.filter(
|
||||
(c) => c.articleId === params.where.articleId,
|
||||
);
|
||||
if (params.where.status)
|
||||
result = result.filter((c) => c.status === params.where.status);
|
||||
if (params.where.authorId)
|
||||
result = result.filter((c) => c.authorId === params.where.authorId);
|
||||
if (params.where.parentId !== undefined)
|
||||
result = result.filter((c) => c.parentId === params.where.parentId);
|
||||
}
|
||||
return result.length;
|
||||
},
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devComments.map((c) => c.id), 0) + 1;
|
||||
const comment = {
|
||||
id,
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.devComments.push(comment);
|
||||
return comment;
|
||||
},
|
||||
update: async (params: any) => {
|
||||
const idx = this.devComments.findIndex((c) => c.id === params.where.id);
|
||||
if (idx === -1) return null;
|
||||
this.devComments[idx] = {
|
||||
...this.devComments[idx],
|
||||
...params.data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return this.devComments[idx];
|
||||
},
|
||||
delete: async (params: any) => {
|
||||
const idx = this.devComments.findIndex((c) => c.id === params.where.id);
|
||||
if (idx === -1) return null;
|
||||
this.devComments.splice(idx, 1);
|
||||
return { id: params.where.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createReactionMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any): Promise<any> => null,
|
||||
findUnique: async () => null,
|
||||
findMany: async (params: any) => {
|
||||
let result = [...this.devReactions];
|
||||
if (params.where) {
|
||||
if (params.where.articleId)
|
||||
result = result.filter(
|
||||
(r) => r.articleId === params.where.articleId,
|
||||
);
|
||||
if (params.where.userId)
|
||||
result = result.filter((r) => r.userId === params.where.userId);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
count: async () => 0,
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devReactions.map((r) => r.id), 0) + 1;
|
||||
const reaction = { id, ...data, createdAt: new Date() };
|
||||
this.devReactions.push(reaction);
|
||||
return reaction;
|
||||
},
|
||||
update: async () => null,
|
||||
delete: async (params: any) => {
|
||||
const idx = this.devReactions.findIndex(
|
||||
(r) => r.id === params.where.id,
|
||||
);
|
||||
if (idx === -1) return null;
|
||||
this.devReactions.splice(idx, 1);
|
||||
return { id: params.where.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createEmailMessageMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any) => {
|
||||
let result = [...this.devEmailMessages];
|
||||
if (params.where) {
|
||||
if (params.where.status)
|
||||
result = result.filter((e) => e.status === params.where.status);
|
||||
if (params.where.id)
|
||||
result = result.filter((e) => e.id === params.where.id);
|
||||
}
|
||||
return result[0] || null;
|
||||
},
|
||||
findUnique: async () => null,
|
||||
findMany: async (params: any) => {
|
||||
let result = [...this.devEmailMessages];
|
||||
if (params.where) {
|
||||
if (params.where.status)
|
||||
result = result.filter((e) => e.status === params.where.status);
|
||||
}
|
||||
if (params.orderBy) {
|
||||
const key = Object.keys(params.orderBy)[0];
|
||||
const dir = params.orderBy[key];
|
||||
result.sort((a, b) =>
|
||||
a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
count: async () => 0,
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devEmailMessages.map((e) => e.id), 0) + 1;
|
||||
const email = {
|
||||
id,
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
scheduledAt: data.scheduledAt || new Date(),
|
||||
};
|
||||
this.devEmailMessages.push(email);
|
||||
return email;
|
||||
},
|
||||
update: async (params: any) => {
|
||||
const idx = this.devEmailMessages.findIndex(
|
||||
(e) => e.id === params.where.id,
|
||||
);
|
||||
if (idx === -1) return null;
|
||||
this.devEmailMessages[idx] = {
|
||||
...this.devEmailMessages[idx],
|
||||
...params.data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return this.devEmailMessages[idx];
|
||||
},
|
||||
delete: async (params: any) => {
|
||||
const idx = this.devEmailMessages.findIndex(
|
||||
(e) => e.id === params.where.id,
|
||||
);
|
||||
if (idx === -1) return null;
|
||||
this.devEmailMessages.splice(idx, 1);
|
||||
return { id: params.where.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createCommentAuditMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any): Promise<any> => null,
|
||||
findUnique: async () => null,
|
||||
findMany: async (params: any) => {
|
||||
let result = [...this.devCommentAudits];
|
||||
if (params.where) {
|
||||
if (params.where.commentId)
|
||||
result = result.filter(
|
||||
(a) => a.commentId === params.where.commentId,
|
||||
);
|
||||
}
|
||||
if (params.orderBy) {
|
||||
const key = Object.keys(params.orderBy)[0];
|
||||
const dir = params.orderBy[key];
|
||||
result.sort((a, b) =>
|
||||
a[key] < b[key] ? (dir === 'asc' ? -1 : 1) : dir === 'asc' ? 1 : -1,
|
||||
);
|
||||
}
|
||||
if (params.include) {
|
||||
result = result.map((a) => ({
|
||||
...a,
|
||||
reviewer: a.reviewerId
|
||||
? this.devUsers.find((u) => u.id === a.reviewerId) || null
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
count: async () => 0,
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devCommentAudits.map((a) => a.id), 0) + 1;
|
||||
const audit = {
|
||||
id,
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
reviewedAt: new Date(),
|
||||
};
|
||||
this.devCommentAudits.push(audit);
|
||||
return audit;
|
||||
},
|
||||
update: async () => null,
|
||||
delete: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
private createAnalyticsEventMock(): MockModel<any> & {
|
||||
count: (params: any) => Promise<number>;
|
||||
} {
|
||||
return {
|
||||
findUnique: async () => null,
|
||||
findMany: async () => [],
|
||||
findFirst: async (params: any): Promise<any> => null,
|
||||
count: async (params: any) => {
|
||||
let result = [...this.devAnalyticsEvents];
|
||||
if (params.where) {
|
||||
if (params.where.type)
|
||||
result = result.filter((e) => e.type === params.where.type);
|
||||
if (params.where.createdAt) {
|
||||
if (params.where.createdAt.gte) {
|
||||
result = result.filter(
|
||||
(e) => e.createdAt >= params.where.createdAt.gte,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (params.where.sessionId)
|
||||
result = result.filter(
|
||||
(e) => e.sessionId === params.where.sessionId,
|
||||
);
|
||||
}
|
||||
if (params.distinct && params.distinct.includes('sessionId')) {
|
||||
const sessions = new Set(result.map((e) => e.sessionId));
|
||||
return sessions.size;
|
||||
}
|
||||
return result.length;
|
||||
},
|
||||
create: async (data: any) => {
|
||||
const id = Math.max(...this.devAnalyticsEvents.map((e) => e.id), 0) + 1;
|
||||
const event = { id, ...data, createdAt: new Date() };
|
||||
this.devAnalyticsEvents.push(event);
|
||||
return event;
|
||||
},
|
||||
update: async () => null,
|
||||
delete: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
private createOAuth2ClientMock(): MockModel<any> {
|
||||
return {
|
||||
findUnique: async (params: any) => {
|
||||
if (params.where?.clientId === 'web-client') {
|
||||
return {
|
||||
clientId: 'web-client',
|
||||
clientSecret: 'change_me',
|
||||
redirectUris: ['http://localhost:3000/auth/callback'],
|
||||
scopes: 'read write',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findFirst: async (params: any): Promise<any> => null,
|
||||
findMany: async () => [],
|
||||
count: async () => 0,
|
||||
create: async (data: any) => ({ id: 1, ...data }),
|
||||
update: async () => null,
|
||||
delete: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
private createOAuth2AuthorizationCodeMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
update: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any) => ({
|
||||
id: 1,
|
||||
codeHash: 'hash',
|
||||
clientId: 'web-client',
|
||||
userId: 1,
|
||||
redirectUri: 'http://localhost:3000/auth/callback',
|
||||
scopes: 'read write',
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
used: false,
|
||||
}),
|
||||
findUnique: async () => null,
|
||||
findMany: async () => [],
|
||||
count: async () => 0,
|
||||
create: async (data: any) => ({ id: 1, ...data }),
|
||||
update: async (params: any) => ({ ...params.where, ...params.data }),
|
||||
delete: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
private createOAuth2TokenMock(): MockModel<any> & {
|
||||
findFirst: (params: any) => Promise<any>;
|
||||
} {
|
||||
return {
|
||||
findFirst: async (params: any) => ({
|
||||
id: 1,
|
||||
accessTokenHash: 'hash',
|
||||
clientId: 'web-client',
|
||||
userId: 1,
|
||||
scopes: 'read write',
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
revoked: false,
|
||||
}),
|
||||
findUnique: async () => null,
|
||||
findMany: async () => [],
|
||||
count: async () => 0,
|
||||
create: async (data: any) => ({ id: 1, ...data }),
|
||||
update: async () => null,
|
||||
delete: async () => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
src/modules/session/require-session-user.guard.ts
Normal file
24
src/modules/session/require-session-user.guard.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RequireSessionUser implements CanActivate {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userId = request.session?.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Not authenticated');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
request.user = userWithoutPassword;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
74
src/modules/session/session.controller.ts
Normal file
74
src/modules/session/session.controller.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
Body,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { SessionService } from './session.service';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { RequireSessionUser } from './require-session-user.guard';
|
||||
|
||||
@Controller()
|
||||
export class SessionController {
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get('login')
|
||||
getLogin() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Throttle({
|
||||
limit: 5,
|
||||
ttl: 60000, // 1 minute
|
||||
} as any)
|
||||
async login(
|
||||
@Req() req: Request,
|
||||
@Body() loginDto: { emailOrUsername: string; password: string },
|
||||
) {
|
||||
const user = await this.sessionService.validateUser(
|
||||
loginDto.emailOrUsername,
|
||||
loginDto.password,
|
||||
);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
const { passwordHash, ...safeUser } = user;
|
||||
return { ok: true, user: safeUser };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
const cookieName =
|
||||
this.configService.get<string>('SESSION_COOKIE_NAME') ||
|
||||
'linkshare_session';
|
||||
res.clearCookie(cookieName);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// 测试用:获取当前登录用户信息
|
||||
@Get('me')
|
||||
@UseGuards(RequireSessionUser)
|
||||
async me(@Req() req: Request) {
|
||||
return { ok: true, user: req.user };
|
||||
}
|
||||
}
|
||||
130
src/modules/session/session.e2e-spec.ts
Normal file
130
src/modules/session/session.e2e-spec.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../../app.module';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
|
||||
describe('OAuth2 + Session (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let sessionCookie: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('/login (POST) - success', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/login')
|
||||
.send({ emailOrUsername: 'admin@example.com', password: 'password123' })
|
||||
.expect(200);
|
||||
expect(response.body).toEqual({ ok: true, user: expect.any(Object) });
|
||||
const setCookie = response.headers['set-cookie'] as string[];
|
||||
if (setCookie && setCookie.length > 0) {
|
||||
sessionCookie = setCookie[0].split(';')[0];
|
||||
}
|
||||
expect(sessionCookie).toBeDefined();
|
||||
});
|
||||
|
||||
it('/me (GET) - with session', async () => {
|
||||
expect(sessionCookie).toBeDefined();
|
||||
await request(app.getHttpServer())
|
||||
.get('/me')
|
||||
.set('Cookie', sessionCookie!)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('/oauth2/authorize - not logged in, redirect to login', async () => {
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: 'web-client',
|
||||
redirect_uri: 'http://localhost:3000/auth/callback',
|
||||
scope: 'read write',
|
||||
state: 'teststate',
|
||||
}).toString();
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/oauth2/authorize?${query}`)
|
||||
.expect(302);
|
||||
expect(response.headers.location).toContain('/login?next=');
|
||||
});
|
||||
|
||||
it('/oauth2/authorize - after login, redirect with code', async () => {
|
||||
// 假设 sessionCookie 已存在
|
||||
expect(sessionCookie).toBeDefined();
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: 'web-client',
|
||||
redirect_uri: 'http://localhost:3000/auth/callback',
|
||||
scope: 'read write',
|
||||
state: 'teststate',
|
||||
}).toString();
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/oauth2/authorize?${query}`)
|
||||
.set('Cookie', sessionCookie!)
|
||||
.expect(302);
|
||||
const location = response.headers.location;
|
||||
expect(location).toContain('http://localhost:3000/auth/callback?code=');
|
||||
expect(location).toContain('state=teststate');
|
||||
});
|
||||
|
||||
it('/oauth2/token - authorization_code grant', async () => {
|
||||
// 先从 authorize 获取 code
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: 'web-client',
|
||||
redirect_uri: 'http://localhost:3000/auth/callback',
|
||||
scope: 'read write',
|
||||
state: 'teststate',
|
||||
}).toString();
|
||||
const authResponse = await request(app.getHttpServer())
|
||||
.get(`/oauth2/authorize?${query}`)
|
||||
.set('Cookie', sessionCookie!);
|
||||
const location = authResponse.headers.location;
|
||||
const code = new URL(location).searchParams.get('code');
|
||||
expect(code).toBeDefined();
|
||||
|
||||
// 用 code 换 token
|
||||
const tokenResponse = await request(app.getHttpServer())
|
||||
.post('/oauth2/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: 'http://localhost:3000/auth/callback',
|
||||
client_id: 'web-client',
|
||||
client_secret: 'change_me',
|
||||
})
|
||||
.expect(200);
|
||||
expect(tokenResponse.body).toEqual({
|
||||
access_token: expect.any(String),
|
||||
token_type: 'Bearer',
|
||||
expires_in: expect.any(Number),
|
||||
refresh_token: expect.any(String),
|
||||
scope: 'read write',
|
||||
});
|
||||
});
|
||||
|
||||
it('/oauth2/token - client_credentials grant', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/oauth2/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: 'web-client',
|
||||
client_secret: 'change_me',
|
||||
})
|
||||
.expect(200);
|
||||
expect(response.body).toEqual({
|
||||
access_token: expect.any(String),
|
||||
token_type: 'Bearer',
|
||||
expires_in: expect.any(Number),
|
||||
scope: 'read write',
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/modules/session/session.module.ts
Normal file
40
src/modules/session/session.module.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import session from 'express-session';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SessionService } from './session.service';
|
||||
import { SessionController } from './session.controller';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
controllers: [SessionController],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule implements NestModule {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
const secret =
|
||||
this.configService.get<string>('SESSION_COOKIE_SECRET') || 'change_me';
|
||||
consumer
|
||||
.apply(
|
||||
session({
|
||||
name:
|
||||
this.configService.get<string>('SESSION_COOKIE_NAME') ||
|
||||
'linkshare_session',
|
||||
secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly:
|
||||
this.configService.get<string>('SESSION_COOKIE_HTTPONLY') !==
|
||||
'false',
|
||||
secure:
|
||||
this.configService.get<string>('SESSION_COOKIE_SECURE') ===
|
||||
'true',
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
25
src/modules/session/session.service.ts
Normal file
25
src/modules/session/session.service.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async validateUser(emailOrUsername: string, password: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: emailOrUsername }, { username: emailOrUsername }],
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return null;
|
||||
}
|
||||
const { passwordHash, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
}
|
||||
263
src/modules/users/dto/user.dto.ts
Normal file
263
src/modules/users/dto/user.dto.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsEmail,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
MODERATOR = 'moderator',
|
||||
USER = 'user',
|
||||
}
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({
|
||||
description: '用户名',
|
||||
example: 'john_doe',
|
||||
minLength: 3,
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(3, 50)
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '邮箱地址',
|
||||
example: 'john@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '密码(至少 6 位)',
|
||||
example: 'securePassword123',
|
||||
minLength: 6,
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(6, 100)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '显示名称',
|
||||
example: 'John Doe',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
displayName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '头像 URL',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '用户角色',
|
||||
enum: UserRole,
|
||||
default: UserRole.USER,
|
||||
example: UserRole.USER,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role: UserRole = UserRole.USER;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否激活',
|
||||
default: true,
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean = true;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '用户名',
|
||||
example: 'updated_username',
|
||||
minLength: 3,
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(3, 50)
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '邮箱地址',
|
||||
example: 'updated@example.com',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '密码(至少 6 位)',
|
||||
example: 'newSecurePassword456',
|
||||
minLength: 6,
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(6, 100)
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '显示名称',
|
||||
example: 'Updated Name',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
displayName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '头像 URL',
|
||||
example: 'https://example.com/new-avatar.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '用户角色',
|
||||
enum: UserRole,
|
||||
example: UserRole.MODERATOR,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否激活',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UserResponseDto {
|
||||
@ApiProperty({
|
||||
description: '用户 ID',
|
||||
example: 1,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户名',
|
||||
example: 'john_doe',
|
||||
})
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '邮箱地址',
|
||||
example: 'john@example.com',
|
||||
})
|
||||
@IsString()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '显示名称',
|
||||
example: 'John Doe',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
displayName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '头像 URL',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户角色',
|
||||
enum: UserRole,
|
||||
example: UserRole.USER,
|
||||
})
|
||||
@IsEnum(UserRole)
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否激活',
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class UserQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '页码(从 1 开始)',
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量',
|
||||
example: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 20,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '按用户角色筛选',
|
||||
enum: UserRole,
|
||||
example: UserRole.USER,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '按激活状态筛选',
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
145
src/modules/users/users.controller.ts
Normal file
145
src/modules/users/users.controller.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import {
|
||||
CreateUserDto,
|
||||
UpdateUserDto,
|
||||
UserResponseDto,
|
||||
UserQueryDto,
|
||||
} from './dto/user.dto';
|
||||
import { RequireSessionUser } from '../session/require-session-user.guard';
|
||||
import { RequireRole } from '../common/decorators/require-role.decorator';
|
||||
import { RequireRoleGuard } from '../common/guards/require-role.guard';
|
||||
|
||||
@ApiTags('用户')
|
||||
@Controller('users')
|
||||
@UseGuards(RequireSessionUser)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '创建用户(仅管理员)' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '用户创建成功',
|
||||
type: UserResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数无效' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权创建用户' })
|
||||
async createUser(
|
||||
@Body() createUserDto: CreateUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取用户列表' })
|
||||
@ApiResponse({ status: 200, description: '成功获取用户列表', type: Object })
|
||||
@ApiQuery({
|
||||
name: 'page',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '页码',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
type: Number,
|
||||
description: '每页数量',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'role',
|
||||
required: false,
|
||||
enum: ['admin', 'moderator', 'user'],
|
||||
description: '用户角色',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'isActive',
|
||||
required: false,
|
||||
type: Boolean,
|
||||
description: '是否激活',
|
||||
})
|
||||
async getUsers(@Query() query: UserQueryDto) {
|
||||
const result = await this.usersService.findMany(query);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '根据 ID 获取用户' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '成功获取用户',
|
||||
type: UserResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID', type: Number })
|
||||
async getUser(@Param('id') id: string) {
|
||||
const userId = parseInt(id, 10);
|
||||
return this.usersService.findById(userId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '更新用户(仅管理员)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '用户更新成功',
|
||||
type: UserResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数无效' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权更新用户' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID', type: Number })
|
||||
async updateUser(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
const userId = parseInt(id, 10);
|
||||
return this.usersService.update(userId, updateUserDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(RequireRoleGuard)
|
||||
@RequireRole('admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '删除用户(仅管理员)' })
|
||||
@ApiResponse({ status: 204, description: '用户删除成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '无权删除用户' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID', type: Number })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteUser(@Param('id') id: string) {
|
||||
const userId = parseInt(id, 10);
|
||||
await this.usersService.remove(userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
12
src/modules/users/users.module.ts
Normal file
12
src/modules/users/users.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { RequireRoleGuard } from '../common/guards/require-role.guard';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, RequireRoleGuard],
|
||||
})
|
||||
export class UsersModule {}
|
||||
172
src/modules/users/users.service.ts
Normal file
172
src/modules/users/users.service.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateUserDto,
|
||||
UpdateUserDto,
|
||||
UserResponseDto,
|
||||
UserQueryDto,
|
||||
} from './dto/user.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(data: CreateUserDto): Promise<UserResponseDto> {
|
||||
// Check for existing username or email
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: data.username }, { email: data.email }],
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new HttpException(
|
||||
'Username or email already exists',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
passwordHash: hashedPassword,
|
||||
displayName: data.displayName,
|
||||
avatarUrl: data.avatarUrl,
|
||||
role: data.role,
|
||||
isActive: data.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapToResponseDto(user);
|
||||
}
|
||||
|
||||
async findMany(query: UserQueryDto) {
|
||||
const { page = 1, limit = 20, role, isActive } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
where: {
|
||||
...(role && { role }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.user.count({
|
||||
where: {
|
||||
...(role && { role }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const responseUsers = users.map(this.mapToResponseDto);
|
||||
|
||||
return {
|
||||
users: responseUsers,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<UserResponseDto> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!user) {
|
||||
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return this.mapToResponseDto(user);
|
||||
}
|
||||
|
||||
async update(id: number, data: UpdateUserDto): Promise<UserResponseDto> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check username/email uniqueness if being updated
|
||||
if (data.username || data.email) {
|
||||
const duplicate = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: id } },
|
||||
{
|
||||
OR: [
|
||||
...(data.username ? [{ username: data.username }] : []),
|
||||
...(data.email ? [{ email: data.email }] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new HttpException(
|
||||
'Username or email already in use',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = { ...data };
|
||||
if (data.password) {
|
||||
updateData.passwordHash = await bcrypt.hash(data.password, 10);
|
||||
}
|
||||
delete updateData.password;
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return this.mapToResponseDto(user);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if user has articles or comments before deletion (optional)
|
||||
const articleCount = await this.prisma.article.count({
|
||||
where: { authorId: id },
|
||||
});
|
||||
const commentCount = await this.prisma.comment.count({
|
||||
where: { authorId: id },
|
||||
});
|
||||
|
||||
if (articleCount > 0 || commentCount > 0) {
|
||||
throw new HttpException(
|
||||
'Cannot delete user with existing articles or comments. Deactivate instead.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
private mapToResponseDto(user: any): UserResponseDto {
|
||||
const { passwordHash, ...rest } = user;
|
||||
return {
|
||||
...rest,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
} as UserResponseDto;
|
||||
}
|
||||
}
|
||||
30
src/types/bullmq-full.d.ts
vendored
Normal file
30
src/types/bullmq-full.d.ts
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
declare module 'bullmq' {
|
||||
export interface Job<TData = any> {
|
||||
data: TData;
|
||||
id: string | number;
|
||||
opts: any;
|
||||
// 其他属性...
|
||||
}
|
||||
|
||||
export class Queue {
|
||||
constructor(name: string, options?: any);
|
||||
add(name: string, data?: any, options?: any): Promise<Job>;
|
||||
// 其他方法...
|
||||
}
|
||||
|
||||
export class Worker {
|
||||
constructor(
|
||||
queueName: string,
|
||||
processor: (job: Job) => Promise<any>,
|
||||
options?: any,
|
||||
);
|
||||
// 其他方法...
|
||||
}
|
||||
|
||||
export interface JobOptions {
|
||||
attempts?: number;
|
||||
backoff?: any;
|
||||
delay?: number;
|
||||
// 其他选项...
|
||||
}
|
||||
}
|
||||
3
src/types/bullmq.d.ts
vendored
Normal file
3
src/types/bullmq.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module 'bullmq' {
|
||||
export * from 'bullmq';
|
||||
}
|
||||
15
src/types/express-session.d.ts
vendored
Normal file
15
src/types/express-session.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
import 'express-session';
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/types/nestjs-bullmq.d.ts
vendored
Normal file
31
src/types/nestjs-bullmq.d.ts
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
declare module '@nestjs/bullmq' {
|
||||
import { ClassDecorator, MethodDecorator } from '@nestjs/common';
|
||||
|
||||
export function Processor(queueName: string): ClassDecorator;
|
||||
export function Process(jobName: string): MethodDecorator;
|
||||
|
||||
export const BullModule: any;
|
||||
export interface BullModuleOptions {
|
||||
name?: string;
|
||||
options?: any;
|
||||
}
|
||||
|
||||
export interface Queue {
|
||||
add(name: string, data?: any, options?: any): Promise<any>;
|
||||
getConnection(): any;
|
||||
close(): Promise<any>;
|
||||
on(event: string, callback: (job: any) => void): void;
|
||||
}
|
||||
|
||||
export interface Worker {
|
||||
on(event: string, callback: (job: any, error: Error) => void): void;
|
||||
}
|
||||
|
||||
export interface Job<TData = any> {
|
||||
data: TData;
|
||||
name: string;
|
||||
id: string | number;
|
||||
opts: any;
|
||||
client: any;
|
||||
}
|
||||
}
|
||||
4
src/types/nodemailer.d.ts
vendored
Normal file
4
src/types/nodemailer.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module 'nodemailer' {
|
||||
import * as nodemailer from 'nodemailer';
|
||||
export = nodemailer;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user