version1
This commit is contained in:
parent
fe95091282
commit
f3d4380831
152
README.md
152
README.md
@ -1,5 +1,151 @@
|
||||
# kama-notes
|
||||
# 卡码笔记 (Kama Notes)
|
||||
|
||||
【代码随想录知识星球】项目分享-卡码笔记
|
||||
卡码笔记是一个面向程序员的在线笔记分享和学习平台,旨在为程序员提供一个高效的知识分享和交流空间。
|
||||
|
||||
## 项目介绍
|
||||
## 项目特点
|
||||
|
||||
- 📝 支持Markdown格式的笔记编写
|
||||
- 🔍 强大的笔记搜索功能
|
||||
- 👥 用户互动和社交功能
|
||||
- 📊 笔记数据统计和排行
|
||||
- 🔔 实时消息通知系统
|
||||
- 📱 响应式设计,支持多端访问
|
||||
|
||||
## 技术栈
|
||||
|
||||
***后端技术****
|
||||
|
||||
- 核心框架:Spring Boot 2.7.18
|
||||
|
||||
- 安全框架:Spring Security
|
||||
|
||||
- 持久层:MyBatis
|
||||
|
||||
- 数据库:MySQL 8.0
|
||||
|
||||
- 缓存:Redis
|
||||
|
||||
- 消息推送:WebSocket
|
||||
|
||||
- 搜索:MySQL 全文索引 + Jieba 分词
|
||||
|
||||
- 文件存储:本地文件系统
|
||||
|
||||
- 日志系统:Log4j2
|
||||
|
||||
- 测试框架:JUnit
|
||||
|
||||
- 模板引擎:Thymeleaf
|
||||
|
||||
- Markdown:Flexmark
|
||||
|
||||
- 工具库:Hutool
|
||||
|
||||
**前端技术**
|
||||
|
||||
- 构建工具:Vite
|
||||
- 框架:React + TypeScript
|
||||
- 路由管理:React Router DOM
|
||||
- 状态管理:Redux Toolkit
|
||||
- UI 库:Ant Design
|
||||
- 样式:TailwindCSS
|
||||
- HTTP 客户端:Axios
|
||||
- WebSocket 客户端:原生 WebSocket
|
||||
- Markdown 渲染
|
||||
- 数据可视化
|
||||
- 代码质量:ESLint, Prettier
|
||||
- 版本控制:Husky, Lint-staged
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 16+
|
||||
- JDK 17+
|
||||
- MySQL 8.0+
|
||||
- Maven 3.8+
|
||||
|
||||
### 开发环境搭建
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone https://github.com/youngyangyang04/kamanotes.git
|
||||
cd kama-notes
|
||||
```
|
||||
|
||||
2. 前端启动
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 后端启动
|
||||
```bash
|
||||
IDEA直接启动
|
||||
或
|
||||
cd backend
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
4. 数据库配置
|
||||
- 创建数据库:*kamanote_tech*
|
||||
- 执行SQL脚本:kamanote-tech.sql
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 1. 用户系统
|
||||
- 用户注册和登录
|
||||
- 个人信息管理
|
||||
- 用户主页
|
||||
|
||||
### 2. 笔记管理
|
||||
- 创建和编辑笔记
|
||||
- 笔记分类和标签
|
||||
- 笔记收藏
|
||||
- 笔记搜索
|
||||
|
||||
### 3. 社交功能
|
||||
- 笔记评论
|
||||
- 点赞功能
|
||||
- 消息通知
|
||||
|
||||
### 4. 数据统计
|
||||
- 用户活跃度
|
||||
- 笔记热度排行
|
||||
- 个人数据统计
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── backend/ # 后端项目
|
||||
│ ├── src/
|
||||
│ │ ├── main/
|
||||
│ │ │ ├── java/
|
||||
│ │ │ │ └── com/kama/notes/
|
||||
│ │ │ │ ├── annotation/ # 自定义注解
|
||||
│ │ │ │ ├── aspect/ # AOP切面
|
||||
│ │ │ │ ├── config/ # 配置类
|
||||
│ │ │ │ ├── controller/ # 控制器
|
||||
│ │ │ │ ├── exception/ # 异常处理
|
||||
│ │ │ │ ├── filter/ # 过滤器
|
||||
│ │ │ │ ├── interceptor/ # 拦截器
|
||||
│ │ │ │ ├── mapper/ # 数据访问层
|
||||
│ │ │ │ ├── model/ # 数据模型
|
||||
│ │ │ │ ├── scope/ # 作用域数据
|
||||
│ │ │ │ ├── service/ # 业务逻辑层
|
||||
│ │ │ │ ├── task/ # 定时任务
|
||||
│ │ │ │ └── utils/ # 工具类
|
||||
│ │ │ └── resources/
|
||||
│ │ │ ├── mapper/ # MyBatis映射文件
|
||||
│ │ │ └── application.yml # 配置文件
|
||||
│ │ └── test/ # 测试代码
|
||||
│ └── pom.xml # 项目依赖管理
|
||||
```
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建新的分支: `git checkout -b feature/your-feature`
|
||||
3. 提交更改: `git commit -m 'Add some feature'`
|
||||
4. 推送到分支: `git push origin feature/your-feature`
|
||||
5. 提交Pull Request
|
||||
|
||||
@ -117,6 +117,38 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<!-- 邮件发送依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- 模板引擎 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
<!-- Java Mail API -->
|
||||
<dependency>
|
||||
<groupId>javax.mail</groupId>
|
||||
<artifactId>javax.mail-api</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.sun.mail</groupId>
|
||||
<artifactId>javax.mail</artifactId>
|
||||
<version>1.6.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 添加 jieba 分词依赖 -->
|
||||
<dependency>
|
||||
<groupId>com.huaban</groupId>
|
||||
<artifactId>jieba-analysis</artifactId>
|
||||
<version>1.0.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@ -4,9 +4,14 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* @ClassName Security配置类
|
||||
@ -17,24 +22,38 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors().and()
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.formLogin().disable()
|
||||
.httpBasic().disable();
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(Arrays.asList("http://localhost:5173")); // 允许的前端域名
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void configure(HttpSecurity http) throws Exception {
|
||||
http.csrf()
|
||||
.disable()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/**", "/images/**")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
.and()
|
||||
.formLogin()
|
||||
.disable();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
package com.kama.notes.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kama.notes.utils.JwtUtil;
|
||||
import com.kama.notes.websocket.MessageWebSocketHandler;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
|
||||
|
||||
/**
|
||||
* WebSocket配置类
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public WebSocketConfig(JwtUtil jwtUtil, ObjectMapper objectMapper) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(messageWebSocketHandler(), "/ws/message")
|
||||
.setAllowedOrigins("*"); // 生产环境需要限制允许的域名
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MessageWebSocketHandler messageWebSocketHandler() {
|
||||
return new MessageWebSocketHandler(jwtUtil, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
|
||||
// 设置消息大小限制为8KB
|
||||
container.setMaxTextMessageBufferSize(8192);
|
||||
// 设置二进制消息大小限制为8KB
|
||||
container.setMaxBinaryMessageBufferSize(8192);
|
||||
// 设置空闲超时时间为60秒
|
||||
container.setMaxSessionIdleTimeout(60000L);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package com.kama.notes.controller;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.dto.comment.CommentQueryParams;
|
||||
import com.kama.notes.model.dto.comment.CreateCommentRequest;
|
||||
import com.kama.notes.model.dto.comment.UpdateCommentRequest;
|
||||
import com.kama.notes.model.vo.comment.CommentVO;
|
||||
import com.kama.notes.service.CommentService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 评论控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class CommentController {
|
||||
|
||||
@Autowired
|
||||
private CommentService commentService;
|
||||
|
||||
/**
|
||||
* 创建评论
|
||||
*
|
||||
* @param request 创建评论请求
|
||||
* @return 创建的评论ID
|
||||
*/
|
||||
@PostMapping("/comments")
|
||||
public ApiResponse<Integer> createComment(
|
||||
@Valid
|
||||
@RequestBody
|
||||
CreateCommentRequest request) {
|
||||
return commentService.createComment(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @param request 更新评论请求
|
||||
* @return 空响应
|
||||
*/
|
||||
@PatchMapping("/comments/{commentId}")
|
||||
public ApiResponse<EmptyVO> updateComment(
|
||||
@PathVariable("commentId") Integer commentId,
|
||||
@Valid
|
||||
@RequestBody
|
||||
UpdateCommentRequest request) {
|
||||
return commentService.updateComment(commentId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 空响应
|
||||
*/
|
||||
@DeleteMapping("/comments/{commentId}")
|
||||
public ApiResponse<EmptyVO> deleteComment(
|
||||
@PathVariable("commentId") Integer commentId) {
|
||||
return commentService.deleteComment(commentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评论列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @return 评论列表
|
||||
*/
|
||||
@GetMapping("/comments")
|
||||
public ApiResponse<List<CommentVO>> getComments(
|
||||
@Valid CommentQueryParams params) {
|
||||
return commentService.getComments(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 空响应
|
||||
*/
|
||||
@PostMapping("/comments/{commentId}/like")
|
||||
public ApiResponse<EmptyVO> likeComment(
|
||||
@PathVariable("commentId") Integer commentId) {
|
||||
return commentService.likeComment(commentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消点赞评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 空响应
|
||||
*/
|
||||
@DeleteMapping("/comments/{commentId}/like")
|
||||
public ApiResponse<EmptyVO> unlikeComment(
|
||||
@PathVariable("commentId") Integer commentId) {
|
||||
return commentService.unlikeComment(commentId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.kama.notes.controller;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.service.EmailService;
|
||||
import com.kama.notes.utils.ApiResponseUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.constraints.Email;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/email")
|
||||
@RequiredArgsConstructor
|
||||
public class EmailController {
|
||||
|
||||
private final EmailService emailService;
|
||||
|
||||
@GetMapping("/verify-code")
|
||||
public ApiResponse<Void> sendVerifyCode(
|
||||
@RequestParam @NotBlank @Email String email,
|
||||
@RequestParam @NotBlank String type) {
|
||||
|
||||
// 检查是否可以发送
|
||||
if (!emailService.canSendCode(email)) {
|
||||
return ApiResponseUtil.error("发送太频繁,请稍后再试");
|
||||
}
|
||||
|
||||
try {
|
||||
emailService.sendVerifyCode(email, type);
|
||||
return ApiResponseUtil.success(null);
|
||||
} catch (Exception e) {
|
||||
return ApiResponseUtil.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package com.kama.notes.controller;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.base.PageVO;
|
||||
import com.kama.notes.model.dto.message.MessageQueryParams;
|
||||
import com.kama.notes.model.vo.message.MessageVO;
|
||||
import com.kama.notes.model.vo.message.UnreadCountByType;
|
||||
import com.kama.notes.service.MessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 消息控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
@RequiredArgsConstructor
|
||||
public class MessageController {
|
||||
|
||||
private final MessageService messageService;
|
||||
|
||||
/**
|
||||
* 获取消息列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @return 消息列表,带分页信息
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<PageVO<MessageVO>> getMessages(@Validated MessageQueryParams params) {
|
||||
return messageService.getMessages(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息为已读
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @return 空响应
|
||||
*/
|
||||
@PutMapping("/{messageId}/read")
|
||||
public ApiResponse<EmptyVO> markAsRead(@PathVariable Integer messageId) {
|
||||
return messageService.markAsRead(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记所有消息为已读
|
||||
*
|
||||
* @return 空响应
|
||||
*/
|
||||
@PutMapping("/read/all")
|
||||
public ApiResponse<EmptyVO> markAllAsRead() {
|
||||
return messageService.markAllAsRead();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @return 空响应
|
||||
*/
|
||||
@DeleteMapping("/{messageId}")
|
||||
public ApiResponse<EmptyVO> deleteMessage(@PathVariable Integer messageId) {
|
||||
return messageService.deleteMessage(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读消息数量
|
||||
*
|
||||
* @return 未读消息数量
|
||||
*/
|
||||
@GetMapping("/unread/count")
|
||||
public ApiResponse<Integer> getUnreadCount() {
|
||||
return messageService.getUnreadCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取各类型未读消息数量
|
||||
*
|
||||
* @return 各类型未读消息数量
|
||||
*/
|
||||
@GetMapping("/unread/count/type")
|
||||
public ApiResponse<List<UnreadCountByType>> getUnreadCountByType() {
|
||||
return messageService.getUnreadCountByType();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.kama.notes.controller;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.entity.Note;
|
||||
import com.kama.notes.model.entity.User;
|
||||
import com.kama.notes.service.SearchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
@RequiredArgsConstructor
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
@GetMapping("/notes")
|
||||
public ApiResponse<List<Note>> searchNotes(
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) Integer pageSize) {
|
||||
return searchService.searchNotes(keyword, page, pageSize);
|
||||
}
|
||||
|
||||
@GetMapping("/users")
|
||||
public ApiResponse<List<User>> searchUsers(
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) Integer pageSize) {
|
||||
return searchService.searchUsers(keyword, page, pageSize);
|
||||
}
|
||||
|
||||
@GetMapping("/notes/tag")
|
||||
public ApiResponse<List<Note>> searchNotesByTag(
|
||||
@RequestParam String keyword,
|
||||
@RequestParam String tag,
|
||||
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) Integer pageSize) {
|
||||
return searchService.searchNotesByTag(keyword, tag, page, pageSize);
|
||||
}
|
||||
}
|
||||
36
backend/src/main/java/com/kama/notes/event/MessageEvent.java
Normal file
36
backend/src/main/java/com/kama/notes/event/MessageEvent.java
Normal file
@ -0,0 +1,36 @@
|
||||
package com.kama.notes.event;
|
||||
|
||||
import com.kama.notes.model.vo.message.MessageVO;
|
||||
import lombok.Getter;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* 消息事件
|
||||
* 用于系统内部的消息传递
|
||||
*/
|
||||
@Getter
|
||||
public class MessageEvent extends ApplicationEvent {
|
||||
|
||||
private final MessageVO message;
|
||||
private final Long receiverId;
|
||||
private final String eventType;
|
||||
|
||||
public MessageEvent(Object source, MessageVO message, Long receiverId, String eventType) {
|
||||
super(source);
|
||||
this.message = message;
|
||||
this.receiverId = receiverId;
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
public static MessageEvent createCommentEvent(Object source, MessageVO message, Long receiverId) {
|
||||
return new MessageEvent(source, message, receiverId, "COMMENT");
|
||||
}
|
||||
|
||||
public static MessageEvent createLikeEvent(Object source, MessageVO message, Long receiverId) {
|
||||
return new MessageEvent(source, message, receiverId, "LIKE");
|
||||
}
|
||||
|
||||
public static MessageEvent createSystemEvent(Object source, MessageVO message, Long receiverId) {
|
||||
return new MessageEvent(source, message, receiverId, "SYSTEM");
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ public class ParamExceptionHandler {
|
||||
ex.getBindingResult().getFieldErrors().forEach(error ->
|
||||
errors.put(error.getField(), error.getDefaultMessage())
|
||||
);
|
||||
return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
|
||||
return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
@ -28,11 +28,11 @@ public class ParamExceptionHandler {
|
||||
ex.getConstraintViolations().forEach(violation ->
|
||||
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
|
||||
);
|
||||
return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
|
||||
return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ApiResponse<String> handleException(Exception ex) {
|
||||
return new ApiResponse<>(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error", ex.getMessage());
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package com.kama.notes.listener;
|
||||
|
||||
import com.kama.notes.event.MessageEvent;
|
||||
import com.kama.notes.service.MessageService;
|
||||
import com.kama.notes.websocket.MessageWebSocketHandler;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 消息事件监听器
|
||||
* 用于处理系统内的消息事件
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MessageEventListener {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final MessageWebSocketHandler messageWebSocketHandler;
|
||||
|
||||
/**
|
||||
* 异步处理消息事件
|
||||
*/
|
||||
@Async
|
||||
@EventListener
|
||||
public void handleMessageEvent(MessageEvent event) {
|
||||
try {
|
||||
log.info("收到{}类型的消息事件, 接收者: {}", event.getEventType(), event.getReceiverId());
|
||||
|
||||
// 1. 保存消息到数据库
|
||||
messageService.createMessage(
|
||||
event.getReceiverId(),
|
||||
event.getMessage().getSender().getUserId(),
|
||||
event.getEventType(),
|
||||
event.getMessage().getTargetId(),
|
||||
event.getMessage().getContent()
|
||||
);
|
||||
|
||||
// 2. 如果用户在线,通过WebSocket推送消息
|
||||
if (messageWebSocketHandler.isUserOnline(event.getReceiverId())) {
|
||||
messageWebSocketHandler.sendMessageToUser(event.getReceiverId(), event.getMessage());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理消息事件时发生错误", e);
|
||||
// 这里可以添加重试逻辑或者将失败的消息记录到特定的队列中
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.kama.notes.mapper;
|
||||
|
||||
import com.kama.notes.model.entity.CommentLike;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 评论点赞Mapper接口
|
||||
*/
|
||||
@Mapper
|
||||
public interface CommentLikeMapper {
|
||||
/**
|
||||
* 插入评论点赞
|
||||
*
|
||||
* @param commentLike 评论点赞实体
|
||||
*/
|
||||
void insert(CommentLike commentLike);
|
||||
|
||||
/**
|
||||
* 删除评论点赞
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
void delete(@Param("commentId") Integer commentId, @Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 查询用户点赞的评论ID列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param commentIds 评论ID列表
|
||||
* @return 用户点赞的评论ID集合
|
||||
*/
|
||||
Set<Integer> findUserLikedCommentIds(@Param("userId") Long userId,
|
||||
@Param("commentIds") List<Integer> commentIds);
|
||||
|
||||
@Select("SELECT COUNT(*) > 0 FROM comment_like " +
|
||||
"WHERE user_id = #{userId} AND comment_id = #{commentId}")
|
||||
Boolean checkIsLiked(@Param("userId") Long userId, @Param("commentId") Integer commentId);
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.kama.notes.mapper;
|
||||
|
||||
import com.kama.notes.model.dto.comment.CommentQueryParams;
|
||||
import com.kama.notes.model.entity.Comment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 评论Mapper接口
|
||||
*/
|
||||
@Mapper
|
||||
public interface CommentMapper {
|
||||
/**
|
||||
* 插入评论
|
||||
*
|
||||
* @param comment 评论实体
|
||||
*/
|
||||
void insert(Comment comment);
|
||||
|
||||
/**
|
||||
* 更新评论
|
||||
*
|
||||
* @param comment 评论实体
|
||||
*/
|
||||
void update(Comment comment);
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void deleteById(Integer commentId);
|
||||
|
||||
/**
|
||||
* 根据ID查询评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 评论实体
|
||||
*/
|
||||
Comment findById(Integer commentId);
|
||||
|
||||
/**
|
||||
* 查询评论列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @param pageSize 每页大小
|
||||
* @param offset 偏移量
|
||||
* @return 评论列表
|
||||
*/
|
||||
List<Comment> findByQueryParam(@Param("params") CommentQueryParams params,
|
||||
@Param("pageSize") Integer pageSize,
|
||||
@Param("offset") Integer offset);
|
||||
|
||||
/**
|
||||
* 统计评论数量
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @return 评论数量
|
||||
*/
|
||||
int countByQueryParam(@Param("params") CommentQueryParams params);
|
||||
|
||||
/**
|
||||
* 增加评论点赞数
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void incrementLikeCount(Integer commentId);
|
||||
|
||||
/**
|
||||
* 减少评论点赞数
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void decrementLikeCount(Integer commentId);
|
||||
|
||||
/**
|
||||
* 增加评论回复数
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void incrementReplyCount(Integer commentId);
|
||||
|
||||
/**
|
||||
* 减少评论回复数
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void decrementReplyCount(Integer commentId);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.kama.notes.mapper;
|
||||
|
||||
import com.kama.notes.model.entity.EmailVerifyCode;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface EmailVerifyCodeMapper {
|
||||
/**
|
||||
* 插入验证码记录
|
||||
*/
|
||||
int insert(EmailVerifyCode code);
|
||||
|
||||
/**
|
||||
* 查询最新的有效验证码
|
||||
*/
|
||||
EmailVerifyCode findLatestValidCode(@Param("email") String email, @Param("type") String type);
|
||||
|
||||
/**
|
||||
* 标记验证码为已使用
|
||||
*/
|
||||
int markAsUsed(@Param("id") Long id);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.kama.notes.mapper;
|
||||
|
||||
import com.kama.notes.model.dto.message.MessageQueryParams;
|
||||
import com.kama.notes.model.entity.Message;
|
||||
import com.kama.notes.model.vo.message.UnreadCountByType;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 消息Mapper接口
|
||||
*/
|
||||
@Mapper
|
||||
public interface MessageMapper {
|
||||
/**
|
||||
* 插入消息
|
||||
*
|
||||
* @param message 消息实体
|
||||
* @return 影响行数
|
||||
*/
|
||||
int insert(Message message);
|
||||
|
||||
/**
|
||||
* 根据参数查询消息列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param params 查询参数
|
||||
* @param offset 偏移量
|
||||
* @return 消息列表
|
||||
*/
|
||||
List<Message> selectByParams(@Param("userId") Long userId, @Param("params") MessageQueryParams params, @Param("offset") int offset);
|
||||
|
||||
/**
|
||||
* 统计符合条件的消息数量
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param params 查询参数
|
||||
* @return 消息数量
|
||||
*/
|
||||
int countByParams(@Param("userId") Long userId, @Param("params") MessageQueryParams params);
|
||||
|
||||
/**
|
||||
* 标记消息为已读
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @param userId 用户ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
int markAsRead(@Param("messageId") Integer messageId, @Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 标记所有消息为已读
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
int markAllAsRead(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @param userId 用户ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
int deleteMessage(@Param("messageId") Integer messageId, @Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 统计未读消息数量
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 未读消息数量
|
||||
*/
|
||||
int countUnread(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 按类型统计未读消息数量
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 各类型未读消息数量
|
||||
*/
|
||||
List<UnreadCountByType> countUnreadByType(@Param("userId") Long userId);
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.kama.notes.mapper;
|
||||
|
||||
import com.kama.notes.model.entity.NoteCollect;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 笔记收藏Mapper接口
|
||||
*/
|
||||
@Mapper
|
||||
public interface NoteCollectMapper {
|
||||
/**
|
||||
* 插入收藏记录
|
||||
*
|
||||
* @param noteCollect 收藏记录
|
||||
* @return 影响的行数
|
||||
*/
|
||||
int insert(NoteCollect noteCollect);
|
||||
|
||||
/**
|
||||
* 删除收藏记录
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
* @param userId 用户ID
|
||||
* @return 影响的行数
|
||||
*/
|
||||
int delete(@Param("noteId") Integer noteId, @Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 查找收藏记录
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
* @param userId 用户ID
|
||||
* @return 收藏记录
|
||||
*/
|
||||
NoteCollect findByNoteIdAndUserId(@Param("noteId") Integer noteId, @Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 获取用户收藏的笔记ID列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 笔记ID列表
|
||||
*/
|
||||
List<Integer> findNoteIdsByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.kama.notes.mapper;
|
||||
|
||||
import com.kama.notes.model.entity.NoteComment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface NoteCommentMapper {
|
||||
|
||||
/**
|
||||
* 插入评论
|
||||
*/
|
||||
void insert(NoteComment comment);
|
||||
|
||||
/**
|
||||
* 更新评论
|
||||
*/
|
||||
void update(NoteComment comment);
|
||||
|
||||
/**
|
||||
* 根据ID查询评论
|
||||
*/
|
||||
NoteComment findById(@Param("id") Integer id);
|
||||
|
||||
/**
|
||||
* 查询笔记的评论列表
|
||||
*/
|
||||
List<NoteComment> findByNoteId(@Param("noteId") Integer noteId);
|
||||
}
|
||||
@ -164,4 +164,44 @@ public interface NoteMapper {
|
||||
* @return 笔记总数
|
||||
*/
|
||||
int getTotalNoteCount();
|
||||
|
||||
/**
|
||||
* 增加笔记评论数
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
*/
|
||||
void incrementCommentCount(@Param("noteId") Integer noteId);
|
||||
|
||||
/**
|
||||
* 减少笔记评论数
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
*/
|
||||
void decrementCommentCount(@Param("noteId") Integer noteId);
|
||||
|
||||
/**
|
||||
* 搜索笔记
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @return 笔记列表
|
||||
*/
|
||||
List<Note> searchNotes(@Param("keyword") String keyword,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
/**
|
||||
* 根据标签搜索笔记
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param tag 标签
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @return 笔记列表
|
||||
*/
|
||||
List<Note> searchNotesByTag(@Param("keyword") String keyword,
|
||||
@Param("tag") String tag,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
}
|
||||
|
||||
@ -116,4 +116,24 @@ public interface UserMapper {
|
||||
* @return 总注册人数
|
||||
*/
|
||||
int getTotalRegisterCount();
|
||||
|
||||
/**
|
||||
* 根据邮箱查找用户
|
||||
*
|
||||
* @param email 用户邮箱,用于查询用户信息
|
||||
* @return 返回用户对象,如果未找到则返回null
|
||||
*/
|
||||
User findByEmail(@Param("email") String email);
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @return 用户列表
|
||||
*/
|
||||
List<User> searchUsers(@Param("keyword") String keyword,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
}
|
||||
|
||||
@ -1,14 +1,95 @@
|
||||
package com.kama.notes.model.base;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API响应类
|
||||
*
|
||||
* @param <T> 响应数据类型
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiResponse<T> {
|
||||
private Integer code;
|
||||
private String msg;
|
||||
/**
|
||||
* 响应码
|
||||
*/
|
||||
private int code;
|
||||
|
||||
/**
|
||||
* 响应消息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param code 响应码
|
||||
* @param message 响应消息
|
||||
* @param data 响应数据
|
||||
*/
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应
|
||||
*
|
||||
* @param data 响应数据
|
||||
* @param <T> 响应数据类型
|
||||
* @return API响应
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setCode(200);
|
||||
response.setMessage("success");
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应(无数据)
|
||||
*
|
||||
* @return API响应
|
||||
*/
|
||||
public static ApiResponse<EmptyVO> success() {
|
||||
return success(new EmptyVO());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误响应
|
||||
*
|
||||
* @param code 错误码
|
||||
* @param message 错误消息
|
||||
* @return API响应
|
||||
*/
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setCode(code);
|
||||
response.setMessage(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带数据的错误响应
|
||||
*
|
||||
* @param code 错误码
|
||||
* @param message 错误消息
|
||||
* @param data 错误数据
|
||||
* @return API响应
|
||||
*/
|
||||
public static <T> ApiResponse<T> error(int code, String message, T data) {
|
||||
ApiResponse<T> response = new ApiResponse<>();
|
||||
response.setCode(code);
|
||||
response.setMessage(message);
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.kama.notes.model.base;
|
||||
|
||||
// 占位
|
||||
// 用于操作成功,只需要返回状态码,无任何额外数据时使用
|
||||
/**
|
||||
* 空响应类,用于表示无数据返回的情况
|
||||
*/
|
||||
public class EmptyVO {
|
||||
}
|
||||
|
||||
59
backend/src/main/java/com/kama/notes/model/base/PageVO.java
Normal file
59
backend/src/main/java/com/kama/notes/model/base/PageVO.java
Normal file
@ -0,0 +1,59 @@
|
||||
package com.kama.notes.model.base;
|
||||
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分页数据模型
|
||||
*
|
||||
* @param <T> 数据类型
|
||||
*/
|
||||
@Data
|
||||
public class PageVO<T> {
|
||||
/**
|
||||
* 当前页码
|
||||
*/
|
||||
private Integer page;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
private Integer pageSize;
|
||||
|
||||
/**
|
||||
* 总记录数
|
||||
*/
|
||||
private Integer total;
|
||||
|
||||
/**
|
||||
* 总页数
|
||||
*/
|
||||
private Integer totalPages;
|
||||
|
||||
/**
|
||||
* 数据列表
|
||||
*/
|
||||
private List<T> list;
|
||||
|
||||
public PageVO(Integer page, Integer pageSize, Integer total, List<T> list) {
|
||||
this.page = page;
|
||||
this.pageSize = pageSize;
|
||||
this.total = total;
|
||||
this.list = list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分页结果
|
||||
*
|
||||
* @param list 数据列表
|
||||
* @param page 当前页码
|
||||
* @param pageSize 每页大小
|
||||
* @param total 总记录数
|
||||
* @return 分页结果
|
||||
*/
|
||||
public static <T> PageVO<T> of(List<T> list, Integer page, Integer pageSize, Integer total) {
|
||||
PageVO<T> pageVO = new PageVO<>(page, pageSize, total, list);
|
||||
pageVO.setTotalPages((total + pageSize - 1) / pageSize);
|
||||
return pageVO;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.kama.notes.model.dto.comment;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 评论查询参数
|
||||
*/
|
||||
@Data
|
||||
public class CommentQueryParams {
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
@NotNull(message = "笔记ID不能为空")
|
||||
private Integer noteId;
|
||||
|
||||
/**
|
||||
* 父评论ID
|
||||
*/
|
||||
private Integer parentId;
|
||||
|
||||
/**
|
||||
* 作者ID
|
||||
*/
|
||||
private Long authorId;
|
||||
|
||||
/**
|
||||
* 页码
|
||||
*/
|
||||
@NotNull(message = "页码不能为空")
|
||||
@Min(value = 1, message = "页码必须大于0")
|
||||
private Integer page;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
@NotNull(message = "每页大小不能为空")
|
||||
@Min(value = 1, message = "每页大小必须大于0")
|
||||
private Integer pageSize;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.kama.notes.model.dto.comment;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 创建评论请求
|
||||
*/
|
||||
@Data
|
||||
public class CreateCommentRequest {
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
@NotNull(message = "笔记ID不能为空")
|
||||
private Integer noteId;
|
||||
|
||||
/**
|
||||
* 父评论ID
|
||||
*/
|
||||
private Integer parentId;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
@NotBlank(message = "评论内容不能为空")
|
||||
private String content;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.kama.notes.model.dto.comment;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 更新评论请求
|
||||
*/
|
||||
@Data
|
||||
public class UpdateCommentRequest {
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
@NotBlank(message = "评论内容不能为空")
|
||||
private String content;
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.kama.notes.model.dto.message;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 消息查询参数
|
||||
*/
|
||||
@Data
|
||||
public class MessageQueryParams {
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 是否已读
|
||||
*/
|
||||
private Boolean isRead;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/**
|
||||
* 结束时间
|
||||
*/
|
||||
private LocalDateTime endTime;
|
||||
|
||||
/**
|
||||
* 当前页码,从1开始
|
||||
*/
|
||||
@Min(value = 1, message = "页码必须大于0")
|
||||
private Integer page = 1;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
@Min(value = 1, message = "每页大小必须大于0")
|
||||
private Integer pageSize = 10;
|
||||
|
||||
/**
|
||||
* 排序字段,默认创建时间
|
||||
*/
|
||||
private String sortField = "created_at";
|
||||
|
||||
/**
|
||||
* 排序方向,默认降序
|
||||
*/
|
||||
private String sortOrder = "desc";
|
||||
}
|
||||
@ -2,9 +2,11 @@ package com.kama.notes.model.dto.user;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Email;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.validation.constraints.Size;
|
||||
import javax.validation.constraints.AssertTrue;
|
||||
|
||||
/**
|
||||
* 登录请求DTO
|
||||
@ -14,11 +16,13 @@ public class LoginRequest {
|
||||
/*
|
||||
* 用户账号
|
||||
*/
|
||||
@NotBlank(message = "用户账号不能为空")
|
||||
@Size(min = 6, max = 32, message = "账号长度必须在 6 到 32 个字符之间")
|
||||
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "账号只能包含字母、数字和下划线")
|
||||
private String account;
|
||||
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 登录密码
|
||||
* 必填,长度 6-32
|
||||
@ -26,4 +30,9 @@ public class LoginRequest {
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 32, message = "密码长度必须在 6 到 32 个字符之间")
|
||||
private String password;
|
||||
|
||||
@AssertTrue(message = "账号和邮箱必须至少提供一个")
|
||||
private boolean isValidLogin() {
|
||||
return account != null || email != null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import lombok.Data;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.validation.constraints.Size;
|
||||
import javax.validation.constraints.Email;
|
||||
|
||||
/**
|
||||
* 用户注册请求DTO
|
||||
@ -37,4 +38,16 @@ public class RegisterRequest {
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 32, message = "密码长度必须在 6 到 32 个字符之间")
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 邮箱(选填)
|
||||
*/
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 验证码(选填,邮箱填写时必填)
|
||||
*/
|
||||
@Size(min = 6, max = 6, message = "验证码长度必须为6位")
|
||||
private String verifyCode;
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.kama.notes.model.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 评论实体类
|
||||
*/
|
||||
@Data
|
||||
public class Comment {
|
||||
/**
|
||||
* 评论ID
|
||||
*/
|
||||
private Integer commentId;
|
||||
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
private Integer noteId;
|
||||
|
||||
/**
|
||||
* 作者ID
|
||||
*/
|
||||
private Long authorId;
|
||||
|
||||
/**
|
||||
* 父评论ID
|
||||
*/
|
||||
private Integer parentId;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 点赞数
|
||||
*/
|
||||
private Integer likeCount;
|
||||
|
||||
/**
|
||||
* 回复数
|
||||
*/
|
||||
private Integer replyCount;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.kama.notes.model.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 评论点赞实体类
|
||||
*/
|
||||
@Data
|
||||
public class CommentLike {
|
||||
/**
|
||||
* 评论点赞ID
|
||||
*/
|
||||
private Integer commentLikeId;
|
||||
|
||||
/**
|
||||
* 评论ID
|
||||
*/
|
||||
private Integer commentId;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.kama.notes.model.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class EmailVerifyCode {
|
||||
private Long id;
|
||||
private String email;
|
||||
private String code;
|
||||
private String type;
|
||||
private LocalDateTime expiredAt;
|
||||
private LocalDateTime createdAt;
|
||||
private Boolean used;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.kama.notes.model.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 消息实体类
|
||||
*/
|
||||
@Data
|
||||
public class Message {
|
||||
/**
|
||||
* 消息ID
|
||||
*/
|
||||
private Integer messageId;
|
||||
|
||||
/**
|
||||
* 接收者ID
|
||||
*/
|
||||
private Long receiverId;
|
||||
|
||||
/**
|
||||
* 发送者ID
|
||||
*/
|
||||
private Long senderId;
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 目标ID
|
||||
*/
|
||||
private Integer targetId;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 是否已读
|
||||
*/
|
||||
private Boolean isRead;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@ -3,7 +3,6 @@ package com.kama.notes.model.entity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* @ClassName Note
|
||||
@ -14,47 +13,47 @@ import java.util.Date;
|
||||
*/
|
||||
@Data
|
||||
public class Note {
|
||||
/*
|
||||
* 笔记ID(主键)
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
private Integer noteId;
|
||||
|
||||
/*
|
||||
* 笔记作者ID
|
||||
/**
|
||||
* 作者ID
|
||||
*/
|
||||
private Long authorId;
|
||||
|
||||
/*
|
||||
* 笔记对应的问题ID
|
||||
/**
|
||||
* 问题ID
|
||||
*/
|
||||
private Integer questionId;
|
||||
|
||||
/*
|
||||
/**
|
||||
* 笔记内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/*
|
||||
/**
|
||||
* 点赞数
|
||||
*/
|
||||
private Integer likeCount;
|
||||
|
||||
/*
|
||||
/**
|
||||
* 评论数
|
||||
*/
|
||||
private Integer commentCount;
|
||||
|
||||
/*
|
||||
/**
|
||||
* 收藏数
|
||||
*/
|
||||
private Integer collectCount;
|
||||
|
||||
/*
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/*
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.kama.notes.model.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 笔记收藏实体类
|
||||
*/
|
||||
@Data
|
||||
public class NoteCollect {
|
||||
/**
|
||||
* 收藏ID
|
||||
*/
|
||||
private Integer collectId;
|
||||
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
private Integer noteId;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.kama.notes.model.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class NoteComment {
|
||||
private Integer id;
|
||||
private Integer noteId;
|
||||
private Long userId;
|
||||
private String content;
|
||||
private Date createdAt;
|
||||
private Date updatedAt;
|
||||
private Boolean isDeleted;
|
||||
}
|
||||
@ -64,6 +64,11 @@ public class User {
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 邮箱是否验证
|
||||
*/
|
||||
private Boolean emailVerified;
|
||||
|
||||
/**
|
||||
* 用户学校
|
||||
*/
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.kama.notes.model.vo.comment;
|
||||
|
||||
import com.kama.notes.model.vo.user.UserActionVO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 评论视图对象
|
||||
*/
|
||||
@Data
|
||||
public class CommentVO {
|
||||
/**
|
||||
* 评论ID
|
||||
*/
|
||||
private Integer commentId;
|
||||
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
private Integer noteId;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 点赞数
|
||||
*/
|
||||
private Integer likeCount;
|
||||
|
||||
/**
|
||||
* 回复数
|
||||
*/
|
||||
private Integer replyCount;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 作者信息
|
||||
*/
|
||||
private SimpleAuthorVO author;
|
||||
|
||||
/**
|
||||
* 用户操作信息
|
||||
*/
|
||||
private UserActionVO userActions;
|
||||
|
||||
/**
|
||||
* 回复列表
|
||||
*/
|
||||
private List<CommentVO> replies;
|
||||
|
||||
/**
|
||||
* 简单作者信息
|
||||
*/
|
||||
@Data
|
||||
public static class SimpleAuthorVO {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String avatarUrl;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.kama.notes.model.vo.message;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 消息视图对象
|
||||
*/
|
||||
@Data
|
||||
public class MessageVO {
|
||||
/**
|
||||
* 消息ID
|
||||
*/
|
||||
private Integer messageId;
|
||||
|
||||
/**
|
||||
* 发送者信息
|
||||
*/
|
||||
private SimpleUserVO sender;
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 目标ID
|
||||
*/
|
||||
private Integer targetId;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 是否已读
|
||||
*/
|
||||
private Boolean isRead;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 简单用户信息
|
||||
*/
|
||||
@Data
|
||||
public static class SimpleUserVO {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String avatarUrl;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.kama.notes.model.vo.message;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 各类型未读消息数量
|
||||
*/
|
||||
@Data
|
||||
public class UnreadCountByType {
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 未读数量
|
||||
*/
|
||||
private Integer count;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.kama.notes.model.vo.user;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserActionVO {
|
||||
private Boolean isLiked;
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.kama.notes.service;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.dto.comment.CommentQueryParams;
|
||||
import com.kama.notes.model.dto.comment.CreateCommentRequest;
|
||||
import com.kama.notes.model.dto.comment.UpdateCommentRequest;
|
||||
import com.kama.notes.model.vo.comment.CommentVO;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 评论服务接口
|
||||
*/
|
||||
@Transactional
|
||||
public interface CommentService {
|
||||
/**
|
||||
* 创建评论
|
||||
*
|
||||
* @param request 创建评论请求
|
||||
* @return 创建的评论ID
|
||||
*/
|
||||
ApiResponse<Integer> createComment(CreateCommentRequest request);
|
||||
|
||||
/**
|
||||
* 更新评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @param request 更新评论请求
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> updateComment(Integer commentId, UpdateCommentRequest request);
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> deleteComment(Integer commentId);
|
||||
|
||||
/**
|
||||
* 获取评论列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @return 评论列表
|
||||
*/
|
||||
ApiResponse<List<CommentVO>> getComments(CommentQueryParams params);
|
||||
|
||||
/**
|
||||
* 点赞评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> likeComment(Integer commentId);
|
||||
|
||||
/**
|
||||
* 取消点赞评论
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> unlikeComment(Integer commentId);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.kama.notes.service;
|
||||
|
||||
public interface EmailService {
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
*
|
||||
* @param email 目标邮箱
|
||||
* @param type 验证码类型(REGISTER/RESET_PASSWORD)
|
||||
* @return 生成的验证码
|
||||
*/
|
||||
String sendVerifyCode(String email, String type);
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
*
|
||||
* @param email 邮箱
|
||||
* @param code 验证码
|
||||
* @param type 验证码类型
|
||||
* @return 验证是否成功
|
||||
*/
|
||||
boolean verifyCode(String email, String code, String type);
|
||||
|
||||
/**
|
||||
* 检查是否可以发送验证码
|
||||
* 用于控制发送频率
|
||||
*
|
||||
* @param email 邮箱
|
||||
* @return 是否可以发送
|
||||
*/
|
||||
boolean canSendCode(String email);
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.kama.notes.service;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.base.PageVO;
|
||||
import com.kama.notes.model.dto.message.MessageQueryParams;
|
||||
import com.kama.notes.model.vo.message.MessageVO;
|
||||
import com.kama.notes.model.vo.message.UnreadCountByType;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 消息服务接口
|
||||
*/
|
||||
@Transactional
|
||||
public interface MessageService {
|
||||
/**
|
||||
* 创建消息
|
||||
*
|
||||
* @param receiverId 接收者ID
|
||||
* @param senderId 发送者ID
|
||||
* @param type 消息类型
|
||||
* @param targetId 目标ID
|
||||
* @param content 消息内容
|
||||
* @return 创建的消息ID
|
||||
*/
|
||||
ApiResponse<Integer> createMessage(Long receiverId, Long senderId, String type, Integer targetId, String content);
|
||||
|
||||
/**
|
||||
* 获取消息列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @return 消息列表,带分页信息
|
||||
*/
|
||||
ApiResponse<PageVO<MessageVO>> getMessages(MessageQueryParams params);
|
||||
|
||||
/**
|
||||
* 标记消息为已读
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> markAsRead(Integer messageId);
|
||||
|
||||
/**
|
||||
* 标记所有消息为已读
|
||||
*
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> markAllAsRead();
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @return 空响应
|
||||
*/
|
||||
ApiResponse<EmptyVO> deleteMessage(Integer messageId);
|
||||
|
||||
/**
|
||||
* 获取未读消息数量
|
||||
*
|
||||
* @return 未读消息数量
|
||||
*/
|
||||
ApiResponse<Integer> getUnreadCount();
|
||||
|
||||
/**
|
||||
* 获取各类型未读消息数量
|
||||
*
|
||||
* @return 各类型未读消息数量
|
||||
*/
|
||||
ApiResponse<List<UnreadCountByType>> getUnreadCountByType();
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.kama.notes.service;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.entity.NoteComment;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface NoteCommentService {
|
||||
|
||||
/**
|
||||
* 创建评论
|
||||
*/
|
||||
ApiResponse<EmptyVO> createComment(Integer noteId, String content);
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*/
|
||||
ApiResponse<EmptyVO> deleteComment(Integer commentId);
|
||||
|
||||
/**
|
||||
* 获取笔记的评论列表
|
||||
*/
|
||||
ApiResponse<List<NoteComment>> getComments(Integer noteId);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.kama.notes.service;
|
||||
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.entity.Note;
|
||||
import com.kama.notes.model.entity.User;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SearchService {
|
||||
/**
|
||||
* 搜索笔记
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param page 页码
|
||||
* @param pageSize 每页大小
|
||||
* @return 笔记列表
|
||||
*/
|
||||
ApiResponse<List<Note>> searchNotes(String keyword, int page, int pageSize);
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param page 页码
|
||||
* @param pageSize 每页大小
|
||||
* @return 用户列表
|
||||
*/
|
||||
ApiResponse<List<User>> searchUsers(String keyword, int page, int pageSize);
|
||||
|
||||
/**
|
||||
* 搜索笔记(带标签)
|
||||
*
|
||||
* @param keyword 关键词
|
||||
* @param tag 标签
|
||||
* @param page 页码
|
||||
* @param pageSize 每页大小
|
||||
* @return 笔记列表
|
||||
*/
|
||||
ApiResponse<List<Note>> searchNotesByTag(String keyword, String tag, int page, int pageSize);
|
||||
}
|
||||
@ -0,0 +1,257 @@
|
||||
package com.kama.notes.service.impl;
|
||||
|
||||
import com.kama.notes.annotation.NeedLogin;
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.mapper.CommentMapper;
|
||||
import com.kama.notes.mapper.NoteMapper;
|
||||
import com.kama.notes.mapper.UserMapper;
|
||||
import com.kama.notes.mapper.CommentLikeMapper;
|
||||
import com.kama.notes.model.entity.Comment;
|
||||
import com.kama.notes.model.entity.Note;
|
||||
import com.kama.notes.model.entity.User;
|
||||
import com.kama.notes.model.dto.comment.CommentQueryParams;
|
||||
import com.kama.notes.model.dto.comment.CreateCommentRequest;
|
||||
import com.kama.notes.model.dto.comment.UpdateCommentRequest;
|
||||
import com.kama.notes.model.vo.comment.CommentVO;
|
||||
import com.kama.notes.model.vo.user.UserVO;
|
||||
import com.kama.notes.model.vo.user.UserActionVO;
|
||||
import com.kama.notes.scope.RequestScopeData;
|
||||
import com.kama.notes.service.CommentService;
|
||||
import com.kama.notes.service.MessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 评论服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommentServiceImpl implements CommentService {
|
||||
private final CommentMapper commentMapper;
|
||||
private final NoteMapper noteMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final CommentLikeMapper commentLikeMapper;
|
||||
private final MessageService messageService;
|
||||
private final RequestScopeData requestScopeData;
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<Integer> createComment(CreateCommentRequest request) {
|
||||
log.info("开始创建评论: request={}", request);
|
||||
|
||||
try {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 获取笔记信息
|
||||
Note note = noteMapper.findById(request.getNoteId());
|
||||
if (note == null) {
|
||||
log.error("笔记不存在: noteId={}", request.getNoteId());
|
||||
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "笔记不存在");
|
||||
}
|
||||
|
||||
// 创建评论
|
||||
Comment comment = new Comment();
|
||||
comment.setNoteId(request.getNoteId());
|
||||
comment.setContent(request.getContent());
|
||||
comment.setAuthorId(userId);
|
||||
comment.setParentId(request.getParentId());
|
||||
comment.setLikeCount(0);
|
||||
comment.setReplyCount(0);
|
||||
comment.setCreatedAt(LocalDateTime.now());
|
||||
comment.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
commentMapper.insert(comment);
|
||||
log.info("评论创建结果: commentId={}", comment.getCommentId());
|
||||
|
||||
// 增加笔记评论数
|
||||
noteMapper.incrementCommentCount(request.getNoteId());
|
||||
|
||||
// 如果是回复评论,增加父评论的回复数
|
||||
if (request.getParentId() != null) {
|
||||
commentMapper.incrementReplyCount(request.getParentId());
|
||||
}
|
||||
|
||||
// 创建消息通知
|
||||
ApiResponse<Integer> messageResponse = messageService.createMessage(
|
||||
note.getAuthorId(), // 接收者是笔记作者
|
||||
userId, // 发送者是评论作者
|
||||
"COMMENT", // 消息类型是评论
|
||||
comment.getCommentId(), // 目标ID是评论ID
|
||||
"评论了你的笔记" // 消息内容
|
||||
);
|
||||
log.info("消息通知已创建: {}", messageResponse);
|
||||
|
||||
return ApiResponse.success(comment.getCommentId());
|
||||
} catch (Exception e) {
|
||||
log.error("创建评论失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "创建评论失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> updateComment(Integer commentId, UpdateCommentRequest request) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 查询评论
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (!comment.getAuthorId().equals(userId)) {
|
||||
return ApiResponse.error(HttpStatus.FORBIDDEN.value(), "无权修改该评论");
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新评论
|
||||
comment.setContent(request.getContent());
|
||||
comment.setUpdatedAt(LocalDateTime.now());
|
||||
commentMapper.update(comment);
|
||||
return ApiResponse.success(new EmptyVO());
|
||||
} catch (Exception e) {
|
||||
log.error("更新评论失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "更新评论失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> deleteComment(Integer commentId) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 查询评论
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (!comment.getAuthorId().equals(userId)) {
|
||||
return ApiResponse.error(HttpStatus.FORBIDDEN.value(), "无权删除该评论");
|
||||
}
|
||||
|
||||
try {
|
||||
// 删除评论
|
||||
commentMapper.deleteById(commentId);
|
||||
return ApiResponse.success(new EmptyVO());
|
||||
} catch (Exception e) {
|
||||
log.error("删除评论失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "删除评论失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<CommentVO>> getComments(CommentQueryParams params) {
|
||||
try {
|
||||
List<Comment> comments = commentMapper.findByQueryParam(params, params.getPageSize(), (params.getPage() - 1) * params.getPageSize());
|
||||
if (comments == null || comments.isEmpty()) {
|
||||
return ApiResponse.success(List.of());
|
||||
}
|
||||
|
||||
List<CommentVO> commentVOs = comments.stream()
|
||||
.map(comment -> {
|
||||
CommentVO vo = new CommentVO();
|
||||
vo.setCommentId(comment.getCommentId());
|
||||
vo.setContent(comment.getContent());
|
||||
vo.setLikeCount(comment.getLikeCount());
|
||||
vo.setReplyCount(comment.getReplyCount());
|
||||
vo.setCreatedAt(comment.getCreatedAt());
|
||||
vo.setUpdatedAt(comment.getUpdatedAt());
|
||||
|
||||
// 设置作者信息
|
||||
User author = userMapper.findById(comment.getAuthorId());
|
||||
if (author != null) {
|
||||
CommentVO.SimpleAuthorVO authorVO = new CommentVO.SimpleAuthorVO();
|
||||
authorVO.setUserId(author.getUserId());
|
||||
authorVO.setUsername(author.getUsername());
|
||||
authorVO.setAvatarUrl(author.getAvatarUrl());
|
||||
vo.setAuthor(authorVO);
|
||||
}
|
||||
|
||||
// 设置用户操作状态
|
||||
Long currentUserId = requestScopeData.getUserId();
|
||||
if (currentUserId != null) {
|
||||
UserActionVO userActions = new UserActionVO();
|
||||
userActions.setIsLiked(commentLikeMapper.checkIsLiked(currentUserId, comment.getCommentId()));
|
||||
vo.setUserActions(userActions);
|
||||
}
|
||||
|
||||
return vo;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ApiResponse.success(commentVOs);
|
||||
} catch (Exception e) {
|
||||
log.error("获取评论列表失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "获取评论列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> likeComment(Integer commentId) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 查询评论
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
// 增加评论点赞数
|
||||
commentMapper.incrementLikeCount(commentId);
|
||||
|
||||
// 发送点赞消息通知
|
||||
messageService.createMessage(
|
||||
comment.getAuthorId(), // 接收者是评论作者
|
||||
userId, // 发送者是点赞用户
|
||||
"LIKE", // 消息类型是点赞
|
||||
commentId, // 目标ID是评论ID
|
||||
"点赞了你的评论" // 消息内容
|
||||
);
|
||||
|
||||
return ApiResponse.success(new EmptyVO());
|
||||
} catch (Exception e) {
|
||||
log.error("点赞评论失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "点赞评论失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> unlikeComment(Integer commentId) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 查询评论
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
// 减少评论点赞数
|
||||
commentMapper.decrementLikeCount(commentId);
|
||||
return ApiResponse.success(new EmptyVO());
|
||||
} catch (Exception e) {
|
||||
log.error("取消点赞评论失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "取消点赞评论失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
package com.kama.notes.service.impl;
|
||||
|
||||
import com.kama.notes.mapper.EmailVerifyCodeMapper;
|
||||
import com.kama.notes.model.entity.EmailVerifyCode;
|
||||
import com.kama.notes.service.EmailService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class EmailServiceImpl implements EmailService {
|
||||
|
||||
@Autowired
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@Autowired
|
||||
private EmailVerifyCodeMapper emailVerifyCodeMapper;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
@Value("${mail.verify-code.expire-minutes}")
|
||||
private int expireMinutes;
|
||||
|
||||
@Value("${mail.verify-code.resend-interval}")
|
||||
private int resendInterval;
|
||||
|
||||
@Override
|
||||
public String sendVerifyCode(String email, String type) {
|
||||
// 检查发送频率
|
||||
if (!canSendCode(email)) {
|
||||
throw new RuntimeException("发送太频繁,请稍后再试");
|
||||
}
|
||||
|
||||
// 生成6位随机验证码
|
||||
String verifyCode = generateVerifyCode();
|
||||
|
||||
try {
|
||||
// 发送邮件
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(fromEmail);
|
||||
message.setTo(email);
|
||||
message.setSubject("卡码笔记 - 验证码");
|
||||
message.setText("您的验证码是:" + verifyCode + ",有效期" + expireMinutes + "分钟,请勿泄露给他人。");
|
||||
mailSender.send(message);
|
||||
|
||||
// 保存验证码记录
|
||||
EmailVerifyCode code = new EmailVerifyCode();
|
||||
code.setEmail(email);
|
||||
code.setCode(verifyCode);
|
||||
code.setType(type);
|
||||
code.setExpiredAt(LocalDateTime.now().plusMinutes(expireMinutes));
|
||||
emailVerifyCodeMapper.insert(code);
|
||||
|
||||
// 记录发送时间到Redis
|
||||
String redisKey = "email:verify:limit:" + email;
|
||||
redisTemplate.opsForValue().set(redisKey, "1", resendInterval, TimeUnit.SECONDS);
|
||||
|
||||
return verifyCode;
|
||||
} catch (Exception e) {
|
||||
log.error("发送验证码邮件失败", e);
|
||||
throw new RuntimeException("发送验证码失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyCode(String email, String code, String type) {
|
||||
// 查询最新的未使用的验证码
|
||||
EmailVerifyCode verifyCode = emailVerifyCodeMapper.findLatestValidCode(email, type);
|
||||
|
||||
if (verifyCode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (verifyCode.getExpiredAt().isBefore(LocalDateTime.now())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查验证码是否正确
|
||||
if (!verifyCode.getCode().equals(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 标记验证码为已使用
|
||||
emailVerifyCodeMapper.markAsUsed(verifyCode.getId());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSendCode(String email) {
|
||||
String redisKey = "email:verify:limit:" + email;
|
||||
return redisTemplate.opsForValue().get(redisKey) == null;
|
||||
}
|
||||
|
||||
private String generateVerifyCode() {
|
||||
Random random = new Random();
|
||||
StringBuilder code = new StringBuilder();
|
||||
for (int i = 0; i < 6; i++) {
|
||||
code.append(random.nextInt(10));
|
||||
}
|
||||
return code.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
package com.kama.notes.service.impl;
|
||||
|
||||
import com.kama.notes.mapper.MessageMapper;
|
||||
import com.kama.notes.mapper.UserMapper;
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.base.PageVO;
|
||||
import com.kama.notes.model.dto.message.MessageQueryParams;
|
||||
import com.kama.notes.model.entity.Message;
|
||||
import com.kama.notes.model.entity.User;
|
||||
import com.kama.notes.model.vo.message.MessageVO;
|
||||
import com.kama.notes.model.vo.message.UnreadCountByType;
|
||||
import com.kama.notes.service.MessageService;
|
||||
import com.kama.notes.utils.SecurityUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 消息服务实现类
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MessageServiceImpl implements MessageService {
|
||||
|
||||
private final MessageMapper messageMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageServiceImpl.class);
|
||||
|
||||
// Redis键前缀
|
||||
private static final String UNREAD_COUNT_KEY = "message:unread:count:";
|
||||
private static final String UNREAD_COUNT_BY_TYPE_KEY = "message:unread:type:";
|
||||
private static final String MESSAGE_CACHE_KEY = "message:detail:";
|
||||
|
||||
@Override
|
||||
public ApiResponse<Integer> createMessage(Long receiverId, Long senderId, String type, Integer targetId, String content) {
|
||||
log.info("开始创建消息通知: receiverId={}, senderId={}, type={}, targetId={}, content={}",
|
||||
receiverId, senderId, type, targetId, content);
|
||||
|
||||
try {
|
||||
Message message = new Message();
|
||||
message.setReceiverId(receiverId);
|
||||
message.setSenderId(senderId);
|
||||
message.setType(type);
|
||||
message.setTargetId(targetId);
|
||||
message.setContent(content);
|
||||
message.setIsRead(false);
|
||||
message.setCreatedAt(LocalDateTime.now());
|
||||
message.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
int rows = messageMapper.insert(message);
|
||||
log.info("消息通知创建结果: messageId={}, 影响行数={}", message.getMessageId(), rows);
|
||||
|
||||
// 清除相关缓存
|
||||
clearMessageCache(receiverId);
|
||||
|
||||
return ApiResponse.success(message.getMessageId());
|
||||
} catch (Exception e) {
|
||||
log.error("创建消息通知失败", e);
|
||||
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "创建消息通知失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<PageVO<MessageVO>> getMessages(MessageQueryParams params) {
|
||||
Long currentUserId = SecurityUtils.getCurrentUserId();
|
||||
|
||||
// 获取总记录数
|
||||
int total = messageMapper.countByParams(currentUserId, params);
|
||||
|
||||
// 计算偏移量
|
||||
int offset = (params.getPage() - 1) * params.getPageSize();
|
||||
|
||||
// 获取当前页数据
|
||||
List<Message> messages = messageMapper.selectByParams(currentUserId, params, offset);
|
||||
|
||||
// 转换为VO对象
|
||||
List<MessageVO> messageVOs = messages.stream().map(message -> {
|
||||
MessageVO vo = new MessageVO();
|
||||
vo.setMessageId(message.getMessageId());
|
||||
vo.setType(message.getType());
|
||||
vo.setTargetId(message.getTargetId());
|
||||
vo.setContent(message.getContent());
|
||||
vo.setIsRead(message.getIsRead());
|
||||
vo.setCreatedAt(message.getCreatedAt());
|
||||
|
||||
// 从缓存获取发送者信息
|
||||
User sender = getUserFromCache(message.getSenderId());
|
||||
if (sender != null) {
|
||||
MessageVO.SimpleUserVO senderVO = new MessageVO.SimpleUserVO();
|
||||
senderVO.setUserId(sender.getUserId());
|
||||
senderVO.setUsername(sender.getUsername());
|
||||
senderVO.setAvatarUrl(sender.getAvatarUrl());
|
||||
vo.setSender(senderVO);
|
||||
}
|
||||
|
||||
return vo;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
// 创建分页结果
|
||||
PageVO<MessageVO> pageVO = PageVO.of(messageVOs, params.getPage(), params.getPageSize(), total);
|
||||
return ApiResponse.success(pageVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<EmptyVO> markAsRead(Integer messageId) {
|
||||
Long currentUserId = SecurityUtils.getCurrentUserId();
|
||||
messageMapper.markAsRead(messageId, currentUserId);
|
||||
|
||||
// 清除相关缓存
|
||||
clearMessageCache(currentUserId);
|
||||
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<EmptyVO> markAllAsRead() {
|
||||
Long currentUserId = SecurityUtils.getCurrentUserId();
|
||||
messageMapper.markAllAsRead(currentUserId);
|
||||
|
||||
// 清除相关缓存
|
||||
clearMessageCache(currentUserId);
|
||||
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<EmptyVO> deleteMessage(Integer messageId) {
|
||||
Long currentUserId = SecurityUtils.getCurrentUserId();
|
||||
messageMapper.deleteMessage(messageId, currentUserId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<Integer> getUnreadCount() {
|
||||
Long currentUserId = SecurityUtils.getCurrentUserId();
|
||||
String cacheKey = UNREAD_COUNT_KEY + currentUserId;
|
||||
|
||||
// 尝试从缓存获取
|
||||
Integer count = (Integer) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (count == null) {
|
||||
// 缓存未命中,从数据库查询
|
||||
count = messageMapper.countUnread(currentUserId);
|
||||
// 将结果存入缓存,设置5分钟过期
|
||||
redisTemplate.opsForValue().set(cacheKey, count, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
return ApiResponse.success(count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<UnreadCountByType>> getUnreadCountByType() {
|
||||
Long currentUserId = SecurityUtils.getCurrentUserId();
|
||||
String cacheKey = UNREAD_COUNT_BY_TYPE_KEY + currentUserId;
|
||||
|
||||
// 尝试从缓存获取
|
||||
@SuppressWarnings("unchecked")
|
||||
List<UnreadCountByType> result = (List<UnreadCountByType>) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (result == null) {
|
||||
// 缓存未命中,从数据库查询
|
||||
result = messageMapper.countUnreadByType(currentUserId);
|
||||
// 将结果存入缓存,设置5分钟过期
|
||||
redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取用户信息
|
||||
*/
|
||||
private User getUserFromCache(Long userId) {
|
||||
String cacheKey = "user:detail:" + userId;
|
||||
User user = (User) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (user == null) {
|
||||
user = userMapper.findById(userId);
|
||||
if (user != null) {
|
||||
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户相关的消息缓存
|
||||
*/
|
||||
private void clearMessageCache(Long userId) {
|
||||
redisTemplate.delete(UNREAD_COUNT_KEY + userId);
|
||||
redisTemplate.delete(UNREAD_COUNT_BY_TYPE_KEY + userId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.kama.notes.service.impl;
|
||||
|
||||
import com.kama.notes.annotation.NeedLogin;
|
||||
import com.kama.notes.mapper.NoteCommentMapper;
|
||||
import com.kama.notes.mapper.NoteMapper;
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.entity.Note;
|
||||
import com.kama.notes.model.entity.NoteComment;
|
||||
import com.kama.notes.scope.RequestScopeData;
|
||||
import com.kama.notes.service.MessageService;
|
||||
import com.kama.notes.service.NoteCommentService;
|
||||
import com.kama.notes.utils.ApiResponseUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoteCommentServiceImpl implements NoteCommentService {
|
||||
|
||||
private final NoteCommentMapper noteCommentMapper;
|
||||
private final NoteMapper noteMapper;
|
||||
private final RequestScopeData requestScopeData;
|
||||
private final MessageService messageService;
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> createComment(Integer noteId, String content) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 查询笔记
|
||||
Note note = noteMapper.findById(noteId);
|
||||
if (note == null) {
|
||||
return ApiResponseUtil.error("笔记不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建评论
|
||||
NoteComment comment = new NoteComment();
|
||||
comment.setNoteId(noteId);
|
||||
comment.setUserId(userId);
|
||||
comment.setContent(content);
|
||||
comment.setCreatedAt(new Date());
|
||||
comment.setUpdatedAt(new Date());
|
||||
comment.setIsDeleted(false);
|
||||
noteCommentMapper.insert(comment);
|
||||
|
||||
// 发送评论通知
|
||||
messageService.createMessage(
|
||||
note.getAuthorId(),
|
||||
userId,
|
||||
"COMMENT",
|
||||
noteId,
|
||||
"评论了你的笔记"
|
||||
);
|
||||
|
||||
return ApiResponseUtil.success("评论成功");
|
||||
} catch (Exception e) {
|
||||
return ApiResponseUtil.error("评论失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
public ApiResponse<EmptyVO> deleteComment(Integer commentId) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
// 查询评论
|
||||
NoteComment comment = noteCommentMapper.findById(commentId);
|
||||
if (comment == null || !comment.getUserId().equals(userId)) {
|
||||
return ApiResponseUtil.error("无权删除该评论");
|
||||
}
|
||||
|
||||
try {
|
||||
// 软删除评论
|
||||
comment.setIsDeleted(true);
|
||||
comment.setUpdatedAt(new Date());
|
||||
noteCommentMapper.update(comment);
|
||||
return ApiResponseUtil.success("删除成功");
|
||||
} catch (Exception e) {
|
||||
return ApiResponseUtil.error("删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<NoteComment>> getComments(Integer noteId) {
|
||||
try {
|
||||
List<NoteComment> comments = noteCommentMapper.findByNoteId(noteId);
|
||||
return ApiResponseUtil.success("获取评论成功", comments);
|
||||
} catch (Exception e) {
|
||||
return ApiResponseUtil.error("获取评论失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,72 +5,97 @@ import com.kama.notes.mapper.NoteLikeMapper;
|
||||
import com.kama.notes.mapper.NoteMapper;
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.base.EmptyVO;
|
||||
import com.kama.notes.model.entity.Note;
|
||||
import com.kama.notes.model.entity.NoteLike;
|
||||
import com.kama.notes.scope.RequestScopeData;
|
||||
import com.kama.notes.service.MessageService;
|
||||
import com.kama.notes.service.NoteLikeService;
|
||||
import com.kama.notes.utils.ApiResponseUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NoteLikeServiceImpl implements NoteLikeService {
|
||||
|
||||
@Autowired
|
||||
private NoteLikeMapper noteLikeMapper;
|
||||
|
||||
@Autowired
|
||||
private NoteMapper noteMapper;
|
||||
|
||||
@Autowired
|
||||
private RequestScopeData requestScopeData;
|
||||
|
||||
@Override
|
||||
public Set<Integer> findUserLikedNoteIds(Long userId, List<Integer> noteIds) {
|
||||
List<Integer> userLikedNoteIds = noteLikeMapper.findUserLikedNoteIds(userId, noteIds);
|
||||
return new HashSet<>(userLikedNoteIds);
|
||||
}
|
||||
private final NoteLikeMapper noteLikeMapper;
|
||||
private final NoteMapper noteMapper;
|
||||
private final RequestScopeData requestScopeData;
|
||||
private final MessageService messageService;
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> likeNote(Integer noteId) {
|
||||
|
||||
Long userId = requestScopeData.getUserId();
|
||||
NoteLike noteLike = noteLikeMapper.findByUserIdAndNoteId(userId, noteId);
|
||||
|
||||
if (noteLike != null) {
|
||||
return ApiResponseUtil.success("已经点赞过了");
|
||||
// 查询笔记
|
||||
Note note = noteMapper.findById(noteId);
|
||||
if (note == null) {
|
||||
return ApiResponseUtil.error("笔记不存在");
|
||||
}
|
||||
|
||||
noteLike = new NoteLike();
|
||||
noteLike.setUserId(userId);
|
||||
noteLike.setNoteId(noteId);
|
||||
noteLikeMapper.insert(noteLike);
|
||||
try {
|
||||
// 创建点赞记录
|
||||
NoteLike noteLike = new NoteLike();
|
||||
noteLike.setNoteId(noteId);
|
||||
noteLike.setUserId(userId);
|
||||
noteLike.setCreatedAt(new Date());
|
||||
noteLikeMapper.insert(noteLike);
|
||||
|
||||
// 更新笔记点赞数
|
||||
noteMapper.likeNote(noteId);
|
||||
// 增加笔记点赞数
|
||||
noteMapper.likeNote(noteId);
|
||||
|
||||
return ApiResponseUtil.success("点赞成功");
|
||||
// 发送点赞通知
|
||||
messageService.createMessage(
|
||||
note.getAuthorId(),
|
||||
userId,
|
||||
"LIKE",
|
||||
noteId,
|
||||
"点赞了你的笔记"
|
||||
);
|
||||
|
||||
return ApiResponseUtil.success("点赞成功");
|
||||
} catch (Exception e) {
|
||||
return ApiResponseUtil.error("点赞失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NeedLogin
|
||||
@Transactional
|
||||
public ApiResponse<EmptyVO> unlikeNote(Integer noteId) {
|
||||
Long userId = requestScopeData.getUserId();
|
||||
|
||||
NoteLike noteLike = noteLikeMapper.findByUserIdAndNoteId(userId, noteId);
|
||||
|
||||
if (noteLike == null) {
|
||||
return ApiResponseUtil.success("已经取消点赞过了");
|
||||
// 查询笔记
|
||||
Note note = noteMapper.findById(noteId);
|
||||
if (note == null) {
|
||||
return ApiResponseUtil.error("笔记不存在");
|
||||
}
|
||||
|
||||
noteLikeMapper.delete(noteLike);
|
||||
try {
|
||||
// 删除点赞记录
|
||||
NoteLike noteLike = noteLikeMapper.findByUserIdAndNoteId(userId, noteId);
|
||||
if (noteLike != null) {
|
||||
noteLikeMapper.delete(noteLike);
|
||||
// 减少笔记点赞数
|
||||
noteMapper.unlikeNote(noteId);
|
||||
}
|
||||
return ApiResponseUtil.success("取消点赞成功");
|
||||
} catch (Exception e) {
|
||||
return ApiResponseUtil.error("取消点赞失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 更新笔记点赞数
|
||||
noteMapper.unlikeNote(noteId);
|
||||
return ApiResponseUtil.success("取消点赞成功");
|
||||
@Override
|
||||
public Set<Integer> findUserLikedNoteIds(Long userId, List<Integer> noteIds) {
|
||||
List<Integer> likedIds = noteLikeMapper.findUserLikedNoteIds(userId, noteIds);
|
||||
return new HashSet<>(likedIds);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
package com.kama.notes.service.impl;
|
||||
|
||||
import com.kama.notes.mapper.NoteMapper;
|
||||
import com.kama.notes.mapper.UserMapper;
|
||||
import com.kama.notes.model.base.ApiResponse;
|
||||
import com.kama.notes.model.entity.Note;
|
||||
import com.kama.notes.model.entity.User;
|
||||
import com.kama.notes.service.SearchService;
|
||||
import com.kama.notes.utils.ApiResponseUtil;
|
||||
import com.kama.notes.utils.SearchUtils;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Log4j2
|
||||
@Service
|
||||
public class SearchServiceImpl implements SearchService {
|
||||
|
||||
@Autowired
|
||||
private NoteMapper noteMapper;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String NOTE_SEARCH_CACHE_KEY = "search:note:%s:%d:%d";
|
||||
private static final String USER_SEARCH_CACHE_KEY = "search:user:%s:%d:%d";
|
||||
private static final String NOTE_TAG_SEARCH_CACHE_KEY = "search:note:tag:%s:%s:%d:%d";
|
||||
private static final long CACHE_EXPIRE_TIME = 30; // 分钟
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<Note>> searchNotes(String keyword, int page, int pageSize) {
|
||||
try {
|
||||
String cacheKey = String.format(NOTE_SEARCH_CACHE_KEY, keyword, page, pageSize);
|
||||
|
||||
// 尝试从缓存获取
|
||||
List<Note> cachedResult = (List<Note>) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedResult != null) {
|
||||
return ApiResponseUtil.success("搜索成功", cachedResult);
|
||||
}
|
||||
|
||||
// 处理关键词
|
||||
keyword = SearchUtils.preprocessKeyword(keyword);
|
||||
|
||||
// 计算偏移量
|
||||
int offset = (page - 1) * pageSize;
|
||||
|
||||
// 执行搜索
|
||||
List<Note> notes = noteMapper.searchNotes(keyword, pageSize, offset);
|
||||
|
||||
// 存入缓存
|
||||
redisTemplate.opsForValue().set(cacheKey, notes, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
|
||||
|
||||
return ApiResponseUtil.success("搜索成功", notes);
|
||||
} catch (Exception e) {
|
||||
log.error("搜索笔记失败", e);
|
||||
return ApiResponseUtil.error("搜索失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<User>> searchUsers(String keyword, int page, int pageSize) {
|
||||
try {
|
||||
String cacheKey = String.format(USER_SEARCH_CACHE_KEY, keyword, page, pageSize);
|
||||
|
||||
// 尝试从缓存获取
|
||||
List<User> cachedResult = (List<User>) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedResult != null) {
|
||||
return ApiResponseUtil.success("搜索成功", cachedResult);
|
||||
}
|
||||
|
||||
// 计算偏移量
|
||||
int offset = (page - 1) * pageSize;
|
||||
|
||||
// 执行搜索
|
||||
List<User> users = userMapper.searchUsers(keyword, pageSize, offset);
|
||||
|
||||
// 存入缓存
|
||||
redisTemplate.opsForValue().set(cacheKey, users, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
|
||||
|
||||
return ApiResponseUtil.success("搜索成功", users);
|
||||
} catch (Exception e) {
|
||||
log.error("搜索用户失败", e);
|
||||
return ApiResponseUtil.error("搜索失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<Note>> searchNotesByTag(String keyword, String tag, int page, int pageSize) {
|
||||
try {
|
||||
String cacheKey = String.format(NOTE_TAG_SEARCH_CACHE_KEY, keyword, tag, page, pageSize);
|
||||
|
||||
// 尝试从缓存获取
|
||||
List<Note> cachedResult = (List<Note>) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cachedResult != null) {
|
||||
return ApiResponseUtil.success("搜索成功", cachedResult);
|
||||
}
|
||||
|
||||
// 处理关键词
|
||||
keyword = SearchUtils.preprocessKeyword(keyword);
|
||||
|
||||
// 计算偏移量
|
||||
int offset = (page - 1) * pageSize;
|
||||
|
||||
// 执行搜索
|
||||
List<Note> notes = noteMapper.searchNotesByTag(keyword, tag, pageSize, offset);
|
||||
|
||||
// 存入缓存
|
||||
redisTemplate.opsForValue().set(cacheKey, notes, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
|
||||
|
||||
return ApiResponseUtil.success("搜索成功", notes);
|
||||
} catch (Exception e) {
|
||||
log.error("搜索笔记失败", e);
|
||||
return ApiResponseUtil.error("搜索失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import com.kama.notes.model.vo.user.RegisterVO;
|
||||
import com.kama.notes.model.vo.user.LoginUserVO;
|
||||
import com.kama.notes.model.vo.user.UserVO;
|
||||
import com.kama.notes.scope.RequestScopeData;
|
||||
import com.kama.notes.service.EmailService;
|
||||
import com.kama.notes.service.FileService;
|
||||
import com.kama.notes.service.UserService;
|
||||
import com.kama.notes.utils.ApiResponseUtil;
|
||||
@ -50,22 +51,43 @@ public class UserServiceImpl implements UserService {
|
||||
@Autowired
|
||||
private RequestScopeData requestScopeData;
|
||||
|
||||
@Autowired
|
||||
private EmailService emailService;
|
||||
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ApiResponse<RegisterVO> register(RegisterRequest request) {
|
||||
// 检查账号是否已存在
|
||||
// TODO: 可以优化
|
||||
User existingUser = userMapper.findByAccount(request.getAccount());
|
||||
if (existingUser != null) {
|
||||
return ApiResponseUtil.error("账号重复");
|
||||
}
|
||||
|
||||
// 如果提供了邮箱,则进行邮箱相关验证
|
||||
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
|
||||
// 检查邮箱是否已存在
|
||||
existingUser = userMapper.findByEmail(request.getEmail());
|
||||
if (existingUser != null) {
|
||||
return ApiResponseUtil.error("邮箱已被使用");
|
||||
}
|
||||
|
||||
// 如果提供了邮箱但没有提供验证码
|
||||
if (request.getVerifyCode() == null || request.getVerifyCode().isEmpty()) {
|
||||
return ApiResponseUtil.error("请提供邮箱验证码");
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
if (!emailService.verifyCode(request.getEmail(), request.getVerifyCode(), "REGISTER")) {
|
||||
return ApiResponseUtil.error("验证码无效或已过期");
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
User user = new User();
|
||||
|
||||
BeanUtils.copyProperties(request, user);
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
user.setEmailVerified(request.getEmail() != null && !request.getEmail().isEmpty()); // 只有提供邮箱时才设置验证状态
|
||||
|
||||
try {
|
||||
// 保存用户
|
||||
@ -87,12 +109,20 @@ public class UserServiceImpl implements UserService {
|
||||
|
||||
@Override
|
||||
public ApiResponse<LoginUserVO> login(LoginRequest request) {
|
||||
User user = null;
|
||||
|
||||
User user = userMapper.findByAccount(request.getAccount());
|
||||
// 根据账号或邮箱查找用户
|
||||
if (request.getAccount() != null && !request.getAccount().isEmpty()) {
|
||||
user = userMapper.findByAccount(request.getAccount());
|
||||
} else if (request.getEmail() != null && !request.getEmail().isEmpty()) {
|
||||
user = userMapper.findByEmail(request.getEmail());
|
||||
} else {
|
||||
return ApiResponseUtil.error("请提供账号或邮箱");
|
||||
}
|
||||
|
||||
// 验证账号以及密码
|
||||
if (user == null) {
|
||||
return ApiResponseUtil.error("账号不存在");
|
||||
return ApiResponseUtil.error("用户不存在");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
|
||||
@ -14,18 +14,18 @@ public class ApiResponseUtil {
|
||||
* @return ApiResponse
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(String message) {
|
||||
return new ApiResponse<>(HttpStatus.OK.value(), message, null);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(HttpStatus.OK.value(), message, data);
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建参数错误的响应
|
||||
*/
|
||||
public static <T> ApiResponse<T> error(String msg) {
|
||||
return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), msg, null);
|
||||
return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), msg);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
47
backend/src/main/java/com/kama/notes/utils/SearchUtils.java
Normal file
47
backend/src/main/java/com/kama/notes/utils/SearchUtils.java
Normal file
@ -0,0 +1,47 @@
|
||||
package com.kama.notes.utils;
|
||||
|
||||
import com.huaban.analysis.jieba.JiebaSegmenter;
|
||||
import com.huaban.analysis.jieba.SegToken;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SearchUtils {
|
||||
private static final JiebaSegmenter segmenter = new JiebaSegmenter();
|
||||
|
||||
/**
|
||||
* 预处理搜索关键词
|
||||
* 1. 去除特殊字符
|
||||
* 2. 分词
|
||||
* 3. 组合搜索词
|
||||
*
|
||||
* @param keyword 原始关键词
|
||||
* @return 处理后的关键词
|
||||
*/
|
||||
public static String preprocessKeyword(String keyword) {
|
||||
if (!StringUtils.hasText(keyword)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 1. 去除特殊字符
|
||||
keyword = keyword.replaceAll("[\\p{P}\\p{S}]", " ");
|
||||
|
||||
// 2. 分词
|
||||
List<String> words = segmenter.sentenceProcess(keyword);
|
||||
|
||||
// 3. 组合搜索词
|
||||
return String.join(" ", words);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算分页的偏移量
|
||||
*
|
||||
* @param page 页码(从1开始)
|
||||
* @param pageSize 每页大小
|
||||
* @return 偏移量
|
||||
*/
|
||||
public static int calculateOffset(int page, int pageSize) {
|
||||
return Math.max(0, (page - 1) * pageSize);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.kama.notes.utils;
|
||||
|
||||
import com.kama.notes.model.entity.User;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
/**
|
||||
* 安全工具类
|
||||
*/
|
||||
public class SecurityUtils {
|
||||
/**
|
||||
* 获取当前登录用户ID
|
||||
*
|
||||
* @return 用户ID
|
||||
*/
|
||||
public static Long getCurrentUserId() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof User) {
|
||||
return ((User) authentication.getPrincipal()).getUserId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户
|
||||
*
|
||||
* @return 用户实体
|
||||
*/
|
||||
public static User getCurrentUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof User) {
|
||||
return (User) authentication.getPrincipal();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package com.kama.notes.websocket;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kama.notes.model.vo.message.MessageVO;
|
||||
import com.kama.notes.utils.JwtUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* WebSocket消息处理器
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class MessageWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// 用户ID -> WebSocket会话的映射
|
||||
private static final Map<Long, WebSocketSession> USER_SESSIONS = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) {
|
||||
Long userId = getUserIdFromSession(session);
|
||||
if (userId != null) {
|
||||
log.info("用户[{}]建立WebSocket连接", userId);
|
||||
USER_SESSIONS.put(userId, session);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||
// 处理接收到的消息
|
||||
try {
|
||||
String payload = message.getPayload();
|
||||
log.debug("收到消息: {}", payload);
|
||||
// 这里可以添加消息处理逻辑
|
||||
} catch (Exception e) {
|
||||
log.error("处理WebSocket消息时发生错误", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
Long userId = getUserIdFromSession(session);
|
||||
if (userId != null) {
|
||||
log.info("用户[{}]断开WebSocket连接", userId);
|
||||
USER_SESSIONS.remove(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给指定用户
|
||||
*/
|
||||
public void sendMessageToUser(Long userId, MessageVO message) {
|
||||
WebSocketSession session = USER_SESSIONS.get(userId);
|
||||
if (session != null && session.isOpen()) {
|
||||
try {
|
||||
String messageText = objectMapper.writeValueAsString(message);
|
||||
session.sendMessage(new TextMessage(messageText));
|
||||
log.debug("发送消息到用户[{}]: {}", userId, messageText);
|
||||
} catch (Exception e) {
|
||||
log.error("发送消息给用户[{}]时发生错误", userId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从WebSocket会话中获取用户ID
|
||||
*/
|
||||
private Long getUserIdFromSession(WebSocketSession session) {
|
||||
String token = session.getHandshakeHeaders().getFirst("Authorization");
|
||||
if (token != null && token.startsWith("Bearer ")) {
|
||||
token = token.substring(7);
|
||||
return jwtUtil.getUserIdFromToken(token);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前在线用户数
|
||||
*/
|
||||
public int getOnlineUserCount() {
|
||||
return USER_SESSIONS.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否在线
|
||||
*/
|
||||
public boolean isUserOnline(Long userId) {
|
||||
WebSocketSession session = USER_SESSIONS.get(userId);
|
||||
return session != null && session.isOpen();
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,23 @@ spring:
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
max-wait: 2000
|
||||
mail:
|
||||
host: smtp.qq.com
|
||||
port: 465
|
||||
username: 864508127@qq.com
|
||||
password: eqlekpslhcsgbfhf
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
ssl:
|
||||
enable: true
|
||||
socketFactory:
|
||||
port: 465
|
||||
class: javax.net.ssl.SSLSocketFactory
|
||||
protocol: smtps
|
||||
default-encoding: UTF-8
|
||||
|
||||
mybatis:
|
||||
mapper-locations: classpath:mapper/*.xml
|
||||
configuration:
|
||||
@ -33,3 +50,10 @@ jwt:
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
# 自定义邮件配置
|
||||
mail:
|
||||
verify-code:
|
||||
expire-minutes: 15
|
||||
resend-interval: 60
|
||||
template-path: "templates/mail/verify-code.html"
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
CREATE TABLE note_comment (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
note_id INT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
FOREIGN KEY (note_id) REFERENCES note(id),
|
||||
FOREIGN KEY (user_id) REFERENCES user(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@ -0,0 +1,33 @@
|
||||
-- 评论表
|
||||
CREATE TABLE IF NOT EXISTS `comment` (
|
||||
`comment_id` INT NOT NULL AUTO_INCREMENT COMMENT '评论ID',
|
||||
`note_id` INT UNSIGNED NOT NULL COMMENT '笔记ID',
|
||||
`author_id` BIGINT UNSIGNED NOT NULL COMMENT '作者ID',
|
||||
`parent_id` INT DEFAULT NULL COMMENT '父评论ID',
|
||||
`content` TEXT NOT NULL COMMENT '评论内容',
|
||||
`like_count` INT NOT NULL DEFAULT 0 COMMENT '点赞数',
|
||||
`reply_count` INT NOT NULL DEFAULT 0 COMMENT '回复数',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`comment_id`),
|
||||
KEY `idx_note_id` (`note_id`),
|
||||
KEY `idx_author_id` (`author_id`),
|
||||
KEY `idx_parent_id` (`parent_id`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
CONSTRAINT `fk_comment_note` FOREIGN KEY (`note_id`) REFERENCES `note` (`note_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_comment_author` FOREIGN KEY (`author_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_comment_parent` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`comment_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';
|
||||
|
||||
-- 评论点赞表
|
||||
CREATE TABLE IF NOT EXISTS `comment_like` (
|
||||
`comment_like_id` INT NOT NULL AUTO_INCREMENT COMMENT '评论点赞ID',
|
||||
`comment_id` INT NOT NULL COMMENT '评论ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`comment_like_id`),
|
||||
UNIQUE KEY `uk_comment_user` (`comment_id`, `user_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
CONSTRAINT `fk_comment_like_comment` FOREIGN KEY (`comment_id`) REFERENCES `comment` (`comment_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_comment_like_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论点赞表';
|
||||
@ -0,0 +1,18 @@
|
||||
-- 消息表
|
||||
CREATE TABLE IF NOT EXISTS `message` (
|
||||
`message_id` INT NOT NULL AUTO_INCREMENT COMMENT '消息ID',
|
||||
`receiver_id` BIGINT UNSIGNED NOT NULL COMMENT '接收者ID',
|
||||
`sender_id` BIGINT UNSIGNED NOT NULL COMMENT '发送者ID',
|
||||
`type` VARCHAR(20) NOT NULL COMMENT '消息类型: COMMENT-评论, LIKE-点赞',
|
||||
`target_id` INT NOT NULL COMMENT '目标ID(评论ID或笔记ID)',
|
||||
`content` TEXT NOT NULL COMMENT '消息内容',
|
||||
`is_read` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读: 0-未读, 1-已读',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`message_id`),
|
||||
KEY `idx_receiver_id` (`receiver_id`),
|
||||
KEY `idx_sender_id` (`sender_id`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
CONSTRAINT `fk_message_receiver` FOREIGN KEY (`receiver_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_message_sender` FOREIGN KEY (`sender_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
|
||||
@ -0,0 +1,70 @@
|
||||
-- 用户表添加邮箱相关字段(如果不存在)
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = "user";
|
||||
SET @columnname = "email";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (COLUMN_NAME = @columnname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE user ADD COLUMN email VARCHAR(100) COMMENT '用户邮箱'"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- 添加email_verified列(如果不存在)
|
||||
SET @columnname = "email_verified";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (COLUMN_NAME = @columnname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE user ADD COLUMN email_verified BOOLEAN DEFAULT FALSE COMMENT '邮箱是否验证'"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- 添加邮箱唯一索引(如果不存在)
|
||||
SET @indexname = "idx_email";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE user ADD UNIQUE INDEX idx_email (email)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- 创建邮箱验证码表(如果不存在)
|
||||
CREATE TABLE IF NOT EXISTS email_verify_code (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
email VARCHAR(100) NOT NULL COMMENT '邮箱地址',
|
||||
code VARCHAR(6) NOT NULL COMMENT '验证码',
|
||||
type VARCHAR(20) NOT NULL COMMENT '验证码类型:REGISTER-注册,RESET_PASSWORD-重置密码',
|
||||
expired_at TIMESTAMP NOT NULL COMMENT '过期时间',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已使用',
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_code (code),
|
||||
INDEX idx_expired_at (expired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='邮箱验证码表';
|
||||
@ -0,0 +1,114 @@
|
||||
-- 为笔记表添加搜索向量字段和索引
|
||||
ALTER TABLE note
|
||||
ADD COLUMN search_vector TEXT GENERATED ALWAYS AS
|
||||
(CONCAT_WS(' ', title, content)) STORED;
|
||||
|
||||
-- 添加全文索引(如果不存在)
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = "note";
|
||||
SET @indexname = "idx_note_search";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE note ADD FULLTEXT INDEX idx_note_search(search_vector)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- 添加普通索引(如果不存在)
|
||||
SET @indexname = "idx_created_at";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE note ADD INDEX idx_created_at(created_at)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
SET @indexname = "idx_user_id";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE note ADD INDEX idx_user_id(user_id)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- 为标签表添加索引(如果不存在)
|
||||
SET @tablename = "tag";
|
||||
SET @indexname = "idx_name";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE tag ADD INDEX idx_name(name)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
SET @indexname = "idx_user_id";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE tag ADD INDEX idx_user_id(user_id)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
|
||||
-- 为用户表添加组合索引(如果不存在)
|
||||
SET @tablename = "user";
|
||||
SET @indexname = "idx_search";
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
(TABLE_SCHEMA = @dbname)
|
||||
AND (TABLE_NAME = @tablename)
|
||||
AND (INDEX_NAME = @indexname)
|
||||
) > 0,
|
||||
"SELECT 1",
|
||||
"ALTER TABLE user ADD INDEX idx_search(username, account, email)"
|
||||
));
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
36
backend/src/main/resources/mapper/CommentLikeMapper.xml
Normal file
36
backend/src/main/resources/mapper/CommentLikeMapper.xml
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.kama.notes.mapper.CommentLikeMapper">
|
||||
<!-- 评论点赞结果映射 -->
|
||||
<resultMap id="commentLikeMap" type="com.kama.notes.model.entity.CommentLike">
|
||||
<id property="commentLikeId" column="comment_like_id"/>
|
||||
<result property="commentId" column="comment_id"/>
|
||||
<result property="userId" column="user_id"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- 插入评论点赞 -->
|
||||
<insert id="insert" useGeneratedKeys="true" keyProperty="commentLikeId">
|
||||
INSERT INTO comment_like (
|
||||
comment_id, user_id, created_at
|
||||
) VALUES (
|
||||
#{commentId}, #{userId}, #{createdAt}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 删除评论点赞 -->
|
||||
<delete id="delete">
|
||||
DELETE FROM comment_like
|
||||
WHERE comment_id = #{commentId} AND user_id = #{userId}
|
||||
</delete>
|
||||
|
||||
<!-- 查询用户点赞的评论ID列表 -->
|
||||
<select id="findUserLikedCommentIds" resultType="integer">
|
||||
SELECT DISTINCT comment_id FROM comment_like
|
||||
WHERE user_id = #{userId}
|
||||
AND comment_id IN
|
||||
<foreach collection="commentIds" item="commentId" open="(" separator="," close=")">
|
||||
#{commentId}
|
||||
</foreach>
|
||||
</select>
|
||||
</mapper>
|
||||
95
backend/src/main/resources/mapper/CommentMapper.xml
Normal file
95
backend/src/main/resources/mapper/CommentMapper.xml
Normal file
@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.kama.notes.mapper.CommentMapper">
|
||||
<!-- 评论结果映射 -->
|
||||
<resultMap id="commentMap" type="com.kama.notes.model.entity.Comment">
|
||||
<id property="commentId" column="comment_id"/>
|
||||
<result property="noteId" column="note_id"/>
|
||||
<result property="authorId" column="author_id"/>
|
||||
<result property="parentId" column="parent_id"/>
|
||||
<result property="content" column="content"/>
|
||||
<result property="likeCount" column="like_count"/>
|
||||
<result property="replyCount" column="reply_count"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- 插入评论 -->
|
||||
<insert id="insert" useGeneratedKeys="true" keyProperty="commentId">
|
||||
INSERT INTO comment (
|
||||
note_id, author_id, parent_id, content,
|
||||
like_count, reply_count, created_at, updated_at
|
||||
) VALUES (
|
||||
#{noteId}, #{authorId}, #{parentId}, #{content},
|
||||
#{likeCount}, #{replyCount}, #{createdAt}, #{updatedAt}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 更新评论 -->
|
||||
<update id="update">
|
||||
UPDATE comment
|
||||
<set>
|
||||
<if test="content != null">content = #{content},</if>
|
||||
<if test="likeCount != null">like_count = #{likeCount},</if>
|
||||
<if test="replyCount != null">reply_count = #{replyCount},</if>
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
WHERE comment_id = #{commentId}
|
||||
</update>
|
||||
|
||||
<!-- 删除评论 -->
|
||||
<delete id="deleteById">
|
||||
DELETE FROM comment WHERE comment_id = #{commentId}
|
||||
</delete>
|
||||
|
||||
<!-- 根据ID查询评论 -->
|
||||
<select id="findById" resultMap="commentMap">
|
||||
SELECT * FROM comment WHERE comment_id = #{commentId}
|
||||
</select>
|
||||
|
||||
<!-- 查询评论列表 -->
|
||||
<select id="findByQueryParam" resultMap="commentMap">
|
||||
SELECT * FROM comment
|
||||
<where>
|
||||
<if test="params.noteId != null">AND note_id = #{params.noteId}</if>
|
||||
<if test="params.parentId != null">AND parent_id = #{params.parentId}</if>
|
||||
<if test="params.authorId != null">AND author_id = #{params.authorId}</if>
|
||||
</where>
|
||||
ORDER BY created_at DESC
|
||||
LIMIT #{pageSize} OFFSET #{offset}
|
||||
</select>
|
||||
|
||||
<!-- 统计评论数量 -->
|
||||
<select id="countByQueryParam" resultType="int">
|
||||
SELECT COUNT(*) FROM comment
|
||||
<where>
|
||||
<if test="params.noteId != null">AND note_id = #{params.noteId}</if>
|
||||
<if test="params.parentId != null">AND parent_id = #{params.parentId}</if>
|
||||
<if test="params.authorId != null">AND author_id = #{params.authorId}</if>
|
||||
</where>
|
||||
</select>
|
||||
|
||||
<!-- 增加评论点赞数 -->
|
||||
<update id="incrementLikeCount">
|
||||
UPDATE comment SET like_count = like_count + 1
|
||||
WHERE comment_id = #{commentId}
|
||||
</update>
|
||||
|
||||
<!-- 减少评论点赞数 -->
|
||||
<update id="decrementLikeCount">
|
||||
UPDATE comment SET like_count = like_count - 1
|
||||
WHERE comment_id = #{commentId} AND like_count > 0
|
||||
</update>
|
||||
|
||||
<!-- 增加评论回复数 -->
|
||||
<update id="incrementReplyCount">
|
||||
UPDATE comment SET reply_count = reply_count + 1
|
||||
WHERE comment_id = #{commentId}
|
||||
</update>
|
||||
|
||||
<!-- 减少评论回复数 -->
|
||||
<update id="decrementReplyCount">
|
||||
UPDATE comment SET reply_count = reply_count - 1
|
||||
WHERE comment_id = #{commentId} AND reply_count > 0
|
||||
</update>
|
||||
</mapper>
|
||||
29
backend/src/main/resources/mapper/EmailVerifyCodeMapper.xml
Normal file
29
backend/src/main/resources/mapper/EmailVerifyCodeMapper.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.kama.notes.mapper.EmailVerifyCodeMapper">
|
||||
|
||||
<insert id="insert" parameterType="com.kama.notes.model.entity.EmailVerifyCode" useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO email_verify_code (
|
||||
email, code, type, expired_at, created_at, used
|
||||
) VALUES (
|
||||
#{email}, #{code}, #{type}, #{expiredAt}, NOW(), FALSE
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="findLatestValidCode" resultType="com.kama.notes.model.entity.EmailVerifyCode">
|
||||
SELECT * FROM email_verify_code
|
||||
WHERE email = #{email}
|
||||
AND type = #{type}
|
||||
AND used = FALSE
|
||||
AND expired_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="markAsUsed">
|
||||
UPDATE email_verify_code
|
||||
SET used = TRUE
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
110
backend/src/main/resources/mapper/MessageMapper.xml
Normal file
110
backend/src/main/resources/mapper/MessageMapper.xml
Normal file
@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.kama.notes.mapper.MessageMapper">
|
||||
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.Message">
|
||||
<id column="message_id" property="messageId"/>
|
||||
<result column="receiver_id" property="receiverId"/>
|
||||
<result column="sender_id" property="senderId"/>
|
||||
<result column="type" property="type"/>
|
||||
<result column="target_id" property="targetId"/>
|
||||
<result column="content" property="content"/>
|
||||
<result column="is_read" property="isRead"/>
|
||||
<result column="created_at" property="createdAt"/>
|
||||
<result column="updated_at" property="updatedAt"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
message_id, receiver_id, sender_id, type, target_id, content, is_read, created_at, updated_at
|
||||
</sql>
|
||||
|
||||
<insert id="insert" parameterType="com.kama.notes.model.entity.Message" useGeneratedKeys="true" keyProperty="messageId">
|
||||
INSERT INTO message (
|
||||
receiver_id, sender_id, type, target_id, content, is_read, created_at, updated_at
|
||||
) VALUES (
|
||||
#{receiverId}, #{senderId}, #{type}, #{targetId}, #{content}, #{isRead}, #{createdAt}, #{updatedAt}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="selectByParams" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List"/>
|
||||
FROM message
|
||||
<where>
|
||||
receiver_id = #{userId}
|
||||
<if test="params.type != null">
|
||||
AND type = #{params.type}
|
||||
</if>
|
||||
<if test="params.isRead != null">
|
||||
AND is_read = #{params.isRead}
|
||||
</if>
|
||||
<if test="params.startTime != null">
|
||||
AND created_at >= #{params.startTime}
|
||||
</if>
|
||||
<if test="params.endTime != null">
|
||||
AND created_at <= #{params.endTime}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY ${params.sortField} ${params.sortOrder}
|
||||
LIMIT #{params.pageSize}
|
||||
OFFSET #{offset}
|
||||
</select>
|
||||
|
||||
<select id="countByParams" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
<where>
|
||||
receiver_id = #{userId}
|
||||
<if test="params.type != null">
|
||||
AND type = #{params.type}
|
||||
</if>
|
||||
<if test="params.isRead != null">
|
||||
AND is_read = #{params.isRead}
|
||||
</if>
|
||||
<if test="params.startTime != null">
|
||||
AND created_at >= #{params.startTime}
|
||||
</if>
|
||||
<if test="params.endTime != null">
|
||||
AND created_at <= #{params.endTime}
|
||||
</if>
|
||||
</where>
|
||||
</select>
|
||||
|
||||
<update id="markAsRead">
|
||||
UPDATE message
|
||||
SET is_read = true,
|
||||
updated_at = NOW()
|
||||
WHERE message_id = #{messageId}
|
||||
AND receiver_id = #{userId}
|
||||
</update>
|
||||
|
||||
<update id="markAllAsRead">
|
||||
UPDATE message
|
||||
SET is_read = true,
|
||||
updated_at = NOW()
|
||||
WHERE receiver_id = #{userId}
|
||||
AND is_read = false
|
||||
</update>
|
||||
|
||||
<delete id="deleteMessage">
|
||||
DELETE FROM message
|
||||
WHERE message_id = #{messageId}
|
||||
AND receiver_id = #{userId}
|
||||
</delete>
|
||||
|
||||
<select id="countUnread" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE receiver_id = #{userId}
|
||||
AND is_read = false
|
||||
</select>
|
||||
|
||||
<select id="countUnreadByType" resultType="com.kama.notes.model.vo.message.UnreadCountByType">
|
||||
SELECT
|
||||
type,
|
||||
COUNT(*) as count
|
||||
FROM message
|
||||
WHERE receiver_id = #{userId}
|
||||
AND is_read = false
|
||||
GROUP BY type
|
||||
</select>
|
||||
</mapper>
|
||||
41
backend/src/main/resources/mapper/NoteCollectMapper.xml
Normal file
41
backend/src/main/resources/mapper/NoteCollectMapper.xml
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.kama.notes.mapper.NoteCollectMapper">
|
||||
<!-- 收藏结果映射 -->
|
||||
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.NoteCollect">
|
||||
<id column="collect_id" property="collectId"/>
|
||||
<result column="note_id" property="noteId"/>
|
||||
<result column="user_id" property="userId"/>
|
||||
<result column="created_at" property="createdAt"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- 插入收藏记录 -->
|
||||
<insert id="insert" parameterType="com.kama.notes.model.entity.NoteCollect" useGeneratedKeys="true" keyProperty="collectId">
|
||||
INSERT INTO note_collect (note_id, user_id)
|
||||
VALUES (#{noteId}, #{userId})
|
||||
</insert>
|
||||
|
||||
<!-- 删除收藏记录 -->
|
||||
<delete id="delete">
|
||||
DELETE FROM note_collect
|
||||
WHERE note_id = #{noteId}
|
||||
AND user_id = #{userId}
|
||||
</delete>
|
||||
|
||||
<!-- 根据用户ID和笔记ID查询收藏记录 -->
|
||||
<select id="findByNoteIdAndUserId" resultMap="BaseResultMap">
|
||||
SELECT *
|
||||
FROM note_collect
|
||||
WHERE note_id = #{noteId}
|
||||
AND user_id = #{userId}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 查询用户收藏的笔记ID列表 -->
|
||||
<select id="findNoteIdsByUserId" resultType="java.lang.Integer">
|
||||
SELECT note_id
|
||||
FROM note_collect
|
||||
WHERE user_id = #{userId}
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
</mapper>
|
||||
47
backend/src/main/resources/mapper/NoteCommentMapper.xml
Normal file
47
backend/src/main/resources/mapper/NoteCommentMapper.xml
Normal file
@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.kama.notes.mapper.NoteCommentMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.NoteComment">
|
||||
<id column="id" property="id" />
|
||||
<result column="note_id" property="noteId" />
|
||||
<result column="user_id" property="userId" />
|
||||
<result column="content" property="content" />
|
||||
<result column="created_at" property="createdAt" />
|
||||
<result column="updated_at" property="updatedAt" />
|
||||
<result column="is_deleted" property="isDeleted" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
id, note_id, user_id, content, created_at, updated_at, is_deleted
|
||||
</sql>
|
||||
|
||||
<insert id="insert" parameterType="com.kama.notes.model.entity.NoteComment" useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO note_comment (
|
||||
note_id, user_id, content, created_at, updated_at, is_deleted
|
||||
) VALUES (
|
||||
#{noteId}, #{userId}, #{content}, #{createdAt}, #{updatedAt}, #{isDeleted}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="update" parameterType="com.kama.notes.model.entity.NoteComment">
|
||||
UPDATE note_comment
|
||||
SET updated_at = #{updatedAt},
|
||||
is_deleted = #{isDeleted}
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="findById" resultMap="BaseResultMap">
|
||||
SELECT <include refid="Base_Column_List" />
|
||||
FROM note_comment
|
||||
WHERE id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="findByNoteId" resultMap="BaseResultMap">
|
||||
SELECT <include refid="Base_Column_List" />
|
||||
FROM note_comment
|
||||
WHERE note_id = #{noteId}
|
||||
AND is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
</mapper>
|
||||
@ -76,10 +76,20 @@
|
||||
VALUES (#{questionId}, #{authorId}, #{content})
|
||||
</insert>
|
||||
|
||||
<select id="findById" resultType="com.kama.notes.model.entity.Note">
|
||||
SELECT *
|
||||
FROM note
|
||||
WHERE note_id = #{noteId}
|
||||
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.Note">
|
||||
<id column="note_id" property="noteId"/>
|
||||
<result column="author_id" property="authorId"/>
|
||||
<result column="question_id" property="questionId"/>
|
||||
<result column="content" property="content"/>
|
||||
<result column="like_count" property="likeCount"/>
|
||||
<result column="comment_count" property="commentCount"/>
|
||||
<result column="collect_count" property="collectCount"/>
|
||||
<result column="created_at" property="createdAt"/>
|
||||
<result column="updated_at" property="updatedAt"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findById" resultMap="BaseResultMap">
|
||||
SELECT * FROM note WHERE note_id = #{noteId}
|
||||
</select>
|
||||
|
||||
<update id="update">
|
||||
@ -240,4 +250,47 @@
|
||||
FROM note
|
||||
</select>
|
||||
|
||||
<update id="incrementCommentCount">
|
||||
UPDATE note
|
||||
SET comment_count = comment_count + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE note_id = #{noteId}
|
||||
</update>
|
||||
|
||||
<update id="decrementCommentCount">
|
||||
UPDATE note
|
||||
SET comment_count = CASE
|
||||
WHEN comment_count > 0 THEN comment_count - 1
|
||||
ELSE 0
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE note_id = #{noteId}
|
||||
</update>
|
||||
|
||||
<!-- 搜索笔记 -->
|
||||
<select id="searchNotes" resultType="com.kama.notes.model.entity.Note">
|
||||
SELECT
|
||||
n.*,
|
||||
MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) as relevance
|
||||
FROM note n
|
||||
WHERE MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
|
||||
ORDER BY relevance DESC
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
</select>
|
||||
|
||||
<!-- 根据标签搜索笔记 -->
|
||||
<select id="searchNotesByTag" resultType="com.kama.notes.model.entity.Note">
|
||||
SELECT DISTINCT
|
||||
n.*,
|
||||
MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) as relevance
|
||||
FROM note n
|
||||
LEFT JOIN note_tag nt ON n.id = nt.note_id
|
||||
LEFT JOIN tag t ON nt.tag_id = t.id
|
||||
WHERE
|
||||
MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
|
||||
OR t.name LIKE CONCAT('%', #{tag}, '%')
|
||||
ORDER BY relevance DESC
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@ -9,8 +9,29 @@
|
||||
</select>
|
||||
|
||||
<insert id="insert" keyProperty="userId" useGeneratedKeys="true">
|
||||
INSERT INTO user (account, username, password)
|
||||
VALUES (#{account}, #{username}, #{password})
|
||||
INSERT INTO user (
|
||||
account,
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
email_verified,
|
||||
gender,
|
||||
is_banned,
|
||||
is_admin,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
#{account},
|
||||
#{username},
|
||||
#{password},
|
||||
#{email},
|
||||
#{emailVerified},
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="findById" resultType="com.kama.notes.model.entity.User">
|
||||
@ -103,4 +124,28 @@
|
||||
SELECT COUNT(*)
|
||||
FROM user
|
||||
</select>
|
||||
|
||||
<select id="findByEmail" resultType="com.kama.notes.model.entity.User">
|
||||
SELECT * FROM user WHERE email = #{email}
|
||||
</select>
|
||||
|
||||
<!-- 搜索用户 -->
|
||||
<select id="searchUsers" resultType="com.kama.notes.model.entity.User">
|
||||
SELECT * FROM user
|
||||
WHERE
|
||||
username LIKE CONCAT(#{keyword}, '%')
|
||||
OR account LIKE CONCAT(#{keyword}, '%')
|
||||
OR email LIKE CONCAT(#{keyword}, '%')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN username = #{keyword} THEN 1
|
||||
WHEN account = #{keyword} THEN 2
|
||||
WHEN email = #{keyword} THEN 3
|
||||
WHEN username LIKE CONCAT(#{keyword}, '%') THEN 4
|
||||
WHEN account LIKE CONCAT(#{keyword}, '%') THEN 5
|
||||
WHEN email LIKE CONCAT(#{keyword}, '%') THEN 6
|
||||
ELSE 7
|
||||
END
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
23
backend/src/main/resources/templates/mail/verify-code.html
Normal file
23
backend/src/main/resources/templates/mail/verify-code.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>验证码</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f4f4f4;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #333; margin-bottom: 20px;">卡码笔记 - 验证码</h2>
|
||||
<p style="color: #666; line-height: 1.6;">亲爱的用户:</p>
|
||||
<p style="color: #666; line-height: 1.6;">您正在进行<span th:text="${operationType}">操作</span>,验证码为:</p>
|
||||
<div style="background-color: #f8f8f8; padding: 15px; border-radius: 4px; text-align: center; margin: 20px 0;">
|
||||
<span style="font-size: 24px; font-weight: bold; color: #1890ff;" th:text="${verifyCode}">123456</span>
|
||||
</div>
|
||||
<p style="color: #666; line-height: 1.6;">验证码有效期为15分钟,请尽快完成验证。</p>
|
||||
<p style="color: #666; line-height: 1.6;">如果这不是您的操作,请忽略此邮件。</p>
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 12px;">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>© 2024 卡码笔记. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,37 @@
|
||||
package com.kama.notes.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
public class EmailServiceTest {
|
||||
|
||||
@Autowired
|
||||
private EmailService emailService;
|
||||
|
||||
@Test
|
||||
public void testSendVerifyCode() {
|
||||
String email = "864508127@qq.com"; // 使用实际的QQ邮箱
|
||||
String type = "REGISTER";
|
||||
|
||||
String verifyCode = emailService.sendVerifyCode(email, type);
|
||||
System.out.println("Generated verify code: " + verifyCode);
|
||||
|
||||
// 验证验证码
|
||||
boolean verified = emailService.verifyCode(email, verifyCode, type);
|
||||
assert verified : "Verification should succeed with correct code";
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendFrequencyLimit() {
|
||||
String email = "864508127@qq.com"; // 使用实际的QQ邮箱
|
||||
|
||||
// 第一次发送应该成功
|
||||
assert emailService.canSendCode(email) : "Should be able to send first code";
|
||||
emailService.sendVerifyCode(email, "REGISTER");
|
||||
|
||||
// 第二次发送应该被限制
|
||||
assert !emailService.canSendCode(email) : "Should not be able to send second code immediately";
|
||||
}
|
||||
}
|
||||
49
frontend/package-lock.json
generated
49
frontend/package-lock.json
generated
@ -15,7 +15,9 @@
|
||||
"@reduxjs/toolkit": "^2.4.0",
|
||||
"antd": "^5.22.2",
|
||||
"antd-img-crop": "^4.24.0",
|
||||
"axios": "^1.7.9",
|
||||
"cherry-markdown": "^0.8.55",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highlight.js": "^11.11.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
@ -2703,6 +2705,17 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz",
|
||||
@ -3614,6 +3627,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
|
||||
@ -4183,6 +4206,26 @@
|
||||
"integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||
@ -8649,6 +8692,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz",
|
||||
|
||||
@ -26,7 +26,9 @@
|
||||
"@reduxjs/toolkit": "^2.4.0",
|
||||
"antd": "^5.22.2",
|
||||
"antd-img-crop": "^4.24.0",
|
||||
"axios": "^1.7.9",
|
||||
"cherry-markdown": "^0.8.55",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highlight.js": "^11.11.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
|
||||
99
frontend/src/apps/user/components/comment/CommentInput.tsx
Normal file
99
frontend/src/apps/user/components/comment/CommentInput.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button, Input, message } from 'antd'
|
||||
import { createComment } from '../../../../request/api/comment'
|
||||
import { useUser } from '../../../../domain/user/hooks/useUser'
|
||||
|
||||
interface CommentInputProps {
|
||||
noteId: number
|
||||
parentId?: number
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const CommentInput: React.FC<CommentInputProps> = ({
|
||||
noteId,
|
||||
parentId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
placeholder = '写下你的评论...',
|
||||
}) => {
|
||||
const [content, setContent] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const user = useUser()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim()) {
|
||||
message.warning('评论内容不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (!user.userId) {
|
||||
message.warning('请先登录后再发表评论')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const { data: response } = await createComment({
|
||||
noteId,
|
||||
parentId,
|
||||
content: content.trim(),
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
setContent('')
|
||||
message.success('评论成功')
|
||||
onSuccess?.()
|
||||
} else {
|
||||
throw new Error(response.message || '评论失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('评论失败:', error)
|
||||
if (error.response?.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
} else {
|
||||
message.error(error.message || '评论失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.userId) {
|
||||
return (
|
||||
<div className="comment-input">
|
||||
<Input.TextArea
|
||||
disabled
|
||||
placeholder="请先登录后再发表评论"
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comment-input">
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
<div className="mt-2 flex justify-end space-x-2">
|
||||
{parentId && (
|
||||
<Button onClick={onCancel} disabled={submitting}>
|
||||
取消回复
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
发布评论
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommentInput
|
||||
57
frontend/src/apps/user/components/comment/CommentItem.tsx
Normal file
57
frontend/src/apps/user/components/comment/CommentItem.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { Avatar, Button, List } from 'antd'
|
||||
import { LikeOutlined, LikeFilled } from '@ant-design/icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import { Comment } from '../../../../domain/comment/types'
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment
|
||||
onCommentSuccess: () => void
|
||||
onLike: (isLiked: boolean) => void
|
||||
}
|
||||
|
||||
const CommentItem: React.FC<CommentItemProps> = ({
|
||||
comment,
|
||||
onCommentSuccess,
|
||||
onLike,
|
||||
}) => {
|
||||
return (
|
||||
<List.Item
|
||||
key={comment.commentId}
|
||||
actions={[
|
||||
<Button
|
||||
key="like"
|
||||
type="text"
|
||||
icon={
|
||||
comment.userActions?.isLiked ? <LikeFilled /> : <LikeOutlined />
|
||||
}
|
||||
onClick={() => onLike(comment.userActions?.isLiked || false)}
|
||||
>
|
||||
{comment.likeCount || 0} 赞
|
||||
</Button>,
|
||||
<Button key="reply" type="text" onClick={onCommentSuccess}>
|
||||
回复
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={comment.author?.avatarUrl} />}
|
||||
title={comment.author?.username}
|
||||
description={
|
||||
<div>
|
||||
<div>{comment.content}</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{formatDistanceToNow(new Date(comment.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommentItem
|
||||
178
frontend/src/apps/user/components/comment/CommentList.tsx
Normal file
178
frontend/src/apps/user/components/comment/CommentList.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState, useContext } from 'react'
|
||||
import { Avatar, Button, List, message } from 'antd'
|
||||
import {
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
StarOutlined,
|
||||
StarFilled,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import CommentInput from './CommentInput'
|
||||
import {
|
||||
getComments,
|
||||
likeComment,
|
||||
unlikeComment,
|
||||
} from '../../../../request/api/comment'
|
||||
import { UserContext } from '../../../../domain/user/context/UserContext'
|
||||
import { Comment } from '../../../../domain/comment/types'
|
||||
import CommentItem from './CommentItem'
|
||||
|
||||
interface CommentListProps {
|
||||
noteId: number
|
||||
onCommentCountChange?: () => void
|
||||
}
|
||||
|
||||
const CommentList: React.FC<CommentListProps> = ({
|
||||
noteId,
|
||||
onCommentCountChange,
|
||||
}) => {
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [replyTo, setReplyTo] = useState<Comment | null>(null)
|
||||
const { currentUser } = useContext(UserContext)
|
||||
|
||||
const fetchComments = async () => {
|
||||
if (!noteId) {
|
||||
console.warn('noteId is required to fetch comments')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getComments({
|
||||
noteId,
|
||||
page,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
if (response?.data?.code === 200) {
|
||||
const newComments = response.data.data || []
|
||||
setComments((prev) =>
|
||||
page === 1 ? newComments : [...prev, ...newComments],
|
||||
)
|
||||
setHasMore(newComments.length === 10)
|
||||
onCommentCountChange?.()
|
||||
} else {
|
||||
console.error('获取评论失败:', response)
|
||||
message.error(response?.data?.message || '获取评论失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取评论失败:', err)
|
||||
message.error('获取评论失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (noteId) {
|
||||
setPage(1)
|
||||
fetchComments()
|
||||
}
|
||||
}, [noteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (noteId && page > 1) {
|
||||
fetchComments()
|
||||
}
|
||||
}, [page])
|
||||
|
||||
const handleLike = async (commentId: number, isLiked: boolean) => {
|
||||
if (!currentUser) {
|
||||
message.warning('请先登录后再点赞')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await (isLiked
|
||||
? unlikeComment(commentId)
|
||||
: likeComment(commentId))
|
||||
if (response?.data?.code === 200) {
|
||||
setComments((prev) =>
|
||||
prev.map((comment) =>
|
||||
comment.commentId === commentId
|
||||
? {
|
||||
...comment,
|
||||
likeCount: isLiked
|
||||
? Math.max(0, comment.likeCount - 1)
|
||||
: comment.likeCount + 1,
|
||||
userActions: {
|
||||
...comment.userActions,
|
||||
isLiked: !isLiked,
|
||||
},
|
||||
}
|
||||
: comment,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
message.error(
|
||||
response?.data?.message || (isLiked ? '取消点赞失败' : '点赞失败'),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(isLiked ? '取消点赞失败:' : '点赞失败:', err)
|
||||
message.error(isLiked ? '取消点赞失败' : '点赞失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReply = (comment: Comment) => {
|
||||
if (!currentUser) {
|
||||
message.warning('请先登录后再回复')
|
||||
return
|
||||
}
|
||||
setReplyTo(comment)
|
||||
}
|
||||
|
||||
const handleCommentSuccess = () => {
|
||||
setPage(1)
|
||||
fetchComments()
|
||||
setReplyTo(null)
|
||||
onCommentCountChange?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comment-list">
|
||||
<CommentInput
|
||||
noteId={noteId}
|
||||
parentId={replyTo?.commentId}
|
||||
onSuccess={handleCommentSuccess}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
placeholder={
|
||||
replyTo ? `回复 ${replyTo.author.username}:` : '写下你的评论...'
|
||||
}
|
||||
/>
|
||||
<List
|
||||
className="mt-4"
|
||||
loading={loading}
|
||||
itemLayout="horizontal"
|
||||
dataSource={comments || []}
|
||||
locale={{ emptyText: '暂无评论' }}
|
||||
loadMore={
|
||||
hasMore &&
|
||||
!loading &&
|
||||
comments.length > 0 && (
|
||||
<div className="mt-4 text-center">
|
||||
<Button onClick={() => setPage((p) => p + 1)} loading={loading}>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderItem={(comment) => (
|
||||
<CommentItem
|
||||
key={comment.commentId}
|
||||
comment={comment}
|
||||
onCommentSuccess={() => handleReply(comment)}
|
||||
onLike={(isLiked) => handleLike(comment.commentId, isLiked)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommentList
|
||||
37
frontend/src/apps/user/components/message/MessageBadge.tsx
Normal file
37
frontend/src/apps/user/components/message/MessageBadge.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Badge } from 'antd'
|
||||
import { BellOutlined } from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getUnreadCount } from '@/request/api/message'
|
||||
|
||||
const MessageBadge: React.FC = () => {
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const { data } = await getUnreadCount()
|
||||
if (data.code === 200) {
|
||||
setUnreadCount(data.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取未读消息数量失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount()
|
||||
// 每分钟刷新一次未读消息数量
|
||||
const timer = setInterval(fetchUnreadCount, 60000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Link to="/messages">
|
||||
<Badge count={unreadCount} overflowCount={99}>
|
||||
<BellOutlined style={{ fontSize: '20px' }} />
|
||||
</Badge>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageBadge
|
||||
183
frontend/src/apps/user/components/message/MessageList.tsx
Normal file
183
frontend/src/apps/user/components/message/MessageList.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Avatar, Button, List, message, Tag } from 'antd'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import { getMessages, markAsRead, markAllAsRead } from '@/request/api/message'
|
||||
import { Message } from '@/domain/message/types'
|
||||
import { messageWebSocket } from '@/domain/message/service/messageWebSocket'
|
||||
|
||||
interface MessageListProps {
|
||||
onUnreadCountChange?: () => void
|
||||
}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({ onUnreadCountChange }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
|
||||
// 处理新消息
|
||||
const handleNewMessage = (newMessage: Message) => {
|
||||
setMessages((prev) => [newMessage, ...prev])
|
||||
message.info('收到新消息')
|
||||
onUnreadCountChange?.()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 添加WebSocket消息处理器
|
||||
messageWebSocket.addMessageHandler(handleNewMessage)
|
||||
|
||||
// 组件卸载时移除处理器
|
||||
return () => {
|
||||
messageWebSocket.removeMessageHandler(handleNewMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { data } = await getMessages({
|
||||
page,
|
||||
pageSize: 10,
|
||||
})
|
||||
if (data.code === 200) {
|
||||
setMessages((prev) =>
|
||||
page === 1 ? data.data.list : [...prev, ...data.data.list],
|
||||
)
|
||||
setHasMore(data.data.list.length === 10)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取消息失败:', err)
|
||||
message.error('获取消息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages()
|
||||
}, [page])
|
||||
|
||||
const handleMarkAsRead = async (messageId: number) => {
|
||||
try {
|
||||
const { data } = await markAsRead(messageId)
|
||||
if (data.code === 200) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.messageId === messageId
|
||||
? {
|
||||
...msg,
|
||||
isRead: true,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
)
|
||||
onUnreadCountChange?.()
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('标记已读失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
const { data } = await markAllAsRead()
|
||||
if (data.code === 200) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => ({
|
||||
...msg,
|
||||
isRead: true,
|
||||
})),
|
||||
)
|
||||
onUnreadCountChange?.()
|
||||
message.success('已全部标记为已读')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('标记全部已读失败')
|
||||
}
|
||||
}
|
||||
|
||||
const renderMessageContent = (msg: Message) => {
|
||||
switch (msg.type) {
|
||||
case 'COMMENT':
|
||||
return (
|
||||
<div>
|
||||
<Tag color="blue">评论</Tag>
|
||||
{msg.content}
|
||||
</div>
|
||||
)
|
||||
case 'LIKE':
|
||||
return (
|
||||
<div>
|
||||
<Tag color="red">点赞</Tag>
|
||||
{msg.content}
|
||||
</div>
|
||||
)
|
||||
case 'SYSTEM':
|
||||
return (
|
||||
<div>
|
||||
<Tag color="green">系统</Tag>
|
||||
{msg.content}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return msg.content
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.length > 0 && (
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button onClick={handleMarkAllAsRead}>全部标记为已读</Button>
|
||||
</div>
|
||||
)}
|
||||
<List
|
||||
loading={loading}
|
||||
itemLayout="horizontal"
|
||||
dataSource={messages}
|
||||
locale={{ emptyText: '暂无消息' }}
|
||||
renderItem={(msg) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
!msg.isRead && (
|
||||
<Button
|
||||
key="mark-read"
|
||||
type="link"
|
||||
onClick={() => handleMarkAsRead(msg.messageId)}
|
||||
>
|
||||
标记已读
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{msg.sender.username}</span>
|
||||
{!msg.isRead && (
|
||||
<Tag color="red" className="ml-2">
|
||||
未读
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={renderMessageContent(msg)}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
loadMore={
|
||||
hasMore && (
|
||||
<div className="mt-4 text-center">
|
||||
<Button onClick={() => setPage((p) => p + 1)} loading={loading}>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageList
|
||||
15
frontend/src/apps/user/pages/MessagePage.tsx
Normal file
15
frontend/src/apps/user/pages/MessagePage.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { Card } from 'antd'
|
||||
import MessageList from '../components/message/MessageList'
|
||||
|
||||
const MessagePage: React.FC = () => {
|
||||
return (
|
||||
<div className="message-page">
|
||||
<Card title="消息中心">
|
||||
<MessageList />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessagePage
|
||||
@ -31,3 +31,8 @@ export const USER_NOTE = '/user-center/note'
|
||||
* 题目路径
|
||||
*/
|
||||
export const QUESTION = '/questions'
|
||||
|
||||
/**
|
||||
* 消息中心
|
||||
*/
|
||||
export const MESSAGE_CENTER = '/messages'
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
USER_HOME,
|
||||
USER_INFO,
|
||||
USER_NOTE,
|
||||
MESSAGE_CENTER,
|
||||
} from './config.ts'
|
||||
import { NotFound } from '../../../base/components'
|
||||
import HomePage from '../pages/home/HomePage.tsx'
|
||||
@ -22,6 +23,7 @@ import QuestionSetPage from '../pages/questionSet/QuestionSetPage.tsx'
|
||||
import QuestionPage from '../pages/question/QuestionPage.tsx'
|
||||
import UserHomePage from '../pages/userHome/UserHomePage.tsx'
|
||||
import QuestionListPage from '../pages/questionList/QuestionListPage.tsx'
|
||||
import MessagePage from '../pages/MessagePage.tsx'
|
||||
|
||||
export const UserRouteConfig = (
|
||||
<Route path={HOME} element={<UserApp />}>
|
||||
@ -37,6 +39,7 @@ export const UserRouteConfig = (
|
||||
<Route path={`${QUESTION}/:questionId`} element={<QuestionPage />} />
|
||||
<Route path={`${USER_HOME}/:userId`} element={<UserHomePage />} />
|
||||
<Route path={`${QUESTION_LIST}`} element={<QuestionListPage />} />
|
||||
<Route path={MESSAGE_CENTER} element={<MessagePage />} />
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Route>
|
||||
)
|
||||
|
||||
9
frontend/src/base/constants.ts
Normal file
9
frontend/src/base/constants.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Token key in localStorage
|
||||
export const TOKEN_KEY = 'token'
|
||||
export const kamanoteUserToken = TOKEN_KEY
|
||||
|
||||
// API Host
|
||||
export const kamanoteHost =
|
||||
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
|
||||
|
||||
// 其他常量...
|
||||
13
frontend/src/base/regex.ts
Normal file
13
frontend/src/base/regex.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// 字母数字下划线
|
||||
export const ALPHANUMERIC_UNDERSCORE = /^[a-zA-Z0-9_]+$/
|
||||
|
||||
// 字母数字下划线中文
|
||||
export const ALPHANUMERIC_UNDERSCORE_CHINESE =
|
||||
/^[\u4e00-\u9fa5_a-zA-Z0-9\-\.]+$/
|
||||
|
||||
// 密码允许的字符
|
||||
export const PASSWORD_ALLOWABLE_CHARACTERS =
|
||||
/^[a-zA-Z0-9!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+$/
|
||||
|
||||
// 邮箱正则表达式
|
||||
export const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||||
100
frontend/src/components/CommentInput.tsx
Normal file
100
frontend/src/components/CommentInput.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Input, Button, message } from 'antd'
|
||||
import { useUser } from '../domain/user/hooks/useUser'
|
||||
import { TOKEN_KEY } from '../base/constants'
|
||||
import { commentService } from '../domain/comment/service/commentService'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
interface CommentInputProps {
|
||||
noteId: number
|
||||
parentId?: number
|
||||
onCommentAdded?: () => void
|
||||
}
|
||||
|
||||
const CommentInput: React.FC<CommentInputProps> = ({
|
||||
noteId,
|
||||
parentId,
|
||||
onCommentAdded,
|
||||
}) => {
|
||||
const [content, setContent] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { currentUser } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
console.log('CommentInput组件状态:', {
|
||||
noteId,
|
||||
parentId,
|
||||
currentUser: currentUser ? '已登录' : '未登录',
|
||||
token: localStorage.getItem(TOKEN_KEY) ? '存在' : '不存在',
|
||||
})
|
||||
}, [noteId, parentId, currentUser])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedContent = content.trim()
|
||||
if (!trimmedContent) {
|
||||
message.warning('评论内容不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
message.warning('请先登录后再发表评论')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('准备提交评论:', {
|
||||
noteId,
|
||||
parentId,
|
||||
content: trimmedContent,
|
||||
token: localStorage.getItem(TOKEN_KEY) ? '存在' : '不存在',
|
||||
})
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await commentService.createComment({
|
||||
noteId,
|
||||
parentId,
|
||||
content: trimmedContent,
|
||||
})
|
||||
|
||||
console.log('评论提交成功')
|
||||
message.success('评论发表成功')
|
||||
setContent('')
|
||||
onCommentAdded?.()
|
||||
} catch (error) {
|
||||
console.error('评论提交失败:', error)
|
||||
const axiosError = error as AxiosError
|
||||
if (axiosError.response?.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
} else {
|
||||
message.error('评论发表失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comment-input">
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={currentUser ? '写下你的评论...' : '请先登录后再发表评论'}
|
||||
disabled={!currentUser}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
style={{ marginBottom: '10px' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
disabled={!currentUser || !content.trim()}
|
||||
>
|
||||
发表评论
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommentInput
|
||||
19
frontend/src/domain/comment/service/commentService.ts
Normal file
19
frontend/src/domain/comment/service/commentService.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { http } from '../../../request/http'
|
||||
|
||||
export interface CreateCommentParams {
|
||||
noteId: number
|
||||
parentId?: number
|
||||
content: string
|
||||
}
|
||||
|
||||
class CommentService {
|
||||
async createComment(params: CreateCommentParams) {
|
||||
return http.post('/api/comments', params)
|
||||
}
|
||||
|
||||
async getComments(noteId: number) {
|
||||
return http.get(`/api/comments/${noteId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const commentService = new CommentService()
|
||||
58
frontend/src/domain/comment/types.ts
Normal file
58
frontend/src/domain/comment/types.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 评论作者信息
|
||||
*/
|
||||
export interface CommentAuthor {
|
||||
userId: number
|
||||
username: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户操作状态
|
||||
*/
|
||||
export interface CommentUserActions {
|
||||
isLiked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论数据
|
||||
*/
|
||||
export interface Comment {
|
||||
commentId: number
|
||||
noteId: number
|
||||
content: string
|
||||
likeCount: number
|
||||
replyCount: number
|
||||
createdAt: string
|
||||
author: CommentAuthor
|
||||
userActions?: CommentUserActions
|
||||
replies?: Comment[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建评论请求参数
|
||||
*/
|
||||
export interface CreateCommentParams {
|
||||
noteId: number
|
||||
parentId?: number
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论查询参数
|
||||
*/
|
||||
export interface CommentQueryParams {
|
||||
noteId: number
|
||||
parentId?: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论API响应
|
||||
*/
|
||||
export interface CommentResponse {
|
||||
code: number
|
||||
message: string
|
||||
data: Comment[]
|
||||
}
|
||||
96
frontend/src/domain/message/service/messageWebSocket.ts
Normal file
96
frontend/src/domain/message/service/messageWebSocket.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Message } from '../types'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
class MessageWebSocketService {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectTimeout = 3000
|
||||
private messageHandlers: ((message: Message) => void)[] = []
|
||||
|
||||
constructor() {
|
||||
this.connect()
|
||||
}
|
||||
|
||||
private connect() {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
console.warn('未登录,无法建立WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:8080'}/ws/message`
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
// 发送认证信息
|
||||
this.ws?.send(JSON.stringify({ type: 'AUTH', token }))
|
||||
// 重置重连次数
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as Message
|
||||
this.notifyHandlers(message)
|
||||
} catch (error) {
|
||||
console.error('处理WebSocket消息时发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket连接已关闭')
|
||||
this.attemptReconnect()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket发生错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('WebSocket重连次数超过最大限制')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
console.log(`尝试第${this.reconnectAttempts}次重连...`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect()
|
||||
}, this.reconnectTimeout)
|
||||
}
|
||||
|
||||
public addMessageHandler(handler: (message: Message) => void) {
|
||||
this.messageHandlers.push(handler)
|
||||
}
|
||||
|
||||
public removeMessageHandler(handler: (message: Message) => void) {
|
||||
const index = this.messageHandlers.indexOf(handler)
|
||||
if (index !== -1) {
|
||||
this.messageHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyHandlers(message: Message) {
|
||||
this.messageHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(message)
|
||||
} catch (error) {
|
||||
console.error('执行消息处理器时发生错误:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const messageWebSocket = new MessageWebSocketService()
|
||||
54
frontend/src/domain/message/types.ts
Normal file
54
frontend/src/domain/message/types.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 消息发送者信息
|
||||
*/
|
||||
export interface MessageSender {
|
||||
userId: number
|
||||
username: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
export type MessageType = 'COMMENT' | 'LIKE'
|
||||
|
||||
/**
|
||||
* 消息数据
|
||||
*/
|
||||
export interface Message {
|
||||
messageId: number
|
||||
sender: MessageSender
|
||||
type: MessageType
|
||||
targetId: number
|
||||
content: string
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息查询参数
|
||||
*/
|
||||
export interface MessageQueryParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
type?: MessageType
|
||||
isRead?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息API响应
|
||||
*/
|
||||
export interface MessageResponse {
|
||||
code: number
|
||||
message: string
|
||||
data: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 未读消息数量响应
|
||||
*/
|
||||
export interface UnreadCountResponse {
|
||||
code: number
|
||||
message: string
|
||||
data: number
|
||||
}
|
||||
132
frontend/src/domain/note/components/NoteComments.tsx
Normal file
132
frontend/src/domain/note/components/NoteComments.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Avatar, Button, Input, List, message } from 'antd'
|
||||
import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
getComments,
|
||||
} from '@/request/api/comment'
|
||||
import { NoteComment } from '@/domain/note/types'
|
||||
import { useUser } from '@/domain/user/hooks/useUser'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
|
||||
interface NoteCommentsProps {
|
||||
noteId: number
|
||||
}
|
||||
|
||||
export function NoteComments({ noteId }: NoteCommentsProps) {
|
||||
const [comments, setComments] = useState<NoteComment[]>([])
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { currentUser } = useUser()
|
||||
|
||||
// 加载评论列表
|
||||
const loadComments = async () => {
|
||||
try {
|
||||
const { data } = await getComments(noteId)
|
||||
setComments(data)
|
||||
} catch (error) {
|
||||
message.error('加载评论失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交评论
|
||||
const handleSubmit = async () => {
|
||||
if (!currentUser) {
|
||||
message.warning('请先登录')
|
||||
return
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
message.warning('请输入评论内容')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createComment(noteId, content.trim())
|
||||
message.success('评论成功')
|
||||
setContent('')
|
||||
loadComments()
|
||||
} catch (error) {
|
||||
message.error('评论失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
const handleDelete = async (commentId: number) => {
|
||||
try {
|
||||
await deleteComment(commentId)
|
||||
message.success('删除成功')
|
||||
loadComments()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadComments()
|
||||
}, [noteId])
|
||||
|
||||
return (
|
||||
<div className="note-comments">
|
||||
<div className="comment-input">
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="写下你的评论..."
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
style={{ marginTop: 8, float: 'right' }}
|
||||
>
|
||||
发表评论
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<List
|
||||
style={{ clear: 'both', marginTop: 16 }}
|
||||
itemLayout="horizontal"
|
||||
dataSource={comments}
|
||||
renderItem={(comment) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
comment.userId === currentUser?.userId && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={currentUser?.avatarUrl} />}
|
||||
title={currentUser?.username}
|
||||
description={
|
||||
<div>
|
||||
<div>{comment.content}</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{formatDistanceToNow(new Date(comment.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -21,6 +21,7 @@ interface NoteItemProps {
|
||||
showAuthor?: boolean // 是否展示作者信息
|
||||
showQuestion?: boolean // 是否展示题目信息
|
||||
showOptions?: boolean // 是否展示点赞/收藏/评论等按钮
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const NoteItem: React.FC<NoteItemProps> = ({
|
||||
@ -32,6 +33,7 @@ const NoteItem: React.FC<NoteItemProps> = ({
|
||||
toggleIsModalOpen,
|
||||
handleCollectionQueryParams,
|
||||
handleSelectedNoteId,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
|
||||
@ -69,6 +71,7 @@ const NoteItem: React.FC<NoteItemProps> = ({
|
||||
toggleIsModalOpen={toggleIsModalOpen}
|
||||
handleCollectionQueryParams={handleCollectionQueryParams}
|
||||
handleSelectedNoteId={handleSelectedNoteId}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { NoteWithRelations } from '../types/serviceTypes.ts'
|
||||
import LikeButton from './LikeButton.tsx'
|
||||
import CollectButton from './CollectButton.tsx'
|
||||
import { Button, Drawer, message } from 'antd'
|
||||
import {
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
StarOutlined,
|
||||
StarFilled,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useNoteLike } from '../../noteLike'
|
||||
import { useApp } from '../../../base/hooks'
|
||||
import { message } from 'antd'
|
||||
import CommentList from '../../../apps/user/components/comment/CommentList'
|
||||
|
||||
interface OptionsCardProps {
|
||||
note?: NoteWithRelations
|
||||
@ -12,6 +18,7 @@ interface OptionsCardProps {
|
||||
toggleIsModalOpen: () => void
|
||||
handleCollectionQueryParams: (noteId: number) => void
|
||||
handleSelectedNoteId: (noteId: number) => void
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const OptionsCard: React.FC<OptionsCardProps> = ({
|
||||
@ -20,12 +27,20 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
|
||||
toggleIsModalOpen,
|
||||
handleCollectionQueryParams,
|
||||
handleSelectedNoteId,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const { like, unLike } = useNoteLike()
|
||||
|
||||
const [commentDrawerVisible, setCommentDrawerVisible] = useState(false)
|
||||
const [localCommentCount, setLocalCommentCount] = useState(0)
|
||||
const app = useApp()
|
||||
let likeLoading = false
|
||||
|
||||
useEffect(() => {
|
||||
if (note?.commentCount !== undefined) {
|
||||
setLocalCommentCount(note.commentCount)
|
||||
}
|
||||
}, [note?.commentCount])
|
||||
|
||||
/**
|
||||
* 点赞按钮点击处理函数
|
||||
*/
|
||||
@ -35,11 +50,7 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// 处理没有传递函数的情况
|
||||
if (!setNoteLikeStatus) return
|
||||
|
||||
// 处理没有 note 或 userActions 的情况
|
||||
if (!note || !note.userActions) return
|
||||
if (!setNoteLikeStatus || !note || !note.userActions) return
|
||||
|
||||
if (likeLoading) return
|
||||
|
||||
@ -47,7 +58,7 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
|
||||
setNoteLikeStatus(note.noteId, !note.userActions.isLiked)
|
||||
|
||||
if (note.userActions.isLiked) {
|
||||
await unLike(note!.noteId)
|
||||
await unLike(note.noteId)
|
||||
} else {
|
||||
await like(note.noteId)
|
||||
}
|
||||
@ -63,30 +74,75 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
|
||||
message.info('请先登录')
|
||||
return
|
||||
}
|
||||
// 处理没有 note 或 userActions 为空的情况
|
||||
if (!note || !note.userActions) return
|
||||
|
||||
// 获取收藏列表
|
||||
toggleIsModalOpen()
|
||||
handleCollectionQueryParams(note.noteId)
|
||||
handleSelectedNoteId(note.noteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论按钮点击处理函数
|
||||
*/
|
||||
const handleCommentClick = () => {
|
||||
if (!app.isLogin) {
|
||||
message.info('请先登录')
|
||||
return
|
||||
}
|
||||
setCommentDrawerVisible(true)
|
||||
}
|
||||
|
||||
// 评论成功后刷新笔记数据
|
||||
const handleCommentSuccess = () => {
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<LikeButton
|
||||
key={`li${note?.noteId}`}
|
||||
likeCount={note?.likeCount ?? 0}
|
||||
currentUserLiked={note?.userActions?.isLiked ?? false}
|
||||
clickHandle={likeButtonClickHandle}
|
||||
></LikeButton>
|
||||
<CollectButton
|
||||
key={`c${note?.noteId}`}
|
||||
collectCount={note?.collectCount ?? 0}
|
||||
currentUserCollected={note?.userActions?.isCollected ?? false}
|
||||
clickHandle={collectButtonClickHandle}
|
||||
></CollectButton>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
type="text"
|
||||
className="flex items-center"
|
||||
icon={note?.userActions?.isLiked ? <LikeFilled /> : <LikeOutlined />}
|
||||
onClick={likeButtonClickHandle}
|
||||
>
|
||||
{note?.likeCount || 0} 次点赞
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="flex items-center"
|
||||
icon={
|
||||
note?.userActions?.isCollected ? <StarFilled /> : <StarOutlined />
|
||||
}
|
||||
onClick={collectButtonClickHandle}
|
||||
>
|
||||
{note?.collectCount || 0} 次收藏
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="flex items-center"
|
||||
icon={<MessageOutlined />}
|
||||
onClick={handleCommentClick}
|
||||
>
|
||||
{localCommentCount} 条评论
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title="评论"
|
||||
placement="right"
|
||||
width={500}
|
||||
onClose={() => setCommentDrawerVisible(false)}
|
||||
open={commentDrawerVisible}
|
||||
>
|
||||
{note && (
|
||||
<CommentList
|
||||
noteId={note.noteId}
|
||||
onCommentCountChange={handleCommentSuccess}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
12
frontend/src/domain/note/types.ts
Normal file
12
frontend/src/domain/note/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 笔记评论
|
||||
*/
|
||||
export interface NoteComment {
|
||||
id: number
|
||||
noteId: number
|
||||
userId: number
|
||||
content: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isDeleted: boolean
|
||||
}
|
||||
@ -9,6 +9,7 @@ export const userApiList: ApiList = {
|
||||
getUser: ['GET', '/api/users/{userId}'],
|
||||
updateMe: ['PATCH', '/api/users/me'],
|
||||
uploadImage: ['POST', '/api/upload/image'],
|
||||
sendVerifyCode: ['GET', '/api/email/verify-code'],
|
||||
}
|
||||
|
||||
// 管理端的 apiList
|
||||
|
||||
@ -4,21 +4,61 @@ import {
|
||||
ALPHANUMERIC_UNDERSCORE,
|
||||
ALPHANUMERIC_UNDERSCORE_CHINESE,
|
||||
PASSWORD_ALLOWABLE_CHARACTERS,
|
||||
EMAIL_PATTERN,
|
||||
} from '../../../base/regex'
|
||||
import { useLogin } from '../hooks/useLogin.ts'
|
||||
import { useRegister } from '../hooks/useRegister.ts'
|
||||
import { userService } from '../service/userService.ts'
|
||||
import { useForm } from 'antd/es/form/Form'
|
||||
|
||||
const LoginModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState('login')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
const { loginHandle } = useLogin()
|
||||
const { registerHandle } = useRegister()
|
||||
|
||||
const [form] = useForm()
|
||||
|
||||
// 发送验证码
|
||||
const handleSendVerifyCode = async () => {
|
||||
try {
|
||||
// 验证邮箱
|
||||
await form.validateFields(['email'])
|
||||
const email = form.getFieldValue('email')
|
||||
|
||||
if (!email) {
|
||||
message.error('请输入邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
await userService.sendVerifyCode({
|
||||
email,
|
||||
type: 'REGISTER',
|
||||
})
|
||||
|
||||
message.success('验证码已发送')
|
||||
// 开始倒计时
|
||||
setCountdown(60)
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '发送失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onFinish(values: any) {
|
||||
try {
|
||||
setLoading(true)
|
||||
@ -28,10 +68,7 @@ const LoginModal: React.FC = () => {
|
||||
} else if (value === 'register') {
|
||||
await registerHandle(values)
|
||||
message.success('注册成功')
|
||||
} else {
|
||||
message.error('走到这个分支表示出大问题辣!')
|
||||
}
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
@ -52,44 +89,110 @@ const LoginModal: React.FC = () => {
|
||||
layout={'vertical'}
|
||||
form={form}
|
||||
>
|
||||
<Form.Item
|
||||
label="账号"
|
||||
name="account"
|
||||
rules={[
|
||||
{ required: true, message: '请输入账号' },
|
||||
{
|
||||
pattern: ALPHANUMERIC_UNDERSCORE,
|
||||
message: '账号只能包含字母、数字和下划线',
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
max: 16,
|
||||
message: '账号长度在 6 - 16 个字符',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
{value === 'register' && (
|
||||
{value === 'login' && (
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
name="username"
|
||||
label="账号或邮箱"
|
||||
name={form.getFieldValue('email') ? 'email' : 'account'}
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ required: true, message: '请输入账号或邮箱' },
|
||||
{
|
||||
pattern: ALPHANUMERIC_UNDERSCORE_CHINESE,
|
||||
message: '昵称只能包含中文、字母、数字和下划线',
|
||||
},
|
||||
{
|
||||
min: 1,
|
||||
max: 16,
|
||||
message: '昵称长度在 1 - 16 个字符之间',
|
||||
pattern: form.getFieldValue('email')
|
||||
? EMAIL_PATTERN
|
||||
: ALPHANUMERIC_UNDERSCORE,
|
||||
message: form.getFieldValue('email')
|
||||
? '邮箱格式不正确'
|
||||
: '账号只能包含字母、数字和下划线',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input autoComplete={'off'} />
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
// 根据输入内容判断是邮箱还是账号
|
||||
const value = e.target.value
|
||||
if (value.includes('@')) {
|
||||
form.setFieldsValue({ email: value, account: undefined })
|
||||
} else {
|
||||
form.setFieldsValue({ account: value, email: undefined })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{value === 'register' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="账号"
|
||||
name="account"
|
||||
rules={[
|
||||
{ required: true, message: '请输入账号' },
|
||||
{
|
||||
pattern: ALPHANUMERIC_UNDERSCORE,
|
||||
message: '账号只能包含字母、数字和下划线',
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
max: 16,
|
||||
message: '账号长度在 6 - 16 个字符',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{
|
||||
pattern: ALPHANUMERIC_UNDERSCORE_CHINESE,
|
||||
message: '昵称只能包含中文、字母、数字和下划线',
|
||||
},
|
||||
{
|
||||
min: 1,
|
||||
max: 16,
|
||||
message: '昵称长度在 1 - 16 个字符之间',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input autoComplete={'off'} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
]}
|
||||
>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="验证码"
|
||||
name="verifyCode"
|
||||
rules={[
|
||||
{ required: true, message: '请输入验证码' },
|
||||
{ len: 6, message: '验证码长度必须为6位' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
suffix={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={countdown > 0}
|
||||
onClick={handleSendVerifyCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s后重试` : '发送验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
@ -108,6 +211,7 @@ const LoginModal: React.FC = () => {
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
{value === 'register' ? '注册' : '登录'}
|
||||
</Button>
|
||||
@ -141,7 +245,7 @@ const LoginModal: React.FC = () => {
|
||||
]}
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
></Segmented>
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center pb-4">
|
||||
<LoginForm />
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import React from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Config, Logout, Permissions, User } from '@icon-park/react'
|
||||
import { USER_CENTER, USER_HOME } from '../../../apps/user/router/config.ts'
|
||||
import { Config, Logout, Permissions, User, Message } from '@icon-park/react'
|
||||
import {
|
||||
USER_CENTER,
|
||||
USER_HOME,
|
||||
MESSAGE_CENTER,
|
||||
} from '../../../apps/user/router/config.ts'
|
||||
import { useUser } from '../hooks/useUser.ts'
|
||||
import { useApp } from '../../../base/hooks'
|
||||
import { useLogout } from '../hooks/useLogout.ts'
|
||||
@ -39,6 +43,14 @@ const ProfileMenu: React.FC = () => {
|
||||
/>
|
||||
个人信息
|
||||
</NavLink>
|
||||
<NavLink className={itemCss} to={MESSAGE_CENTER}>
|
||||
<Message
|
||||
theme="multi-color"
|
||||
size="18"
|
||||
fill={['#333', '#8dbaf1', '#ffffff', '#e64155']}
|
||||
/>
|
||||
消息中心
|
||||
</NavLink>
|
||||
{/* 只有管理员才能查看后台内容 */}
|
||||
{user.isAdmin === Admin.ADMIN && (
|
||||
<NavLink className={itemCss} to={app.isAdminApp ? '/' : '/admin'}>
|
||||
|
||||
97
frontend/src/domain/user/context/UserContext.tsx
Normal file
97
frontend/src/domain/user/context/UserContext.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import React, { createContext, useState, useEffect } from 'react'
|
||||
import { User } from '../types/types'
|
||||
import { userService } from '../service/userService'
|
||||
import { TOKEN_KEY } from '../../../base/constants'
|
||||
import { message } from 'antd'
|
||||
|
||||
interface UserContextType {
|
||||
currentUser: User | null
|
||||
setCurrentUser: (user: User | null) => void
|
||||
reloadUser: () => Promise<void>
|
||||
}
|
||||
|
||||
export const UserContext = createContext<UserContextType>({
|
||||
currentUser: null,
|
||||
setCurrentUser: () => {},
|
||||
reloadUser: async () => {},
|
||||
})
|
||||
|
||||
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(() => {
|
||||
// 尝试从 localStorage 获取用户信息
|
||||
const savedUser = localStorage.getItem('currentUser')
|
||||
return savedUser ? JSON.parse(savedUser) : null
|
||||
})
|
||||
|
||||
const reloadUser = async () => {
|
||||
console.log('开始重新加载用户信息')
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
console.log('从localStorage获取的token:', token ? '存在' : '不存在')
|
||||
|
||||
if (!token) {
|
||||
console.log('未找到token,清除当前用户')
|
||||
setCurrentUser(null)
|
||||
localStorage.removeItem('currentUser')
|
||||
return
|
||||
}
|
||||
|
||||
const resp = await userService.whoamiService()
|
||||
console.log('获取用户信息响应:', resp)
|
||||
|
||||
if (resp && resp.data) {
|
||||
console.log('设置当前用户:', resp.data)
|
||||
setCurrentUser(resp.data)
|
||||
localStorage.setItem('currentUser', JSON.stringify(resp.data))
|
||||
} else {
|
||||
console.log('响应中没有用户数据,清除当前用户')
|
||||
setCurrentUser(null)
|
||||
localStorage.removeItem('currentUser')
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error)
|
||||
setCurrentUser(null)
|
||||
localStorage.removeItem('currentUser')
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时,如果有 token 就自动加载用户信息
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (token && !currentUser) {
|
||||
reloadUser()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('用户状态更新:', currentUser)
|
||||
if (currentUser) {
|
||||
localStorage.setItem('currentUser', JSON.stringify(currentUser))
|
||||
} else {
|
||||
localStorage.removeItem('currentUser')
|
||||
}
|
||||
}, [currentUser])
|
||||
|
||||
const contextValue = {
|
||||
currentUser,
|
||||
setCurrentUser: (user: User | null) => {
|
||||
console.log('设置用户状态:', user)
|
||||
setCurrentUser(user)
|
||||
if (!user) {
|
||||
localStorage.removeItem('currentUser')
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} else {
|
||||
localStorage.setItem('currentUser', JSON.stringify(user))
|
||||
}
|
||||
},
|
||||
reloadUser,
|
||||
}
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={contextValue}>{children}</UserContext.Provider>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,7 @@ import {
|
||||
LoginBody,
|
||||
RegisterBody,
|
||||
RegisterData,
|
||||
SendVerifyCodeBody,
|
||||
UploadImageData,
|
||||
UserListQueryParams,
|
||||
} from '../types/serviceTypes.ts'
|
||||
@ -63,7 +64,16 @@ export const userService = {
|
||||
return httpClient.request<UploadImageData>(userApiList.uploadImage, {
|
||||
body: body,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
sendVerifyCode: (body: SendVerifyCodeBody) => {
|
||||
return httpClient.request<void>(userApiList.sendVerifyCode, {
|
||||
queryParams: body,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
44
frontend/src/domain/user/types.ts
Normal file
44
frontend/src/domain/user/types.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 用户类型
|
||||
*/
|
||||
export interface User {
|
||||
userId: number
|
||||
username: string
|
||||
avatarUrl: string
|
||||
email: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录请求参数
|
||||
*/
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册请求参数
|
||||
*/
|
||||
export interface RegisterParams {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户API响应
|
||||
*/
|
||||
export interface UserResponse {
|
||||
code: number
|
||||
message: string
|
||||
data: User
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员枚举
|
||||
*/
|
||||
export enum Admin {
|
||||
ADMIN = 1,
|
||||
USER = 0,
|
||||
}
|
||||
@ -5,6 +5,8 @@ export type RegisterBody = {
|
||||
username: string
|
||||
account: string
|
||||
password: string
|
||||
email: string
|
||||
verifyCode: string
|
||||
}
|
||||
|
||||
export type RegisterData = {
|
||||
@ -15,10 +17,19 @@ export type RegisterData = {
|
||||
* 登录时的请求参数和返回参数
|
||||
*/
|
||||
export type LoginBody = {
|
||||
account: string
|
||||
account?: string
|
||||
email?: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码的请求参数
|
||||
*/
|
||||
export type SendVerifyCodeBody = {
|
||||
email: string
|
||||
type: 'REGISTER' | 'RESET_PASSWORD'
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片返回的数据类型
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user