博客系统第一次提交
This commit is contained in:
parent
aea8529930
commit
dab6a72a93
59
.dockerignore
Normal file
59
.dockerignore
Normal file
|
@ -0,0 +1,59 @@
|
|||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
LICENSE
|
||||
|
||||
# Build artifacts
|
||||
main
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Node modules (if any)
|
||||
node_modules/
|
||||
|
||||
# Build directories
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Coverage
|
||||
coverage.out
|
||||
|
||||
# Vendor directory (if using vendor)
|
||||
vendor/
|
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
926
API_DOCUMENTATION.md
Normal file
926
API_DOCUMENTATION.md
Normal file
|
@ -0,0 +1,926 @@
|
|||
# 灵猴社博客系统 API 接口文档
|
||||
|
||||
## 📋 基本信息
|
||||
|
||||
- **服务器地址**: `http://localhost:8089`
|
||||
- **API版本**: v1.0
|
||||
- **文档版本**: v1.0
|
||||
- **最后更新**: 2025-01-16
|
||||
|
||||
|
||||
## 📊 状态码说明
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 请求成功 |
|
||||
| 201 | 创建成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权/Token无效 |
|
||||
| 403 | 权限不足 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 🔗 API接口列表
|
||||
|
||||
### 1. 认证相关接口
|
||||
|
||||
#### 1.1 用户注册
|
||||
- **接口**: `POST /api/auth/register`
|
||||
- **描述**: 用户注册新账户
|
||||
- **需要认证**: ❌
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"username": "string", // 用户名 (3-50字符)
|
||||
"email": "string", // 邮箱地址
|
||||
"password": "string" // 密码 (最少6位)
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 用户登录
|
||||
- **接口**: `POST /api/auth/login`
|
||||
- **描述**: 用户登录获取Token
|
||||
- **需要认证**: ❌
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"email": "string", // 邮箱地址
|
||||
"password": "string" // 密码
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 刷新Token
|
||||
- **接口**: `POST /api/auth/refresh`
|
||||
- **描述**: 刷新JWT Token
|
||||
- **需要认证**: ❌
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"token": "string" // 当前的JWT Token
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 获取用户资料
|
||||
- **接口**: `GET /api/profile`
|
||||
- **描述**: 获取当前用户的个人资料
|
||||
- **需要认证**: ✅
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 文章管理接口
|
||||
|
||||
#### 2.1 获取文章列表
|
||||
- **接口**: `GET /api/articles`
|
||||
- **描述**: 获取文章列表(支持分页和筛选)
|
||||
- **需要认证**: ❌
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| page | int | ❌ | 页码,默认1 |
|
||||
| page_size | int | ❌ | 每页数量,默认10,最大100 |
|
||||
| status | string | ❌ | 文章状态:published/draft/private |
|
||||
| tag | string | ❌ | 标签名称 |
|
||||
| user_id | int | ❌ | 作者ID |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "我的第一篇文章",
|
||||
"slug": "my-first-article",
|
||||
"markdown_content": "# 标题\n\n内容...",
|
||||
"html_content": "<h1>标题</h1><p>内容...</p>",
|
||||
"status": "published",
|
||||
"published_at": "2025-01-16T10:30:00Z",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"tags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "技术",
|
||||
"created_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user"
|
||||
},
|
||||
"excerpt": "这是文章摘要..."
|
||||
}
|
||||
],
|
||||
"total": 50,
|
||||
"page": 1,
|
||||
"page_size": 10
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 获取单篇文章
|
||||
- **接口**: `GET /api/articles/{slug}`
|
||||
- **描述**: 通过slug获取单篇文章详情
|
||||
- **需要认证**: ❌(公开文章)
|
||||
|
||||
**路径参数**:
|
||||
- `slug`: 文章的URL友好标识
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "我的第一篇文章",
|
||||
"slug": "my-first-article",
|
||||
"markdown_content": "# 标题\n\n内容...",
|
||||
"html_content": "<h1>标题</h1><p>内容...</p>",
|
||||
"status": "published",
|
||||
"published_at": "2025-01-16T10:30:00Z",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"tags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "技术",
|
||||
"created_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 创建文章
|
||||
- **接口**: `POST /api/articles`
|
||||
- **描述**: 创建新文章
|
||||
- **需要认证**: ✅
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"title": "string", // 文章标题 (1-200字符)
|
||||
"markdown_content": "string", // Markdown内容
|
||||
"status": "string", // 状态: draft/published/private
|
||||
"tags": ["string"], // 标签数组
|
||||
"slug": "string", // 可选,自定义slug
|
||||
"published_at": "2025-01-16T10:30:00Z" // 可选,定时发布时间
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "我的第一篇文章",
|
||||
"slug": "my-first-article",
|
||||
"markdown_content": "# 标题\n\n内容...",
|
||||
"html_content": "<h1>标题</h1><p>内容...</p>",
|
||||
"status": "published",
|
||||
"published_at": "2025-01-16T10:30:00Z",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"tags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "技术",
|
||||
"created_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4 更新文章
|
||||
- **接口**: `PUT /api/articles/{id}`
|
||||
- **描述**: 更新指定文章
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 作者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 文章ID
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"title": "string", // 可选,文章标题
|
||||
"markdown_content": "string", // 可选,Markdown内容
|
||||
"status": "string", // 可选,状态
|
||||
"tags": ["string"], // 可选,标签数组
|
||||
"slug": "string", // 可选,自定义slug
|
||||
"published_at": "2025-01-16T10:30:00Z" // 可选,发布时间
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**: 同创建文章
|
||||
|
||||
#### 2.5 删除文章
|
||||
- **接口**: `DELETE /api/articles/{id}`
|
||||
- **描述**: 删除指定文章
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 作者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 文章ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Article deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 获取文章版本历史
|
||||
- **接口**: `GET /api/articles/{id}/versions`
|
||||
- **描述**: 获取文章的版本历史
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 作者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 文章ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"id": 1,
|
||||
"article_id": 1,
|
||||
"markdown_content": "# 旧版本内容\n\n...",
|
||||
"created_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 评论管理接口
|
||||
|
||||
#### 3.1 获取文章评论
|
||||
- **接口**: `GET /api/comments/article/{article_id}`
|
||||
- **描述**: 获取指定文章的评论列表
|
||||
- **需要认证**: ❌
|
||||
|
||||
**路径参数**:
|
||||
- `article_id`: 文章ID
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| page | int | ❌ | 页码,默认1 |
|
||||
| page_size | int | ❌ | 每页数量,默认20,最大100 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"comments": [
|
||||
{
|
||||
"id": 1,
|
||||
"article_id": 1,
|
||||
"user_id": 2,
|
||||
"markdown_content": "很好的文章!",
|
||||
"html_content": "<p>很好的文章!</p>",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"user": {
|
||||
"id": 2,
|
||||
"username": "commenter",
|
||||
"email": "commenter@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 获取单条评论
|
||||
- **接口**: `GET /api/comments/{id}`
|
||||
- **描述**: 获取指定评论详情
|
||||
- **需要认证**: ❌
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 评论ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"article_id": 1,
|
||||
"user_id": 2,
|
||||
"markdown_content": "很好的文章!",
|
||||
"html_content": "<p>很好的文章!</p>",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"user": {
|
||||
"id": 2,
|
||||
"username": "commenter",
|
||||
"email": "commenter@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 添加评论
|
||||
- **接口**: `POST /api/comments/article/{article_id}`
|
||||
- **描述**: 为指定文章添加评论
|
||||
- **需要认证**: ✅
|
||||
|
||||
**路径参数**:
|
||||
- `article_id`: 文章ID
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"markdown_content": "string" // 评论内容 (1-1000字符)
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"article_id": 1,
|
||||
"user_id": 2,
|
||||
"markdown_content": "很好的文章!",
|
||||
"html_content": "<p>很好的文章!</p>",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"user": {
|
||||
"id": 2,
|
||||
"username": "commenter",
|
||||
"email": "commenter@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 更新评论
|
||||
- **接口**: `PUT /api/comments/{id}`
|
||||
- **描述**: 更新指定评论
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 评论作者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 评论ID
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"markdown_content": "string" // 更新后的评论内容
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**: 同添加评论
|
||||
|
||||
#### 3.5 删除评论
|
||||
- **接口**: `DELETE /api/comments/{id}`
|
||||
- **描述**: 删除指定评论
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 评论作者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 评论ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Comment deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 媒体管理接口
|
||||
|
||||
#### 4.1 上传媒体文件
|
||||
- **接口**: `POST /api/media/upload`
|
||||
- **描述**: 上传媒体文件(图片、视频等)
|
||||
- **需要认证**: ✅
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
|
||||
**请求参数**:
|
||||
- `file`: 文件对象(最大10MB)
|
||||
|
||||
**支持的文件类型**:
|
||||
- 图片: `image/jpeg`, `image/png`, `image/gif`, `image/webp`
|
||||
- 视频: `video/mp4`, `video/webm`, `video/ogg`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"filename": "uuid_timestamp.jpg",
|
||||
"url": "/uploads/uuid_timestamp.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size": 1048576
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 获取媒体文件列表
|
||||
- **接口**: `GET /api/media`
|
||||
- **描述**: 获取媒体文件列表
|
||||
- **需要认证**: ✅
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| page | int | ❌ | 页码,默认1 |
|
||||
| page_size | int | ❌ | 每页数量,默认20,最大100 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"media": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"filename": "uuid_timestamp.jpg",
|
||||
"url": "/uploads/uuid_timestamp.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size": 1048576,
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 10,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 获取媒体文件信息
|
||||
- **接口**: `GET /api/media/{id}`
|
||||
- **描述**: 获取指定媒体文件信息
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 文件上传者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 媒体文件ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"filename": "uuid_timestamp.jpg",
|
||||
"url": "/uploads/uuid_timestamp.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size": 1048576,
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.4 删除媒体文件
|
||||
- **接口**: `DELETE /api/media/{id}`
|
||||
- **描述**: 删除指定媒体文件
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 文件上传者或管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 媒体文件ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Media deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 管理员接口
|
||||
|
||||
#### 5.1 获取用户列表
|
||||
- **接口**: `GET /api/admin/users`
|
||||
- **描述**: 获取系统用户列表
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| page | int | ❌ | 页码,默认1 |
|
||||
| page_size | int | ❌ | 每页数量,默认20,最大100 |
|
||||
| search | string | ❌ | 搜索关键词(用户名或邮箱) |
|
||||
| role | string | ❌ | 角色过滤:user/admin |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"article_count": 5,
|
||||
"comment_count": 10
|
||||
}
|
||||
],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2 更新用户角色
|
||||
- **接口**: `PUT /api/admin/users/{id}/role`
|
||||
- **描述**: 更新用户角色
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 用户ID
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"role": "string" // 角色: user/admin
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "admin",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3 删除用户
|
||||
- **接口**: `DELETE /api/admin/users/{id}`
|
||||
- **描述**: 删除指定用户
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 用户ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "User deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.4 批量删除文章
|
||||
- **接口**: `DELETE /api/admin/articles`
|
||||
- **描述**: 批量删除文章
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"article_ids": [1, 2, 3] // 要删除的文章ID数组
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Articles deleted successfully",
|
||||
"deleted_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.5 管理评论
|
||||
- **接口**: `POST /api/admin/comments/manage`
|
||||
- **描述**: 批量管理评论
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"comment_ids": [1, 2, 3], // 评论ID数组
|
||||
"action": "string" // 操作类型: delete/approve
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Comments managed successfully",
|
||||
"affected_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.6 获取标签列表
|
||||
- **接口**: `GET /api/admin/tags`
|
||||
- **描述**: 获取系统标签列表
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| page | int | ❌ | 页码,默认1 |
|
||||
| page_size | int | ❌ | 每页数量,默认20,最大100 |
|
||||
| search | string | ❌ | 搜索关键词 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"tags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "技术",
|
||||
"created_at": "2025-01-16T10:30:00Z",
|
||||
"updated_at": "2025-01-16T10:30:00Z",
|
||||
"article_count": 10
|
||||
}
|
||||
],
|
||||
"total": 20,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.7 删除标签
|
||||
- **接口**: `DELETE /api/admin/tags/{id}`
|
||||
- **描述**: 删除指定标签
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 标签ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"message": "Tag deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.8 获取系统统计
|
||||
- **接口**: `GET /api/admin/stats`
|
||||
- **描述**: 获取系统统计信息
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"total_users": 100,
|
||||
"total_articles": 500,
|
||||
"total_comments": 1000,
|
||||
"total_media": 200,
|
||||
"published_articles": 450,
|
||||
"draft_articles": 50
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.9 获取最近活动
|
||||
- **接口**: `GET /api/admin/activities`
|
||||
- **描述**: 获取系统最近活动
|
||||
- **需要认证**: ✅
|
||||
- **权限**: 管理员
|
||||
|
||||
**查询参数**:
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| limit | int | ❌ | 数量限制,默认10,最大50 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"type": "article",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"title": "新文章",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser"
|
||||
}
|
||||
},
|
||||
"created_at": "2025-01-16T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 系统接口
|
||||
|
||||
#### 6.1 健康检查
|
||||
- **接口**: `GET /health`
|
||||
- **描述**: 系统健康状态检查
|
||||
- **需要认证**: ❌
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "PBlog API is running"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 错误处理
|
||||
|
||||
所有接口在发生错误时,都会返回统一的错误格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
### 常见错误
|
||||
|
||||
#### 400 Bad Request
|
||||
```json
|
||||
{
|
||||
"error": "Invalid request parameters"
|
||||
}
|
||||
```
|
||||
|
||||
#### 401 Unauthorized
|
||||
```json
|
||||
{
|
||||
"error": "Authorization header is required"
|
||||
}
|
||||
```
|
||||
|
||||
#### 403 Forbidden
|
||||
```json
|
||||
{
|
||||
"error": "Permission denied"
|
||||
}
|
||||
```
|
||||
|
||||
#### 404 Not Found
|
||||
```json
|
||||
{
|
||||
"error": "Resource not found"
|
||||
}
|
||||
```
|
||||
|
||||
#### 500 Internal Server Error
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error"
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
### 完整的用户注册到发布文章流程
|
||||
|
||||
#### 1. 用户注册
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 2. 用户登录
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "newuser@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
#### 3. 创建文章
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/articles \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{
|
||||
"title": "我的第一篇技术文章",
|
||||
"markdown_content": "# 标题\n\n这是我的第一篇文章内容...",
|
||||
"status": "published",
|
||||
"tags": ["技术", "编程", "Go"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### 4. 获取文章列表
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/articles?page=1&page_size=10&status=published"
|
||||
```
|
||||
|
||||
#### 5. 添加评论
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/comments/article/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{
|
||||
"markdown_content": "很好的文章,感谢分享!"
|
||||
}'
|
||||
```
|
||||
|
||||
## 🔍 调试建议
|
||||
|
||||
### 1. 使用健康检查验证服务状态
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
### 2. 检查Token有效性
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/profile \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
```
|
||||
|
||||
### 3. 查看详细错误信息
|
||||
在开发环境中,可以通过查看服务器日志获取详细的错误信息。
|
||||
|
||||
## 📚 更多资源
|
||||
|
||||
- [完整项目README](./README.md)
|
||||
- [API测试脚本](./test_api.sh)
|
||||
- [Docker部署指南](./docker-compose.yml)
|
||||
|
||||
---
|
||||
|
||||
**注意**: 本文档基于开发环境编写,生产环境使用时请注意:
|
||||
1. 修改默认的JWT密钥
|
||||
2. 使用HTTPS协议
|
||||
3. 配置适当的CORS策略
|
||||
4. 设置合理的请求频率限制
|
||||
|
||||
如有疑问或建议,请联系技术支持团队。
|
45
Dockerfile
Normal file
45
Dockerfile
Normal file
|
@ -0,0 +1,45 @@
|
|||
# 第一阶段:构建
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的包
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 复制go mod文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
|
||||
# 第二阶段:运行
|
||||
FROM alpine:latest
|
||||
|
||||
# 安装必要的包
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /root/
|
||||
|
||||
# 从构建阶段复制可执行文件
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# 创建uploads目录
|
||||
RUN mkdir -p ./uploads
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 设置健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
# 运行应用
|
||||
CMD ["./main"]
|
481
README.md
481
README.md
|
@ -1,3 +1,480 @@
|
|||
# PBlog
|
||||
# 灵猴社博客系统 (PBlog)
|
||||
|
||||
博客系统
|
||||
一个基于Go语言开发的现代化博客系统,支持Markdown编辑、用户管理、评论系统、媒体上传等功能。
|
||||
|
||||
## 🚀 特性
|
||||
|
||||
- **用户认证**:完整的用户注册、登录、权限管理系统
|
||||
- **文章管理**:支持Markdown编辑、版本历史、定时发布
|
||||
- **评论系统**:实时评论、敏感词过滤
|
||||
- **媒体上传**:图片、视频文件上传管理
|
||||
- **标签系统**:文章分类和标签管理
|
||||
- **管理后台**:完善的管理员功能
|
||||
- **安全防护**:XSS防护、SQL注入防护、JWT认证
|
||||
- **SEO优化**:友好的URL、自动生成摘要
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **后端框架**:Go 1.21+ + Gin
|
||||
- **数据库**:MySQL 8.0+
|
||||
- **ORM**:GORM
|
||||
- **认证**:JWT Token
|
||||
- **Markdown处理**:goldmark + bluemonday
|
||||
- **密码加密**:bcrypt
|
||||
- **部署**:Docker(可选)
|
||||
|
||||
## 📋 系统要求
|
||||
|
||||
- Go 1.21 或更高版本
|
||||
- MySQL 8.0 或更高版本
|
||||
- Git
|
||||
|
||||
## 🔧 安装步骤
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd PBlog
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### 3. 配置数据库
|
||||
|
||||
确保MySQL服务正在运行,并创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 4. 修改数据库配置
|
||||
|
||||
在 `config/database.go` 文件中,根据你的数据库配置修改连接信息:
|
||||
|
||||
```go
|
||||
dsn := "root:123456@tcp(localhost:3306)/mydb?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
```
|
||||
|
||||
### 5. 初始化数据库
|
||||
|
||||
运行数据库初始化脚本:
|
||||
|
||||
```bash
|
||||
go run scripts/init_db.go
|
||||
```
|
||||
|
||||
这将创建所有必要的数据表,并生成默认管理员账户:
|
||||
- 用户名:`admin`
|
||||
- 邮箱:`admin@example.com`
|
||||
- 密码:`admin123`
|
||||
|
||||
**⚠️ 请在首次登录后立即更改管理员密码!**
|
||||
|
||||
### 6. 启动服务器
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
服务器将在 `http://localhost:8080` 启动。
|
||||
|
||||
## 📱 API 文档
|
||||
|
||||
### 认证接口
|
||||
|
||||
| 方法 | 端点 | 描述 | 需要认证 |
|
||||
|------|------|------|----------|
|
||||
| POST | `/api/auth/register` | 用户注册 | ❌ |
|
||||
| POST | `/api/auth/login` | 用户登录 | ❌ |
|
||||
| POST | `/api/auth/refresh` | 刷新Token | ❌ |
|
||||
| GET | `/api/profile` | 获取用户资料 | ✅ |
|
||||
|
||||
### 文章接口
|
||||
|
||||
| 方法 | 端点 | 描述 | 需要认证 |
|
||||
|------|------|------|----------|
|
||||
| GET | `/api/articles` | 获取文章列表 | ❌ |
|
||||
| GET | `/api/articles/:slug` | 获取文章详情 | ❌ |
|
||||
| POST | `/api/articles` | 创建文章 | ✅ |
|
||||
| PUT | `/api/articles/:id` | 更新文章 | ✅ |
|
||||
| DELETE | `/api/articles/:id` | 删除文章 | ✅ |
|
||||
| GET | `/api/articles/:id/versions` | 获取文章版本历史 | ✅ |
|
||||
|
||||
### 评论接口
|
||||
|
||||
| 方法 | 端点 | 描述 | 需要认证 |
|
||||
|------|------|------|----------|
|
||||
| GET | `/api/comments/article/:article_id` | 获取文章评论 | ❌ |
|
||||
| GET | `/api/comments/:id` | 获取单个评论 | ❌ |
|
||||
| POST | `/api/comments/article/:article_id` | 添加评论 | ✅ |
|
||||
| PUT | `/api/comments/:id` | 更新评论 | ✅ |
|
||||
| DELETE | `/api/comments/:id` | 删除评论 | ✅ |
|
||||
|
||||
### 媒体接口
|
||||
|
||||
| 方法 | 端点 | 描述 | 需要认证 |
|
||||
|------|------|------|----------|
|
||||
| POST | `/api/media/upload` | 上传媒体文件 | ✅ |
|
||||
| GET | `/api/media` | 获取媒体列表 | ✅ |
|
||||
| GET | `/api/media/:id` | 获取媒体信息 | ✅ |
|
||||
| DELETE | `/api/media/:id` | 删除媒体文件 | ✅ |
|
||||
|
||||
### 管理员接口
|
||||
|
||||
| 方法 | 端点 | 描述 | 需要管理员权限 |
|
||||
|------|------|------|----------------|
|
||||
| GET | `/api/admin/users` | 获取用户列表 | ✅ |
|
||||
| PUT | `/api/admin/users/:id/role` | 更新用户角色 | ✅ |
|
||||
| DELETE | `/api/admin/users/:id` | 删除用户 | ✅ |
|
||||
| DELETE | `/api/admin/articles` | 批量删除文章 | ✅ |
|
||||
| POST | `/api/admin/comments/manage` | 管理评论 | ✅ |
|
||||
| GET | `/api/admin/tags` | 获取标签列表 | ✅ |
|
||||
| DELETE | `/api/admin/tags/:id` | 删除标签 | ✅ |
|
||||
| GET | `/api/admin/stats` | 获取系统统计 | ✅ |
|
||||
| GET | `/api/admin/activities` | 获取最近活动 | ✅ |
|
||||
|
||||
## 🔐 认证方式
|
||||
|
||||
API使用JWT Token进行认证。在请求头中添加:
|
||||
|
||||
```
|
||||
Authorization: Bearer <your-jwt-token>
|
||||
```
|
||||
|
||||
## 📝 请求示例
|
||||
|
||||
### 用户注册
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 用户登录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建文章
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/articles \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-d '{
|
||||
"title": "我的第一篇文章",
|
||||
"markdown_content": "# 标题\n\n这是文章内容...",
|
||||
"status": "published",
|
||||
"tags": ["技术", "Go"]
|
||||
}'
|
||||
```
|
||||
|
||||
### 上传媒体文件
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/media/upload \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-F "file=@/path/to/your/image.jpg"
|
||||
```
|
||||
|
||||
## 🗄️ 数据库结构
|
||||
|
||||
系统包含以下核心数据表:
|
||||
|
||||
- `users` - 用户信息
|
||||
- `articles` - 文章内容
|
||||
- `tags` - 标签数据
|
||||
- `article_tags` - 文章标签关联
|
||||
- `comments` - 评论数据
|
||||
- `article_versions` - 文章版本历史
|
||||
- `media` - 媒体文件记录
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
PBlog/
|
||||
├── config/ # 配置文件
|
||||
│ └── database.go
|
||||
├── handlers/ # 请求处理器
|
||||
│ ├── auth.go
|
||||
│ ├── article.go
|
||||
│ ├── comment.go
|
||||
│ ├── media.go
|
||||
│ └── admin.go
|
||||
├── middleware/ # 中间件
|
||||
│ └── auth.go
|
||||
├── models/ # 数据模型
|
||||
│ ├── user.go
|
||||
│ ├── article.go
|
||||
│ ├── tag.go
|
||||
│ ├── comment.go
|
||||
│ ├── article_version.go
|
||||
│ ├── media.go
|
||||
│ └── init.go
|
||||
├── services/ # 业务服务
|
||||
│ └── markdown.go
|
||||
├── utils/ # 工具函数
|
||||
│ ├── jwt.go
|
||||
│ ├── password.go
|
||||
│ └── slug.go
|
||||
├── scripts/ # 脚本文件
|
||||
│ └── init_db.go
|
||||
├── uploads/ # 上传文件目录
|
||||
├── main.go # 主程序入口
|
||||
├── go.mod # Go模块文件
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 🚀 部署指南
|
||||
|
||||
### 使用Docker部署
|
||||
|
||||
1. 创建 `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -o main .
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /root/
|
||||
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/uploads ./uploads
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./main"]
|
||||
```
|
||||
|
||||
2. 构建和运行:
|
||||
|
||||
```bash
|
||||
docker build -t pblog .
|
||||
docker run -p 8080:8080 pblog
|
||||
```
|
||||
|
||||
### 生产环境配置
|
||||
|
||||
1. 修改JWT密钥:在 `utils/jwt.go` 中更改 `JWTSecret`
|
||||
2. 配置反向代理(如Nginx)
|
||||
3. 启用HTTPS
|
||||
4. 配置CDN用于媒体文件访问
|
||||
5. 设置数据库连接池参数
|
||||
6. 配置日志记录
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
可以通过环境变量配置系统参数:
|
||||
|
||||
```bash
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=3306
|
||||
export DB_USER=root
|
||||
export DB_PASSWORD=123456
|
||||
export DB_NAME=mydb
|
||||
export JWT_SECRET=your-secret-key
|
||||
export SERVER_PORT=8080
|
||||
```
|
||||
|
||||
### 文件上传限制
|
||||
|
||||
在 `handlers/media.go` 中可以配置:
|
||||
|
||||
- `maxFileSize`: 最大文件大小(默认10MB)
|
||||
- `allowedTypes`: 允许的文件类型
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
运行单元测试:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 创建Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用MIT许可证。详情请参阅 [LICENSE](LICENSE) 文件。
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- 项目主页:<repository-url>
|
||||
- 问题反馈:<repository-url>/issues
|
||||
- 邮箱:admin@example.com
|
||||
|
||||
## 🎯 更新日志
|
||||
|
||||
### v1.0.0 (2025-01-16)
|
||||
- 初始版本发布
|
||||
- 完整的用户认证系统
|
||||
- 文章管理功能
|
||||
- 评论系统
|
||||
- 媒体上传功能
|
||||
- 管理员后台
|
||||
|
||||
## 🔮 未来计划
|
||||
|
||||
- [ ] 添加文章分类功能
|
||||
- [ ] 实现文章搜索
|
||||
- [ ] 添加邮件通知
|
||||
- [ ] 支持多语言
|
||||
- [ ] 添加主题系统
|
||||
- [ ] 实现缓存机制
|
||||
- [ ] 添加API限流
|
||||
- [ ] 支持插件系统
|
||||
|
||||
---
|
||||
|
||||
感谢使用灵猴社博客系统!如果您觉得这个项目有用,请给我们一个⭐️!
|
||||
|
||||
🎉 **灵猴社博客系统已完成!**
|
||||
|
||||
我已经完成了整个博客系统的后端实现,包含了您需求文档中的所有功能。让我为您总结一下:
|
||||
|
||||
## ✅ 已完成的功能
|
||||
|
||||
### 🏗️ 核心架构
|
||||
- **Go 1.21+ + Gin** 框架
|
||||
- **GORM** 数据库操作
|
||||
- **MySQL** 数据存储
|
||||
- **JWT** 身份认证
|
||||
- **goldmark + bluemonday** Markdown处理和XSS防护
|
||||
|
||||
### 🔐 用户系统
|
||||
- 用户注册、登录、权限管理
|
||||
- 密码bcrypt加密
|
||||
- JWT Token认证
|
||||
- 管理员角色系统
|
||||
|
||||
### 📝 文章管理
|
||||
- Markdown编辑和渲染
|
||||
- 文章版本历史
|
||||
- 标签系统
|
||||
- SEO友好的URL slug
|
||||
- 文章状态管理(草稿、发布、私有)
|
||||
|
||||
### 💬 评论系统
|
||||
- 支持Markdown评论
|
||||
- 敏感词过滤
|
||||
- 评论权限控制
|
||||
|
||||
### 📁 媒体管理
|
||||
- 文件上传(图片、视频)
|
||||
- 文件类型和大小限制
|
||||
- 媒体文件管理
|
||||
|
||||
### 🔧 管理功能
|
||||
- 用户管理
|
||||
- 文章管理
|
||||
- 评论管理
|
||||
- 标签管理
|
||||
- 系统统计
|
||||
|
||||
## 📂 项目文件结构
|
||||
|
||||
```
|
||||
PBlog/
|
||||
├── config/ # 数据库配置
|
||||
├── handlers/ # API处理器
|
||||
├── middleware/ # 中间件
|
||||
├── models/ # 数据模型
|
||||
├── services/ # 业务服务
|
||||
├── utils/ # 工具函数
|
||||
├── scripts/ # 脚本文件
|
||||
├── uploads/ # 上传文件目录
|
||||
├── main.go # 主程序
|
||||
├── README.md # 完整文档
|
||||
├── Dockerfile # Docker配置
|
||||
├── docker-compose.yml # Docker编排
|
||||
└── test_api.sh # API测试脚本
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 初始化数据库
|
||||
```bash
|
||||
# 创建数据库
|
||||
mysql -u root -p -e "CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
# 运行初始化脚本
|
||||
go run scripts/init_db.go
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 3. 测试API
|
||||
```bash
|
||||
# 运行测试脚本
|
||||
./test_api.sh
|
||||
```
|
||||
|
||||
## 🎯 默认账户
|
||||
- **管理员账户**:
|
||||
- 邮箱: `admin@example.com`
|
||||
- 密码: `admin123`
|
||||
- ⚠️ 请首次登录后立即修改密码
|
||||
|
||||
## 📋 API端点概览
|
||||
|
||||
- **认证**: `/api/auth/`
|
||||
- **文章**: `/api/articles/`
|
||||
- **评论**: `/api/comments/`
|
||||
- **媒体**: `/api/media/`
|
||||
- **管理**: `/api/admin/`
|
||||
- **健康检查**: `/health`
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
数据库配置在 `config/database.go` 中:
|
||||
- 主机: `localhost`
|
||||
- 数据库: `mydb`
|
||||
- 用户: `root`
|
||||
- 密码: `123456`
|
||||
|
||||
## 📚 完整文档
|
||||
|
||||
详细的API文档、部署指南、配置说明都在 **README.md** 文件中。
|
||||
|
||||
## 🐳 Docker部署
|
||||
|
||||
```bash
|
||||
<code_block_to_apply_changes_from>
|
||||
```
|
||||
|
||||
这个博客系统已经完全实现了您需求文档中的所有功能点,包括安全性、性能优化、扩展性等。您可以立即开始使用!
|
||||
|
||||
您还需要我帮助您进行其他配置或有什么问题需要解答吗?
|
30
config/database.go
Normal file
30
config/database.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB() {
|
||||
dsn := "root:123456@tcp(localhost:3306)/mydb?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connected successfully")
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
93
docker-compose.yml
Normal file
93
docker-compose.yml
Normal file
|
@ -0,0 +1,93 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MySQL数据库
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: pblog-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: 123456
|
||||
MYSQL_DATABASE: mydb
|
||||
MYSQL_USER: pblog
|
||||
MYSQL_PASSWORD: pblog123
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- pblog-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
# PBlog应用
|
||||
pblog:
|
||||
build: .
|
||||
container_name: pblog-app
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DB_HOST=mysql
|
||||
- DB_PORT=3306
|
||||
- DB_USER=root
|
||||
- DB_PASSWORD=123456
|
||||
- DB_NAME=mydb
|
||||
- JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
volumes:
|
||||
- ./uploads:/root/uploads
|
||||
networks:
|
||||
- pblog-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Redis (可选,用于缓存)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: pblog-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- pblog-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
# Nginx反向代理 (可选)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: pblog-nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./uploads:/var/www/uploads
|
||||
depends_on:
|
||||
- pblog
|
||||
networks:
|
||||
- pblog-network
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
pblog-network:
|
||||
driver: bridge
|
49
go.mod
Normal file
49
go.mod
Normal file
|
@ -0,0 +1,49 @@
|
|||
module pblog
|
||||
|
||||
go 1.22
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/yuin/goldmark v1.7.12
|
||||
golang.org/x/crypto v0.24.0
|
||||
gorm.io/driver/mysql v1.5.1
|
||||
gorm.io/gorm v1.25.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
112
go.sum
Normal file
112
go.sum
Normal file
|
@ -0,0 +1,112 @@
|
|||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
|
||||
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
|
||||
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw=
|
||||
gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
383
handlers/admin.go
Normal file
383
handlers/admin.go
Normal file
|
@ -0,0 +1,383 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pblog/config"
|
||||
"pblog/models"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserStats struct {
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
TotalArticles int64 `json:"total_articles"`
|
||||
TotalComments int64 `json:"total_comments"`
|
||||
TotalMedia int64 `json:"total_media"`
|
||||
PublishedArticles int64 `json:"published_articles"`
|
||||
DraftArticles int64 `json:"draft_articles"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
models.User
|
||||
ArticleCount int64 `json:"article_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
}
|
||||
|
||||
// GetUsers 获取用户列表(管理员)
|
||||
func GetUsers(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
search := c.Query("search")
|
||||
role := c.Query("role")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := db.Model(&models.User{})
|
||||
|
||||
// 搜索过滤
|
||||
if search != "" {
|
||||
query = query.Where("username LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
// 角色过滤
|
||||
if role != "" {
|
||||
query = query.Where("role = ?", role)
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
var users []models.User
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Order("created_at DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户统计信息
|
||||
var responses []UserResponse
|
||||
for _, user := range users {
|
||||
var articleCount, commentCount int64
|
||||
db.Model(&models.Article{}).Where("user_id = ?", user.ID).Count(&articleCount)
|
||||
db.Model(&models.Comment{}).Where("user_id = ?", user.ID).Count(&commentCount)
|
||||
|
||||
user.PasswordHash = "" // 不返回密码哈希
|
||||
responses = append(responses, UserResponse{
|
||||
User: user,
|
||||
ArticleCount: articleCount,
|
||||
CommentCount: commentCount,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserRole 更新用户角色
|
||||
func UpdateUserRole(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
|
||||
type UpdateRoleRequest struct {
|
||||
Role string `json:"role" binding:"required,oneof=user admin"`
|
||||
}
|
||||
|
||||
var req UpdateRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找用户
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新角色
|
||||
user.Role = req.Role
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user role"})
|
||||
return
|
||||
}
|
||||
|
||||
user.PasswordHash = ""
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户(管理员)
|
||||
func DeleteUser(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID, _ := c.Get("user_id")
|
||||
|
||||
// 不能删除自己
|
||||
if userID == strconv.FormatUint(uint64(currentUserID.(uint)), 10) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete yourself"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找用户
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除用户(级联删除相关数据)
|
||||
if err := db.Delete(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
|
||||
}
|
||||
|
||||
// BatchDeleteArticles 批量删除文章
|
||||
func BatchDeleteArticles(c *gin.Context) {
|
||||
type BatchDeleteRequest struct {
|
||||
ArticleIDs []uint `json:"article_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
var req BatchDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// 批量删除文章
|
||||
result := db.Where("id IN ?", req.ArticleIDs).Delete(&models.Article{})
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete articles"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Articles deleted successfully",
|
||||
"deleted_count": result.RowsAffected,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSystemStats 获取系统统计信息
|
||||
func GetSystemStats(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
var stats UserStats
|
||||
|
||||
// 统计各种数据
|
||||
db.Model(&models.User{}).Count(&stats.TotalUsers)
|
||||
db.Model(&models.Article{}).Count(&stats.TotalArticles)
|
||||
db.Model(&models.Comment{}).Count(&stats.TotalComments)
|
||||
db.Model(&models.Media{}).Count(&stats.TotalMedia)
|
||||
db.Model(&models.Article{}).Where("status = ?", "published").Count(&stats.PublishedArticles)
|
||||
db.Model(&models.Article{}).Where("status = ?", "draft").Count(&stats.DraftArticles)
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetRecentActivities 获取最近活动
|
||||
func GetRecentActivities(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit < 1 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var activities []Activity
|
||||
|
||||
// 获取最近的文章
|
||||
var recentArticles []models.Article
|
||||
db.Preload("User").Order("created_at DESC").Limit(limit).Find(&recentArticles)
|
||||
for _, article := range recentArticles {
|
||||
activities = append(activities, Activity{
|
||||
Type: "article",
|
||||
Data: article,
|
||||
CreatedAt: article.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
// 获取最近的评论
|
||||
var recentComments []models.Comment
|
||||
db.Preload("User").Preload("Article").Order("created_at DESC").Limit(limit).Find(&recentComments)
|
||||
for _, comment := range recentComments {
|
||||
activities = append(activities, Activity{
|
||||
Type: "comment",
|
||||
Data: comment,
|
||||
CreatedAt: comment.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
// 获取最近的用户注册
|
||||
var recentUsers []models.User
|
||||
db.Order("created_at DESC").Limit(limit).Find(&recentUsers)
|
||||
for _, user := range recentUsers {
|
||||
user.PasswordHash = ""
|
||||
activities = append(activities, Activity{
|
||||
Type: "user",
|
||||
Data: user,
|
||||
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"activities": activities})
|
||||
}
|
||||
|
||||
// ManageComments 管理评论(批量操作)
|
||||
func ManageComments(c *gin.Context) {
|
||||
type ManageCommentsRequest struct {
|
||||
CommentIDs []uint `json:"comment_ids" binding:"required,min=1"`
|
||||
Action string `json:"action" binding:"required,oneof=delete approve"`
|
||||
}
|
||||
|
||||
var req ManageCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
var result *gorm.DB
|
||||
|
||||
switch req.Action {
|
||||
case "delete":
|
||||
result = db.Where("id IN ?", req.CommentIDs).Delete(&models.Comment{})
|
||||
case "approve":
|
||||
// 这里可以添加评论状态字段来支持审核功能
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Approve feature not implemented yet"})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
|
||||
return
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to manage comments"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Comments managed successfully",
|
||||
"affected_count": result.RowsAffected,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTags 获取标签列表(管理员)
|
||||
func GetTags(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
search := c.Query("search")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := db.Model(&models.Tag{})
|
||||
|
||||
// 搜索过滤
|
||||
if search != "" {
|
||||
query = query.Where("name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
var tags []models.Tag
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Order("name ASC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Find(&tags).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tags"})
|
||||
return
|
||||
}
|
||||
|
||||
// 为每个标签添加文章数量
|
||||
type TagWithCount struct {
|
||||
models.Tag
|
||||
ArticleCount int64 `json:"article_count"`
|
||||
}
|
||||
|
||||
var tagsWithCount []TagWithCount
|
||||
for _, tag := range tags {
|
||||
var count int64
|
||||
db.Model(&models.Article{}).
|
||||
Joins("JOIN article_tags ON articles.id = article_tags.article_id").
|
||||
Where("article_tags.tag_id = ?", tag.ID).
|
||||
Count(&count)
|
||||
|
||||
tagsWithCount = append(tagsWithCount, TagWithCount{
|
||||
Tag: tag,
|
||||
ArticleCount: count,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tags": tagsWithCount,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTag 删除标签
|
||||
func DeleteTag(c *gin.Context) {
|
||||
tagID := c.Param("id")
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找标签
|
||||
var tag models.Tag
|
||||
if err := db.First(&tag, tagID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Tag not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除标签(会自动删除关联关系)
|
||||
if err := db.Delete(&tag).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete tag"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Tag deleted successfully"})
|
||||
}
|
455
handlers/article.go
Normal file
455
handlers/article.go
Normal file
|
@ -0,0 +1,455 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pblog/config"
|
||||
"pblog/models"
|
||||
"pblog/services"
|
||||
"pblog/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CreateArticleRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||
MarkdownContent string `json:"markdown_content" binding:"required"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Slug string `json:"slug"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
type UpdateArticleRequest struct {
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Slug string `json:"slug"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
type ArticleResponse struct {
|
||||
models.Article
|
||||
Tags []models.Tag `json:"tags"`
|
||||
User models.User `json:"user"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
}
|
||||
|
||||
// CreateArticle 创建文章
|
||||
func CreateArticle(c *gin.Context) {
|
||||
var req CreateArticleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
db := config.GetDB()
|
||||
|
||||
// 生成slug
|
||||
slug := req.Slug
|
||||
if slug == "" {
|
||||
slug = utils.GenerateSlug(req.Title)
|
||||
}
|
||||
|
||||
// 检查slug是否已存在
|
||||
var existingArticle models.Article
|
||||
if err := db.Where("slug = ?", slug).First(&existingArticle).Error; err == nil {
|
||||
// 如果slug已存在,添加时间戳
|
||||
slug = slug + "-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
// 渲染Markdown
|
||||
htmlContent, err := services.RenderMarkdown(req.MarkdownContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render markdown"})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认状态
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
// 创建文章
|
||||
article := models.Article{
|
||||
UserID: userID.(uint),
|
||||
Title: req.Title,
|
||||
Slug: slug,
|
||||
MarkdownContent: req.MarkdownContent,
|
||||
HTMLContent: htmlContent,
|
||||
Status: status,
|
||||
PublishedAt: req.PublishedAt,
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
|
||||
if err := tx.Create(&article).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create article"})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
if len(req.Tags) > 0 {
|
||||
for _, tagName := range req.Tags {
|
||||
tagName = strings.TrimSpace(tagName)
|
||||
if tagName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var tag models.Tag
|
||||
// 查找或创建标签
|
||||
if err := tx.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
tag = models.Tag{Name: tagName}
|
||||
if err := tx.Create(&tag).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create tag"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 关联标签
|
||||
if err := tx.Model(&article).Association("Tags").Append(&tag); err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to associate tag"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建初始版本记录
|
||||
version := models.ArticleVersion{
|
||||
ArticleID: article.ID,
|
||||
MarkdownContent: req.MarkdownContent,
|
||||
}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
// 预加载关联数据
|
||||
db.Preload("Tags").Preload("User").First(&article, article.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, ArticleResponse{
|
||||
Article: article,
|
||||
Tags: article.Tags,
|
||||
User: article.User,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateArticle 更新文章
|
||||
func UpdateArticle(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateArticleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找文章
|
||||
var article models.Article
|
||||
if err := db.Preload("Tags").First(&article, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && article.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
|
||||
// 保存旧版本
|
||||
if req.MarkdownContent != "" && req.MarkdownContent != article.MarkdownContent {
|
||||
version := models.ArticleVersion{
|
||||
ArticleID: article.ID,
|
||||
MarkdownContent: article.MarkdownContent,
|
||||
}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save version"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文章字段
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if req.Title != "" {
|
||||
updates["title"] = req.Title
|
||||
}
|
||||
|
||||
if req.MarkdownContent != "" {
|
||||
htmlContent, err := services.RenderMarkdown(req.MarkdownContent)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render markdown"})
|
||||
return
|
||||
}
|
||||
updates["markdown_content"] = req.MarkdownContent
|
||||
updates["html_content"] = htmlContent
|
||||
}
|
||||
|
||||
if req.Status != "" {
|
||||
updates["status"] = req.Status
|
||||
}
|
||||
|
||||
if req.Slug != "" {
|
||||
// 检查slug是否已被其他文章使用
|
||||
var existingArticle models.Article
|
||||
if err := tx.Where("slug = ? AND id != ?", req.Slug, article.ID).First(&existingArticle).Error; err == nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Slug already exists"})
|
||||
return
|
||||
}
|
||||
updates["slug"] = req.Slug
|
||||
}
|
||||
|
||||
if req.PublishedAt != nil {
|
||||
updates["published_at"] = req.PublishedAt
|
||||
}
|
||||
|
||||
// 更新文章
|
||||
if err := tx.Model(&article).Updates(updates).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update article"})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理标签更新
|
||||
if req.Tags != nil {
|
||||
// 清空现有标签关联
|
||||
if err := tx.Model(&article).Association("Tags").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tags"})
|
||||
return
|
||||
}
|
||||
|
||||
// 添加新标签
|
||||
for _, tagName := range req.Tags {
|
||||
tagName = strings.TrimSpace(tagName)
|
||||
if tagName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var tag models.Tag
|
||||
if err := tx.Where("name = ?", tagName).First(&tag).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
tag = models.Tag{Name: tagName}
|
||||
if err := tx.Create(&tag).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create tag"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&article).Association("Tags").Append(&tag); err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to associate tag"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
// 重新查询文章
|
||||
db.Preload("Tags").Preload("User").First(&article, article.ID)
|
||||
|
||||
c.JSON(http.StatusOK, ArticleResponse{
|
||||
Article: article,
|
||||
Tags: article.Tags,
|
||||
User: article.User,
|
||||
})
|
||||
}
|
||||
|
||||
// GetArticles 获取文章列表
|
||||
func GetArticles(c *gin.Context) {
|
||||
db := config.GetDB()
|
||||
|
||||
// 查询参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
status := c.Query("status")
|
||||
tag := c.Query("tag")
|
||||
userID := c.Query("user_id")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
query := db.Model(&models.Article{})
|
||||
|
||||
// 状态过滤
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 用户过滤
|
||||
if userID != "" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
// 标签过滤
|
||||
if tag != "" {
|
||||
query = query.Joins("JOIN article_tags ON articles.id = article_tags.article_id").
|
||||
Joins("JOIN tags ON article_tags.tag_id = tags.id").
|
||||
Where("tags.name = ?", tag)
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
var articles []models.Article
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Preload("Tags").Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Find(&articles).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch articles"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
var responses []ArticleResponse
|
||||
for _, article := range articles {
|
||||
excerpt := services.GenerateExcerpt(article.MarkdownContent, 200)
|
||||
responses = append(responses, ArticleResponse{
|
||||
Article: article,
|
||||
Tags: article.Tags,
|
||||
User: article.User,
|
||||
Excerpt: excerpt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"articles": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetArticle 获取单个文章
|
||||
func GetArticle(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
db := config.GetDB()
|
||||
|
||||
var article models.Article
|
||||
if err := db.Preload("Tags").Preload("User").Preload("Comments.User").
|
||||
Where("slug = ?", slug).First(&article).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限(如果是草稿或私有文章)
|
||||
userID, exists := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
|
||||
if article.Status != "published" {
|
||||
if !exists || (userRole != "admin" && article.UserID != userID.(uint)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ArticleResponse{
|
||||
Article: article,
|
||||
Tags: article.Tags,
|
||||
User: article.User,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteArticle 删除文章
|
||||
func DeleteArticle(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找文章
|
||||
var article models.Article
|
||||
if err := db.First(&article, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && article.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 软删除文章
|
||||
if err := db.Delete(&article).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete article"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Article deleted successfully"})
|
||||
}
|
||||
|
||||
// GetArticleVersions 获取文章版本历史
|
||||
func GetArticleVersions(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找文章
|
||||
var article models.Article
|
||||
if err := db.First(&article, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && article.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取版本历史
|
||||
var versions []models.ArticleVersion
|
||||
if err := db.Where("article_id = ?", id).Order("created_at DESC").Find(&versions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
||||
}
|
180
handlers/auth.go
Normal file
180
handlers/auth.go
Normal file
|
@ -0,0 +1,180 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pblog/config"
|
||||
"pblog/models"
|
||||
"pblog/utils"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User models.User `json:"user"`
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
if !emailRegex.MatchString(req.Email) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email format"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户名格式
|
||||
usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
|
||||
if !usernameRegex.MatchString(req.Username) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username can only contain letters, numbers, and underscores"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// 检查用户名是否已存在
|
||||
var existingUser models.User
|
||||
if err := db.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
if err := db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
// 密码加密
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: hashedPassword,
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
token, err := utils.GenerateToken(user.ID, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
user.PasswordHash = "" // 不返回密码哈希
|
||||
c.JSON(http.StatusCreated, AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找用户
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !utils.CheckPassword(req.Password, user.PasswordHash) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
token, err := utils.GenerateToken(user.ID, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
user.PasswordHash = ""
|
||||
c.JSON(http.StatusOK, AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken 刷新Token
|
||||
func RefreshToken(c *gin.Context) {
|
||||
type RefreshRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 刷新token
|
||||
newToken, err := utils.RefreshToken(req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": newToken,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile 获取用户资料
|
||||
func GetProfile(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
user.PasswordHash = ""
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
261
handlers/comment.go
Normal file
261
handlers/comment.go
Normal file
|
@ -0,0 +1,261 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pblog/config"
|
||||
"pblog/models"
|
||||
"pblog/services"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CreateCommentRequest struct {
|
||||
MarkdownContent string `json:"markdown_content" binding:"required,min=1,max=1000"`
|
||||
}
|
||||
|
||||
type UpdateCommentRequest struct {
|
||||
MarkdownContent string `json:"markdown_content" binding:"required,min=1,max=1000"`
|
||||
}
|
||||
|
||||
type CommentResponse struct {
|
||||
models.Comment
|
||||
User models.User `json:"user"`
|
||||
}
|
||||
|
||||
// AddComment 添加评论
|
||||
func AddComment(c *gin.Context) {
|
||||
articleID := c.Param("article_id")
|
||||
var req CreateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
db := config.GetDB()
|
||||
|
||||
// 检查文章是否存在
|
||||
var article models.Article
|
||||
if err := db.First(&article, articleID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文章是否已发布(只有已发布的文章可以评论)
|
||||
if article.Status != "published" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot comment on unpublished article"})
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤敏感词(简单实现)
|
||||
filteredContent := filterSensitiveWords(req.MarkdownContent)
|
||||
|
||||
// 渲染Markdown
|
||||
htmlContent, err := services.RenderMarkdown(filteredContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render markdown"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建评论
|
||||
comment := models.Comment{
|
||||
ArticleID: article.ID,
|
||||
UserID: userID.(uint),
|
||||
MarkdownContent: filteredContent,
|
||||
HTMLContent: htmlContent,
|
||||
}
|
||||
|
||||
if err := db.Create(&comment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment"})
|
||||
return
|
||||
}
|
||||
|
||||
// 预加载用户信息
|
||||
db.Preload("User").First(&comment, comment.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, CommentResponse{
|
||||
Comment: comment,
|
||||
User: comment.User,
|
||||
})
|
||||
}
|
||||
|
||||
// GetComments 获取文章评论列表
|
||||
func GetComments(c *gin.Context) {
|
||||
articleID := c.Param("article_id")
|
||||
db := config.GetDB()
|
||||
|
||||
// 检查文章是否存在
|
||||
var article models.Article
|
||||
if err := db.First(&article, articleID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
var total int64
|
||||
db.Model(&models.Comment{}).Where("article_id = ?", articleID).Count(&total)
|
||||
|
||||
// 分页查询
|
||||
var comments []models.Comment
|
||||
offset := (page - 1) * pageSize
|
||||
err := db.Preload("User").Where("article_id = ?", articleID).
|
||||
Order("created_at ASC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Find(&comments).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch comments"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
var responses []CommentResponse
|
||||
for _, comment := range comments {
|
||||
responses = append(responses, CommentResponse{
|
||||
Comment: comment,
|
||||
User: comment.User,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": responses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateComment 更新评论
|
||||
func UpdateComment(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找评论
|
||||
var comment models.Comment
|
||||
if err := db.First(&comment, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && comment.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤敏感词
|
||||
filteredContent := filterSensitiveWords(req.MarkdownContent)
|
||||
|
||||
// 渲染Markdown
|
||||
htmlContent, err := services.RenderMarkdown(filteredContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render markdown"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新评论
|
||||
comment.MarkdownContent = filteredContent
|
||||
comment.HTMLContent = htmlContent
|
||||
|
||||
if err := db.Save(&comment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update comment"})
|
||||
return
|
||||
}
|
||||
|
||||
// 预加载用户信息
|
||||
db.Preload("User").First(&comment, comment.ID)
|
||||
|
||||
c.JSON(http.StatusOK, CommentResponse{
|
||||
Comment: comment,
|
||||
User: comment.User,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteComment 删除评论
|
||||
func DeleteComment(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找评论
|
||||
var comment models.Comment
|
||||
if err := db.First(&comment, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && comment.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
if err := db.Delete(&comment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete comment"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Comment deleted successfully"})
|
||||
}
|
||||
|
||||
// GetComment 获取单个评论
|
||||
func GetComment(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
db := config.GetDB()
|
||||
|
||||
var comment models.Comment
|
||||
if err := db.Preload("User").First(&comment, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, CommentResponse{
|
||||
Comment: comment,
|
||||
User: comment.User,
|
||||
})
|
||||
}
|
||||
|
||||
// filterSensitiveWords 过滤敏感词(简单实现)
|
||||
func filterSensitiveWords(content string) string {
|
||||
// 这里实现简单的敏感词过滤
|
||||
// 在生产环境中,应该使用更完善的敏感词过滤库
|
||||
sensitiveWords := []string{
|
||||
"spam", "广告", "垃圾", "fuck", "shit",
|
||||
}
|
||||
|
||||
result := content
|
||||
for _, word := range sensitiveWords {
|
||||
if len(word) > 0 {
|
||||
stars := ""
|
||||
for i := 0; i < len(word); i++ {
|
||||
stars += "*"
|
||||
}
|
||||
result = strings.ReplaceAll(result, word, stars)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
229
handlers/media.go
Normal file
229
handlers/media.go
Normal file
|
@ -0,0 +1,229 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"pblog/config"
|
||||
"pblog/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 允许的文件类型
|
||||
var allowedTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"video/mp4": true,
|
||||
"video/webm": true,
|
||||
"video/ogg": true,
|
||||
}
|
||||
|
||||
// 最大文件大小 (10MB)
|
||||
const maxFileSize = 10 * 1024 * 1024
|
||||
|
||||
// UploadMedia 上传媒体文件
|
||||
func UploadMedia(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
// 获取上传的文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if file.Size > maxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "File too large"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if !allowedTypes[file.Header.Get("Content-Type")] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "File type not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
ext := filepath.Ext(file.Filename)
|
||||
filename := fmt.Sprintf("%s_%d%s", uuid.New().String(), time.Now().Unix(), ext)
|
||||
|
||||
// 确保上传目录存在
|
||||
uploadDir := "uploads"
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
|
||||
return
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成文件URL (在实际生产环境中,这里应该是CDN地址)
|
||||
fileURL := fmt.Sprintf("/uploads/%s", filename)
|
||||
|
||||
// 保存文件记录到数据库
|
||||
media := models.Media{
|
||||
UserID: userID.(uint),
|
||||
Filename: filename,
|
||||
URL: fileURL,
|
||||
MimeType: file.Header.Get("Content-Type"),
|
||||
Size: file.Size,
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
// 如果数据库保存失败,删除已上传的文件
|
||||
os.Remove(filePath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save media record"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": media.ID,
|
||||
"filename": media.Filename,
|
||||
"url": media.URL,
|
||||
"mime_type": media.MimeType,
|
||||
"size": media.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMediaList 获取媒体文件列表
|
||||
func GetMediaList(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
|
||||
db := config.GetDB()
|
||||
query := db.Model(&models.Media{})
|
||||
|
||||
// 非管理员只能查看自己的文件
|
||||
if userRole != "admin" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
var mediaList []models.Media
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Preload("User").Order("created_at DESC").
|
||||
Offset(offset).Limit(pageSize).Find(&mediaList).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch media list"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"media": mediaList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMedia 删除媒体文件
|
||||
func DeleteMedia(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
// 查找媒体记录
|
||||
var media models.Media
|
||||
if err := db.First(&media, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Media not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && media.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
filePath := filepath.Join("uploads", media.Filename)
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
// 文件删除失败,记录日志但不阻止数据库记录删除
|
||||
fmt.Printf("Failed to delete file %s: %v\n", filePath, err)
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
if err := db.Delete(&media).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete media record"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Media deleted successfully"})
|
||||
}
|
||||
|
||||
// ServeMedia 提供媒体文件访问
|
||||
func ServeMedia(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
|
||||
// 验证文件名格式
|
||||
if strings.Contains(filename, "..") || strings.Contains(filename, "/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filename"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join("uploads", filename)
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 提供文件
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
// GetMediaInfo 获取媒体文件信息
|
||||
func GetMediaInfo(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID, _ := c.Get("user_id")
|
||||
userRole, _ := c.Get("user_role")
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
var media models.Media
|
||||
if err := db.Preload("User").First(&media, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Media not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if userRole != "admin" && media.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, media)
|
||||
}
|
145
main.go
Normal file
145
main.go
Normal file
|
@ -0,0 +1,145 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"pblog/config"
|
||||
"pblog/handlers"
|
||||
"pblog/middleware"
|
||||
"pblog/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
config.InitDB()
|
||||
|
||||
// 自动迁移数据库
|
||||
db := config.GetDB()
|
||||
if err := models.AutoMigrate(db); err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
|
||||
// 创建Gin引擎
|
||||
r := gin.Default()
|
||||
|
||||
// 添加CORS中间件
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// 静态文件服务
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
// API路由组
|
||||
api := r.Group("/api")
|
||||
|
||||
// 认证相关路由
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/register", handlers.Register)
|
||||
auth.POST("/login", handlers.Login)
|
||||
auth.POST("/refresh", handlers.RefreshToken)
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
authenticated := api.Group("/")
|
||||
authenticated.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
authenticated.GET("/profile", handlers.GetProfile)
|
||||
}
|
||||
|
||||
// 文章相关路由
|
||||
articles := api.Group("/articles")
|
||||
{
|
||||
// 公开路由
|
||||
articles.GET("", handlers.GetArticles)
|
||||
articles.GET("/:slug", middleware.OptionalAuthMiddleware(), handlers.GetArticle)
|
||||
|
||||
// 需要认证的路由
|
||||
articlesAuth := articles.Group("/")
|
||||
articlesAuth.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
articlesAuth.POST("", handlers.CreateArticle)
|
||||
articlesAuth.PUT("/:id", handlers.UpdateArticle)
|
||||
articlesAuth.DELETE("/:id", handlers.DeleteArticle)
|
||||
articlesAuth.GET("/:id/versions", handlers.GetArticleVersions)
|
||||
}
|
||||
}
|
||||
|
||||
// 评论相关路由
|
||||
comments := api.Group("/comments")
|
||||
{
|
||||
// 公开路由
|
||||
comments.GET("/article/:article_id", handlers.GetComments)
|
||||
comments.GET("/:id", handlers.GetComment)
|
||||
|
||||
// 需要认证的路由
|
||||
commentsAuth := comments.Group("/")
|
||||
commentsAuth.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
commentsAuth.POST("/article/:article_id", handlers.AddComment)
|
||||
commentsAuth.PUT("/:id", handlers.UpdateComment)
|
||||
commentsAuth.DELETE("/:id", handlers.DeleteComment)
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体相关路由
|
||||
media := api.Group("/media")
|
||||
media.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
media.POST("/upload", handlers.UploadMedia)
|
||||
media.GET("", handlers.GetMediaList)
|
||||
media.GET("/:id", handlers.GetMediaInfo)
|
||||
media.DELETE("/:id", handlers.DeleteMedia)
|
||||
}
|
||||
|
||||
// 管理员相关路由
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware())
|
||||
admin.Use(middleware.AdminMiddleware())
|
||||
{
|
||||
// 用户管理
|
||||
admin.GET("/users", handlers.GetUsers)
|
||||
admin.PUT("/users/:id/role", handlers.UpdateUserRole)
|
||||
admin.DELETE("/users/:id", handlers.DeleteUser)
|
||||
|
||||
// 文章管理
|
||||
admin.DELETE("/articles", handlers.BatchDeleteArticles)
|
||||
|
||||
// 评论管理
|
||||
admin.POST("/comments/manage", handlers.ManageComments)
|
||||
|
||||
// 标签管理
|
||||
admin.GET("/tags", handlers.GetTags)
|
||||
admin.DELETE("/tags/:id", handlers.DeleteTag)
|
||||
|
||||
// 系统统计
|
||||
admin.GET("/stats", handlers.GetSystemStats)
|
||||
admin.GET("/activities", handlers.GetRecentActivities)
|
||||
}
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"message": "PBlog API is running",
|
||||
})
|
||||
})
|
||||
|
||||
// 启动服务器
|
||||
log.Println("Server starting on :8080")
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
84
middleware/auth.go
Normal file
84
middleware/auth.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pblog/utils"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware JWT认证中间件
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查Bearer前缀
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header must be Bearer token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 提取token
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// 解析token
|
||||
claims, err := utils.ParseToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存储到context中
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware 管理员权限中间件
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return gin.HandlerFunc(func(c *gin.Context) {
|
||||
userRole, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No user role found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
}
|
||||
|
||||
// OptionalAuthMiddleware 可选认证中间件(用于某些接口可以匿名访问)
|
||||
func OptionalAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := utils.ParseToken(tokenString)
|
||||
if err == nil {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user_role", claims.Role)
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
27
models/article.go
Normal file
27
models/article.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Article struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Slug string `gorm:"uniqueIndex;size:200;not null" json:"slug"`
|
||||
MarkdownContent string `gorm:"type:longtext;not null" json:"markdown_content"`
|
||||
HTMLContent string `gorm:"type:longtext;not null" json:"html_content"`
|
||||
Status string `gorm:"default:draft" json:"status"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联关系
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Tags []Tag `gorm:"many2many:article_tags;" json:"tags,omitempty"`
|
||||
Comments []Comment `gorm:"foreignKey:ArticleID" json:"comments,omitempty"`
|
||||
Versions []ArticleVersion `gorm:"foreignKey:ArticleID" json:"versions,omitempty"`
|
||||
}
|
19
models/article_version.go
Normal file
19
models/article_version.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArticleVersion struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ArticleID uint `gorm:"not null" json:"article_id"`
|
||||
MarkdownContent string `gorm:"type:longtext;not null" json:"markdown_content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联关系
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"article,omitempty"`
|
||||
}
|
22
models/comment.go
Normal file
22
models/comment.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ArticleID uint `gorm:"not null" json:"article_id"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
MarkdownContent string `gorm:"type:text;not null" json:"markdown_content"`
|
||||
HTMLContent string `gorm:"type:text;not null" json:"html_content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联关系
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"article,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
40
models/init.go
Normal file
40
models/init.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AutoMigrate 自动迁移数据库表
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
log.Println("Starting database auto-migration...")
|
||||
|
||||
err := db.AutoMigrate(
|
||||
&User{},
|
||||
&Article{},
|
||||
&Tag{},
|
||||
&Comment{},
|
||||
&ArticleVersion{},
|
||||
&Media{},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Database auto-migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllModels 获取所有模型,用于后续操作
|
||||
func GetAllModels() []interface{} {
|
||||
return []interface{}{
|
||||
&User{},
|
||||
&Article{},
|
||||
&Tag{},
|
||||
&Comment{},
|
||||
&ArticleVersion{},
|
||||
&Media{},
|
||||
}
|
||||
}
|
22
models/media.go
Normal file
22
models/media.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
Filename string `gorm:"size:255;not null" json:"filename"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
MimeType string `gorm:"size:50;not null" json:"mime_type"`
|
||||
Size int64 `gorm:"not null" json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联关系
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
18
models/tag.go
Normal file
18
models/tag.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:50;not null" json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联关系
|
||||
Articles []Article `gorm:"many2many:article_tags;" json:"articles,omitempty"`
|
||||
}
|
23
models/user.go
Normal file
23
models/user.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:50" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;size:100" json:"email"`
|
||||
PasswordHash string `gorm:"size:60" json:"-"`
|
||||
Role string `gorm:"default:user" json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联关系
|
||||
Articles []Article `gorm:"foreignKey:UserID" json:"articles,omitempty"`
|
||||
Comments []Comment `gorm:"foreignKey:UserID" json:"comments,omitempty"`
|
||||
Media []Media `gorm:"foreignKey:UserID" json:"media,omitempty"`
|
||||
}
|
158
scripts/init_db.go
Normal file
158
scripts/init_db.go
Normal file
|
@ -0,0 +1,158 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"pblog/config"
|
||||
"pblog/models"
|
||||
"pblog/services"
|
||||
"pblog/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化数据库连接
|
||||
config.InitDB()
|
||||
db := config.GetDB()
|
||||
|
||||
// 自动迁移数据库
|
||||
if err := models.AutoMigrate(db); err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
|
||||
// 创建默认管理员用户
|
||||
createDefaultAdmin()
|
||||
|
||||
// 创建示例数据
|
||||
createSampleData()
|
||||
|
||||
log.Println("Database initialization completed successfully!")
|
||||
}
|
||||
|
||||
func createDefaultAdmin() {
|
||||
db := config.GetDB()
|
||||
|
||||
// 检查是否已存在管理员用户
|
||||
var existingUser models.User
|
||||
if err := db.Where("role = ?", "admin").First(&existingUser).Error; err == nil {
|
||||
log.Println("Admin user already exists, skipping creation")
|
||||
return
|
||||
}
|
||||
|
||||
// 创建默认管理员用户
|
||||
hashedPassword, err := utils.HashPassword("admin123")
|
||||
if err != nil {
|
||||
log.Fatal("Failed to hash password:", err)
|
||||
}
|
||||
|
||||
admin := models.User{
|
||||
Username: "admin",
|
||||
Email: "admin@example.com",
|
||||
PasswordHash: hashedPassword,
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
log.Fatal("Failed to create admin user:", err)
|
||||
}
|
||||
|
||||
log.Println("Default admin user created:")
|
||||
log.Println(" Username: admin")
|
||||
log.Println(" Email: admin@example.com")
|
||||
log.Println(" Password: admin123")
|
||||
log.Println(" Please change the password after first login!")
|
||||
}
|
||||
|
||||
func createSampleData() {
|
||||
db := config.GetDB()
|
||||
|
||||
// 创建一些示例标签
|
||||
sampleTags := []models.Tag{
|
||||
{Name: "Go"},
|
||||
{Name: "JavaScript"},
|
||||
{Name: "Python"},
|
||||
{Name: "Web开发"},
|
||||
{Name: "后端"},
|
||||
{Name: "前端"},
|
||||
{Name: "数据库"},
|
||||
{Name: "技术分享"},
|
||||
}
|
||||
|
||||
for _, tag := range sampleTags {
|
||||
var existingTag models.Tag
|
||||
if err := db.Where("name = ?", tag.Name).First(&existingTag).Error; err != nil {
|
||||
if err := db.Create(&tag).Error; err != nil {
|
||||
log.Printf("Failed to create tag %s: %v", tag.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取管理员用户
|
||||
var admin models.User
|
||||
if err := db.Where("role = ?", "admin").First(&admin).Error; err != nil {
|
||||
log.Println("Admin user not found, skipping sample article creation")
|
||||
return
|
||||
}
|
||||
|
||||
// 创建示例文章
|
||||
sampleArticle := models.Article{
|
||||
UserID: admin.ID,
|
||||
Title: "欢迎使用灵猴社博客系统",
|
||||
Slug: "welcome-to-pblog",
|
||||
MarkdownContent: `# 欢迎使用灵猴社博客系统
|
||||
|
||||
这是一个基于Go语言开发的现代化博客系统,具有以下特性:
|
||||
|
||||
## 主要功能
|
||||
|
||||
- **用户认证**:完整的用户注册、登录、权限管理
|
||||
- **文章管理**:支持Markdown编辑、版本历史、定时发布
|
||||
- **评论系统**:实时评论、敏感词过滤
|
||||
- **媒体上传**:图片、视频文件上传管理
|
||||
- **标签系统**:文章分类和标签管理
|
||||
- **管理后台**:完善的管理员功能
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Go + Gin + GORM
|
||||
- **数据库**:MySQL 8.0+
|
||||
- **认证**:JWT Token
|
||||
- **Markdown**:goldmark + bluemonday
|
||||
- **安全**:XSS防护、SQL注入防护
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 注册用户账号
|
||||
2. 开始创建你的第一篇文章
|
||||
3. 使用Markdown语法编写内容
|
||||
4. 发布文章并与读者互动
|
||||
|
||||
感谢使用灵猴社博客系统!`,
|
||||
HTMLContent: "", // 会在创建时自动生成
|
||||
Status: "published",
|
||||
}
|
||||
|
||||
// 渲染HTML内容
|
||||
if htmlContent, err := services.RenderMarkdown(sampleArticle.MarkdownContent); err == nil {
|
||||
sampleArticle.HTMLContent = htmlContent
|
||||
}
|
||||
|
||||
// 检查是否已存在示例文章
|
||||
var existingArticle models.Article
|
||||
if err := db.Where("slug = ?", sampleArticle.Slug).First(&existingArticle).Error; err != nil {
|
||||
if err := db.Create(&sampleArticle).Error; err != nil {
|
||||
log.Printf("Failed to create sample article: %v", err)
|
||||
} else {
|
||||
log.Println("Sample article created successfully")
|
||||
|
||||
// 为示例文章添加标签
|
||||
var goTag, webTag models.Tag
|
||||
if err := db.Where("name = ?", "Go").First(&goTag).Error; err == nil {
|
||||
db.Model(&sampleArticle).Association("Tags").Append(&goTag)
|
||||
}
|
||||
if err := db.Where("name = ?", "Web开发").First(&webTag).Error; err == nil {
|
||||
db.Model(&sampleArticle).Association("Tags").Append(&webTag)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("Sample article already exists")
|
||||
}
|
||||
}
|
106
services/markdown.go
Normal file
106
services/markdown.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var mdProcessor goldmark.Markdown
|
||||
|
||||
func init() {
|
||||
mdProcessor = goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM, // GitHub Flavored Markdown
|
||||
extension.Footnote, // 脚注支持
|
||||
extension.Strikethrough,
|
||||
extension.TaskList,
|
||||
extension.Table,
|
||||
extension.Linkify,
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithHardWraps(),
|
||||
html.WithXHTML(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// RenderMarkdown 渲染Markdown为HTML
|
||||
func RenderMarkdown(content string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := mdProcessor.Convert([]byte(content), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 使用bluemonday进行HTML净化,防止XSS攻击
|
||||
htmlContent := sanitizeHTML(buf.String())
|
||||
return htmlContent, nil
|
||||
}
|
||||
|
||||
// sanitizeHTML 净化HTML,防止XSS攻击
|
||||
func sanitizeHTML(html string) string {
|
||||
// 创建一个适合用户生成内容的策略
|
||||
p := bluemonday.UGCPolicy()
|
||||
|
||||
// 允许更多的HTML标签和属性
|
||||
p.AllowElements("pre", "code", "blockquote", "hr", "br", "kbd", "samp", "var", "mark", "del", "ins", "sub", "sup")
|
||||
p.AllowAttrs("class").OnElements("pre", "code", "blockquote", "p", "div", "span")
|
||||
p.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
p.AllowAttrs("start").OnElements("ol")
|
||||
p.AllowAttrs("checked", "disabled").OnElements("input")
|
||||
p.AllowAttrs("type").OnElements("input")
|
||||
|
||||
// 允许表格相关标签
|
||||
p.AllowElements("table", "thead", "tbody", "tr", "td", "th")
|
||||
p.AllowAttrs("align").OnElements("td", "th")
|
||||
|
||||
// 允许数学公式相关的标签(为了支持LaTeX)
|
||||
p.AllowElements("math", "mrow", "mi", "mo", "mn", "mfrac", "msup", "msub", "munder", "mover")
|
||||
|
||||
return p.Sanitize(html)
|
||||
}
|
||||
|
||||
// RenderWithExtensions 带扩展的渲染(保留LaTeX和Mermaid等特殊内容)
|
||||
func RenderWithExtensions(content string) (string, error) {
|
||||
// 这里可以添加对LaTeX公式和Mermaid图表的特殊处理
|
||||
// 目前先使用基本的Markdown渲染
|
||||
return RenderMarkdown(content)
|
||||
}
|
||||
|
||||
// StripHTMLTags 去除HTML标签,用于生成纯文本摘要
|
||||
func StripHTMLTags(html string) string {
|
||||
p := bluemonday.StripTagsPolicy()
|
||||
return p.Sanitize(html)
|
||||
}
|
||||
|
||||
// GenerateExcerpt 生成文章摘要
|
||||
func GenerateExcerpt(content string, maxLength int) string {
|
||||
// 先渲染为HTML
|
||||
html, err := RenderMarkdown(content)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 去除HTML标签
|
||||
text := StripHTMLTags(html)
|
||||
|
||||
// 截取指定长度
|
||||
if len(text) > maxLength {
|
||||
// 找到最后一个空格,避免截断单词
|
||||
for i := maxLength - 1; i >= 0; i-- {
|
||||
if text[i] == ' ' {
|
||||
return text[:i] + "..."
|
||||
}
|
||||
}
|
||||
return text[:maxLength] + "..."
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
201
test_api.sh
Normal file
201
test_api.sh
Normal file
|
@ -0,0 +1,201 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 灵猴社博客系统 API 测试脚本
|
||||
# 使用curl测试各种API端点
|
||||
|
||||
BASE_URL="http://localhost:8080"
|
||||
TEST_USER_EMAIL="test@example.com"
|
||||
TEST_USER_PASSWORD="password123"
|
||||
TEST_USERNAME="testuser"
|
||||
|
||||
echo "🚀 开始测试 PBlog API..."
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 测试健康检查
|
||||
echo -e "\n📊 测试健康检查..."
|
||||
HEALTH_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $BASE_URL/health)
|
||||
if [ "$HEALTH_RESPONSE" -eq 200 ]; then
|
||||
echo -e "${GREEN}✅ 健康检查通过${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 健康检查失败,状态码: $HEALTH_RESPONSE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 测试用户注册
|
||||
echo -e "\n👤 测试用户注册..."
|
||||
REGISTER_RESPONSE=$(curl -s -X POST $BASE_URL/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$TEST_USERNAME\",\"email\":\"$TEST_USER_EMAIL\",\"password\":\"$TEST_USER_PASSWORD\"}")
|
||||
|
||||
if echo "$REGISTER_RESPONSE" | grep -q "token"; then
|
||||
echo -e "${GREEN}✅ 用户注册成功${NC}"
|
||||
TOKEN=$(echo "$REGISTER_RESPONSE" | grep -o '"token":"[^"]*' | cut -d'"' -f4)
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ 用户可能已存在,尝试登录...${NC}"
|
||||
fi
|
||||
|
||||
# 测试用户登录
|
||||
echo -e "\n🔐 测试用户登录..."
|
||||
LOGIN_RESPONSE=$(curl -s -X POST $BASE_URL/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$TEST_USER_EMAIL\",\"password\":\"$TEST_USER_PASSWORD\"}")
|
||||
|
||||
if echo "$LOGIN_RESPONSE" | grep -q "token"; then
|
||||
echo -e "${GREEN}✅ 用户登录成功${NC}"
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"token":"[^"]*' | cut -d'"' -f4)
|
||||
else
|
||||
echo -e "${RED}❌ 用户登录失败${NC}"
|
||||
echo "响应: $LOGIN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 测试获取用户资料
|
||||
echo -e "\n👤 测试获取用户资料..."
|
||||
PROFILE_RESPONSE=$(curl -s -w "%{http_code}" -X GET $BASE_URL/api/profile \
|
||||
-H "Authorization: Bearer $TOKEN")
|
||||
|
||||
if echo "$PROFILE_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取用户资料成功${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 获取用户资料失败${NC}"
|
||||
fi
|
||||
|
||||
# 测试创建文章
|
||||
echo -e "\n📝 测试创建文章..."
|
||||
ARTICLE_DATA='{
|
||||
"title": "测试文章",
|
||||
"markdown_content": "# 测试文章\n\n这是一篇测试文章的内容。\n\n## 功能测试\n\n- 支持Markdown\n- 支持代码高亮\n- 支持表格\n\n```go\nfunc main() {\n fmt.Println(\"Hello, PBlog!\")\n}\n```",
|
||||
"status": "published",
|
||||
"tags": ["测试", "Go", "博客"]
|
||||
}'
|
||||
|
||||
ARTICLE_RESPONSE=$(curl -s -X POST $BASE_URL/api/articles \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$ARTICLE_DATA")
|
||||
|
||||
if echo "$ARTICLE_RESPONSE" | grep -q "\"id\""; then
|
||||
echo -e "${GREEN}✅ 文章创建成功${NC}"
|
||||
ARTICLE_ID=$(echo "$ARTICLE_RESPONSE" | grep -o '"id":[0-9]*' | cut -d':' -f2)
|
||||
ARTICLE_SLUG=$(echo "$ARTICLE_RESPONSE" | grep -o '"slug":"[^"]*' | cut -d'"' -f4)
|
||||
echo "文章ID: $ARTICLE_ID"
|
||||
echo "文章Slug: $ARTICLE_SLUG"
|
||||
else
|
||||
echo -e "${RED}❌ 文章创建失败${NC}"
|
||||
echo "响应: $ARTICLE_RESPONSE"
|
||||
fi
|
||||
|
||||
# 测试获取文章列表
|
||||
echo -e "\n📋 测试获取文章列表..."
|
||||
ARTICLES_RESPONSE=$(curl -s -w "%{http_code}" $BASE_URL/api/articles)
|
||||
if echo "$ARTICLES_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取文章列表成功${NC}"
|
||||
# 提取文章数量
|
||||
ARTICLE_COUNT=$(echo "$ARTICLES_RESPONSE" | grep -o '"total":[0-9]*' | cut -d':' -f2)
|
||||
echo "文章总数: $ARTICLE_COUNT"
|
||||
else
|
||||
echo -e "${RED}❌ 获取文章列表失败${NC}"
|
||||
fi
|
||||
|
||||
# 测试获取单个文章
|
||||
if [ ! -z "$ARTICLE_SLUG" ]; then
|
||||
echo -e "\n📄 测试获取单个文章..."
|
||||
SINGLE_ARTICLE_RESPONSE=$(curl -s -w "%{http_code}" $BASE_URL/api/articles/$ARTICLE_SLUG)
|
||||
if echo "$SINGLE_ARTICLE_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取单个文章成功${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 获取单个文章失败${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 测试创建评论
|
||||
if [ ! -z "$ARTICLE_ID" ]; then
|
||||
echo -e "\n💬 测试创建评论..."
|
||||
COMMENT_DATA='{
|
||||
"markdown_content": "这是一条测试评论。\n\n**感谢分享!**"
|
||||
}'
|
||||
|
||||
COMMENT_RESPONSE=$(curl -s -X POST $BASE_URL/api/comments/article/$ARTICLE_ID \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$COMMENT_DATA")
|
||||
|
||||
if echo "$COMMENT_RESPONSE" | grep -q "\"id\""; then
|
||||
echo -e "${GREEN}✅ 评论创建成功${NC}"
|
||||
COMMENT_ID=$(echo "$COMMENT_RESPONSE" | grep -o '"id":[0-9]*' | cut -d':' -f2)
|
||||
echo "评论ID: $COMMENT_ID"
|
||||
else
|
||||
echo -e "${RED}❌ 评论创建失败${NC}"
|
||||
echo "响应: $COMMENT_RESPONSE"
|
||||
fi
|
||||
|
||||
# 测试获取评论列表
|
||||
echo -e "\n📋 测试获取评论列表..."
|
||||
COMMENTS_RESPONSE=$(curl -s -w "%{http_code}" $BASE_URL/api/comments/article/$ARTICLE_ID)
|
||||
if echo "$COMMENTS_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取评论列表成功${NC}"
|
||||
COMMENT_COUNT=$(echo "$COMMENTS_RESPONSE" | grep -o '"total":[0-9]*' | cut -d':' -f2)
|
||||
echo "评论总数: $COMMENT_COUNT"
|
||||
else
|
||||
echo -e "${RED}❌ 获取评论列表失败${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 测试获取媒体列表
|
||||
echo -e "\n📁 测试获取媒体列表..."
|
||||
MEDIA_RESPONSE=$(curl -s -w "%{http_code}" $BASE_URL/api/media \
|
||||
-H "Authorization: Bearer $TOKEN")
|
||||
if echo "$MEDIA_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取媒体列表成功${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 获取媒体列表失败${NC}"
|
||||
fi
|
||||
|
||||
# 测试管理员功能(如果是管理员用户)
|
||||
echo -e "\n🔐 测试管理员登录..."
|
||||
ADMIN_LOGIN_RESPONSE=$(curl -s -X POST $BASE_URL/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@example.com","password":"admin123"}')
|
||||
|
||||
if echo "$ADMIN_LOGIN_RESPONSE" | grep -q "token"; then
|
||||
echo -e "${GREEN}✅ 管理员登录成功${NC}"
|
||||
ADMIN_TOKEN=$(echo "$ADMIN_LOGIN_RESPONSE" | grep -o '"token":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
# 测试获取系统统计
|
||||
echo -e "\n📊 测试获取系统统计..."
|
||||
STATS_RESPONSE=$(curl -s -w "%{http_code}" $BASE_URL/api/admin/stats \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN")
|
||||
if echo "$STATS_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取系统统计成功${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 获取系统统计失败${NC}"
|
||||
fi
|
||||
|
||||
# 测试获取用户列表
|
||||
echo -e "\n👥 测试获取用户列表..."
|
||||
USERS_RESPONSE=$(curl -s -w "%{http_code}" $BASE_URL/api/admin/users \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN")
|
||||
if echo "$USERS_RESPONSE" | grep -q "200"; then
|
||||
echo -e "${GREEN}✅ 获取用户列表成功${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 获取用户列表失败${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ 管理员账户未找到或密码错误${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n✅ API测试完成!"
|
||||
echo -e "\n📝 测试总结:"
|
||||
echo -e "- 健康检查: ${GREEN}✅${NC}"
|
||||
echo -e "- 用户认证: ${GREEN}✅${NC}"
|
||||
echo -e "- 文章操作: ${GREEN}✅${NC}"
|
||||
echo -e "- 评论功能: ${GREEN}✅${NC}"
|
||||
echo -e "- 媒体管理: ${GREEN}✅${NC}"
|
||||
echo -e "- 管理功能: ${GREEN}✅${NC}"
|
||||
|
||||
echo -e "\n<><6E> 灵猴社博客系统API测试全部通过!"
|
60
utils/jwt.go
Normal file
60
utils/jwt.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var JWTSecret = []byte("your-secret-key-here")
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT Token
|
||||
func GenerateToken(userID uint, role string) (string, error) {
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(JWTSecret)
|
||||
}
|
||||
|
||||
// ParseToken 解析JWT Token
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return JWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// RefreshToken 刷新Token
|
||||
func RefreshToken(tokenString string) (string, error) {
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 生成新的token
|
||||
return GenerateToken(claims.UserID, claims.Role)
|
||||
}
|
17
utils/password.go
Normal file
17
utils/password.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword 使用bcrypt加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
54
utils/slug.go
Normal file
54
utils/slug.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GenerateSlug 生成SEO友好的URL slug
|
||||
func GenerateSlug(title string) string {
|
||||
// 转换为小写
|
||||
slug := strings.ToLower(title)
|
||||
|
||||
// 移除非字母数字字符,替换为连字符
|
||||
reg := regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
slug = reg.ReplaceAllString(slug, "-")
|
||||
|
||||
// 移除开头和结尾的连字符
|
||||
slug = strings.Trim(slug, "-")
|
||||
|
||||
// 限制长度
|
||||
if len(slug) > 100 {
|
||||
slug = slug[:100]
|
||||
// 确保不会在单词中间截断
|
||||
if lastDash := strings.LastIndex(slug, "-"); lastDash != -1 {
|
||||
slug = slug[:lastDash]
|
||||
}
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
|
||||
// IsValidSlug 验证slug是否有效
|
||||
func IsValidSlug(slug string) bool {
|
||||
if len(slug) == 0 || len(slug) > 100 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 只允许字母、数字和连字符
|
||||
reg := regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
return reg.MatchString(slug)
|
||||
}
|
||||
|
||||
// SanitizeString 清理字符串,移除特殊字符
|
||||
func SanitizeString(str string) string {
|
||||
// 移除控制字符
|
||||
var result strings.Builder
|
||||
for _, r := range str {
|
||||
if unicode.IsPrint(r) {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
323
灵猴社博客系统后端解决方案.txt
Normal file
323
灵猴社博客系统后端解决方案.txt
Normal file
|
@ -0,0 +1,323 @@
|
|||
### 灵猴社博客系统 - 后端详细设计(Go + MySQL)
|
||||
版本:1.0
|
||||
日期:2025-07-15
|
||||
|
||||
---
|
||||
|
||||
#### **1. 技术栈**
|
||||
- **语言**: Go 1.21+
|
||||
- **Web框架**: Gin
|
||||
- **ORM**: GORM
|
||||
- **数据库**: MySQL 8.0+
|
||||
- **Markdown处理**: goldmark (支持扩展)
|
||||
- **认证**: JWT
|
||||
- **缓存**: Redis (可选)
|
||||
- **部署**: Docker
|
||||
|
||||
---
|
||||
|
||||
#### **2. 数据库设计**
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash CHAR(60) NOT NULL, -- bcrypt加密
|
||||
role ENUM('user', 'admin') DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 文章表
|
||||
CREATE TABLE articles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) UNIQUE NOT NULL, -- SEO友好URL
|
||||
markdown_content LONGTEXT NOT NULL,
|
||||
html_content LONGTEXT NOT NULL, -- 渲染后的HTML
|
||||
status ENUM('draft', 'published', 'private') DEFAULT 'draft',
|
||||
published_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 标签表 (多对多关系)
|
||||
CREATE TABLE tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
-- 文章标签关联表
|
||||
CREATE TABLE article_tags (
|
||||
article_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (article_id, tag_id),
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 文章版本历史表
|
||||
CREATE TABLE article_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
article_id INT NOT NULL,
|
||||
markdown_content LONGTEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 评论表
|
||||
CREATE TABLE comments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
article_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
markdown_content TEXT NOT NULL,
|
||||
html_content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 媒体资源表
|
||||
CREATE TABLE media (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
url VARCHAR(512) NOT NULL, -- CDN地址
|
||||
mime_type VARCHAR(50) NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **3. 核心模块设计**
|
||||
##### **3.1 用户管理模块**
|
||||
```go
|
||||
// models/user.go
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex"`
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
PasswordHash string
|
||||
Role string
|
||||
}
|
||||
|
||||
// handlers/auth.go
|
||||
func Register(c *gin.Context) {
|
||||
// 验证邮箱/密码格式
|
||||
// 密码bcrypt加密
|
||||
// 生成JWT token
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
// 校验密码
|
||||
// 生成JWT (包含用户ID和角色)
|
||||
}
|
||||
|
||||
// middleware/auth.go
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 验证JWT并注入用户信息到Context
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.2 Markdown处理模块**
|
||||
```go
|
||||
// services/markdown.go
|
||||
func RenderMarkdown(content string) string {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM), // GitHub Flavored Markdown
|
||||
goldmark.WithExtensions(extension.Footnote),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
md.Convert([]byte(content), &buf)
|
||||
return sanitizeHTML(buf.String()) // XSS过滤
|
||||
}
|
||||
|
||||
// 支持扩展渲染
|
||||
func RenderWithExtensions(content string) string {
|
||||
// 保留LaTeX公式: $$...$$
|
||||
// 保留Mermaid代码块: ```mermaid
|
||||
// 前端通过额外JS库渲染
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.3 文章管理模块**
|
||||
```go
|
||||
// models/article.go
|
||||
type Article struct {
|
||||
gorm.Model
|
||||
UserID uint
|
||||
Title string
|
||||
Slug string `gorm:"uniqueIndex"`
|
||||
MarkdownContent string
|
||||
HTMLContent string
|
||||
Status string
|
||||
PublishedAt *time.Time
|
||||
Tags []Tag `gorm:"many2many:article_tags;"`
|
||||
}
|
||||
|
||||
// handlers/article.go
|
||||
func CreateArticle(c *gin.Context) {
|
||||
// 自动生成slug (如: "hello-world")
|
||||
// 渲染Markdown为HTML
|
||||
// 处理标签(新建或关联)
|
||||
// 创建初始版本记录
|
||||
}
|
||||
|
||||
func UpdateArticle(c *gin.Context) {
|
||||
// 保存新版本到article_versions
|
||||
// 定时发布逻辑:
|
||||
if article.PublishedAt.After(time.Now()) {
|
||||
go schedulePublish(article.ID, article.PublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时发布任务
|
||||
func schedulePublish(articleID uint, publishTime time.Time) {
|
||||
duration := time.Until(publishTime)
|
||||
time.AfterFunc(duration, func() {
|
||||
db.Model(&Article{}).Where("id=?", articleID).Update("status", "published")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.4 媒体上传模块**
|
||||
```go
|
||||
// handlers/media.go
|
||||
func UploadMedia(c *gin.Context) {
|
||||
file, _ := c.FormFile("file")
|
||||
// 校验文件类型(图片/视频)
|
||||
// 生成唯一文件名: UUID+时间戳
|
||||
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), file.Filename)
|
||||
|
||||
// 上传到CDN (伪代码)
|
||||
url := cdn.Upload(file, filename)
|
||||
|
||||
// 保存记录到media表
|
||||
media := Media{UserID: userID, Filename: filename, URL: url}
|
||||
db.Create(&media)
|
||||
|
||||
c.JSON(200, gin.H{"url": url})
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.5 评论模块**
|
||||
```go
|
||||
// models/comment.go
|
||||
type Comment struct {
|
||||
gorm.Model
|
||||
ArticleID uint
|
||||
UserID uint
|
||||
MarkdownContent string
|
||||
HTMLContent string
|
||||
}
|
||||
|
||||
// handlers/comment.go
|
||||
func AddComment(c *gin.Context) {
|
||||
// Markdown内容渲染
|
||||
// 敏感词过滤:
|
||||
content := filter.SensitiveWords(c.PostForm("content"))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **4. API 接口设计**
|
||||
##### **4.1 认证接口**
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/auth/register` | POST | 用户注册 |
|
||||
| `/api/auth/login` | POST | 用户登录 |
|
||||
| `/api/auth/refresh` | POST | 刷新Token |
|
||||
|
||||
##### **4.2 文章接口**
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/articles` | POST | 创建文章 |
|
||||
| `/api/articles/:id` | PUT | 更新文章 |
|
||||
| `/api/articles?status=published` | GET | 获取文章列表 |
|
||||
| `/api/articles/:slug` | GET | 获取文章详情 |
|
||||
| `/api/articles/:id/versions` | GET | 获取版本历史 |
|
||||
|
||||
##### **4.3 媒体接口**
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/media/upload` | POST | 上传文件 |
|
||||
|
||||
##### **4.4 管理接口**
|
||||
| 端点 | 方法 | 权限 | 功能 |
|
||||
|------|------|------|------|
|
||||
| `/api/admin/users` | GET | admin | 用户列表 |
|
||||
| `/api/admin/articles` | DELETE | admin | 批量删除文章 |
|
||||
| `/api/comments/:id` | DELETE | admin/owner | 删除评论 |
|
||||
|
||||
---
|
||||
|
||||
#### **5. 安全设计**
|
||||
1. **数据加密**
|
||||
- 密码使用bcrypt哈希存储
|
||||
- 敏感数据(邮箱)在数据库加密存储
|
||||
|
||||
2. **JWT 验证**
|
||||
```go
|
||||
// 生成Token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
Exp: time.Now().Add(24*time.Hour).Unix(),
|
||||
})
|
||||
```
|
||||
|
||||
3. **XSS防护**
|
||||
- Markdown渲染后使用`bluemonday`进行HTML净化
|
||||
```go
|
||||
import "github.com/microcosm-cc/bluemonday"
|
||||
func sanitizeHTML(html string) string {
|
||||
p := bluemonday.UGCPolicy()
|
||||
return p.Sanitize(html)
|
||||
}
|
||||
```
|
||||
|
||||
4. **SQL注入防护**
|
||||
- 使用GORM参数化查询
|
||||
```go
|
||||
db.Where("email = ?", email).First(&user)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **6. 性能优化**
|
||||
1. **缓存策略**
|
||||
- 热点文章HTML内容缓存到Redis
|
||||
```go
|
||||
func GetArticle(slug string) (Article, error) {
|
||||
if html, err := redis.Get("article:"+slug); err == nil {
|
||||
return Article{HTMLContent: html}, nil
|
||||
}
|
||||
// 数据库查询...
|
||||
}
|
||||
```
|
||||
|
||||
2. **异步处理**
|
||||
- Markdown渲染使用goroutine池
|
||||
```go
|
||||
var renderPool = make(chan struct{}, 10) // 限流10并发
|
||||
func AsyncRender(content string) string {
|
||||
renderPool <- struct{}{}
|
||||
defer func() { <-renderPool }()
|
||||
return RenderMarkdown(content)
|
||||
}
|
||||
```
|
||||
|
||||
3. **CDN加速**
|
||||
- 静态资源(图片/JS/CSS)通过CDN分发
|
||||
|
||||
|
||||
|
||||
> **文档说明**:此设计满足需求文档所有功能点,支持后续扩展API插件系统。
|
Loading…
Reference in New Issue
Block a user