完成backend的用户、分类、新闻相关接口的开发

This commit is contained in:
shenjianZ 2026-01-12 17:19:24 +08:00
parent 73fee7d713
commit 1c187a00cf
40 changed files with 2844 additions and 50 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir:*)"
]
}
}

1081
backend/API_DOCUMENT.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,10 @@
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.aisi</groupId>
<artifactId>chery</artifactId>
<artifactId>NewClassifier</artifactId>
<version>0.0.1</version>
<name>chery</name>
<description>chery</description>
<name>NewClassifier</name>
<description>NewClassifier</description>
<url/>
<licenses>
<license>
@ -95,6 +95,11 @@
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
</dependencies>
<build>

View File

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

View File

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

View File

@ -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<List<CategoryVo>> getAllCategories() {
return categoryService.getAllCategories();
}
@GetMapping("/with-count")
@Operation(summary = "获取分类及新闻数量")
public RestBean<List<CategoryVo>> getAllCategoriesWithNewsCount() {
return categoryService.getAllCategoriesWithNewsCount();
}
@GetMapping("/{id}")
@Operation(summary = "获取分类详情")
public RestBean<CategoryVo> getCategoryDetail(
@Parameter(description = "分类ID") @PathVariable Integer id) {
return categoryService.getCategoryDetail(id);
}
@PostMapping
@Operation(summary = "创建分类")
@PreAuthorize("hasRole('ADMIN')")
public RestBean<CategoryVo> createCategory(@Valid @RequestBody CategoryCreateDto createDto) {
return categoryService.createCategory(createDto);
}
@PutMapping("/{id}")
@Operation(summary = "更新分类")
@PreAuthorize("hasRole('ADMIN')")
public RestBean<CategoryVo> 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<Void> deleteCategory(
@Parameter(description = "分类ID") @PathVariable Integer id) {
return categoryService.deleteCategory(id);
}
}

View File

@ -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<PageResult<NewsVo>> getNewsList(NewsQueryDto queryDto) {
return newsService.getNewsList(queryDto);
}
@GetMapping("/{id}")
@Operation(summary = "获取新闻详情")
public RestBean<NewsVo> getNewsDetail(
@Parameter(description = "新闻ID") @PathVariable Long id) {
return newsService.getNewsDetail(id);
}
@PostMapping
@Operation(summary = "创建新闻")
@PreAuthorize("hasRole('ADMIN')")
public RestBean<NewsVo> createNews(@Valid @RequestBody NewsCreateDto createDto) {
return newsService.createNews(createDto);
}
@PutMapping
@Operation(summary = "更新新闻")
@PreAuthorize("hasRole('ADMIN')")
public RestBean<NewsVo> updateNews(@Valid @RequestBody NewsUpdateDto updateDto) {
return newsService.updateNews(updateDto);
}
@DeleteMapping("/{id}")
@Operation(summary = "删除新闻")
@PreAuthorize("hasRole('ADMIN')")
public RestBean<Void> 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<Void> batchDeleteNews(@RequestBody List<Long> ids) {
return newsService.batchDeleteNews(ids);
}
@GetMapping("/search")
@Operation(summary = "搜索新闻")
public RestBean<PageResult<NewsVo>> 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<PageResult<NewsVo>> 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<PageResult<NewsVo>> 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<Map<String, Object>> getNewsStatistics() {
return newsService.getNewsStatistics();
}
}

View File

