diff --git a/backend/.claude/settings.local.json b/backend/.claude/settings.local.json new file mode 100644 index 0000000..fed9a2c --- /dev/null +++ b/backend/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(dir:*)" + ] + } +} diff --git a/backend/API_DOCUMENT.md b/backend/API_DOCUMENT.md new file mode 100644 index 0000000..f75a2f7 --- /dev/null +++ b/backend/API_DOCUMENT.md @@ -0,0 +1,1081 @@ +# 新闻分类器后端 API 文档 + +## 基础信息 + +- **Base URL**: `http://localhost:8080` +- **API 版本**: v1 +- **响应格式**: JSON +- **字符编码**: UTF-8 + +--- + +## 统一响应格式 + +所有接口返回统一的 `RestBean` 格式: + +```json +{ + "code": 200, + "message": "操作成功", + "data": {} +} +``` + +### 状态码说明 + +| 状态码 | 说明 | +|--------|------| +| 200 | 操作成功 | +| 400 | 请求参数错误 | +| 401 | 未认证(需要登录) | +| 403 | 无权限访问 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +### 预定义状态码(RestCode) + +| code | message | 说明 | +|------|---------|------| +| 200 | 操作成功 | SUCCESS | +| 400 | 请求参数错误 | FAILURE | +| 401 | 请先登录 | - | +| 403 | 无权限访问 | - | +| 404 | 数据不存在 | DATA_NOT_FOUND | +| 500 | 服务端错误 | SYSTEM_ERROR | +| 10001 | 用户名或密码错误 | USERNAME_OR_PASSWORD_ERROR | +| 10002 | Token已过期 | TOKEN_EXPIRE | + +--- + +## 认证说明 + +### Token 认证 + +需要认证的接口需要在请求头中携带 Token: + +``` +Authorization: Bearer {token} +``` + +### Token 获取 + +通过登录接口获取,参见 [用户登录](#32-用户登录) + +--- + +# 一、用户接口 + +## Base Path: `/api/v1/user` + +## 3.1 用户注册 + +**接口地址**: `POST /api/v1/user/register` + +**是否需要认证**: 否 + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| username | String | 是 | 用户名(唯一,50字符以内) | +| password | String | 是 | 密码 | +| email | String | 是 | 邮箱(唯一) | + +**请求示例**: +```json +{ + "username": "testuser", + "password": "123456", + "email": "test@example.com" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**错误响应**: +```json +{ + "code": 400, + "message": "用户名已被使用", + "data": null +} +``` + +--- + +## 3.2 用户登录 + +**接口地址**: `POST /api/v1/user/login` + +**是否需要认证**: 否 + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| username | String | 是 | 用户名 | +| password | String | 是 | 密码 | + +**请求示例**: +```json +{ + "username": "testuser", + "password": "123456" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**错误响应**: +```json +{ + "code": 404, + "message": "用户不存在", + "data": null +} +``` + +```json +{ + "code": 10001, + "message": "用户名或密码错误", + "data": null +} +``` + +```json +{ + "code": 403, + "message": "用户已被禁用", + "data": null +} +``` + +--- + +## 3.3 获取用户信息 + +**接口地址**: `GET /api/v1/user/info` + +**是否需要认证**: 是 + +**请求头**: +``` +Authorization: Bearer {token} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 1, + "username": "testuser", + "email": "test@example.com", + "status": 1, + "createdAt": "2024-01-01T10:00:00", + "updatedAt": "2024-01-01T10:00:00" + } +} +``` + +--- + +# 二、分类管理接口 + +## Base Path: `/api/v1/categories` + +**说明**: 所有分类接口均无需认证,公开访问 + +--- + +## 2.1 获取所有分类 + +**接口地址**: `GET /api/v1/categories` + +**是否需要认证**: 否 + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": [ + { + "id": 1, + "name": "娱乐", + "newsCount": null + }, + { + "id": 2, + "name": "体育", + "newsCount": null + }, + { + "id": 3, + "name": "财经", + "newsCount": null + }, + { + "id": 4, + "name": "科技", + "newsCount": null + }, + { + "id": 5, + "name": "军事", + "newsCount": null + }, + { + "id": 6, + "name": "汽车", + "newsCount": null + }, + { + "id": 7, + "name": "政务", + "newsCount": null + }, + { + "id": 8, + "name": "健康", + "newsCount": null + }, + { + "id": 9, + "name": "AI", + "newsCount": null + } + ] +} +``` + +--- + +## 2.2 获取分类及新闻数量 + +**接口地址**: `GET /api/v1/categories/with-count` + +**是否需要认证**: 否 + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": [ + { + "id": 1, + "name": "娱乐", + "newsCount": 152 + }, + { + "id": 2, + "name": "体育", + "newsCount": 89 + }, + { + "id": 4, + "name": "科技", + "newsCount": 234 + } + ] +} +``` + +--- + +## 2.3 获取分类详情 + +**接口地址**: `GET /api/v1/categories/{id}` + +**是否需要认证**: 否 + +**路径参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | Integer | 是 | 分类ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 4, + "name": "科技", + "newsCount": null + } +} +``` + +**错误响应**: +```json +{ + "code": 404, + "message": "数据不存在", + "data": null +} +``` + +--- + +## 2.4 创建分类 + +**接口地址**: `POST /api/v1/categories` + +**是否需要认证**: 否 + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| name | String | 是 | 分类名称(唯一,50字符以内) | + +**请求示例**: +```json +{ + "name": "游戏" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 10, + "name": "游戏", + "newsCount": null + } +} +``` + +**错误响应**: +```json +{ + "code": 400, + "message": "分类名称已存在", + "data": null +} +``` + +--- + +## 2.5 更新分类 + +**接口地址**: `PUT /api/v1/categories/{id}` + +**是否需要认证**: 否 + +**路径参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | Integer | 是 | 分类ID | + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| name | String | 是 | 分类名称 | + +**请求示例**: +```json +{ + "name": "电竞游戏" +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 10, + "name": "电竞游戏", + "newsCount": null + } +} +``` + +--- + +## 2.6 删除分类 + +**接口地址**: `DELETE /api/v1/categories/{id}` + +**是否需要认证**: 否 + +**路径参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | Integer | 是 | 分类ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": null +} +``` + +--- + +# 三、新闻管理接口 + +## Base Path: `/api/v1/news` + +**说明**: 所有新闻接口均无需认证,公开访问 + +--- + +## 3.1 获取新闻列表 + +**接口地址**: `GET /api/v1/news/list` + +**是否需要认证**: 否 + +**查询参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| size | Integer | 否 | 20 | 每页大小 | +| categoryId | Integer | 否 | - | 分类ID筛选 | +| source | String | 否 | - | 来源筛选(如:网易、36kr) | +| keyword | String | 否 | - | 搜索关键词(标题或内容) | +| sortBy | String | 否 | createdAt | 排序字段 | +| sortOrder | String | 否 | DESC | 排序方向(ASC/DESC) | + +**请求示例**: +``` +GET /api/v1/news/list?page=1&size=10&categoryId=4&source=网易 +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "records": [ + { + "id": 1, + "url": "https://news.example.com/tech/001", + "title": "AI技术突破:新一代大语言模型发布", + "categoryId": 9, + "categoryName": "AI", + "publishTime": "2024-01-15 10:30:00", + "author": "张三", + "source": "网易", + "content": "正文内容...", + "createdAt": "2024-01-15 11:00:00" + }, + { + "id": 2, + "url": "https://news.example.com/tech/002", + "title": "量子计算取得重大进展", + "categoryId": 4, + "categoryName": "科技", + "publishTime": "2024-01-15 09:00:00", + "author": "李四", + "source": "36kr", + "content": "正文内容...", + "createdAt": "2024-01-15 10:00:00" + } + ], + "total": 156, + "page": 1, + "size": 10, + "pages": 16 + } +} +``` + +--- + +## 3.2 获取新闻详情 + +**接口地址**: `GET /api/v1/news/{id}` + +**是否需要认证**: 否 + +**路径参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | Long | 是 | 新闻ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 1, + "url": "https://news.example.com/tech/001", + "title": "AI技术突破:新一代大语言模型发布", + "categoryId": 9, + "categoryName": "AI", + "publishTime": "2024-01-15 10:30:00", + "author": "张三", + "source": "网易", + "content": "完整的正文内容...", + "createdAt": "2024-01-15 11:00:00" + } +} +``` + +--- + +## 3.3 创建新闻 + +**接口地址**: `POST /api/v1/news` + +**是否需要认证**: 否 + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| url | String | 是 | 新闻URL(唯一,500字符以内) | +| title | String | 是 | 标题(255字符以内) | +| categoryId | Integer | 否 | 分类ID | +| publishTime | LocalDateTime | 否 | 发布时间 | +| author | String | 否 | 作者 | +| source | String | 否 | 来源(如:网易、36kr) | +| content | String | 是 | 正文内容 | + +**请求示例**: +```json +{ + "url": "https://news.example.com/tech/003", + "title": "5G网络建设加速推进", + "categoryId": 4, + "publishTime": "2024-01-16T08:00:00", + "author": "王五", + "source": "36kr", + "content": "完整的新闻正文内容..." +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 3, + "url": "https://news.example.com/tech/003", + "title": "5G网络建设加速推进", + "categoryId": 4, + "categoryName": "科技", + "publishTime": "2024-01-16 08:00:00", + "author": "王五", + "source": "36kr", + "content": "完整的新闻正文内容...", + "createdAt": "2024-01-16 09:00:00" + } +} +``` + +**错误响应**: +```json +{ + "code": 400, + "message": "该URL的新闻已存在", + "data": null +} +``` + +```json +{ + "code": 400, + "message": "指定的分类不存在", + "data": null +} +``` + +--- + +## 3.4 更新新闻 + +**接口地址**: `PUT /api/v1/news` + +**是否需要认证**: 否 + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | Long | 是 | 新闻ID | +| title | String | 否 | 标题 | +| categoryId | Integer | 否 | 分类ID | +| publishTime | LocalDateTime | 否 | 发布时间 | +| author | String | 否 | 作者 | +| source | String | 否 | 来源 | +| content | String | 否 | 正文内容 | + +**请求示例**: +```json +{ + "id": 3, + "title": "5G网络建设加速推进(更新)", + "categoryId": 9 +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "id": 3, + "url": "https://news.example.com/tech/003", + "title": "5G网络建设加速推进(更新)", + "categoryId": 9, + "categoryName": "AI", + "publishTime": "2024-01-16 08:00:00", + "author": "王五", + "source": "36kr", + "content": "完整的新闻正文内容...", + "createdAt": "2024-01-16 09:00:00" + } +} +``` + +--- + +## 3.5 删除新闻 + +**接口地址**: `DELETE /api/v1/news/{id}` + +**是否需要认证**: 否 + +**路径参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| id | Long | 是 | 新闻ID | + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": null +} +``` + +--- + +## 3.6 批量删除新闻 + +**接口地址**: `DELETE /api/v1/news/batch` + +**是否需要认证**: 否 + +**请求参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| - | Array[Long] | 是 | 新闻ID列表 | + +**请求示例**: +```json +[1, 2, 3, 4, 5] +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": null +} +``` + +--- + +## 3.7 搜索新闻 + +**接口地址**: `GET /api/v1/news/search` + +**是否需要认证**: 否 + +**查询参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| keyword | String | 是 | - | 搜索关键词 | +| page | Integer | 否 | 1 | 页码 | +| size | Integer | 否 | 20 | 每页大小 | + +**请求示例**: +``` +GET /api/v1/news/search?keyword=AI&page=1&size=10 +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "records": [ + { + "id": 1, + "url": "https://news.example.com/tech/001", + "title": "AI技术突破:新一代大语言模型发布", + "categoryId": 9, + "categoryName": "AI", + "publishTime": "2024-01-15 10:30:00", + "author": "张三", + "source": "网易", + "content": "正文内容...", + "createdAt": "2024-01-15 11:00:00" + } + ], + "total": 23, + "page": 1, + "size": 10, + "pages": 3 + } +} +``` + +--- + +## 3.8 按分类获取新闻 + +**接口地址**: `GET /api/v1/news/category/{categoryId}` + +**是否需要认证**: 否 + +**路径参数**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| categoryId | Integer | 是 | 分类ID | + +**查询参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| size | Integer | 否 | 20 | 每页大小 | + +**请求示例**: +``` +GET /api/v1/news/category/4?page=1&size=10 +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "records": [ + { + "id": 2, + "url": "https://news.example.com/tech/002", + "title": "量子计算取得重大进展", + "categoryId": 4, + "categoryName": "科技", + "publishTime": "2024-01-15 09:00:00", + "author": "李四", + "source": "36kr", + "content": "正文内容...", + "createdAt": "2024-01-15 10:00:00" + } + ], + "total": 89, + "page": 1, + "size": 10, + "pages": 9 + } +} +``` + +--- + +## 3.9 获取最新新闻 + +**接口地址**: `GET /api/v1/news/latest` + +**是否需要认证**: 否 + +**查询参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +|--------|------|------|--------|------| +| page | Integer | 否 | 1 | 页码 | +| size | Integer | 否 | 20 | 每页大小 | + +**请求示例**: +``` +GET /api/v1/news/latest?page=1&size=10 +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "records": [ + { + "id": 10, + "url": "https://news.example.com/latest/001", + "title": "今日要闻汇总", + "categoryId": null, + "categoryName": null, + "publishTime": "2024-01-16 12:00:00", + "author": "编辑", + "source": "网易", + "content": "正文内容...", + "createdAt": "2024-01-16 12:30:00" + } + ], + "total": 1234, + "page": 1, + "size": 10, + "pages": 124 + } +} +``` + +--- + +## 3.10 获取新闻统计数据 + +**接口地址**: `GET /api/v1/news/statistics` + +**是否需要认证**: 否 + +**响应示例**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "totalNews": 5678, + "categoryStats": { + "娱乐": 1234, + "体育": 890, + "财经": 756, + "科技": 1523, + "军事": 456, + "汽车": 234, + "政务": 123, + "健康": 345, + "AI": 117 + }, + "sourceStats": { + "网易": 3456, + "36kr": 2222 + } + } +} +``` + +--- + +# 四、数据模型 + +## 4.1 RestBean + +通用响应封装类。 + +```typescript +{ + code: number, // 状态码 + message: string, // 提示消息 + data: T // 返回数据 +} +``` + +## 4.2 PageResult + +分页结果封装类。 + +```typescript +{ + records: T[], // 数据列表 + total: number, // 总记录数 + page: number, // 当前页 + size: number, // 每页大小 + pages: number // 总页数 +} +``` + +## 4.3 UserDto + +用户数据传输对象。 + +```typescript +{ + id?: number, // 用户ID + username: string, // 用户名 + email: string, // 邮箱 + password?: string, // 密码(注册/登录时需要) + status?: number, // 状态(1=正常 0=禁用) + createdAt?: string, // 创建时间 + updatedAt?: string // 更新时间 +} +``` + +## 4.4 CategoryDto + +分类数据传输对象。 + +```typescript +{ + id: number, // 分类ID + name: string, // 分类名称 + newsCount?: number // 该分类下的新闻数量 +} +``` + +## 4.5 NewsDto + +新闻数据传输对象。 + +```typescript +{ + id: number, // 新闻ID + url: string, // 新闻URL + title: string, // 标题 + categoryId: number, // 分类ID + categoryName: string, // 分类名称 + publishTime: string, // 发布时间 + author: string, // 作者 + source: string, // 来源(网易/36kr) + content: string, // 正文内容 + createdAt: string // 入库时间 +} +``` + +--- + +# 五、常见错误 + +## 5.1 参数校验错误 + +```json +{ + "code": 400, + "message": "分类名称不能为空", + "data": null +} +``` + +## 5.2 资源不存在 + +```json +{ + "code": 404, + "message": "数据不存在", + "data": null +} +``` + +## 5.3 服务器错误 + +```json +{ + "code": 500, + "message": "服务端错误", + "data": null +} +``` + +--- + +# 六、接口调试 + +## 6.1 Swagger UI + +启动项目后访问:`http://localhost:8080/swagger-ui.html` + +在 Swagger UI 中可以直接测试所有接口。 + +## 6.2 cURL 示例 + +### 用户注册 +```bash +curl -X POST http://localhost:8080/api/v1/user/register \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"123456","email":"test@example.com"}' +``` + +### 用户登录 +```bash +curl -X POST http://localhost:8080/api/v1/user/login \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"123456"}' +``` + +### 获取新闻列表 +```bash +curl -X GET "http://localhost:8080/api/v1/news/list?page=1&size=10" +``` + +### 创建新闻 +```bash +curl -X POST http://localhost:8080/api/v1/news \ + -H "Content-Type: application/json" \ + -d '{ + "url":"https://example.com/news/001", + "title":"测试新闻", + "categoryId":4, + "content":"这是新闻正文内容" + }' +``` + +--- + +# 附录 + +## 初始分类数据 + +| ID | 名称 | +|----|------| +| 1 | 娱乐 | +| 2 | 体育 | +| 3 | 财经 | +| 4 | 科技 | +| 5 | 军事 | +| 6 | 汽车 | +| 7 | 政务 | +| 8 | 健康 | +| 9 | AI | + +## 版本历史 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2024-01-16 | 初始版本 | diff --git a/backend/pom.xml b/backend/pom.xml index b8bde66..41bb52c 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -9,10 +9,10 @@ com.aisi - chery + NewClassifier 0.0.1 - chery - chery + NewClassifier + NewClassifier @@ -95,6 +95,11 @@ spring-boot-starter-logging + + org.hibernate.validator + hibernate-validator + + diff --git a/backend/src/main/java/com/aisi/newsclassifier/config/JpaConfig.java b/backend/src/main/java/com/aisi/newsclassifier/config/JpaConfig.java new file mode 100644 index 0000000..63d1e6d --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.aisi.newsclassifier.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 配置类 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/config/SecurityConfig.java b/backend/src/main/java/com/aisi/newsclassifier/config/SecurityConfig.java index 42f7aaf..8c6fcec 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/config/SecurityConfig.java +++ b/backend/src/main/java/com/aisi/newsclassifier/config/SecurityConfig.java @@ -27,8 +27,12 @@ public class SecurityConfig { .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/v3/api-docs/**","/swagger-ui/**").permitAll() + .requestMatchers("/v3/api-docs/**","/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/api/v1/user/register", "/api/v1/user/login").permitAll() + // 分类和新闻接口通过 Controller 注解控制权限 + .requestMatchers("/api/v1/categories/**").permitAll() + .requestMatchers("/api/v1/news/**").permitAll() + .requestMatchers("/api/v1/user/info").authenticated() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/com/aisi/newsclassifier/controller/CategoryController.java b/backend/src/main/java/com/aisi/newsclassifier/controller/CategoryController.java new file mode 100644 index 0000000..0d3d52c --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/controller/CategoryController.java @@ -0,0 +1,70 @@ +package com.aisi.newsclassifier.controller; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.dto.CategoryCreateDto; +import com.aisi.newsclassifier.domain.vo.CategoryVo; +import com.aisi.newsclassifier.service.CategoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 分类管理接口 + */ +@RestController +@RequestMapping("/api/v1/categories") +@RequiredArgsConstructor +@Tag(name = "分类管理接口") +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping + @Operation(summary = "获取所有分类") + public RestBean> getAllCategories() { + return categoryService.getAllCategories(); + } + + @GetMapping("/with-count") + @Operation(summary = "获取分类及新闻数量") + public RestBean> getAllCategoriesWithNewsCount() { + return categoryService.getAllCategoriesWithNewsCount(); + } + + @GetMapping("/{id}") + @Operation(summary = "获取分类详情") + public RestBean getCategoryDetail( + @Parameter(description = "分类ID") @PathVariable Integer id) { + return categoryService.getCategoryDetail(id); + } + + @PostMapping + @Operation(summary = "创建分类") + @PreAuthorize("hasRole('ADMIN')") + public RestBean createCategory(@Valid @RequestBody CategoryCreateDto createDto) { + return categoryService.createCategory(createDto); + } + + @PutMapping("/{id}") + @Operation(summary = "更新分类") + @PreAuthorize("hasRole('ADMIN')") + public RestBean updateCategory( + @Parameter(description = "分类ID") @PathVariable Integer id, + @Valid @RequestBody CategoryCreateDto updateDto) { + return categoryService.updateCategory(id, updateDto); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除分类") + @PreAuthorize("hasRole('ADMIN')") + public RestBean deleteCategory( + @Parameter(description = "分类ID") @PathVariable Integer id) { + return categoryService.deleteCategory(id); + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/controller/NewsController.java b/backend/src/main/java/com/aisi/newsclassifier/controller/NewsController.java new file mode 100644 index 0000000..f1e3ac3 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/controller/NewsController.java @@ -0,0 +1,105 @@ +package com.aisi.newsclassifier.controller; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.RestCode; +import com.aisi.newsclassifier.domain.dto.*; +import com.aisi.newsclassifier.domain.vo.NewsVo; +import com.aisi.newsclassifier.service.NewsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 新闻管理接口 + */ +@RestController +@RequestMapping("/api/v1/news") +@RequiredArgsConstructor +@Tag(name = "新闻管理接口") +public class NewsController { + + private final NewsService newsService; + + @GetMapping("/list") + @Operation(summary = "获取新闻列表") + public RestBean> getNewsList(NewsQueryDto queryDto) { + return newsService.getNewsList(queryDto); + } + + @GetMapping("/{id}") + @Operation(summary = "获取新闻详情") + public RestBean getNewsDetail( + @Parameter(description = "新闻ID") @PathVariable Long id) { + return newsService.getNewsDetail(id); + } + + @PostMapping + @Operation(summary = "创建新闻") + @PreAuthorize("hasRole('ADMIN')") + public RestBean createNews(@Valid @RequestBody NewsCreateDto createDto) { + return newsService.createNews(createDto); + } + + @PutMapping + @Operation(summary = "更新新闻") + @PreAuthorize("hasRole('ADMIN')") + public RestBean updateNews(@Valid @RequestBody NewsUpdateDto updateDto) { + return newsService.updateNews(updateDto); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除新闻") + @PreAuthorize("hasRole('ADMIN')") + public RestBean deleteNews( + @Parameter(description = "新闻ID") @PathVariable Long id) { + if (id == null) + return RestBean.failure(RestCode.FAILURE); + return newsService.deleteNews(id); + } + + @DeleteMapping("/batch") + @Operation(summary = "批量删除新闻") + @PreAuthorize("hasRole('ADMIN')") + public RestBean batchDeleteNews(@RequestBody List ids) { + return newsService.batchDeleteNews(ids); + } + + @GetMapping("/search") + @Operation(summary = "搜索新闻") + public RestBean> searchNews( + @Parameter(description = "搜索关键词") @RequestParam String keyword, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") Integer size) { + return newsService.searchNews(keyword, page, size); + } + + @GetMapping("/category/{categoryId}") + @Operation(summary = "按分类获取新闻") + public RestBean> getNewsByCategory( + @Parameter(description = "分类ID") @PathVariable Integer categoryId, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") Integer size) { + return newsService.getNewsByCategory(categoryId, page, size); + } + + @GetMapping("/latest") + @Operation(summary = "获取最新新闻") + public RestBean> getLatestNews( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page, + @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") Integer size) { + return newsService.getLatestNews(page, size); + } + + @GetMapping("/statistics") + @Operation(summary = "获取新闻统计数据") + public RestBean> getNewsStatistics() { + return newsService.getNewsStatistics(); + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/controller/UserController.java b/backend/src/main/java/com/aisi/newsclassifier/controller/UserController.java index 6e0bdc3..56e9e9c 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/controller/UserController.java +++ b/backend/src/main/java/com/aisi/newsclassifier/controller/UserController.java @@ -2,8 +2,7 @@ package com.aisi.newsclassifier.controller; import com.aisi.newsclassifier.domain.RestBean; import com.aisi.newsclassifier.domain.dto.UserDto; -import com.aisi.newsclassifier.domain.entity.User; -import com.aisi.newsclassifier.repository.UserRepository; +import com.aisi.newsclassifier.domain.vo.UserVo; import com.aisi.newsclassifier.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -20,7 +19,7 @@ public class UserController { @GetMapping("info") @Operation(summary = "用户信息") - public RestBean getUserInfo() { + public RestBean getUserInfo() { return userService.getUserInfo(); } diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/CustomUserDetails.java b/backend/src/main/java/com/aisi/newsclassifier/domain/CustomUserDetails.java index 80019a5..ddd68a4 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/domain/CustomUserDetails.java +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/CustomUserDetails.java @@ -13,12 +13,17 @@ public class CustomUserDetails implements UserDetails { private final String username; private final String password; private final Collection authorities; + private final boolean enabled; + @Getter + private final String role; - public CustomUserDetails(Long id, String username, String password, Collection authorities) { + public CustomUserDetails(Long id, String username, String password, Collection authorities, boolean enabled, String role) { this.id = id; this.username = username; this.password = password; this.authorities = authorities; + this.enabled = enabled; + this.role = role; } @Override @@ -53,6 +58,6 @@ public class CustomUserDetails implements UserDetails { @Override public boolean isEnabled() { - return true; + return enabled; } } diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/CategoryCreateDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/CategoryCreateDto.java new file mode 100644 index 0000000..f4993e9 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/CategoryCreateDto.java @@ -0,0 +1,17 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 分类创建/更新请求DTO + */ +@Data +@Schema(description = "分类创建/更新请求") +public class CategoryCreateDto { + + @NotBlank(message = "分类名称不能为空") + @Schema(description = "分类名称", required = true, example = "科技") + private String name; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/CategoryDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/CategoryDto.java new file mode 100644 index 0000000..58d8467 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/CategoryDto.java @@ -0,0 +1,21 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 分类数据传输对象 + */ +@Data +@Schema(description = "分类数据传输对象") +public class CategoryDto { + + @Schema(description = "分类ID") + private Integer id; + + @Schema(description = "分类名称") + private String name; + + @Schema(description = "该分类下的新闻数量") + private Integer newsCount; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsCreateDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsCreateDto.java new file mode 100644 index 0000000..673d03d --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsCreateDto.java @@ -0,0 +1,39 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 新闻创建请求DTO + */ +@Data +@Schema(description = "新闻创建请求") +public class NewsCreateDto { + + @NotBlank(message = "URL不能为空") + @Schema(description = "新闻URL", required = true) + private String url; + + @NotBlank(message = "标题不能为空") + @Schema(description = "标题", required = true) + private String title; + + @Schema(description = "分类ID") + private Integer categoryId; + + @Schema(description = "发布时间") + private LocalDateTime publishTime; + + @Schema(description = "作者") + private String author; + + @Schema(description = "来源(网易/36kr)") + private String source; + + @NotBlank(message = "内容不能为空") + @Schema(description = "正文内容", required = true) + private String content; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsDto.java new file mode 100644 index 0000000..fefdd61 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsDto.java @@ -0,0 +1,44 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 新闻数据传输对象 + */ +@Data +@Schema(description = "新闻数据传输对象") +public class NewsDto { + + @Schema(description = "新闻ID") + private Long id; + + @Schema(description = "新闻URL") + private String url; + + @Schema(description = "标题") + private String title; + + @Schema(description = "分类ID") + private Integer categoryId; + + @Schema(description = "分类名称") + private String categoryName; + + @Schema(description = "发布时间") + private LocalDateTime publishTime; + + @Schema(description = "作者") + private String author; + + @Schema(description = "来源(网易/36kr)") + private String source; + + @Schema(description = "正文内容") + private String content; + + @Schema(description = "入库时间") + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsQueryDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsQueryDto.java new file mode 100644 index 0000000..3298865 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsQueryDto.java @@ -0,0 +1,33 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 新闻查询参数DTO + */ +@Data +@Schema(description = "新闻查询参数") +public class NewsQueryDto { + + @Schema(description = "页码", example = "1") + private Integer page = 1; + + @Schema(description = "每页大小", example = "20") + private Integer size = 20; + + @Schema(description = "分类ID") + private Integer categoryId; + + @Schema(description = "来源网站(网易/36kr)") + private String source; + + @Schema(description = "搜索关键词(标题或内容)") + private String keyword; + + @Schema(description = "排序字段", example = "createdAt") + private String sortBy = "createdAt"; + + @Schema(description = "排序方向", example = "DESC") + private String sortOrder = "DESC"; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsUpdateDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsUpdateDto.java new file mode 100644 index 0000000..725f234 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/NewsUpdateDto.java @@ -0,0 +1,35 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 新闻更新请求DTO + */ +@Data +@Schema(description = "新闻更新请求") +public class NewsUpdateDto { + + @Schema(description = "新闻ID", required = true) + private Long id; + + @Schema(description = "标题") + private String title; + + @Schema(description = "分类ID") + private Integer categoryId; + + @Schema(description = "发布时间") + private LocalDateTime publishTime; + + @Schema(description = "作者") + private String author; + + @Schema(description = "来源") + private String source; + + @Schema(description = "正文内容") + private String content; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/PageResult.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/PageResult.java new file mode 100644 index 0000000..1c37ec1 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/PageResult.java @@ -0,0 +1,59 @@ +package com.aisi.newsclassifier.domain.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 分页结果封装类 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "分页结果") +public class PageResult { + + @Schema(description = "数据列表") + private List records; + + @Schema(description = "总记录数") + private Long total; + + @Schema(description = "当前页") + private Integer page; + + @Schema(description = "每页大小") + private Integer size; + + @Schema(description = "总页数") + private Integer pages; + + /** + * 创建分页结果 + */ + public static PageResult of(List records, Long total, Integer page, Integer size) { + return new PageResult<>( + records, + total, + page, + size, + (int) Math.ceil((double) total / size) + ); + } + + /** + * 创建空的分页结果 + */ + public static PageResult empty(Integer page, Integer size) { + return new PageResult<>( + List.of(), + 0L, + page, + size, + 0 + ); + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/UserDto.java b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/UserDto.java index b43d745..196b137 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/domain/dto/UserDto.java +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/dto/UserDto.java @@ -3,12 +3,33 @@ package com.aisi.newsclassifier.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.time.LocalDateTime; + @Data +@Schema(description = "用户数据传输对象") public class UserDto { + + @Schema(description = "用户ID") + private Long id; + @Schema(description = "用户名") private String username; - @Schema(description = "邮件") + + @Schema(description = "邮箱") private String email; - @Schema(description = "密码") - private String password; // 注册时需要 + + @Schema(description = "密码(注册/登录时需要)") + private String password; + + @Schema(description = "状态(1=正常 0=禁用)") + private Integer status; + + @Schema(description = "角色(USER=普通用户,ADMIN=管理员)") + private String role; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; } diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/entity/Category.java b/backend/src/main/java/com/aisi/newsclassifier/domain/entity/Category.java new file mode 100644 index 0000000..6bc995d --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/entity/Category.java @@ -0,0 +1,23 @@ +package com.aisi.newsclassifier.domain.entity; + +import jakarta.persistence.*; +import lombok.Data; + +/** + * 新闻分类实体 + */ +@Entity +@Table(name = "news_category", uniqueConstraints = { + @UniqueConstraint(name = "uk_name", columnNames = {"name"}) +}) +@Data +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "name", nullable = false, unique = true, length = 50) + private String name; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/entity/News.java b/backend/src/main/java/com/aisi/newsclassifier/domain/entity/News.java new file mode 100644 index 0000000..c5dc861 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/entity/News.java @@ -0,0 +1,108 @@ +package com.aisi.newsclassifier.domain.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.*; +import lombok.Data; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; + +/** + * 新闻实体 + */ +@Entity +@Table(name = "news", uniqueConstraints = { + @UniqueConstraint(name = "uk_url", columnNames = {"url"}), + @UniqueConstraint(name = "uk_content_hash", columnNames = {"content_hash"}) +}, indexes = { + @Index(name = "idx_category_id", columnList = "category_id"), + @Index(name = "idx_source", columnList = "source") +}) +@Data +public class News { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "url", nullable = false, unique = true, length = 500) + private String url; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "category_id") + private Integer categoryId; + + @Column(name = "publish_time") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime publishTime; + + @Column(name = "author", length = 100) + private String author; + + @Column(name = "source", length = 50) + private String source; + + @Column(name = "content", nullable = false, columnDefinition = "LONGTEXT") + private String content; + + @JsonIgnore + @Column(name = "content_hash", nullable = false, unique = true, length = 64) + private String contentHash; + + @Column(name = "created_at", nullable = false, updatable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + /** + * 生成内容SHA256哈希值(用于去重) + */ + public static String generateContentHash(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256算法不可用", e); + } + } + + /** + * 设置内容并自动生成哈希值 + */ + public void setContent(String content) { + this.content = content; + this.contentHash = generateContentHash(content); + } + + /** + * 设置内容并指定哈希值(用于爬虫已计算哈希的情况) + */ + public void setContentWithHash(String content, String contentHash) { + this.content = content; + this.contentHash = contentHash; + } + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (content != null && contentHash == null) { + contentHash = generateContentHash(content); + } + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/entity/User.java b/backend/src/main/java/com/aisi/newsclassifier/domain/entity/User.java index 3196cfe..78ad5a6 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/domain/entity/User.java +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/entity/User.java @@ -1,23 +1,59 @@ package com.aisi.newsclassifier.domain.entity; +import com.aisi.newsclassifier.domain.enums.Role; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.persistence.*; import lombok.Data; import lombok.ToString; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; @Entity @Table(name = "users") @Data +@EntityListeners(AuditingEntityListener.class) public class User { @Id - @GeneratedValue(strategy = GenerationType.AUTO) // 自增组件 - private Long id; + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; - @Column(nullable = false,unique = true) // 非空+唯一 + @Column(nullable = false, unique = true, length = 50) private String username; @ToString.Exclude + @JsonIgnore + @Column(nullable = false) private String password; + @Column(unique = true) private String email; + + @Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 1 COMMENT '1=正常 0=禁用'") + private Integer status = 1; + + @Column(name = "role", nullable = false, length = 20, columnDefinition = "VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '用户角色:USER=普通用户,ADMIN=管理员'") + @Enumerated(EnumType.STRING) + private Role role = Role.USER; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedAt; + + /** + * 检查用户是否启用 + */ + public boolean isEnabled() { + return status != null && status == 1; + } } diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/enums/Role.java b/backend/src/main/java/com/aisi/newsclassifier/domain/enums/Role.java new file mode 100644 index 0000000..2840904 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/enums/Role.java @@ -0,0 +1,49 @@ +package com.aisi.newsclassifier.domain.enums; + +import lombok.Getter; + +/** + * 用户角色枚举 + */ +@Getter +public enum Role { + + /** + * 普通用户 + */ + USER("ROLE_USER", "普通用户"), + + /** + * 管理员 + */ + ADMIN("ROLE_ADMIN", "管理员"); + + private final String authority; + private final String description; + + Role(String authority, String description) { + this.authority = authority; + this.description = description; + } + + /** + * 从数据库字符串转换为枚举 + */ + public static Role fromString(String role) { + if (role == null) { + return USER; + } + try { + return Role.valueOf(role.toUpperCase()); + } catch (IllegalArgumentException e) { + return USER; + } + } + + /** + * 转换为数据库存储格式 + */ + public String toDbValue() { + return this.name(); + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/vo/CategoryVo.java b/backend/src/main/java/com/aisi/newsclassifier/domain/vo/CategoryVo.java new file mode 100644 index 0000000..e135fb4 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/vo/CategoryVo.java @@ -0,0 +1,18 @@ +package com.aisi.newsclassifier.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "分类视图对象") +public class CategoryVo { + + @Schema(description = "分类ID") + private Integer id; + + @Schema(description = "分类名称") + private String name; + + @Schema(description = "该分类下的新闻数量") + private Integer newsCount; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/vo/NewsVo.java b/backend/src/main/java/com/aisi/newsclassifier/domain/vo/NewsVo.java new file mode 100644 index 0000000..b135394 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/vo/NewsVo.java @@ -0,0 +1,41 @@ +package com.aisi.newsclassifier.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "新闻视图对象") +public class NewsVo { + + @Schema(description = "新闻ID") + private Long id; + + @Schema(description = "新闻URL") + private String url; + + @Schema(description = "标题") + private String title; + + @Schema(description = "分类ID") + private Integer categoryId; + + @Schema(description = "分类名称") + private String categoryName; + + @Schema(description = "发布时间") + private LocalDateTime publishTime; + + @Schema(description = "作者") + private String author; + + @Schema(description = "来源(网易/36kr)") + private String source; + + @Schema(description = "正文内容(摘要或完整内容)") + private String content; + + @Schema(description = "入库时间") + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/domain/vo/UserVo.java b/backend/src/main/java/com/aisi/newsclassifier/domain/vo/UserVo.java new file mode 100644 index 0000000..dd7499e --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/domain/vo/UserVo.java @@ -0,0 +1,32 @@ +package com.aisi.newsclassifier.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "用户视图对象") +public class UserVo { + + @Schema(description = "用户ID") + private Long id; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "状态(1=正常 0=禁用)") + private Integer status; + + @Schema(description = "角色(USER=普通用户,ADMIN=管理员)") + private String role; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/handler/GlobalExceptionHandler.java b/backend/src/main/java/com/aisi/newsclassifier/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..5750766 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/handler/GlobalExceptionHandler.java @@ -0,0 +1,34 @@ +package com.aisi.newsclassifier.handler; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.RestCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理器 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 处理权限拒绝异常 + */ + @ExceptionHandler(AuthorizationDeniedException.class) + public RestBean handleAuthorizationDenied(AuthorizationDeniedException e) { + log.warn("权限拒绝: {}", e.getMessage()); + return RestBean.failure(RestCode.UNAUTHORIZED); + } + + /** + * 处理其他异常 + */ + @ExceptionHandler(Exception.class) + public RestBean handleException(Exception e) { + log.error("系统异常: ", e); + return RestBean.failure(RestCode.SYSTEM_ERROR); + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/repository/CategoryRepository.java b/backend/src/main/java/com/aisi/newsclassifier/repository/CategoryRepository.java new file mode 100644 index 0000000..97acde8 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/repository/CategoryRepository.java @@ -0,0 +1,44 @@ +package com.aisi.newsclassifier.repository; + +import com.aisi.newsclassifier.domain.entity.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 分类数据访问层 + */ +@Repository +public interface CategoryRepository extends JpaRepository { + + /** + * 根据名称查找分类 + */ + Optional findByName(String name); + + /** + * 检查名称是否存在 + */ + boolean existsByName(String name); + + /** + * 查询所有分类,按ID排序 + */ + List findAllByOrderByIdAsc(); + + /** + * 根据名称模糊查询 + */ + List findByNameContainingIgnoreCase(String name); + + /** + * 统计每个分类下的新闻数量 + */ + @Query("SELECT new map(c.id as id, c.name as name, COUNT(n.id) as newsCount) " + + "FROM Category c LEFT JOIN News n ON c.id = n.categoryId " + + "GROUP BY c.id, c.name") + List findAllWithNewsCount(); +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/repository/NewsRepository.java b/backend/src/main/java/com/aisi/newsclassifier/repository/NewsRepository.java new file mode 100644 index 0000000..dd158d4 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/repository/NewsRepository.java @@ -0,0 +1,90 @@ +package com.aisi.newsclassifier.repository; + +import com.aisi.newsclassifier.domain.entity.News; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 新闻数据访问层 + */ +@Repository +public interface NewsRepository extends JpaRepository, JpaSpecificationExecutor { + + /** + * 根据URL查找新闻(用于去重检查) + */ + News findByUrl(String url); + + /** + * 根据内容哈希查找新闻(用于去重检查) + */ + News findByContentHash(String contentHash); + + /** + * 检查URL是否存在 + */ + boolean existsByUrl(String url); + + /** + * 检查内容哈希是否存在 + */ + boolean existsByContentHash(String contentHash); + + /** + * 根据分类ID分页查询新闻 + */ + Page findByCategoryIdOrderByCreatedAtDesc(Integer categoryId, Pageable pageable); + + /** + * 根据来源分页查询新闻 + */ + Page findBySourceOrderByCreatedAtDesc(String source, Pageable pageable); + + /** + * 全文搜索新闻(标题或内容包含关键词) + */ + @Query("SELECT n FROM News n WHERE " + + "(n.title LIKE %:keyword% OR n.content LIKE %:keyword%) " + + "ORDER BY n.createdAt DESC") + Page searchByKeyword(@Param("keyword") String keyword, Pageable pageable); + + /** + * 统计指定分类下的新闻数量 + */ + Long countByCategoryId(Integer categoryId); + + /** + * 统计指定来源的新闻数量 + */ + Long countBySource(String source); + + /** + * 查询最新新闻 + */ + @Query("SELECT n FROM News n ORDER BY n.createdAt DESC") + Page findLatestNews(Pageable pageable); + + /** + * 统计每个分类的新闻数量 + */ + @Query("SELECT n.categoryId, COUNT(n) FROM News n GROUP BY n.categoryId") + List countByCategory(); + + /** + * 统计每个来源的新闻数量 + */ + @Query("SELECT n.source, COUNT(n) FROM News n GROUP BY n.source") + List countBySourceGroup(); + + /** + * 批量删除新闻 + */ + void deleteAllByIdIn(List ids); +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/repository/UserRepository.java b/backend/src/main/java/com/aisi/newsclassifier/repository/UserRepository.java index ef4e862..bf3fbf9 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/repository/UserRepository.java +++ b/backend/src/main/java/com/aisi/newsclassifier/repository/UserRepository.java @@ -3,15 +3,35 @@ package com.aisi.newsclassifier.repository; import com.aisi.newsclassifier.domain.entity.User; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserRepository extends JpaRepository { +import java.util.Optional; - User findByUsername(String username); +public interface UserRepository extends JpaRepository { - User findByEmail(String email); + Optional findByUsername(String username); - User findByUsernameAndPassword(String username, String password); + Optional findByEmail(String email); - User findByEmailAndPassword(String email, String password); + Optional findByUsernameAndPassword(String username, String password); - User findByEmailContaining(String keyword); + Optional findByEmailAndPassword(String email, String password); + + /** + * 根据用户名查找启用的用户 + */ + Optional findByUsernameAndStatus(String username, Integer status); + + /** + * 根据邮箱查找启用的用户 + */ + Optional findByEmailAndStatus(String email, Integer status); + + /** + * 检查用户名是否存在 + */ + boolean existsByUsername(String username); + + /** + * 检查邮箱是否存在 + */ + boolean existsByEmail(String email); } diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/CategoryService.java b/backend/src/main/java/com/aisi/newsclassifier/service/CategoryService.java new file mode 100644 index 0000000..e97491a --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/service/CategoryService.java @@ -0,0 +1,43 @@ +package com.aisi.newsclassifier.service; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.dto.CategoryCreateDto; +import com.aisi.newsclassifier.domain.vo.CategoryVo; + +import java.util.List; + +/** + * 分类服务接口 + */ +public interface CategoryService { + + /** + * 获取所有分类 + */ + RestBean> getAllCategories(); + + /** + * 获取分类详情 + */ + RestBean getCategoryDetail(Integer id); + + /** + * 创建分类 + */ + RestBean createCategory(CategoryCreateDto createDto); + + /** + * 更新分类 + */ + RestBean updateCategory(Integer id, CategoryCreateDto updateDto); + + /** + * 删除分类 + */ + RestBean deleteCategory(Integer id); + + /** + * 获取分类及其新闻数量 + */ + RestBean> getAllCategoriesWithNewsCount(); +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/NewsService.java b/backend/src/main/java/com/aisi/newsclassifier/service/NewsService.java new file mode 100644 index 0000000..2a339e6 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/service/NewsService.java @@ -0,0 +1,64 @@ +package com.aisi.newsclassifier.service; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.dto.*; +import com.aisi.newsclassifier.domain.vo.NewsVo; + +import java.util.List; +import java.util.Map; + +/** + * 新闻服务接口 + */ +public interface NewsService { + + /** + * 分页查询新闻列表 + */ + RestBean> getNewsList(NewsQueryDto queryDto); + + /** + * 获取新闻详情 + */ + RestBean getNewsDetail(Long id); + + /** + * 创建新闻(支持去重检查) + */ + RestBean createNews(NewsCreateDto createDto); + + /** + * 更新新闻 + */ + RestBean updateNews(NewsUpdateDto updateDto); + + /** + * 删除新闻 + */ + RestBean deleteNews(Long id); + + /** + * 批量删除新闻 + */ + RestBean batchDeleteNews(List ids); + + /** + * 搜索新闻 + */ + RestBean> searchNews(String keyword, Integer page, Integer size); + + /** + * 按分类查询新闻 + */ + RestBean> getNewsByCategory(Integer categoryId, Integer page, Integer size); + + /** + * 获取最新新闻 + */ + RestBean> getLatestNews(Integer page, Integer size); + + /** + * 获取新闻统计数据 + */ + RestBean> getNewsStatistics(); +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/UserService.java b/backend/src/main/java/com/aisi/newsclassifier/service/UserService.java index 1f8d8b1..1a702a3 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/service/UserService.java +++ b/backend/src/main/java/com/aisi/newsclassifier/service/UserService.java @@ -2,12 +2,13 @@ package com.aisi.newsclassifier.service; import com.aisi.newsclassifier.domain.RestBean; import com.aisi.newsclassifier.domain.dto.UserDto; +import com.aisi.newsclassifier.domain.vo.UserVo; import org.springframework.stereotype.Service; public interface UserService { - RestBean getUserInfo(); + RestBean getUserInfo(); RestBean register(UserDto userDto); diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/impl/CategoryServiceImpl.java b/backend/src/main/java/com/aisi/newsclassifier/service/impl/CategoryServiceImpl.java new file mode 100644 index 0000000..3c18753 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/service/impl/CategoryServiceImpl.java @@ -0,0 +1,148 @@ +package com.aisi.newsclassifier.service.impl; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.RestCode; +import com.aisi.newsclassifier.domain.dto.CategoryCreateDto; +import com.aisi.newsclassifier.domain.entity.Category; +import com.aisi.newsclassifier.domain.vo.CategoryVo; +import com.aisi.newsclassifier.repository.CategoryRepository; +import com.aisi.newsclassifier.repository.NewsRepository; +import com.aisi.newsclassifier.service.CategoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 分类服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CategoryServiceImpl implements CategoryService { + + private final CategoryRepository categoryRepository; + private final NewsRepository newsRepository; + + @Override + public RestBean> getAllCategories() { + try { + List categories = categoryRepository.findAllByOrderByIdAsc(); + List categoryVos = categories.stream() + .map(this::convertToVo) + .collect(Collectors.toList()); + return RestBean.success(categoryVos); + } catch (Exception e) { + log.error("获取分类列表失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean getCategoryDetail(Integer id) { + try { + return categoryRepository.findById(id) + .map(category -> RestBean.success(convertToVo(category))) + .orElseGet(() -> RestBean.failure(RestCode.DATA_NOT_FOUND, null)); + } catch (Exception e) { + log.error("获取分类详情失败, id={}", id, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean createCategory(CategoryCreateDto createDto) { + try { + // 检查名称是否已存在 + if (categoryRepository.existsByName(createDto.getName())) { + return RestBean.failure(400, "分类名称已存在", null); + } + + Category category = new Category(); + category.setName(createDto.getName()); + + Category savedCategory = categoryRepository.save(category); + return RestBean.success(convertToVo(savedCategory)); + } catch (Exception e) { + log.error("创建分类失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean updateCategory(Integer id, CategoryCreateDto updateDto) { + try { + Category category = categoryRepository.findById(id) + .orElse(null); + + if (category == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + + // 检查名称是否被其他分类使用 + if (!category.getName().equals(updateDto.getName()) && + categoryRepository.existsByName(updateDto.getName())) { + return RestBean.failure(400, "分类名称已存在", null); + } + + category.setName(updateDto.getName()); + + Category updatedCategory = categoryRepository.save(category); + return RestBean.success(convertToVo(updatedCategory)); + } catch (Exception e) { + log.error("更新分类失败, id={}", id, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean deleteCategory(Integer id) { + try { + if (!categoryRepository.existsById(id)) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + + // TODO: 检查是否有新闻使用该分类,有则不允许删除或需要先转移分类 + + categoryRepository.deleteById(id); + return RestBean.success(RestCode.SUCCESS); + } catch (Exception e) { + log.error("删除分类失败, id={}", id, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean> getAllCategoriesWithNewsCount() { + try { + List categories = categoryRepository.findAllByOrderByIdAsc(); + List categoryVos = categories.stream() + .map(category -> { + CategoryVo vo = convertToVo(category); + // 统计该分类下的新闻数量 + Long count = newsRepository.countByCategoryId(category.getId()); + vo.setNewsCount(count.intValue()); + return vo; + }) + .collect(Collectors.toList()); + + return RestBean.success(categoryVos); + } catch (Exception e) { + log.error("获取分类及新闻数量失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + private CategoryVo convertToVo(Category category) { + CategoryVo vo = new CategoryVo(); + vo.setId(category.getId()); + vo.setName(category.getName()); + return vo; + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/impl/CustomUserDetailsService.java b/backend/src/main/java/com/aisi/newsclassifier/service/impl/CustomUserDetailsService.java index a6945d6..8ad264e 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/service/impl/CustomUserDetailsService.java +++ b/backend/src/main/java/com/aisi/newsclassifier/service/impl/CustomUserDetailsService.java @@ -18,15 +18,21 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userRepository.findByUsername(username); - if (user == null) { - throw new UsernameNotFoundException("User not found"); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username)); + + // 检查用户状态 + if (!user.isEnabled()) { + throw new UsernameNotFoundException("用户已被禁用: " + username); } + return new CustomUserDetails( user.getId(), user.getUsername(), - "*********", - List.of(() -> "USER") + user.getPassword(), + List.of(() -> user.getRole().getAuthority()), + user.isEnabled(), + user.getRole().name() ); } } diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/impl/NewsServiceImpl.java b/backend/src/main/java/com/aisi/newsclassifier/service/impl/NewsServiceImpl.java new file mode 100644 index 0000000..1772917 --- /dev/null +++ b/backend/src/main/java/com/aisi/newsclassifier/service/impl/NewsServiceImpl.java @@ -0,0 +1,360 @@ +package com.aisi.newsclassifier.service.impl; + +import com.aisi.newsclassifier.domain.RestBean; +import com.aisi.newsclassifier.domain.RestCode; +import com.aisi.newsclassifier.domain.dto.*; +import com.aisi.newsclassifier.domain.entity.Category; +import com.aisi.newsclassifier.domain.entity.News; +import com.aisi.newsclassifier.domain.vo.NewsVo; +import com.aisi.newsclassifier.repository.CategoryRepository; +import com.aisi.newsclassifier.repository.NewsRepository; +import com.aisi.newsclassifier.service.NewsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 新闻服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsServiceImpl implements NewsService { + + private final NewsRepository newsRepository; + private final CategoryRepository categoryRepository; + + @Override + public RestBean> getNewsList(NewsQueryDto queryDto) { + try { + // 构建分页和排序 + Sort sort = Sort.by( + queryDto.getSortOrder().equalsIgnoreCase("ASC") + ? Sort.Order.asc(queryDto.getSortBy()) + : Sort.Order.desc(queryDto.getSortBy()) + ); + Pageable pageable = PageRequest.of(queryDto.getPage() - 1, queryDto.getSize(), sort); + + // 构建查询条件 + Specification spec = buildSpecification(queryDto); + + // 执行查询 + Page newsPage = newsRepository.findAll(spec, pageable); + + // 转换为VO + List newsVos = convertToVoList(newsPage.getContent()); + + // 构建分页结果 + PageResult pageResult = PageResult.of( + newsVos, + newsPage.getTotalElements(), + queryDto.getPage(), + queryDto.getSize() + ); + + return RestBean.success(pageResult); + } catch (Exception e) { + log.error("查询新闻列表失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean getNewsDetail(Long id) { + try { + News news = newsRepository.findById(id).orElse(null); + if (news == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + return RestBean.success(convertToVo(news)); + } catch (Exception e) { + log.error("获取新闻详情失败, id={}", id, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean createNews(NewsCreateDto createDto) { + try { + // 检查URL是否重复 + if (newsRepository.existsByUrl(createDto.getUrl())) { + return RestBean.failure(400, "该URL的新闻已存在", null); + } + + // 检查分类是否存在 + if (createDto.getCategoryId() != null) { + if (!categoryRepository.existsById(createDto.getCategoryId())) { + return RestBean.failure(400, "指定的分类不存在", null); + } + } + + News news = new News(); + news.setUrl(createDto.getUrl()); + news.setTitle(createDto.getTitle()); + news.setCategoryId(createDto.getCategoryId()); + news.setPublishTime(createDto.getPublishTime()); + news.setAuthor(createDto.getAuthor()); + news.setSource(createDto.getSource()); + news.setContent(createDto.getContent()); // 会自动生成contentHash + + News savedNews = newsRepository.save(news); + return RestBean.success(convertToVo(savedNews)); + } catch (Exception e) { + log.error("创建新闻失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean updateNews(NewsUpdateDto updateDto) { + try { + News news = newsRepository.findById(updateDto.getId()).orElse(null); + if (news == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + + // 检查分类是否存在 + if (updateDto.getCategoryId() != null) { + if (!categoryRepository.existsById(updateDto.getCategoryId())) { + return RestBean.failure(400, "指定的分类不存在", null); + } + } + + // 更新字段 + if (StringUtils.hasText(updateDto.getTitle())) { + news.setTitle(updateDto.getTitle()); + } + if (updateDto.getCategoryId() != null) { + news.setCategoryId(updateDto.getCategoryId()); + } + if (updateDto.getPublishTime() != null) { + news.setPublishTime(updateDto.getPublishTime()); + } + if (StringUtils.hasText(updateDto.getAuthor())) { + news.setAuthor(updateDto.getAuthor()); + } + if (StringUtils.hasText(updateDto.getSource())) { + news.setSource(updateDto.getSource()); + } + if (StringUtils.hasText(updateDto.getContent())) { + news.setContent(updateDto.getContent()); // 会自动重新生成contentHash + } + + News updatedNews = newsRepository.save(news); + return RestBean.success(convertToVo(updatedNews)); + } catch (Exception e) { + log.error("更新新闻失败, id={}", updateDto.getId(), e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean deleteNews(Long id) { + try { + if (!newsRepository.existsById(id)) { + return RestBean.failure(RestCode.DATA_NOT_FOUND, null); + } + newsRepository.deleteById(id); + return RestBean.success(RestCode.SUCCESS); + } catch (Exception e) { + log.error("删除新闻失败, id={}", id, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + @Transactional + public RestBean batchDeleteNews(List ids) { + try { + newsRepository.deleteAllByIdIn(ids); + return RestBean.success(RestCode.SUCCESS); + } catch (Exception e) { + log.error("批量删除新闻失败, ids={}", ids, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean> searchNews(String keyword, Integer page, Integer size) { + try { + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()); + Page newsPage = newsRepository.searchByKeyword(keyword, pageable); + + List newsVos = convertToVoList(newsPage.getContent()); + PageResult pageResult = PageResult.of( + newsVos, + newsPage.getTotalElements(), + page, + size + ); + + return RestBean.success(pageResult); + } catch (Exception e) { + log.error("搜索新闻失败, keyword={}", keyword, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean> getNewsByCategory(Integer categoryId, Integer page, Integer size) { + try { + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()); + Page newsPage = newsRepository.findByCategoryIdOrderByCreatedAtDesc(categoryId, pageable); + + List newsVos = convertToVoList(newsPage.getContent()); + PageResult pageResult = PageResult.of( + newsVos, + newsPage.getTotalElements(), + page, + size + ); + + return RestBean.success(pageResult); + } catch (Exception e) { + log.error("按分类查询新闻失败, categoryId={}", categoryId, e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean> getLatestNews(Integer page, Integer size) { + try { + Pageable pageable = PageRequest.of(page - 1, size); + Page newsPage = newsRepository.findLatestNews(pageable); + + List newsVos = convertToVoList(newsPage.getContent()); + PageResult pageResult = PageResult.of( + newsVos, + newsPage.getTotalElements(), + page, + size + ); + + return RestBean.success(pageResult); + } catch (Exception e) { + log.error("获取最新新闻失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + @Override + public RestBean> getNewsStatistics() { + try { + Map stats = new HashMap<>(); + + // 总新闻数 + stats.put("totalNews", newsRepository.count()); + + // 按分类统计 + List categoryStats = newsRepository.countByCategory(); + Map categoryMap = new HashMap<>(); + for (Object[] stat : categoryStats) { + Integer categoryId = (Integer) stat[0]; + Long count = (Long) stat[1]; + categoryRepository.findById(categoryId).ifPresent(category -> { + categoryMap.put(category.getName(), count); + }); + } + stats.put("categoryStats", categoryMap); + + // 按来源统计 + List sourceStats = newsRepository.countBySourceGroup(); + Map sourceMap = new HashMap<>(); + for (Object[] stat : sourceStats) { + String source = (String) stat[0]; + Long count = (Long) stat[1]; + sourceMap.put(source, count); + } + stats.put("sourceStats", sourceMap); + + return RestBean.success(stats); + } catch (Exception e) { + log.error("获取新闻统计失败", e); + return RestBean.failure(RestCode.SYSTEM_ERROR, null); + } + } + + /** + * 构建查询条件 + */ + + private Specification buildSpecification(NewsQueryDto queryDto) { + return (root, query, cb) -> { + // 不带前缀,直接用 Predicate + List predicates = new ArrayList<>(); + + // 分类筛选 + if (queryDto.getCategoryId() != null) { + predicates.add(cb.equal(root.get("categoryId"), queryDto.getCategoryId())); + } + + // 来源筛选 + if (StringUtils.hasText(queryDto.getSource())) { + predicates.add(cb.equal(root.get("source"), queryDto.getSource())); + } + + // 关键词搜索 + if (StringUtils.hasText(queryDto.getKeyword())) { + String keyword = "%" + queryDto.getKeyword() + "%"; + predicates.add(cb.or( + cb.like(root.get("title"), keyword), + cb.like(root.get("content"), keyword) + )); + } + + // 返回组合条件 + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + /** + * 转换单个新闻为VO + */ + private NewsVo convertToVo(News news) { + NewsVo vo = new NewsVo(); + vo.setId(news.getId()); + vo.setUrl(news.getUrl()); + vo.setTitle(news.getTitle()); + vo.setCategoryId(news.getCategoryId()); + vo.setPublishTime(news.getPublishTime()); + vo.setAuthor(news.getAuthor()); + vo.setSource(news.getSource()); + vo.setContent(news.getContent()); + vo.setCreatedAt(news.getCreatedAt()); + + // 查询分类信息 + if (news.getCategoryId() != null) { + categoryRepository.findById(news.getCategoryId()).ifPresent(category -> { + vo.setCategoryName(category.getName()); + }); + } + + return vo; + } + + /** + * 转换新闻列表为VO列表 + */ + private List convertToVoList(List newsList) { + return newsList.stream() + .map(this::convertToVo) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/aisi/newsclassifier/service/impl/UserServiceImpl.java b/backend/src/main/java/com/aisi/newsclassifier/service/impl/UserServiceImpl.java index b4d15d5..837a037 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/service/impl/UserServiceImpl.java +++ b/backend/src/main/java/com/aisi/newsclassifier/service/impl/UserServiceImpl.java @@ -4,13 +4,13 @@ import com.aisi.newsclassifier.domain.RestBean; import com.aisi.newsclassifier.domain.RestCode; import com.aisi.newsclassifier.domain.dto.UserDto; import com.aisi.newsclassifier.domain.entity.User; +import com.aisi.newsclassifier.domain.enums.Role; +import com.aisi.newsclassifier.domain.vo.UserVo; import com.aisi.newsclassifier.repository.UserRepository; import com.aisi.newsclassifier.service.UserService; import com.aisi.newsclassifier.utils.JwtUtil; import com.aisi.newsclassifier.utils.SecurityUtils; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -21,43 +21,75 @@ public class UserServiceImpl implements UserService { private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; private final UserRepository userRepository; - private SecurityUtils securityUtils; + private final SecurityUtils securityUtils; @Override - public RestBean getUserInfo() { + public RestBean getUserInfo() { String username = SecurityUtils.getUsername(); - User user = userRepository.findByUsername(username); - return RestBean.success(user.toString()); + User user = userRepository.findByUsername(username) + .orElse(null); + if (user == null) { + return RestBean.failure(RestCode.DATA_NOT_FOUND); + } + // 转换为 UserVo 返回 + UserVo userVo = new UserVo(); + userVo.setId(user.getId()); + userVo.setUsername(user.getUsername()); + userVo.setEmail(user.getEmail()); + userVo.setStatus(user.getStatus()); + userVo.setRole(user.getRole().name()); + userVo.setCreatedAt(user.getCreatedAt()); + userVo.setUpdatedAt(user.getUpdatedAt()); + return RestBean.success(userVo); } @Override public RestBean register(UserDto userDto) { - if (userRepository.findByUsername(userDto.getUsername()) != null) { - return RestBean.failure(RestCode.FAILURE,"Username is already in use"); + // 检查用户名是否存在 + if (userRepository.existsByUsername(userDto.getUsername())) { + return RestBean.failure(400, "用户名已被使用", null); } - if (userRepository.findByEmail(userDto.getEmail()) != null) { - return RestBean.failure(RestCode.FAILURE, "Email is already in use"); + // 检查邮箱是否存在 + if (userRepository.existsByEmail(userDto.getEmail())) { + return RestBean.failure(400, "邮箱已被使用", null); } + User user = new User(); user.setUsername(userDto.getUsername()); user.setPassword(passwordEncoder.encode(userDto.getPassword())); user.setEmail(userDto.getEmail()); - String token = jwtUtil.generateToken(user.getId(),user.getUsername()); + user.setRole(Role.USER); // 新注册用户默认为普通用户 + // 默认状态为1(正常),已在实体类中设置 + userRepository.save(user); + + // 生成token + String token = jwtUtil.generateToken(user.getId(), user.getUsername()); return RestBean.success(token); } @Override public RestBean login(UserDto userDto) { - User user = userRepository.findByUsername(userDto.getUsername()); + // 查找用户 + User user = userRepository.findByUsername(userDto.getUsername()) + .orElse(null); + if (user == null) { - return RestBean.failure(RestCode.DATA_NOT_FOUND, "User does not exist"); + return RestBean.failure(RestCode.DATA_NOT_FOUND, "用户不存在"); } + + // 检查用户状态 + if (!user.isEnabled()) { + return RestBean.failure(403, "用户已被禁用", null); + } + + // 验证密码 if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) { - return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR,"Username or password is incorrect"); + return RestBean.failure(RestCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误"); } - // 刷新token + + // 生成token String token = jwtUtil.generateToken(user.getId(), user.getUsername()); - return RestBean.success(RestCode.SUCCESS, token); + return RestBean.success(token); } } diff --git a/backend/src/main/java/com/aisi/newsclassifier/utils/JwtUtil.java b/backend/src/main/java/com/aisi/newsclassifier/utils/JwtUtil.java index 0a4b5d2..6ae4030 100644 --- a/backend/src/main/java/com/aisi/newsclassifier/utils/JwtUtil.java +++ b/backend/src/main/java/com/aisi/newsclassifier/utils/JwtUtil.java @@ -2,16 +2,28 @@ package com.aisi.newsclassifier.utils; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; import java.security.Key; import java.util.Date; @Component public class JwtUtil { - private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); - private final long expiration = 1000 * 60 * 60; // 1小时 + @Value("${jwt.secret}") + private String secret; + + private final long expiration = 1000 * 60 * 60 *24; // 24小时 + + private Key getSigningKey() { + // 确保密钥足够长(至少 256 位 = 32 字节) + if (secret.length() < 32) { + throw new IllegalArgumentException("JWT secret must be at least 32 characters long"); + } + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } public String generateToken(Long userId,String username) { return Jwts.builder() @@ -19,7 +31,7 @@ public class JwtUtil { .claim("id", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(key) + .signWith(getSigningKey(), SignatureAlgorithm.HS512) .compact(); } @@ -38,7 +50,7 @@ public class JwtUtil { private Jws parseClaims(String token) { return Jwts.parserBuilder() - .setSigningKey(key) + .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); } diff --git a/backend/src/main/resources/application-dev.yaml b/backend/src/main/resources/application-dev.yaml index 6f2544b..829d501 100644 --- a/backend/src/main/resources/application-dev.yaml +++ b/backend/src/main/resources/application-dev.yaml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:news}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:news}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true username: ${DB_USER:root} password: ${DB_PASS:root} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/backend/src/main/resources/application-prod.yaml b/backend/src/main/resources/application-prod.yaml index d89014b..b0d224d 100644 --- a/backend/src/main/resources/application-prod.yaml +++ b/backend/src/main/resources/application-prod.yaml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:news}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:news}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true username: ${DB_USER:root} password: ${DB_PASS:root} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 107b9e4..7b098b8 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -5,4 +5,7 @@ spring: active: dev server: - port: 8080 \ No newline at end of file + port: 8080 + +jwt: + secret: newsClassifierSecretKeyForJWT2024MustBeLongEnoughForHS256Algorithm \ No newline at end of file diff --git a/backend/src/main/resources/sql/init.sql b/backend/src/main/resources/sql/init.sql new file mode 100644 index 0000000..f41c35a --- /dev/null +++ b/backend/src/main/resources/sql/init.sql @@ -0,0 +1,73 @@ +-- ============================================ +-- 新闻分类器数据库初始化脚本 +-- ============================================ + +-- 1. 创建分类表 +CREATE TABLE IF NOT EXISTS news_category ( + id INT NOT NULL AUTO_INCREMENT COMMENT '分类ID', + name VARCHAR(50) NOT NULL COMMENT '分类名称', + PRIMARY KEY (id), + UNIQUE KEY uk_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='新闻分类表'; + +-- 2. 插入初始分类数据 +INSERT INTO news_category (id, name) VALUES +(1, '娱乐'), +(2, '体育'), +(3, '财经'), +(4, '科技'), +(5, '军事'), +(6, '汽车'), +(7, '政务'), +(8, '健康'), +(9, 'AI') +ON DUPLICATE KEY UPDATE name=VALUES(name); + +-- 3. 创建新闻表 +CREATE TABLE IF NOT EXISTS news ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键', + url VARCHAR(500) NOT NULL COMMENT '新闻原始URL', + title VARCHAR(255) NOT NULL COMMENT '新闻标题', + category_id INT NULL COMMENT '新闻分类ID', + publish_time DATETIME NULL COMMENT '发布时间', + author VARCHAR(100) NULL COMMENT '作者/来源', + source VARCHAR(50) NULL COMMENT '新闻来源(网易/36kr)', + content LONGTEXT NOT NULL COMMENT '新闻正文', + content_hash CHAR(64) NOT NULL COMMENT '正文内容hash,用于去重', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', + + PRIMARY KEY (id), + UNIQUE KEY uk_url (url), + UNIQUE KEY uk_content_hash (content_hash), + KEY idx_category_id (category_id), + KEY idx_source (source), + KEY idx_created_at (created_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='新闻表'; + +-- 4. 添加外键约束(可选,根据需求决定是否启用) +-- ALTER TABLE news +-- ADD CONSTRAINT fk_news_category +-- FOREIGN KEY (category_id) +-- REFERENCES news_category(id) +-- ON DELETE SET NULL +-- ON UPDATE CASCADE; + + +CREATE TABLE users ( + `id` bigint NOT NULL AUTO_INCREMENT, + `email` varchar(255) DEFAULT NULL, + `password` varchar(255) NOT NULL, + `username` varchar(50) NOT NULL, + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', + `role` VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '用户角色:USER=普通用户,ADMIN=管理员', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY uk_users_username (`username`), + UNIQUE KEY uk_users_email (`email`), + KEY idx_users_role (`role`) +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_0900_ai_ci; +