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:
烧瑚烙饼 2026-03-28 16:53:25 +08:00
parent 97f81fd010
commit 37742571ae
81 changed files with 10047 additions and 143 deletions

66
.env.example Normal file
View 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
View 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 字,可选)
- 封面图 URLUInput可选
- 状态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)
- 表格展示
- 用户信息(头像 + 名称 + 邮箱)
- 角色UBadgeadmin/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()
- 切换按钮UIconsun/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评论审核日志
- OAuth2TokenOAuth2 令牌)
- AnalyticsEvent分析事件
### 认证机制
- **Web 端**: Session CookieHttpOnlySecure
- **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
View 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
View 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
View 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
View File

@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
[![NestJS](https://img.shields.io/badge/NestJS-11.x-red)](https://nestjs.com)
[![Bun](https://img.shields.io/badge/Bun-1.1+-blue)](https://bun.sh)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16+-blue)](https://www.postgresql.org)
[![Redis](https://img.shields.io/badge/Redis-7+-red)](https://redis.io)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](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
View 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
View 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
View 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',
},
};

View File

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

File diff suppressed because it is too large Load Diff

11
pnpm-workspace.yaml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

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

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

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

View File

@ -0,0 +1,3 @@
export const JOB_NAMES = {
MODERATION_LLM_REVIEW: 'moderation:llm_review',
};

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

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

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

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

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MeController } from './me.controller';
@Module({
controllers: [MeController],
})
export class MeModule {}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ModerationService } from './moderation.service';
@Module({
providers: [ModerationService],
exports: [ModerationService],
})
export class ModerationModule {}

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

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

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

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

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

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

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

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,3 @@
declare module 'bullmq' {
export * from 'bullmq';
}

15
src/types/express-session.d.ts vendored Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
declare module 'nodemailer' {
import * as nodemailer from 'nodemailer';
export = nodemailer;
}