diff --git a/README.md b/README.md index 24c5ddc..8fce8ae 100644 --- a/README.md +++ b/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 diff --git a/backend/pom.xml b/backend/pom.xml index 99341c7..10a3204 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -117,6 +117,38 @@ org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-websocket + + + + org.springframework.boot + spring-boot-starter-mail + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + javax.mail + javax.mail-api + 1.6.2 + + + com.sun.mail + javax.mail + 1.6.2 + + + + + com.huaban + jieba-analysis + 1.0.2 + diff --git a/backend/src/main/java/com/kama/notes/config/SecurityConfig.java b/backend/src/main/java/com/kama/notes/config/SecurityConfig.java index 116eb00..265e982 100644 --- a/backend/src/main/java/com/kama/notes/config/SecurityConfig.java +++ b/backend/src/main/java/com/kama/notes/config/SecurityConfig.java @@ -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(); - } } diff --git a/backend/src/main/java/com/kama/notes/config/WebSocketConfig.java b/backend/src/main/java/com/kama/notes/config/WebSocketConfig.java new file mode 100644 index 0000000..727b8b9 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/config/WebSocketConfig.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/controller/CommentController.java b/backend/src/main/java/com/kama/notes/controller/CommentController.java new file mode 100644 index 0000000..81a41ac --- /dev/null +++ b/backend/src/main/java/com/kama/notes/controller/CommentController.java @@ -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 createComment( + @Valid + @RequestBody + CreateCommentRequest request) { + return commentService.createComment(request); + } + + /** + * 更新评论 + * + * @param commentId 评论ID + * @param request 更新评论请求 + * @return 空响应 + */ + @PatchMapping("/comments/{commentId}") + public ApiResponse updateComment( + @PathVariable("commentId") Integer commentId, + @Valid + @RequestBody + UpdateCommentRequest request) { + return commentService.updateComment(commentId, request); + } + + /** + * 删除评论 + * + * @param commentId 评论ID + * @return 空响应 + */ + @DeleteMapping("/comments/{commentId}") + public ApiResponse deleteComment( + @PathVariable("commentId") Integer commentId) { + return commentService.deleteComment(commentId); + } + + /** + * 获取评论列表 + * + * @param params 查询参数 + * @return 评论列表 + */ + @GetMapping("/comments") + public ApiResponse> getComments( + @Valid CommentQueryParams params) { + return commentService.getComments(params); + } + + /** + * 点赞评论 + * + * @param commentId 评论ID + * @return 空响应 + */ + @PostMapping("/comments/{commentId}/like") + public ApiResponse likeComment( + @PathVariable("commentId") Integer commentId) { + return commentService.likeComment(commentId); + } + + /** + * 取消点赞评论 + * + * @param commentId 评论ID + * @return 空响应 + */ + @DeleteMapping("/comments/{commentId}/like") + public ApiResponse unlikeComment( + @PathVariable("commentId") Integer commentId) { + return commentService.unlikeComment(commentId); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/controller/EmailController.java b/backend/src/main/java/com/kama/notes/controller/EmailController.java new file mode 100644 index 0000000..9431f4f --- /dev/null +++ b/backend/src/main/java/com/kama/notes/controller/EmailController.java @@ -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 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()); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/controller/MessageController.java b/backend/src/main/java/com/kama/notes/controller/MessageController.java new file mode 100644 index 0000000..f00e1c1 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/controller/MessageController.java @@ -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> getMessages(@Validated MessageQueryParams params) { + return messageService.getMessages(params); + } + + /** + * 标记消息为已读 + * + * @param messageId 消息ID + * @return 空响应 + */ + @PutMapping("/{messageId}/read") + public ApiResponse markAsRead(@PathVariable Integer messageId) { + return messageService.markAsRead(messageId); + } + + /** + * 标记所有消息为已读 + * + * @return 空响应 + */ + @PutMapping("/read/all") + public ApiResponse markAllAsRead() { + return messageService.markAllAsRead(); + } + + /** + * 删除消息 + * + * @param messageId 消息ID + * @return 空响应 + */ + @DeleteMapping("/{messageId}") + public ApiResponse deleteMessage(@PathVariable Integer messageId) { + return messageService.deleteMessage(messageId); + } + + /** + * 获取未读消息数量 + * + * @return 未读消息数量 + */ + @GetMapping("/unread/count") + public ApiResponse getUnreadCount() { + return messageService.getUnreadCount(); + } + + /** + * 获取各类型未读消息数量 + * + * @return 各类型未读消息数量 + */ + @GetMapping("/unread/count/type") + public ApiResponse> getUnreadCountByType() { + return messageService.getUnreadCountByType(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/controller/SearchController.java b/backend/src/main/java/com/kama/notes/controller/SearchController.java new file mode 100644 index 0000000..0903b87 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/controller/SearchController.java @@ -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> 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> 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> 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); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/event/MessageEvent.java b/backend/src/main/java/com/kama/notes/event/MessageEvent.java new file mode 100644 index 0000000..9f4412e --- /dev/null +++ b/backend/src/main/java/com/kama/notes/event/MessageEvent.java @@ -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"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/exception/ParamExceptionHandler.java b/backend/src/main/java/com/kama/notes/exception/ParamExceptionHandler.java index 2011507..ef81d6f 100644 --- a/backend/src/main/java/com/kama/notes/exception/ParamExceptionHandler.java +++ b/backend/src/main/java/com/kama/notes/exception/ParamExceptionHandler.java @@ -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 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()); } } diff --git a/backend/src/main/java/com/kama/notes/listener/MessageEventListener.java b/backend/src/main/java/com/kama/notes/listener/MessageEventListener.java new file mode 100644 index 0000000..2cd88d4 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/listener/MessageEventListener.java @@ -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); + // 这里可以添加重试逻辑或者将失败的消息记录到特定的队列中 + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/CommentLikeMapper.java b/backend/src/main/java/com/kama/notes/mapper/CommentLikeMapper.java new file mode 100644 index 0000000..b0ef5b9 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/mapper/CommentLikeMapper.java @@ -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 findUserLikedCommentIds(@Param("userId") Long userId, + @Param("commentIds") List 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); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/CommentMapper.java b/backend/src/main/java/com/kama/notes/mapper/CommentMapper.java new file mode 100644 index 0000000..22c06e1 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/mapper/CommentMapper.java @@ -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 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); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/EmailVerifyCodeMapper.java b/backend/src/main/java/com/kama/notes/mapper/EmailVerifyCodeMapper.java new file mode 100644 index 0000000..538b91f --- /dev/null +++ b/backend/src/main/java/com/kama/notes/mapper/EmailVerifyCodeMapper.java @@ -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); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/MessageMapper.java b/backend/src/main/java/com/kama/notes/mapper/MessageMapper.java new file mode 100644 index 0000000..93b2958 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/mapper/MessageMapper.java @@ -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 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 countUnreadByType(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/NoteCollectMapper.java b/backend/src/main/java/com/kama/notes/mapper/NoteCollectMapper.java new file mode 100644 index 0000000..313639a --- /dev/null +++ b/backend/src/main/java/com/kama/notes/mapper/NoteCollectMapper.java @@ -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 findNoteIdsByUserId(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/NoteCommentMapper.java b/backend/src/main/java/com/kama/notes/mapper/NoteCommentMapper.java new file mode 100644 index 0000000..5d0cae1 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/mapper/NoteCommentMapper.java @@ -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 findByNoteId(@Param("noteId") Integer noteId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/mapper/NoteMapper.java b/backend/src/main/java/com/kama/notes/mapper/NoteMapper.java index 6344627..48661c1 100644 --- a/backend/src/main/java/com/kama/notes/mapper/NoteMapper.java +++ b/backend/src/main/java/com/kama/notes/mapper/NoteMapper.java @@ -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 searchNotes(@Param("keyword") String keyword, + @Param("limit") int limit, + @Param("offset") int offset); + + /** + * 根据标签搜索笔记 + * + * @param keyword 关键词 + * @param tag 标签 + * @param limit 限制数量 + * @param offset 偏移量 + * @return 笔记列表 + */ + List searchNotesByTag(@Param("keyword") String keyword, + @Param("tag") String tag, + @Param("limit") int limit, + @Param("offset") int offset); } diff --git a/backend/src/main/java/com/kama/notes/mapper/UserMapper.java b/backend/src/main/java/com/kama/notes/mapper/UserMapper.java index c07b4bc..5a90fa0 100644 --- a/backend/src/main/java/com/kama/notes/mapper/UserMapper.java +++ b/backend/src/main/java/com/kama/notes/mapper/UserMapper.java @@ -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 searchUsers(@Param("keyword") String keyword, + @Param("limit") int limit, + @Param("offset") int offset); } diff --git a/backend/src/main/java/com/kama/notes/model/base/ApiResponse.java b/backend/src/main/java/com/kama/notes/model/base/ApiResponse.java index 50144fa..94cbbb2 100644 --- a/backend/src/main/java/com/kama/notes/model/base/ApiResponse.java +++ b/backend/src/main/java/com/kama/notes/model/base/ApiResponse.java @@ -1,14 +1,95 @@ package com.kama.notes.model.base; -import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +/** + * API响应类 + * + * @param 响应数据类型 + */ @Data @NoArgsConstructor -@AllArgsConstructor public class ApiResponse { - 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 响应数据类型 + * @return API响应 + */ + public static ApiResponse success(T data) { + ApiResponse response = new ApiResponse<>(); + response.setCode(200); + response.setMessage("success"); + response.setData(data); + return response; + } + + /** + * 创建成功响应(无数据) + * + * @return API响应 + */ + public static ApiResponse success() { + return success(new EmptyVO()); + } + + /** + * 创建错误响应 + * + * @param code 错误码 + * @param message 错误消息 + * @return API响应 + */ + public static ApiResponse error(int code, String message) { + ApiResponse response = new ApiResponse<>(); + response.setCode(code); + response.setMessage(message); + return response; + } + + /** + * 创建带数据的错误响应 + * + * @param code 错误码 + * @param message 错误消息 + * @param data 错误数据 + * @return API响应 + */ + public static ApiResponse error(int code, String message, T data) { + ApiResponse response = new ApiResponse<>(); + response.setCode(code); + response.setMessage(message); + response.setData(data); + return response; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/base/EmptyVO.java b/backend/src/main/java/com/kama/notes/model/base/EmptyVO.java index 7c7bf4c..7217878 100644 --- a/backend/src/main/java/com/kama/notes/model/base/EmptyVO.java +++ b/backend/src/main/java/com/kama/notes/model/base/EmptyVO.java @@ -1,6 +1,7 @@ package com.kama.notes.model.base; -// 占位 -// 用于操作成功,只需要返回状态码,无任何额外数据时使用 +/** + * 空响应类,用于表示无数据返回的情况 + */ public class EmptyVO { } diff --git a/backend/src/main/java/com/kama/notes/model/base/PageVO.java b/backend/src/main/java/com/kama/notes/model/base/PageVO.java new file mode 100644 index 0000000..7755ea6 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/base/PageVO.java @@ -0,0 +1,59 @@ +package com.kama.notes.model.base; + +import lombok.Data; +import java.util.List; + +/** + * 分页数据模型 + * + * @param 数据类型 + */ +@Data +public class PageVO { + /** + * 当前页码 + */ + private Integer page; + + /** + * 每页大小 + */ + private Integer pageSize; + + /** + * 总记录数 + */ + private Integer total; + + /** + * 总页数 + */ + private Integer totalPages; + + /** + * 数据列表 + */ + private List list; + + public PageVO(Integer page, Integer pageSize, Integer total, List list) { + this.page = page; + this.pageSize = pageSize; + this.total = total; + this.list = list; + } + + /** + * 创建分页结果 + * + * @param list 数据列表 + * @param page 当前页码 + * @param pageSize 每页大小 + * @param total 总记录数 + * @return 分页结果 + */ + public static PageVO of(List list, Integer page, Integer pageSize, Integer total) { + PageVO pageVO = new PageVO<>(page, pageSize, total, list); + pageVO.setTotalPages((total + pageSize - 1) / pageSize); + return pageVO; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/dto/comment/CommentQueryParams.java b/backend/src/main/java/com/kama/notes/model/dto/comment/CommentQueryParams.java new file mode 100644 index 0000000..b9f31c1 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/dto/comment/CommentQueryParams.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/dto/comment/CreateCommentRequest.java b/backend/src/main/java/com/kama/notes/model/dto/comment/CreateCommentRequest.java new file mode 100644 index 0000000..a1fa0d4 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/dto/comment/CreateCommentRequest.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/dto/comment/UpdateCommentRequest.java b/backend/src/main/java/com/kama/notes/model/dto/comment/UpdateCommentRequest.java new file mode 100644 index 0000000..ee980a1 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/dto/comment/UpdateCommentRequest.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/dto/message/MessageQueryParams.java b/backend/src/main/java/com/kama/notes/model/dto/message/MessageQueryParams.java new file mode 100644 index 0000000..5c03f9d --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/dto/message/MessageQueryParams.java @@ -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"; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/dto/user/LoginRequest.java b/backend/src/main/java/com/kama/notes/model/dto/user/LoginRequest.java index fea0a23..7c52ca5 100644 --- a/backend/src/main/java/com/kama/notes/model/dto/user/LoginRequest.java +++ b/backend/src/main/java/com/kama/notes/model/dto/user/LoginRequest.java @@ -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; + } } diff --git a/backend/src/main/java/com/kama/notes/model/dto/user/RegisterRequest.java b/backend/src/main/java/com/kama/notes/model/dto/user/RegisterRequest.java index 52d458d..396aef5 100644 --- a/backend/src/main/java/com/kama/notes/model/dto/user/RegisterRequest.java +++ b/backend/src/main/java/com/kama/notes/model/dto/user/RegisterRequest.java @@ -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; } \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/Comment.java b/backend/src/main/java/com/kama/notes/model/entity/Comment.java new file mode 100644 index 0000000..af84b2f --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/entity/Comment.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/CommentLike.java b/backend/src/main/java/com/kama/notes/model/entity/CommentLike.java new file mode 100644 index 0000000..a4a732b --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/entity/CommentLike.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/EmailVerifyCode.java b/backend/src/main/java/com/kama/notes/model/entity/EmailVerifyCode.java new file mode 100644 index 0000000..2aa1669 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/entity/EmailVerifyCode.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/Message.java b/backend/src/main/java/com/kama/notes/model/entity/Message.java new file mode 100644 index 0000000..91f21f9 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/entity/Message.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/Note.java b/backend/src/main/java/com/kama/notes/model/entity/Note.java index f0921dd..037d166 100644 --- a/backend/src/main/java/com/kama/notes/model/entity/Note.java +++ b/backend/src/main/java/com/kama/notes/model/entity/Note.java @@ -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; diff --git a/backend/src/main/java/com/kama/notes/model/entity/NoteCollect.java b/backend/src/main/java/com/kama/notes/model/entity/NoteCollect.java new file mode 100644 index 0000000..66c61da --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/entity/NoteCollect.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/NoteComment.java b/backend/src/main/java/com/kama/notes/model/entity/NoteComment.java new file mode 100644 index 0000000..ab58154 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/entity/NoteComment.java @@ -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; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/entity/User.java b/backend/src/main/java/com/kama/notes/model/entity/User.java index fc07b0b..40ce97b 100644 --- a/backend/src/main/java/com/kama/notes/model/entity/User.java +++ b/backend/src/main/java/com/kama/notes/model/entity/User.java @@ -64,6 +64,11 @@ public class User { */ private String email; + /** + * 邮箱是否验证 + */ + private Boolean emailVerified; + /** * 用户学校 */ diff --git a/backend/src/main/java/com/kama/notes/model/vo/comment/CommentVO.java b/backend/src/main/java/com/kama/notes/model/vo/comment/CommentVO.java new file mode 100644 index 0000000..52903b3 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/vo/comment/CommentVO.java @@ -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 replies; + + /** + * 简单作者信息 + */ + @Data + public static class SimpleAuthorVO { + private Long userId; + private String username; + private String avatarUrl; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/vo/message/MessageVO.java b/backend/src/main/java/com/kama/notes/model/vo/message/MessageVO.java new file mode 100644 index 0000000..1d656a7 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/vo/message/MessageVO.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/vo/message/UnreadCountByType.java b/backend/src/main/java/com/kama/notes/model/vo/message/UnreadCountByType.java new file mode 100644 index 0000000..10319cb --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/vo/message/UnreadCountByType.java @@ -0,0 +1,19 @@ +package com.kama.notes.model.vo.message; + +import lombok.Data; + +/** + * 各类型未读消息数量 + */ +@Data +public class UnreadCountByType { + /** + * 消息类型 + */ + private String type; + + /** + * 未读数量 + */ + private Integer count; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/model/vo/user/UserActionVO.java b/backend/src/main/java/com/kama/notes/model/vo/user/UserActionVO.java new file mode 100644 index 0000000..f7c9e90 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/model/vo/user/UserActionVO.java @@ -0,0 +1,8 @@ +package com.kama.notes.model.vo.user; + +import lombok.Data; + +@Data +public class UserActionVO { + private Boolean isLiked; +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/CommentService.java b/backend/src/main/java/com/kama/notes/service/CommentService.java new file mode 100644 index 0000000..badb598 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/CommentService.java @@ -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 createComment(CreateCommentRequest request); + + /** + * 更新评论 + * + * @param commentId 评论ID + * @param request 更新评论请求 + * @return 空响应 + */ + ApiResponse updateComment(Integer commentId, UpdateCommentRequest request); + + /** + * 删除评论 + * + * @param commentId 评论ID + * @return 空响应 + */ + ApiResponse deleteComment(Integer commentId); + + /** + * 获取评论列表 + * + * @param params 查询参数 + * @return 评论列表 + */ + ApiResponse> getComments(CommentQueryParams params); + + /** + * 点赞评论 + * + * @param commentId 评论ID + * @return 空响应 + */ + ApiResponse likeComment(Integer commentId); + + /** + * 取消点赞评论 + * + * @param commentId 评论ID + * @return 空响应 + */ + ApiResponse unlikeComment(Integer commentId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/EmailService.java b/backend/src/main/java/com/kama/notes/service/EmailService.java new file mode 100644 index 0000000..327fccf --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/EmailService.java @@ -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); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/MessageService.java b/backend/src/main/java/com/kama/notes/service/MessageService.java new file mode 100644 index 0000000..3f78f04 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/MessageService.java @@ -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 createMessage(Long receiverId, Long senderId, String type, Integer targetId, String content); + + /** + * 获取消息列表 + * + * @param params 查询参数 + * @return 消息列表,带分页信息 + */ + ApiResponse> getMessages(MessageQueryParams params); + + /** + * 标记消息为已读 + * + * @param messageId 消息ID + * @return 空响应 + */ + ApiResponse markAsRead(Integer messageId); + + /** + * 标记所有消息为已读 + * + * @return 空响应 + */ + ApiResponse markAllAsRead(); + + /** + * 删除消息 + * + * @param messageId 消息ID + * @return 空响应 + */ + ApiResponse deleteMessage(Integer messageId); + + /** + * 获取未读消息数量 + * + * @return 未读消息数量 + */ + ApiResponse getUnreadCount(); + + /** + * 获取各类型未读消息数量 + * + * @return 各类型未读消息数量 + */ + ApiResponse> getUnreadCountByType(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/NoteCommentService.java b/backend/src/main/java/com/kama/notes/service/NoteCommentService.java new file mode 100644 index 0000000..87846d6 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/NoteCommentService.java @@ -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 createComment(Integer noteId, String content); + + /** + * 删除评论 + */ + ApiResponse deleteComment(Integer commentId); + + /** + * 获取笔记的评论列表 + */ + ApiResponse> getComments(Integer noteId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/SearchService.java b/backend/src/main/java/com/kama/notes/service/SearchService.java new file mode 100644 index 0000000..c002d9b --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/SearchService.java @@ -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> searchNotes(String keyword, int page, int pageSize); + + /** + * 搜索用户 + * + * @param keyword 关键词 + * @param page 页码 + * @param pageSize 每页大小 + * @return 用户列表 + */ + ApiResponse> searchUsers(String keyword, int page, int pageSize); + + /** + * 搜索笔记(带标签) + * + * @param keyword 关键词 + * @param tag 标签 + * @param page 页码 + * @param pageSize 每页大小 + * @return 笔记列表 + */ + ApiResponse> searchNotesByTag(String keyword, String tag, int page, int pageSize); +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/impl/CommentServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/CommentServiceImpl.java new file mode 100644 index 0000000..85e6a3d --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/impl/CommentServiceImpl.java @@ -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 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 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 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 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> getComments(CommentQueryParams params) { + try { + List comments = commentMapper.findByQueryParam(params, params.getPageSize(), (params.getPage() - 1) * params.getPageSize()); + if (comments == null || comments.isEmpty()) { + return ApiResponse.success(List.of()); + } + + List 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 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 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(), "取消点赞评论失败"); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/impl/EmailServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/EmailServiceImpl.java new file mode 100644 index 0000000..73ecc1b --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/impl/EmailServiceImpl.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/impl/MessageServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/MessageServiceImpl.java new file mode 100644 index 0000000..300a84e --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/impl/MessageServiceImpl.java @@ -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 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 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> getMessages(MessageQueryParams params) { + Long currentUserId = SecurityUtils.getCurrentUserId(); + + // 获取总记录数 + int total = messageMapper.countByParams(currentUserId, params); + + // 计算偏移量 + int offset = (params.getPage() - 1) * params.getPageSize(); + + // 获取当前页数据 + List messages = messageMapper.selectByParams(currentUserId, params, offset); + + // 转换为VO对象 + List 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 pageVO = PageVO.of(messageVOs, params.getPage(), params.getPageSize(), total); + return ApiResponse.success(pageVO); + } + + @Override + public ApiResponse markAsRead(Integer messageId) { + Long currentUserId = SecurityUtils.getCurrentUserId(); + messageMapper.markAsRead(messageId, currentUserId); + + // 清除相关缓存 + clearMessageCache(currentUserId); + + return ApiResponse.success(); + } + + @Override + public ApiResponse markAllAsRead() { + Long currentUserId = SecurityUtils.getCurrentUserId(); + messageMapper.markAllAsRead(currentUserId); + + // 清除相关缓存 + clearMessageCache(currentUserId); + + return ApiResponse.success(); + } + + @Override + public ApiResponse deleteMessage(Integer messageId) { + Long currentUserId = SecurityUtils.getCurrentUserId(); + messageMapper.deleteMessage(messageId, currentUserId); + return ApiResponse.success(); + } + + @Override + public ApiResponse 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> getUnreadCountByType() { + Long currentUserId = SecurityUtils.getCurrentUserId(); + String cacheKey = UNREAD_COUNT_BY_TYPE_KEY + currentUserId; + + // 尝试从缓存获取 + @SuppressWarnings("unchecked") + List result = (List) 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); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/impl/NoteCommentServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/NoteCommentServiceImpl.java new file mode 100644 index 0000000..000392e --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/impl/NoteCommentServiceImpl.java @@ -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 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 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> getComments(Integer noteId) { + try { + List comments = noteCommentMapper.findByNoteId(noteId); + return ApiResponseUtil.success("获取评论成功", comments); + } catch (Exception e) { + return ApiResponseUtil.error("获取评论失败"); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/impl/NoteLikeServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/NoteLikeServiceImpl.java index a9051a0..c62d449 100644 --- a/backend/src/main/java/com/kama/notes/service/impl/NoteLikeServiceImpl.java +++ b/backend/src/main/java/com/kama/notes/service/impl/NoteLikeServiceImpl.java @@ -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 findUserLikedNoteIds(Long userId, List noteIds) { - List 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 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 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 findUserLikedNoteIds(Long userId, List noteIds) { + List likedIds = noteLikeMapper.findUserLikedNoteIds(userId, noteIds); + return new HashSet<>(likedIds); } } diff --git a/backend/src/main/java/com/kama/notes/service/impl/SearchServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/SearchServiceImpl.java new file mode 100644 index 0000000..2942097 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/service/impl/SearchServiceImpl.java @@ -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 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> searchNotes(String keyword, int page, int pageSize) { + try { + String cacheKey = String.format(NOTE_SEARCH_CACHE_KEY, keyword, page, pageSize); + + // 尝试从缓存获取 + List cachedResult = (List) redisTemplate.opsForValue().get(cacheKey); + if (cachedResult != null) { + return ApiResponseUtil.success("搜索成功", cachedResult); + } + + // 处理关键词 + keyword = SearchUtils.preprocessKeyword(keyword); + + // 计算偏移量 + int offset = (page - 1) * pageSize; + + // 执行搜索 + List 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> searchUsers(String keyword, int page, int pageSize) { + try { + String cacheKey = String.format(USER_SEARCH_CACHE_KEY, keyword, page, pageSize); + + // 尝试从缓存获取 + List cachedResult = (List) redisTemplate.opsForValue().get(cacheKey); + if (cachedResult != null) { + return ApiResponseUtil.success("搜索成功", cachedResult); + } + + // 计算偏移量 + int offset = (page - 1) * pageSize; + + // 执行搜索 + List 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> searchNotesByTag(String keyword, String tag, int page, int pageSize) { + try { + String cacheKey = String.format(NOTE_TAG_SEARCH_CACHE_KEY, keyword, tag, page, pageSize); + + // 尝试从缓存获取 + List cachedResult = (List) redisTemplate.opsForValue().get(cacheKey); + if (cachedResult != null) { + return ApiResponseUtil.success("搜索成功", cachedResult); + } + + // 处理关键词 + keyword = SearchUtils.preprocessKeyword(keyword); + + // 计算偏移量 + int offset = (page - 1) * pageSize; + + // 执行搜索 + List 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("搜索失败"); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/service/impl/UserServiceImpl.java b/backend/src/main/java/com/kama/notes/service/impl/UserServiceImpl.java index 3593d29..3489757 100644 --- a/backend/src/main/java/com/kama/notes/service/impl/UserServiceImpl.java +++ b/backend/src/main/java/com/kama/notes/service/impl/UserServiceImpl.java @@ -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 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 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())) { diff --git a/backend/src/main/java/com/kama/notes/utils/ApiResponseUtil.java b/backend/src/main/java/com/kama/notes/utils/ApiResponseUtil.java index 911dff9..47cdbf2 100644 --- a/backend/src/main/java/com/kama/notes/utils/ApiResponseUtil.java +++ b/backend/src/main/java/com/kama/notes/utils/ApiResponseUtil.java @@ -14,18 +14,18 @@ public class ApiResponseUtil { * @return ApiResponse */ public static ApiResponse success(String message) { - return new ApiResponse<>(HttpStatus.OK.value(), message, null); + return ApiResponse.success(null); } public static ApiResponse success(String message, T data) { - return new ApiResponse<>(HttpStatus.OK.value(), message, data); + return ApiResponse.success(data); } /** * 构建参数错误的响应 */ public static ApiResponse error(String msg) { - return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), msg, null); + return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), msg); } /** diff --git a/backend/src/main/java/com/kama/notes/utils/SearchUtils.java b/backend/src/main/java/com/kama/notes/utils/SearchUtils.java new file mode 100644 index 0000000..bf9cf6f --- /dev/null +++ b/backend/src/main/java/com/kama/notes/utils/SearchUtils.java @@ -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 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); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/utils/SecurityUtils.java b/backend/src/main/java/com/kama/notes/utils/SecurityUtils.java new file mode 100644 index 0000000..14a9970 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/utils/SecurityUtils.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/kama/notes/websocket/MessageWebSocketHandler.java b/backend/src/main/java/com/kama/notes/websocket/MessageWebSocketHandler.java new file mode 100644 index 0000000..1d63e68 --- /dev/null +++ b/backend/src/main/java/com/kama/notes/websocket/MessageWebSocketHandler.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 68e4b24..d8e6f01 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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" diff --git a/backend/src/main/resources/db/migration/V20240320_1__create_note_comment_table.sql b/backend/src/main/resources/db/migration/V20240320_1__create_note_comment_table.sql new file mode 100644 index 0000000..6d9ccc6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20240320_1__create_note_comment_table.sql @@ -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; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V20240325_1__create_comment_tables.sql b/backend/src/main/resources/db/migration/V20240325_1__create_comment_tables.sql new file mode 100644 index 0000000..0c320f5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20240325_1__create_comment_tables.sql @@ -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='评论点赞表'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V20240325_2__create_message_tables.sql b/backend/src/main/resources/db/migration/V20240325_2__create_message_tables.sql new file mode 100644 index 0000000..61c2a83 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20240325_2__create_message_tables.sql @@ -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='消息表'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V20240326_1__add_email_verification.sql b/backend/src/main/resources/db/migration/V20240326_1__add_email_verification.sql new file mode 100644 index 0000000..78d6718 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20240326_1__add_email_verification.sql @@ -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='邮箱验证码表'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V20240327_1__optimize_search.sql b/backend/src/main/resources/db/migration/V20240327_1__optimize_search.sql new file mode 100644 index 0000000..8fd9931 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20240327_1__optimize_search.sql @@ -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; \ No newline at end of file diff --git a/backend/src/main/resources/mapper/CommentLikeMapper.xml b/backend/src/main/resources/mapper/CommentLikeMapper.xml new file mode 100644 index 0000000..bb3bc62 --- /dev/null +++ b/backend/src/main/resources/mapper/CommentLikeMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + INSERT INTO comment_like ( + comment_id, user_id, created_at + ) VALUES ( + #{commentId}, #{userId}, #{createdAt} + ) + + + + + DELETE FROM comment_like + WHERE comment_id = #{commentId} AND user_id = #{userId} + + + + + \ No newline at end of file diff --git a/backend/src/main/resources/mapper/CommentMapper.xml b/backend/src/main/resources/mapper/CommentMapper.xml new file mode 100644 index 0000000..9a33fbb --- /dev/null +++ b/backend/src/main/resources/mapper/CommentMapper.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + 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} + ) + + + + + UPDATE comment + + content = #{content}, + like_count = #{likeCount}, + reply_count = #{replyCount}, + updated_at = CURRENT_TIMESTAMP + + WHERE comment_id = #{commentId} + + + + + DELETE FROM comment WHERE comment_id = #{commentId} + + + + + + + + + + + + + + UPDATE comment SET like_count = like_count + 1 + WHERE comment_id = #{commentId} + + + + + UPDATE comment SET like_count = like_count - 1 + WHERE comment_id = #{commentId} AND like_count > 0 + + + + + UPDATE comment SET reply_count = reply_count + 1 + WHERE comment_id = #{commentId} + + + + + UPDATE comment SET reply_count = reply_count - 1 + WHERE comment_id = #{commentId} AND reply_count > 0 + + \ No newline at end of file diff --git a/backend/src/main/resources/mapper/EmailVerifyCodeMapper.xml b/backend/src/main/resources/mapper/EmailVerifyCodeMapper.xml new file mode 100644 index 0000000..73eb842 --- /dev/null +++ b/backend/src/main/resources/mapper/EmailVerifyCodeMapper.xml @@ -0,0 +1,29 @@ + + + + + + INSERT INTO email_verify_code ( + email, code, type, expired_at, created_at, used + ) VALUES ( + #{email}, #{code}, #{type}, #{expiredAt}, NOW(), FALSE + ) + + + + + + UPDATE email_verify_code + SET used = TRUE + WHERE id = #{id} + + + \ No newline at end of file diff --git a/backend/src/main/resources/mapper/MessageMapper.xml b/backend/src/main/resources/mapper/MessageMapper.xml new file mode 100644 index 0000000..8745de2 --- /dev/null +++ b/backend/src/main/resources/mapper/MessageMapper.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + message_id, receiver_id, sender_id, type, target_id, content, is_read, created_at, updated_at + + + + 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} + ) + + + + + + + + UPDATE message + SET is_read = true, + updated_at = NOW() + WHERE message_id = #{messageId} + AND receiver_id = #{userId} + + + + UPDATE message + SET is_read = true, + updated_at = NOW() + WHERE receiver_id = #{userId} + AND is_read = false + + + + DELETE FROM message + WHERE message_id = #{messageId} + AND receiver_id = #{userId} + + + + + + \ No newline at end of file diff --git a/backend/src/main/resources/mapper/NoteCollectMapper.xml b/backend/src/main/resources/mapper/NoteCollectMapper.xml new file mode 100644 index 0000000..49ae975 --- /dev/null +++ b/backend/src/main/resources/mapper/NoteCollectMapper.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + INSERT INTO note_collect (note_id, user_id) + VALUES (#{noteId}, #{userId}) + + + + + DELETE FROM note_collect + WHERE note_id = #{noteId} + AND user_id = #{userId} + + + + + + + + \ No newline at end of file diff --git a/backend/src/main/resources/mapper/NoteCommentMapper.xml b/backend/src/main/resources/mapper/NoteCommentMapper.xml new file mode 100644 index 0000000..84e10da --- /dev/null +++ b/backend/src/main/resources/mapper/NoteCommentMapper.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + id, note_id, user_id, content, created_at, updated_at, is_deleted + + + + INSERT INTO note_comment ( + note_id, user_id, content, created_at, updated_at, is_deleted + ) VALUES ( + #{noteId}, #{userId}, #{content}, #{createdAt}, #{updatedAt}, #{isDeleted} + ) + + + + UPDATE note_comment + SET updated_at = #{updatedAt}, + is_deleted = #{isDeleted} + WHERE id = #{id} + + + + + + \ No newline at end of file diff --git a/backend/src/main/resources/mapper/NoteMapper.xml b/backend/src/main/resources/mapper/NoteMapper.xml index 33b43a1..30f3794 100644 --- a/backend/src/main/resources/mapper/NoteMapper.xml +++ b/backend/src/main/resources/mapper/NoteMapper.xml @@ -76,10 +76,20 @@ VALUES (#{questionId}, #{authorId}, #{content}) - + SELECT * FROM note WHERE note_id = #{noteId} @@ -240,4 +250,47 @@ FROM note + + UPDATE note + SET comment_count = comment_count + 1, + updated_at = CURRENT_TIMESTAMP + WHERE note_id = #{noteId} + + + + 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} + + + + + + + + diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml index 7fe599b..8c56305 100644 --- a/backend/src/main/resources/mapper/UserMapper.xml +++ b/backend/src/main/resources/mapper/UserMapper.xml @@ -9,8 +9,29 @@ - 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() + ) + + + + + diff --git a/backend/src/main/resources/templates/mail/verify-code.html b/backend/src/main/resources/templates/mail/verify-code.html new file mode 100644 index 0000000..b717c0f --- /dev/null +++ b/backend/src/main/resources/templates/mail/verify-code.html @@ -0,0 +1,23 @@ + + + + + 验证码 + + +
+

卡码笔记 - 验证码

+

亲爱的用户:

+

您正在进行操作,验证码为:

+
+ 123456 +
+

验证码有效期为15分钟,请尽快完成验证。

+

如果这不是您的操作,请忽略此邮件。

+
+

此邮件由系统自动发送,请勿直接回复。

+

© 2024 卡码笔记. All rights reserved.

+
+
+ + \ No newline at end of file diff --git a/backend/src/test/java/com/kama/notes/service/EmailServiceTest.java b/backend/src/test/java/com/kama/notes/service/EmailServiceTest.java new file mode 100644 index 0000000..96dea20 --- /dev/null +++ b/backend/src/test/java/com/kama/notes/service/EmailServiceTest.java @@ -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"; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca3f13f..7f9c28b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index bf858f2..e4d65fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/apps/user/components/comment/CommentInput.tsx b/frontend/src/apps/user/components/comment/CommentInput.tsx new file mode 100644 index 0000000..316e791 --- /dev/null +++ b/frontend/src/apps/user/components/comment/CommentInput.tsx @@ -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 = ({ + 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 ( +
+ +
+ ) + } + + return ( +
+ setContent(e.target.value)} + placeholder={placeholder} + autoSize={{ minRows: 2, maxRows: 6 }} + maxLength={500} + showCount + /> +
+ {parentId && ( + + )} + +
+
+ ) +} + +export default CommentInput diff --git a/frontend/src/apps/user/components/comment/CommentItem.tsx b/frontend/src/apps/user/components/comment/CommentItem.tsx new file mode 100644 index 0000000..9d5ada3 --- /dev/null +++ b/frontend/src/apps/user/components/comment/CommentItem.tsx @@ -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 = ({ + comment, + onCommentSuccess, + onLike, +}) => { + return ( + : + } + onClick={() => onLike(comment.userActions?.isLiked || false)} + > + {comment.likeCount || 0} 赞 + , + , + ]} + > + } + title={comment.author?.username} + description={ +
+
{comment.content}
+
+ {formatDistanceToNow(new Date(comment.createdAt), { + addSuffix: true, + locale: zhCN, + })} +
+
+ } + /> +
+ ) +} + +export default CommentItem diff --git a/frontend/src/apps/user/components/comment/CommentList.tsx b/frontend/src/apps/user/components/comment/CommentList.tsx new file mode 100644 index 0000000..5d135ed --- /dev/null +++ b/frontend/src/apps/user/components/comment/CommentList.tsx @@ -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 = ({ + noteId, + onCommentCountChange, +}) => { + const [comments, setComments] = useState([]) + const [loading, setLoading] = useState(false) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [replyTo, setReplyTo] = useState(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 ( +
+ setReplyTo(null)} + placeholder={ + replyTo ? `回复 ${replyTo.author.username}:` : '写下你的评论...' + } + /> + 0 && ( +
+ +
+ ) + } + renderItem={(comment) => ( + handleReply(comment)} + onLike={(isLiked) => handleLike(comment.commentId, isLiked)} + /> + )} + /> +
+ ) +} + +export default CommentList diff --git a/frontend/src/apps/user/components/message/MessageBadge.tsx b/frontend/src/apps/user/components/message/MessageBadge.tsx new file mode 100644 index 0000000..bb23149 --- /dev/null +++ b/frontend/src/apps/user/components/message/MessageBadge.tsx @@ -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 ( + + + + + + ) +} + +export default MessageBadge diff --git a/frontend/src/apps/user/components/message/MessageList.tsx b/frontend/src/apps/user/components/message/MessageList.tsx new file mode 100644 index 0000000..62716d8 --- /dev/null +++ b/frontend/src/apps/user/components/message/MessageList.tsx @@ -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 = ({ onUnreadCountChange }) => { + const [messages, setMessages] = useState([]) + 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 ( +
+ 评论 + {msg.content} +
+ ) + case 'LIKE': + return ( +
+ 点赞 + {msg.content} +
+ ) + case 'SYSTEM': + return ( +
+ 系统 + {msg.content} +
+ ) + default: + return msg.content + } + } + + return ( +
+ {messages.length > 0 && ( +
+ +
+ )} + ( + handleMarkAsRead(msg.messageId)} + > + 标记已读 + + ), + ]} + > + + {msg.sender.username} + {!msg.isRead && ( + + 未读 + + )} +
+ } + description={renderMessageContent(msg)} + /> + + )} + loadMore={ + hasMore && ( +
+ +
+ ) + } + /> + + ) +} + +export default MessageList diff --git a/frontend/src/apps/user/pages/MessagePage.tsx b/frontend/src/apps/user/pages/MessagePage.tsx new file mode 100644 index 0000000..17c1a11 --- /dev/null +++ b/frontend/src/apps/user/pages/MessagePage.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { Card } from 'antd' +import MessageList from '../components/message/MessageList' + +const MessagePage: React.FC = () => { + return ( +
+ + + +
+ ) +} + +export default MessagePage diff --git a/frontend/src/apps/user/router/config.ts b/frontend/src/apps/user/router/config.ts index 044605c..46fc2b3 100644 --- a/frontend/src/apps/user/router/config.ts +++ b/frontend/src/apps/user/router/config.ts @@ -31,3 +31,8 @@ export const USER_NOTE = '/user-center/note' * 题目路径 */ export const QUESTION = '/questions' + +/** + * 消息中心 + */ +export const MESSAGE_CENTER = '/messages' diff --git a/frontend/src/apps/user/router/index.tsx b/frontend/src/apps/user/router/index.tsx index 193be91..96b693a 100644 --- a/frontend/src/apps/user/router/index.tsx +++ b/frontend/src/apps/user/router/index.tsx @@ -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 = ( }> @@ -37,6 +39,7 @@ export const UserRouteConfig = ( } /> } /> } /> + } /> } /> ) diff --git a/frontend/src/base/constants.ts b/frontend/src/base/constants.ts new file mode 100644 index 0000000..d7de673 --- /dev/null +++ b/frontend/src/base/constants.ts @@ -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' + +// 其他常量... diff --git a/frontend/src/base/regex.ts b/frontend/src/base/regex.ts new file mode 100644 index 0000000..d0d331b --- /dev/null +++ b/frontend/src/base/regex.ts @@ -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,}$/ diff --git a/frontend/src/components/CommentInput.tsx b/frontend/src/components/CommentInput.tsx new file mode 100644 index 0000000..1ac3a7d --- /dev/null +++ b/frontend/src/components/CommentInput.tsx @@ -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 = ({ + 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 ( +
+