@ -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<String> getUserInfo() {
public RestBean<UserVo> getUserInfo() {
return userService.getUserInfo();
}

View File

@ -13,12 +13,17 @@ public class CustomUserDetails implements UserDetails {
private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean enabled;
@Getter
private final String role;
public CustomUserDetails(Long id, String username, String password, Collection<? extends GrantedAuthority> authorities) {
public CustomUserDetails(Long id, String username, String password, Collection<? extends GrantedAuthority> 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T> {
@Schema(description = "数据列表")
private List<T> records;
@Schema(description = "总记录数")
private Long total;
@Schema(description = "当前页")
private Integer page;
@Schema(description = "每页大小")
private Integer size;
@Schema(description = "总页数")
private Integer pages;
/**
* 创建分页结果
*/
public static <T> PageResult<T> of(List<T> records, Long total, Integer page, Integer size) {
return new PageResult<>(
records,
total,
page,
size,
(int) Math.ceil((double) total / size)
);
}
/**
* 创建空的分页结果
*/
public static <T> PageResult<T> empty(Integer page, Integer size) {
return new PageResult<>(
List.of(),
0L,
page,
size,
0
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Void> handleAuthorizationDenied(AuthorizationDeniedException e) {
log.warn("权限拒绝: {}", e.getMessage());
return RestBean.failure(RestCode.UNAUTHORIZED);
}
/**
* 处理其他异常
*/
@ExceptionHandler(Exception.class)
public RestBean<Void> handleException(Exception e) {
log.error("系统异常: ", e);
return RestBean.failure(RestCode.SYSTEM_ERROR);
}
}

View File

@ -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<Category, Integer> {
/**
* 根据名称查找分类
*/
Optional<Category> findByName(String name);
/**
* 检查名称是否存在
*/
boolean existsByName(String name);
/**
* 查询所有分类按ID排序
*/
List<Category> findAllByOrderByIdAsc();
/**
* 根据名称模糊查询
*/
List<Category> 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<Object[]> findAllWithNewsCount();
}

View File

@ -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<News, Long>, JpaSpecificationExecutor<News> {
/**
* 根据URL查找新闻用于去重检查
*/
News findByUrl(String url);
/**
* 根据内容哈希查找新闻用于去重检查
*/
News findByContentHash(String contentHash);
/**
* 检查URL是否存在
*/
boolean existsByUrl(String url);
/**
* 检查内容哈希是否存在
*/
boolean existsByContentHash(String contentHash);
/**
* 根据分类ID分页查询新闻
*/
Page<News> findByCategoryIdOrderByCreatedAtDesc(Integer categoryId, Pageable pageable);
/**
* 根据来源分页查询新闻
*/
Page<News> 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<News> 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<News> findLatestNews(Pageable pageable);
/**
* 统计每个分类的新闻数量
*/
@Query("SELECT n.categoryId, COUNT(n) FROM News n GROUP BY n.categoryId")
List<Object[]> countByCategory();
/**
* 统计每个来源的新闻数量
*/
@Query("SELECT n.source, COUNT(n) FROM News n GROUP BY n.source")
List<Object[]> countBySourceGroup();
/**
* 批量删除新闻
*/
void deleteAllByIdIn(List<Long> ids);
}

View File

@ -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<User,Long> {
import java.util.Optional;
User findByUsername(String username);
public interface UserRepository extends JpaRepository<User, Long> {
User findByEmail(String email);
Optional<User> findByUsername(String username);
User findByUsernameAndPassword(String username, String password);
Optional<User> findByEmail(String email);
User findByEmailAndPassword(String email, String password);
Optional<User> findByUsernameAndPassword(String username, String password);
User findByEmailContaining(String keyword);
Optional<User> findByEmailAndPassword(String email, String password);
/**
* 根据用户名查找启用的用户
*/
Optional<User> findByUsernameAndStatus(String username, Integer status);
/**
* 根据邮箱查找启用的用户
*/
Optional<User> findByEmailAndStatus(String email, Integer status);
/**
* 检查用户名是否存在
*/
boolean existsByUsername(String username);
/**
* 检查邮箱是否存在
*/
boolean existsByEmail(String email);
}

View File

@ -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<List<CategoryVo>> getAllCategories();
/**
* 获取分类详情
*/
RestBean<CategoryVo> getCategoryDetail(Integer id);
/**
* 创建分类
*/
RestBean<CategoryVo> createCategory(CategoryCreateDto createDto);
/**
* 更新分类
*/
RestBean<CategoryVo> updateCategory(Integer id, CategoryCreateDto updateDto);
/**
* 删除分类
*/
RestBean<Void> deleteCategory(Integer id);
/**
* 获取分类及其新闻数量
*/
RestBean<List<CategoryVo>> getAllCategoriesWithNewsCount();
}

View File

@ -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<PageResult<NewsVo>> getNewsList(NewsQueryDto queryDto);
/**
* 获取新闻详情
*/
RestBean<NewsVo> getNewsDetail(Long id);
/**
* 创建新闻支持去重检查
*/
RestBean<NewsVo> createNews(NewsCreateDto createDto);
/**
* 更新新闻
*/
RestBean<NewsVo> updateNews(NewsUpdateDto updateDto);
/**
* 删除新闻
*/
RestBean<Void> deleteNews(Long id);
/**
* 批量删除新闻
*/
RestBean<Void> batchDeleteNews(List<Long> ids);
/**
* 搜索新闻
*/
RestBean<PageResult<NewsVo>> searchNews(String keyword, Integer page, Integer size);
/**
* 按分类查询新闻
*/
RestBean<PageResult<NewsVo>> getNewsByCategory(Integer categoryId, Integer page, Integer size);
/**
* 获取最新新闻
*/
RestBean<PageResult<NewsVo>> getLatestNews(Integer page, Integer size);
/**
* 获取新闻统计数据
*/
RestBean<Map<String, Object>> getNewsStatistics();
}

View File

@ -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<String> getUserInfo();
RestBean<UserVo> getUserInfo();
RestBean<String> register(UserDto userDto);

View File

@ -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<List<CategoryVo>> getAllCategories() {
try {
List<Category> categories = categoryRepository.findAllByOrderByIdAsc();
List<CategoryVo> 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<CategoryVo> 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<CategoryVo> 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<CategoryVo> 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<Void> 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<List<CategoryVo>> getAllCategoriesWithNewsCount() {
try {
List<Category> categories = categoryRepository.findAllByOrderByIdAsc();
List<CategoryVo> 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;
}
}

View File

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

View File

@ -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<PageResult<NewsVo>> 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<News> spec = buildSpecification(queryDto);
// 执行查询
Page<News> newsPage = newsRepository.findAll(spec, pageable);
// 转换为VO
List<NewsVo> newsVos = convertToVoList(newsPage.getContent());
// 构建分页结果
PageResult<NewsVo> 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<NewsVo> 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<NewsVo> 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<NewsVo> 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<Void> 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<Void> batchDeleteNews(List<Long> 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<PageResult<NewsVo>> searchNews(String keyword, Integer page, Integer size) {
try {
Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending());
Page<News> newsPage = newsRepository.searchByKeyword(keyword, pageable);
List<NewsVo> newsVos = convertToVoList(newsPage.getContent());
PageResult<NewsVo> 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<PageResult<NewsVo>> getNewsByCategory(Integer categoryId, Integer page, Integer size) {
try {
Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending());
Page<News> newsPage = newsRepository.findByCategoryIdOrderByCreatedAtDesc(categoryId, pageable);
List<NewsVo> newsVos = convertToVoList(newsPage.getContent());
PageResult<NewsVo> 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<PageResult<NewsVo>> getLatestNews(Integer page, Integer size) {
try {
Pageable pageable = PageRequest.of(page - 1, size);
Page<News> newsPage = newsRepository.findLatestNews(pageable);
List<NewsVo> newsVos = convertToVoList(newsPage.getContent());
PageResult<NewsVo> 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<Map<String, Object>> getNewsStatistics() {
try {
Map<String, Object> stats = new HashMap<>();
// 总新闻数
stats.put("totalNews", newsRepository.count());
// 按分类统计
List<Object[]> categoryStats = newsRepository.countByCategory();
Map<String, Long> 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<Object[]> sourceStats = newsRepository.countBySourceGroup();
Map<String, Long> 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<News> buildSpecification(NewsQueryDto queryDto) {
return (root, query, cb) -> {
// 不带前缀直接用 Predicate
List<Predicate> 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<NewsVo> convertToVoList(List<News> newsList) {
return newsList.stream()
.map(this::convertToVo)
.collect(Collectors.toList());
}
}

View File

@ -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<String> getUserInfo() {
public RestBean<UserVo> 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<String> 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<String> 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);
}
}

View File

@ -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<Claims> parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
}

View File

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

View File

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

View File

@ -5,4 +5,7 @@ spring:
active: dev
server:
port: 8080
port: 8080
jwt:
secret: newsClassifierSecretKeyForJWT2024MustBeLongEnoughForHS256Algorithm

View File

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