This commit is contained in:
Tong 2025-02-23 11:21:21 +08:00
parent fe95091282
commit f3d4380831
109 changed files with 5630 additions and 486 deletions

152
README.md
View File

@ -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
- MarkdownFlexmark
- 工具库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

View File

@ -117,6 +117,38 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 邮件发送依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 模板引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Java Mail API -->
<dependency>
<groupId>javax.mail</groupId>
<artifactId>javax.mail-api</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>javax.mail</artifactId>
<version>1.6.2</version>
</dependency>
<!-- 添加 jieba 分词依赖 -->
<dependency>
<groupId>com.huaban</groupId>
<artifactId>jieba-analysis</artifactId>
<version>1.0.2</version>
</dependency>
</dependencies>
<build>
<plugins>

View File

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

View File

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

View File

@ -0,0 +1,105 @@
package com.kama.notes.controller;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.dto.comment.CommentQueryParams;
import com.kama.notes.model.dto.comment.CreateCommentRequest;
import com.kama.notes.model.dto.comment.UpdateCommentRequest;
import com.kama.notes.model.vo.comment.CommentVO;
import com.kama.notes.service.CommentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 评论控制器
*/
@Slf4j
@RestController
@RequestMapping("/api")
public class CommentController {
@Autowired
private CommentService commentService;
/**
* 创建评论
*
* @param request 创建评论请求
* @return 创建的评论ID
*/
@PostMapping("/comments")
public ApiResponse<Integer> createComment(
@Valid
@RequestBody
CreateCommentRequest request) {
return commentService.createComment(request);
}
/**
* 更新评论
*
* @param commentId 评论ID
* @param request 更新评论请求
* @return 空响应
*/
@PatchMapping("/comments/{commentId}")
public ApiResponse<EmptyVO> updateComment(
@PathVariable("commentId") Integer commentId,
@Valid
@RequestBody
UpdateCommentRequest request) {
return commentService.updateComment(commentId, request);
}
/**
* 删除评论
*
* @param commentId 评论ID
* @return 空响应
*/
@DeleteMapping("/comments/{commentId}")
public ApiResponse<EmptyVO> deleteComment(
@PathVariable("commentId") Integer commentId) {
return commentService.deleteComment(commentId);
}
/**
* 获取评论列表
*
* @param params 查询参数
* @return 评论列表
*/
@GetMapping("/comments")
public ApiResponse<List<CommentVO>> getComments(
@Valid CommentQueryParams params) {
return commentService.getComments(params);
}
/**
* 点赞评论
*
* @param commentId 评论ID
* @return 空响应
*/
@PostMapping("/comments/{commentId}/like")
public ApiResponse<EmptyVO> likeComment(
@PathVariable("commentId") Integer commentId) {
return commentService.likeComment(commentId);
}
/**
* 取消点赞评论
*
* @param commentId 评论ID
* @return 空响应
*/
@DeleteMapping("/comments/{commentId}/like")
public ApiResponse<EmptyVO> unlikeComment(
@PathVariable("commentId") Integer commentId) {
return commentService.unlikeComment(commentId);
}
}

View File

@ -0,0 +1,36 @@
package com.kama.notes.controller;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.service.EmailService;
import com.kama.notes.utils.ApiResponseUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
@RestController
@RequestMapping("/api/email")
@RequiredArgsConstructor
public class EmailController {
private final EmailService emailService;
@GetMapping("/verify-code")
public ApiResponse<Void> sendVerifyCode(
@RequestParam @NotBlank @Email String email,
@RequestParam @NotBlank String type) {
// 检查是否可以发送
if (!emailService.canSendCode(email)) {
return ApiResponseUtil.error("发送太频繁,请稍后再试");
}
try {
emailService.sendVerifyCode(email, type);
return ApiResponseUtil.success(null);
} catch (Exception e) {
return ApiResponseUtil.error(e.getMessage());
}
}
}

View File

@ -0,0 +1,88 @@
package com.kama.notes.controller;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.base.PageVO;
import com.kama.notes.model.dto.message.MessageQueryParams;
import com.kama.notes.model.vo.message.MessageVO;
import com.kama.notes.model.vo.message.UnreadCountByType;
import com.kama.notes.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 消息控制器
*/
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
/**
* 获取消息列表
*
* @param params 查询参数
* @return 消息列表带分页信息
*/
@GetMapping
public ApiResponse<PageVO<MessageVO>> getMessages(@Validated MessageQueryParams params) {
return messageService.getMessages(params);
}
/**
* 标记消息为已读
*
* @param messageId 消息ID
* @return 空响应
*/
@PutMapping("/{messageId}/read")
public ApiResponse<EmptyVO> markAsRead(@PathVariable Integer messageId) {
return messageService.markAsRead(messageId);
}
/**
* 标记所有消息为已读
*
* @return 空响应
*/
@PutMapping("/read/all")
public ApiResponse<EmptyVO> markAllAsRead() {
return messageService.markAllAsRead();
}
/**
* 删除消息
*
* @param messageId 消息ID
* @return 空响应
*/
@DeleteMapping("/{messageId}")
public ApiResponse<EmptyVO> deleteMessage(@PathVariable Integer messageId) {
return messageService.deleteMessage(messageId);
}
/**
* 获取未读消息数量
*
* @return 未读消息数量
*/
@GetMapping("/unread/count")
public ApiResponse<Integer> getUnreadCount() {
return messageService.getUnreadCount();
}
/**
* 获取各类型未读消息数量
*
* @return 各类型未读消息数量
*/
@GetMapping("/unread/count/type")
public ApiResponse<List<UnreadCountByType>> getUnreadCountByType() {
return messageService.getUnreadCountByType();
}
}

View File

@ -0,0 +1,44 @@
package com.kama.notes.controller;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.entity.Note;
import com.kama.notes.model.entity.User;
import com.kama.notes.service.SearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Min;
import java.util.List;
@RestController
@RequestMapping("/api/search")
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
@GetMapping("/notes")
public ApiResponse<List<Note>> searchNotes(
@RequestParam String keyword,
@RequestParam(defaultValue = "1") @Min(1) Integer page,
@RequestParam(defaultValue = "20") @Min(1) Integer pageSize) {
return searchService.searchNotes(keyword, page, pageSize);
}
@GetMapping("/users")
public ApiResponse<List<User>> searchUsers(
@RequestParam String keyword,
@RequestParam(defaultValue = "1") @Min(1) Integer page,
@RequestParam(defaultValue = "20") @Min(1) Integer pageSize) {
return searchService.searchUsers(keyword, page, pageSize);
}
@GetMapping("/notes/tag")
public ApiResponse<List<Note>> searchNotesByTag(
@RequestParam String keyword,
@RequestParam String tag,
@RequestParam(defaultValue = "1") @Min(1) Integer page,
@RequestParam(defaultValue = "20") @Min(1) Integer pageSize) {
return searchService.searchNotesByTag(keyword, tag, page, pageSize);
}
}

View File

@ -0,0 +1,36 @@
package com.kama.notes.event;
import com.kama.notes.model.vo.message.MessageVO;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
/**
* 消息事件
* 用于系统内部的消息传递
*/
@Getter
public class MessageEvent extends ApplicationEvent {
private final MessageVO message;
private final Long receiverId;
private final String eventType;
public MessageEvent(Object source, MessageVO message, Long receiverId, String eventType) {
super(source);
this.message = message;
this.receiverId = receiverId;
this.eventType = eventType;
}
public static MessageEvent createCommentEvent(Object source, MessageVO message, Long receiverId) {
return new MessageEvent(source, message, receiverId, "COMMENT");
}
public static MessageEvent createLikeEvent(Object source, MessageVO message, Long receiverId) {
return new MessageEvent(source, message, receiverId, "LIKE");
}
public static MessageEvent createSystemEvent(Object source, MessageVO message, Long receiverId) {
return new MessageEvent(source, message, receiverId, "SYSTEM");
}
}

View File

@ -19,7 +19,7 @@ public class ParamExceptionHandler {
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
}
@ExceptionHandler(ConstraintViolationException.class)
@ -28,11 +28,11 @@ public class ParamExceptionHandler {
ex.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
);
return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "Validation Failed", errors);
}
@ExceptionHandler(Exception.class)
public ApiResponse<String> handleException(Exception ex) {
return new ApiResponse<>(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error", ex.getMessage());
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error", ex.getMessage());
}
}

View File

@ -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);
// 这里可以添加重试逻辑或者将失败的消息记录到特定的队列中
}
}
}

View File

@ -0,0 +1,44 @@
package com.kama.notes.mapper;
import com.kama.notes.model.entity.CommentLike;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Set;
/**
* 评论点赞Mapper接口
*/
@Mapper
public interface CommentLikeMapper {
/**
* 插入评论点赞
*
* @param commentLike 评论点赞实体
*/
void insert(CommentLike commentLike);
/**
* 删除评论点赞
*
* @param commentId 评论ID
* @param userId 用户ID
*/
void delete(@Param("commentId") Integer commentId, @Param("userId") Long userId);
/**
* 查询用户点赞的评论ID列表
*
* @param userId 用户ID
* @param commentIds 评论ID列表
* @return 用户点赞的评论ID集合
*/
Set<Integer> findUserLikedCommentIds(@Param("userId") Long userId,
@Param("commentIds") List<Integer> commentIds);
@Select("SELECT COUNT(*) > 0 FROM comment_like " +
"WHERE user_id = #{userId} AND comment_id = #{commentId}")
Boolean checkIsLiked(@Param("userId") Long userId, @Param("commentId") Integer commentId);
}

View File

@ -0,0 +1,91 @@
package com.kama.notes.mapper;
import com.kama.notes.model.dto.comment.CommentQueryParams;
import com.kama.notes.model.entity.Comment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 评论Mapper接口
*/
@Mapper
public interface CommentMapper {
/**
* 插入评论
*
* @param comment 评论实体
*/
void insert(Comment comment);
/**
* 更新评论
*
* @param comment 评论实体
*/
void update(Comment comment);
/**
* 删除评论
*
* @param commentId 评论ID
*/
void deleteById(Integer commentId);
/**
* 根据ID查询评论
*
* @param commentId 评论ID
* @return 评论实体
*/
Comment findById(Integer commentId);
/**
* 查询评论列表
*
* @param params 查询参数
* @param pageSize 每页大小
* @param offset 偏移量
* @return 评论列表
*/
List<Comment> findByQueryParam(@Param("params") CommentQueryParams params,
@Param("pageSize") Integer pageSize,
@Param("offset") Integer offset);
/**
* 统计评论数量
*
* @param params 查询参数
* @return 评论数量
*/
int countByQueryParam(@Param("params") CommentQueryParams params);
/**
* 增加评论点赞数
*
* @param commentId 评论ID
*/
void incrementLikeCount(Integer commentId);
/**
* 减少评论点赞数
*
* @param commentId 评论ID
*/
void decrementLikeCount(Integer commentId);
/**
* 增加评论回复数
*
* @param commentId 评论ID
*/
void incrementReplyCount(Integer commentId);
/**
* 减少评论回复数
*
* @param commentId 评论ID
*/
void decrementReplyCount(Integer commentId);
}

View File

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

View File

@ -0,0 +1,84 @@
package com.kama.notes.mapper;
import com.kama.notes.model.dto.message.MessageQueryParams;
import com.kama.notes.model.entity.Message;
import com.kama.notes.model.vo.message.UnreadCountByType;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 消息Mapper接口
*/
@Mapper
public interface MessageMapper {
/**
* 插入消息
*
* @param message 消息实体
* @return 影响行数
*/
int insert(Message message);
/**
* 根据参数查询消息列表
*
* @param userId 用户ID
* @param params 查询参数
* @param offset 偏移量
* @return 消息列表
*/
List<Message> selectByParams(@Param("userId") Long userId, @Param("params") MessageQueryParams params, @Param("offset") int offset);
/**
* 统计符合条件的消息数量
*
* @param userId 用户ID
* @param params 查询参数
* @return 消息数量
*/
int countByParams(@Param("userId") Long userId, @Param("params") MessageQueryParams params);
/**
* 标记消息为已读
*
* @param messageId 消息ID
* @param userId 用户ID
* @return 影响行数
*/
int markAsRead(@Param("messageId") Integer messageId, @Param("userId") Long userId);
/**
* 标记所有消息为已读
*
* @param userId 用户ID
* @return 影响行数
*/
int markAllAsRead(@Param("userId") Long userId);
/**
* 删除消息
*
* @param messageId 消息ID
* @param userId 用户ID
* @return 影响行数
*/
int deleteMessage(@Param("messageId") Integer messageId, @Param("userId") Long userId);
/**
* 统计未读消息数量
*
* @param userId 用户ID
* @return 未读消息数量
*/
int countUnread(@Param("userId") Long userId);
/**
* 按类型统计未读消息数量
*
* @param userId 用户ID
* @return 各类型未读消息数量
*/
List<UnreadCountByType> countUnreadByType(@Param("userId") Long userId);
}

View File

@ -0,0 +1,47 @@
package com.kama.notes.mapper;
import com.kama.notes.model.entity.NoteCollect;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 笔记收藏Mapper接口
*/
@Mapper
public interface NoteCollectMapper {
/**
* 插入收藏记录
*
* @param noteCollect 收藏记录
* @return 影响的行数
*/
int insert(NoteCollect noteCollect);
/**
* 删除收藏记录
*
* @param noteId 笔记ID
* @param userId 用户ID
* @return 影响的行数
*/
int delete(@Param("noteId") Integer noteId, @Param("userId") Long userId);
/**
* 查找收藏记录
*
* @param noteId 笔记ID
* @param userId 用户ID
* @return 收藏记录
*/
NoteCollect findByNoteIdAndUserId(@Param("noteId") Integer noteId, @Param("userId") Long userId);
/**
* 获取用户收藏的笔记ID列表
*
* @param userId 用户ID
* @return 笔记ID列表
*/
List<Integer> findNoteIdsByUserId(@Param("userId") Long userId);
}

View File

@ -0,0 +1,31 @@
package com.kama.notes.mapper;
import com.kama.notes.model.entity.NoteComment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface NoteCommentMapper {
/**
* 插入评论
*/
void insert(NoteComment comment);
/**
* 更新评论
*/
void update(NoteComment comment);
/**
* 根据ID查询评论
*/
NoteComment findById(@Param("id") Integer id);
/**
* 查询笔记的评论列表
*/
List<NoteComment> findByNoteId(@Param("noteId") Integer noteId);
}

View File

@ -164,4 +164,44 @@ public interface NoteMapper {
* @return 笔记总数
*/
int getTotalNoteCount();
/**
* 增加笔记评论数
*
* @param noteId 笔记ID
*/
void incrementCommentCount(@Param("noteId") Integer noteId);
/**
* 减少笔记评论数
*
* @param noteId 笔记ID
*/
void decrementCommentCount(@Param("noteId") Integer noteId);
/**
* 搜索笔记
*
* @param keyword 关键词
* @param limit 限制数量
* @param offset 偏移量
* @return 笔记列表
*/
List<Note> searchNotes(@Param("keyword") String keyword,
@Param("limit") int limit,
@Param("offset") int offset);
/**
* 根据标签搜索笔记
*
* @param keyword 关键词
* @param tag 标签
* @param limit 限制数量
* @param offset 偏移量
* @return 笔记列表
*/
List<Note> searchNotesByTag(@Param("keyword") String keyword,
@Param("tag") String tag,
@Param("limit") int limit,
@Param("offset") int offset);
}

View File

@ -116,4 +116,24 @@ public interface UserMapper {
* @return 总注册人数
*/
int getTotalRegisterCount();
/**
* 根据邮箱查找用户
*
* @param email 用户邮箱用于查询用户信息
* @return 返回用户对象如果未找到则返回null
*/
User findByEmail(@Param("email") String email);
/**
* 搜索用户
*
* @param keyword 关键词
* @param limit 限制数量
* @param offset 偏移量
* @return 用户列表
*/
List<User> searchUsers(@Param("keyword") String keyword,
@Param("limit") int limit,
@Param("offset") int offset);
}

View File

@ -1,14 +1,95 @@
package com.kama.notes.model.base;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API响应类
*
* @param <T> 响应数据类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private Integer code;
private String msg;
/**
* 响应码
*/
private int code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 构造函数
*
* @param code 响应码
* @param message 响应消息
* @param data 响应数据
*/
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
/**
* 创建成功响应
*
* @param data 响应数据
* @param <T> 响应数据类型
* @return API响应
*/
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
/**
* 创建成功响应无数据
*
* @return API响应
*/
public static ApiResponse<EmptyVO> success() {
return success(new EmptyVO());
}
/**
* 创建错误响应
*
* @param code 错误码
* @param message 错误消息
* @return API响应
*/
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
/**
* 创建带数据的错误响应
*
* @param code 错误码
* @param message 错误消息
* @param data 错误数据
* @return API响应
*/
public static <T> ApiResponse<T> error(int code, String message, T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
response.setData(data);
return response;
}
}

View File

@ -1,6 +1,7 @@
package com.kama.notes.model.base;
// 占位
// 用于操作成功只需要返回状态码无任何额外数据时使用
/**
* 空响应类用于表示无数据返回的情况
*/
public class EmptyVO {
}

View File

@ -0,0 +1,59 @@
package com.kama.notes.model.base;
import lombok.Data;
import java.util.List;
/**
* 分页数据模型
*
* @param <T> 数据类型
*/
@Data
public class PageVO<T> {
/**
* 当前页码
*/
private Integer page;
/**
* 每页大小
*/
private Integer pageSize;
/**
* 总记录数
*/
private Integer total;
/**
* 总页数
*/
private Integer totalPages;
/**
* 数据列表
*/
private List<T> list;
public PageVO(Integer page, Integer pageSize, Integer total, List<T> list) {
this.page = page;
this.pageSize = pageSize;
this.total = total;
this.list = list;
}
/**
* 创建分页结果
*
* @param list 数据列表
* @param page 当前页码
* @param pageSize 每页大小
* @param total 总记录数
* @return 分页结果
*/
public static <T> PageVO<T> of(List<T> list, Integer page, Integer pageSize, Integer total) {
PageVO<T> pageVO = new PageVO<>(page, pageSize, total, list);
pageVO.setTotalPages((total + pageSize - 1) / pageSize);
return pageVO;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,6 +64,11 @@ public class User {
*/
private String email;
/**
* 邮箱是否验证
*/
private Boolean emailVerified;
/**
* 用户学校
*/

View File

@ -0,0 +1,73 @@
package com.kama.notes.model.vo.comment;
import com.kama.notes.model.vo.user.UserActionVO;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 评论视图对象
*/
@Data
public class CommentVO {
/**
* 评论ID
*/
private Integer commentId;
/**
* 笔记ID
*/
private Integer noteId;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 回复数
*/
private Integer replyCount;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
/**
* 作者信息
*/
private SimpleAuthorVO author;
/**
* 用户操作信息
*/
private UserActionVO userActions;
/**
* 回复列表
*/
private List<CommentVO> replies;
/**
* 简单作者信息
*/
@Data
public static class SimpleAuthorVO {
private Long userId;
private String username;
private String avatarUrl;
}
}

View File

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

View File

@ -0,0 +1,19 @@
package com.kama.notes.model.vo.message;
import lombok.Data;
/**
* 各类型未读消息数量
*/
@Data
public class UnreadCountByType {
/**
* 消息类型
*/
private String type;
/**
* 未读数量
*/
private Integer count;
}

View File

@ -0,0 +1,8 @@
package com.kama.notes.model.vo.user;
import lombok.Data;
@Data
public class UserActionVO {
private Boolean isLiked;
}

View File

@ -0,0 +1,66 @@
package com.kama.notes.service;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.dto.comment.CommentQueryParams;
import com.kama.notes.model.dto.comment.CreateCommentRequest;
import com.kama.notes.model.dto.comment.UpdateCommentRequest;
import com.kama.notes.model.vo.comment.CommentVO;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 评论服务接口
*/
@Transactional
public interface CommentService {
/**
* 创建评论
*
* @param request 创建评论请求
* @return 创建的评论ID
*/
ApiResponse<Integer> createComment(CreateCommentRequest request);
/**
* 更新评论
*
* @param commentId 评论ID
* @param request 更新评论请求
* @return 空响应
*/
ApiResponse<EmptyVO> updateComment(Integer commentId, UpdateCommentRequest request);
/**
* 删除评论
*
* @param commentId 评论ID
* @return 空响应
*/
ApiResponse<EmptyVO> deleteComment(Integer commentId);
/**
* 获取评论列表
*
* @param params 查询参数
* @return 评论列表
*/
ApiResponse<List<CommentVO>> getComments(CommentQueryParams params);
/**
* 点赞评论
*
* @param commentId 评论ID
* @return 空响应
*/
ApiResponse<EmptyVO> likeComment(Integer commentId);
/**
* 取消点赞评论
*
* @param commentId 评论ID
* @return 空响应
*/
ApiResponse<EmptyVO> unlikeComment(Integer commentId);
}

View File

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

View File

@ -0,0 +1,74 @@
package com.kama.notes.service;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.base.PageVO;
import com.kama.notes.model.dto.message.MessageQueryParams;
import com.kama.notes.model.vo.message.MessageVO;
import com.kama.notes.model.vo.message.UnreadCountByType;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 消息服务接口
*/
@Transactional
public interface MessageService {
/**
* 创建消息
*
* @param receiverId 接收者ID
* @param senderId 发送者ID
* @param type 消息类型
* @param targetId 目标ID
* @param content 消息内容
* @return 创建的消息ID
*/
ApiResponse<Integer> createMessage(Long receiverId, Long senderId, String type, Integer targetId, String content);
/**
* 获取消息列表
*
* @param params 查询参数
* @return 消息列表带分页信息
*/
ApiResponse<PageVO<MessageVO>> getMessages(MessageQueryParams params);
/**
* 标记消息为已读
*
* @param messageId 消息ID
* @return 空响应
*/
ApiResponse<EmptyVO> markAsRead(Integer messageId);
/**
* 标记所有消息为已读
*
* @return 空响应
*/
ApiResponse<EmptyVO> markAllAsRead();
/**
* 删除消息
*
* @param messageId 消息ID
* @return 空响应
*/
ApiResponse<EmptyVO> deleteMessage(Integer messageId);
/**
* 获取未读消息数量
*
* @return 未读消息数量
*/
ApiResponse<Integer> getUnreadCount();
/**
* 获取各类型未读消息数量
*
* @return 各类型未读消息数量
*/
ApiResponse<List<UnreadCountByType>> getUnreadCountByType();
}

View File

@ -0,0 +1,25 @@
package com.kama.notes.service;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.entity.NoteComment;
import java.util.List;
public interface NoteCommentService {
/**
* 创建评论
*/
ApiResponse<EmptyVO> createComment(Integer noteId, String content);
/**
* 删除评论
*/
ApiResponse<EmptyVO> deleteComment(Integer commentId);
/**
* 获取笔记的评论列表
*/
ApiResponse<List<NoteComment>> getComments(Integer noteId);
}

View File

@ -0,0 +1,40 @@
package com.kama.notes.service;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.entity.Note;
import com.kama.notes.model.entity.User;
import java.util.List;
public interface SearchService {
/**
* 搜索笔记
*
* @param keyword 关键词
* @param page 页码
* @param pageSize 每页大小
* @return 笔记列表
*/
ApiResponse<List<Note>> searchNotes(String keyword, int page, int pageSize);
/**
* 搜索用户
*
* @param keyword 关键词
* @param page 页码
* @param pageSize 每页大小
* @return 用户列表
*/
ApiResponse<List<User>> searchUsers(String keyword, int page, int pageSize);
/**
* 搜索笔记带标签
*
* @param keyword 关键词
* @param tag 标签
* @param page 页码
* @param pageSize 每页大小
* @return 笔记列表
*/
ApiResponse<List<Note>> searchNotesByTag(String keyword, String tag, int page, int pageSize);
}

View File

@ -0,0 +1,257 @@
package com.kama.notes.service.impl;
import com.kama.notes.annotation.NeedLogin;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.mapper.CommentMapper;
import com.kama.notes.mapper.NoteMapper;
import com.kama.notes.mapper.UserMapper;
import com.kama.notes.mapper.CommentLikeMapper;
import com.kama.notes.model.entity.Comment;
import com.kama.notes.model.entity.Note;
import com.kama.notes.model.entity.User;
import com.kama.notes.model.dto.comment.CommentQueryParams;
import com.kama.notes.model.dto.comment.CreateCommentRequest;
import com.kama.notes.model.dto.comment.UpdateCommentRequest;
import com.kama.notes.model.vo.comment.CommentVO;
import com.kama.notes.model.vo.user.UserVO;
import com.kama.notes.model.vo.user.UserActionVO;
import com.kama.notes.scope.RequestScopeData;
import com.kama.notes.service.CommentService;
import com.kama.notes.service.MessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 评论服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommentServiceImpl implements CommentService {
private final CommentMapper commentMapper;
private final NoteMapper noteMapper;
private final UserMapper userMapper;
private final CommentLikeMapper commentLikeMapper;
private final MessageService messageService;
private final RequestScopeData requestScopeData;
@Override
@NeedLogin
@Transactional
public ApiResponse<Integer> createComment(CreateCommentRequest request) {
log.info("开始创建评论: request={}", request);
try {
Long userId = requestScopeData.getUserId();
// 获取笔记信息
Note note = noteMapper.findById(request.getNoteId());
if (note == null) {
log.error("笔记不存在: noteId={}", request.getNoteId());
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "笔记不存在");
}
// 创建评论
Comment comment = new Comment();
comment.setNoteId(request.getNoteId());
comment.setContent(request.getContent());
comment.setAuthorId(userId);
comment.setParentId(request.getParentId());
comment.setLikeCount(0);
comment.setReplyCount(0);
comment.setCreatedAt(LocalDateTime.now());
comment.setUpdatedAt(LocalDateTime.now());
commentMapper.insert(comment);
log.info("评论创建结果: commentId={}", comment.getCommentId());
// 增加笔记评论数
noteMapper.incrementCommentCount(request.getNoteId());
// 如果是回复评论增加父评论的回复数
if (request.getParentId() != null) {
commentMapper.incrementReplyCount(request.getParentId());
}
// 创建消息通知
ApiResponse<Integer> messageResponse = messageService.createMessage(
note.getAuthorId(), // 接收者是笔记作者
userId, // 发送者是评论作者
"COMMENT", // 消息类型是评论
comment.getCommentId(), // 目标ID是评论ID
"评论了你的笔记" // 消息内容
);
log.info("消息通知已创建: {}", messageResponse);
return ApiResponse.success(comment.getCommentId());
} catch (Exception e) {
log.error("创建评论失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "创建评论失败: " + e.getMessage());
}
}
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> updateComment(Integer commentId, UpdateCommentRequest request) {
Long userId = requestScopeData.getUserId();
// 查询评论
Comment comment = commentMapper.findById(commentId);
if (comment == null) {
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
}
// 检查权限
if (!comment.getAuthorId().equals(userId)) {
return ApiResponse.error(HttpStatus.FORBIDDEN.value(), "无权修改该评论");
}
try {
// 更新评论
comment.setContent(request.getContent());
comment.setUpdatedAt(LocalDateTime.now());
commentMapper.update(comment);
return ApiResponse.success(new EmptyVO());
} catch (Exception e) {
log.error("更新评论失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "更新评论失败");
}
}
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> deleteComment(Integer commentId) {
Long userId = requestScopeData.getUserId();
// 查询评论
Comment comment = commentMapper.findById(commentId);
if (comment == null) {
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
}
// 检查权限
if (!comment.getAuthorId().equals(userId)) {
return ApiResponse.error(HttpStatus.FORBIDDEN.value(), "无权删除该评论");
}
try {
// 删除评论
commentMapper.deleteById(commentId);
return ApiResponse.success(new EmptyVO());
} catch (Exception e) {
log.error("删除评论失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "删除评论失败");
}
}
@Override
public ApiResponse<List<CommentVO>> getComments(CommentQueryParams params) {
try {
List<Comment> comments = commentMapper.findByQueryParam(params, params.getPageSize(), (params.getPage() - 1) * params.getPageSize());
if (comments == null || comments.isEmpty()) {
return ApiResponse.success(List.of());
}
List<CommentVO> commentVOs = comments.stream()
.map(comment -> {
CommentVO vo = new CommentVO();
vo.setCommentId(comment.getCommentId());
vo.setContent(comment.getContent());
vo.setLikeCount(comment.getLikeCount());
vo.setReplyCount(comment.getReplyCount());
vo.setCreatedAt(comment.getCreatedAt());
vo.setUpdatedAt(comment.getUpdatedAt());
// 设置作者信息
User author = userMapper.findById(comment.getAuthorId());
if (author != null) {
CommentVO.SimpleAuthorVO authorVO = new CommentVO.SimpleAuthorVO();
authorVO.setUserId(author.getUserId());
authorVO.setUsername(author.getUsername());
authorVO.setAvatarUrl(author.getAvatarUrl());
vo.setAuthor(authorVO);
}
// 设置用户操作状态
Long currentUserId = requestScopeData.getUserId();
if (currentUserId != null) {
UserActionVO userActions = new UserActionVO();
userActions.setIsLiked(commentLikeMapper.checkIsLiked(currentUserId, comment.getCommentId()));
vo.setUserActions(userActions);
}
return vo;
})
.collect(Collectors.toList());
return ApiResponse.success(commentVOs);
} catch (Exception e) {
log.error("获取评论列表失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "获取评论列表失败");
}
}
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> likeComment(Integer commentId) {
Long userId = requestScopeData.getUserId();
// 查询评论
Comment comment = commentMapper.findById(commentId);
if (comment == null) {
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
}
try {
// 增加评论点赞数
commentMapper.incrementLikeCount(commentId);
// 发送点赞消息通知
messageService.createMessage(
comment.getAuthorId(), // 接收者是评论作者
userId, // 发送者是点赞用户
"LIKE", // 消息类型是点赞
commentId, // 目标ID是评论ID
"点赞了你的评论" // 消息内容
);
return ApiResponse.success(new EmptyVO());
} catch (Exception e) {
log.error("点赞评论失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "点赞评论失败");
}
}
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> unlikeComment(Integer commentId) {
Long userId = requestScopeData.getUserId();
// 查询评论
Comment comment = commentMapper.findById(commentId);
if (comment == null) {
return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "评论不存在");
}
try {
// 减少评论点赞数
commentMapper.decrementLikeCount(commentId);
return ApiResponse.success(new EmptyVO());
} catch (Exception e) {
log.error("取消点赞评论失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "取消点赞评论失败");
}
}
}

View File

@ -0,0 +1,117 @@
package com.kama.notes.service.impl;
import com.kama.notes.mapper.EmailVerifyCodeMapper;
import com.kama.notes.model.entity.EmailVerifyCode;
import com.kama.notes.service.EmailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class EmailServiceImpl implements EmailService {
@Autowired
private JavaMailSender mailSender;
@Autowired
private EmailVerifyCodeMapper emailVerifyCodeMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Value("${spring.mail.username}")
private String fromEmail;
@Value("${mail.verify-code.expire-minutes}")
private int expireMinutes;
@Value("${mail.verify-code.resend-interval}")
private int resendInterval;
@Override
public String sendVerifyCode(String email, String type) {
// 检查发送频率
if (!canSendCode(email)) {
throw new RuntimeException("发送太频繁,请稍后再试");
}
// 生成6位随机验证码
String verifyCode = generateVerifyCode();
try {
// 发送邮件
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(email);
message.setSubject("卡码笔记 - 验证码");
message.setText("您的验证码是:" + verifyCode + ",有效期" + expireMinutes + "分钟,请勿泄露给他人。");
mailSender.send(message);
// 保存验证码记录
EmailVerifyCode code = new EmailVerifyCode();
code.setEmail(email);
code.setCode(verifyCode);
code.setType(type);
code.setExpiredAt(LocalDateTime.now().plusMinutes(expireMinutes));
emailVerifyCodeMapper.insert(code);
// 记录发送时间到Redis
String redisKey = "email:verify:limit:" + email;
redisTemplate.opsForValue().set(redisKey, "1", resendInterval, TimeUnit.SECONDS);
return verifyCode;
} catch (Exception e) {
log.error("发送验证码邮件失败", e);
throw new RuntimeException("发送验证码失败,请稍后重试");
}
}
@Override
public boolean verifyCode(String email, String code, String type) {
// 查询最新的未使用的验证码
EmailVerifyCode verifyCode = emailVerifyCodeMapper.findLatestValidCode(email, type);
if (verifyCode == null) {
return false;
}
// 检查是否过期
if (verifyCode.getExpiredAt().isBefore(LocalDateTime.now())) {
return false;
}
// 检查验证码是否正确
if (!verifyCode.getCode().equals(code)) {
return false;
}
// 标记验证码为已使用
emailVerifyCodeMapper.markAsUsed(verifyCode.getId());
return true;
}
@Override
public boolean canSendCode(String email) {
String redisKey = "email:verify:limit:" + email;
return redisTemplate.opsForValue().get(redisKey) == null;
}
private String generateVerifyCode() {
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int i = 0; i < 6; i++) {
code.append(random.nextInt(10));
}
return code.toString();
}
}

View File

@ -0,0 +1,200 @@
package com.kama.notes.service.impl;
import com.kama.notes.mapper.MessageMapper;
import com.kama.notes.mapper.UserMapper;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.base.PageVO;
import com.kama.notes.model.dto.message.MessageQueryParams;
import com.kama.notes.model.entity.Message;
import com.kama.notes.model.entity.User;
import com.kama.notes.model.vo.message.MessageVO;
import com.kama.notes.model.vo.message.UnreadCountByType;
import com.kama.notes.service.MessageService;
import com.kama.notes.utils.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 消息服务实现类
*/
@Service
@RequiredArgsConstructor
public class MessageServiceImpl implements MessageService {
private final MessageMapper messageMapper;
private final UserMapper userMapper;
private final RedisTemplate<String, Object> redisTemplate;
private static final Logger log = LoggerFactory.getLogger(MessageServiceImpl.class);
// Redis键前缀
private static final String UNREAD_COUNT_KEY = "message:unread:count:";
private static final String UNREAD_COUNT_BY_TYPE_KEY = "message:unread:type:";
private static final String MESSAGE_CACHE_KEY = "message:detail:";
@Override
public ApiResponse<Integer> createMessage(Long receiverId, Long senderId, String type, Integer targetId, String content) {
log.info("开始创建消息通知: receiverId={}, senderId={}, type={}, targetId={}, content={}",
receiverId, senderId, type, targetId, content);
try {
Message message = new Message();
message.setReceiverId(receiverId);
message.setSenderId(senderId);
message.setType(type);
message.setTargetId(targetId);
message.setContent(content);
message.setIsRead(false);
message.setCreatedAt(LocalDateTime.now());
message.setUpdatedAt(LocalDateTime.now());
int rows = messageMapper.insert(message);
log.info("消息通知创建结果: messageId={}, 影响行数={}", message.getMessageId(), rows);
// 清除相关缓存
clearMessageCache(receiverId);
return ApiResponse.success(message.getMessageId());
} catch (Exception e) {
log.error("创建消息通知失败", e);
return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "创建消息通知失败: " + e.getMessage());
}
}
@Override
public ApiResponse<PageVO<MessageVO>> getMessages(MessageQueryParams params) {
Long currentUserId = SecurityUtils.getCurrentUserId();
// 获取总记录数
int total = messageMapper.countByParams(currentUserId, params);
// 计算偏移量
int offset = (params.getPage() - 1) * params.getPageSize();
// 获取当前页数据
List<Message> messages = messageMapper.selectByParams(currentUserId, params, offset);
// 转换为VO对象
List<MessageVO> messageVOs = messages.stream().map(message -> {
MessageVO vo = new MessageVO();
vo.setMessageId(message.getMessageId());
vo.setType(message.getType());
vo.setTargetId(message.getTargetId());
vo.setContent(message.getContent());
vo.setIsRead(message.getIsRead());
vo.setCreatedAt(message.getCreatedAt());
// 从缓存获取发送者信息
User sender = getUserFromCache(message.getSenderId());
if (sender != null) {
MessageVO.SimpleUserVO senderVO = new MessageVO.SimpleUserVO();
senderVO.setUserId(sender.getUserId());
senderVO.setUsername(sender.getUsername());
senderVO.setAvatarUrl(sender.getAvatarUrl());
vo.setSender(senderVO);
}
return vo;
}).collect(Collectors.toList());
// 创建分页结果
PageVO<MessageVO> pageVO = PageVO.of(messageVOs, params.getPage(), params.getPageSize(), total);
return ApiResponse.success(pageVO);
}
@Override
public ApiResponse<EmptyVO> markAsRead(Integer messageId) {
Long currentUserId = SecurityUtils.getCurrentUserId();
messageMapper.markAsRead(messageId, currentUserId);
// 清除相关缓存
clearMessageCache(currentUserId);
return ApiResponse.success();
}
@Override
public ApiResponse<EmptyVO> markAllAsRead() {
Long currentUserId = SecurityUtils.getCurrentUserId();
messageMapper.markAllAsRead(currentUserId);
// 清除相关缓存
clearMessageCache(currentUserId);
return ApiResponse.success();
}
@Override
public ApiResponse<EmptyVO> deleteMessage(Integer messageId) {
Long currentUserId = SecurityUtils.getCurrentUserId();
messageMapper.deleteMessage(messageId, currentUserId);
return ApiResponse.success();
}
@Override
public ApiResponse<Integer> getUnreadCount() {
Long currentUserId = SecurityUtils.getCurrentUserId();
String cacheKey = UNREAD_COUNT_KEY + currentUserId;
// 尝试从缓存获取
Integer count = (Integer) redisTemplate.opsForValue().get(cacheKey);
if (count == null) {
// 缓存未命中从数据库查询
count = messageMapper.countUnread(currentUserId);
// 将结果存入缓存设置5分钟过期
redisTemplate.opsForValue().set(cacheKey, count, 5, TimeUnit.MINUTES);
}
return ApiResponse.success(count);
}
@Override
public ApiResponse<List<UnreadCountByType>> getUnreadCountByType() {
Long currentUserId = SecurityUtils.getCurrentUserId();
String cacheKey = UNREAD_COUNT_BY_TYPE_KEY + currentUserId;
// 尝试从缓存获取
@SuppressWarnings("unchecked")
List<UnreadCountByType> result = (List<UnreadCountByType>) redisTemplate.opsForValue().get(cacheKey);
if (result == null) {
// 缓存未命中从数据库查询
result = messageMapper.countUnreadByType(currentUserId);
// 将结果存入缓存设置5分钟过期
redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
}
return ApiResponse.success(result);
}
/**
* 从缓存获取用户信息
*/
private User getUserFromCache(Long userId) {
String cacheKey = "user:detail:" + userId;
User user = (User) redisTemplate.opsForValue().get(cacheKey);
if (user == null) {
user = userMapper.findById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
}
}
return user;
}
/**
* 清除用户相关的消息缓存
*/
private void clearMessageCache(Long userId) {
redisTemplate.delete(UNREAD_COUNT_KEY + userId);
redisTemplate.delete(UNREAD_COUNT_BY_TYPE_KEY + userId);
}
}

View File

@ -0,0 +1,99 @@
package com.kama.notes.service.impl;
import com.kama.notes.annotation.NeedLogin;
import com.kama.notes.mapper.NoteCommentMapper;
import com.kama.notes.mapper.NoteMapper;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.entity.Note;
import com.kama.notes.model.entity.NoteComment;
import com.kama.notes.scope.RequestScopeData;
import com.kama.notes.service.MessageService;
import com.kama.notes.service.NoteCommentService;
import com.kama.notes.utils.ApiResponseUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
@Service
@RequiredArgsConstructor
public class NoteCommentServiceImpl implements NoteCommentService {
private final NoteCommentMapper noteCommentMapper;
private final NoteMapper noteMapper;
private final RequestScopeData requestScopeData;
private final MessageService messageService;
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> createComment(Integer noteId, String content) {
Long userId = requestScopeData.getUserId();
// 查询笔记
Note note = noteMapper.findById(noteId);
if (note == null) {
return ApiResponseUtil.error("笔记不存在");
}
try {
// 创建评论
NoteComment comment = new NoteComment();
comment.setNoteId(noteId);
comment.setUserId(userId);
comment.setContent(content);
comment.setCreatedAt(new Date());
comment.setUpdatedAt(new Date());
comment.setIsDeleted(false);
noteCommentMapper.insert(comment);
// 发送评论通知
messageService.createMessage(
note.getAuthorId(),
userId,
"COMMENT",
noteId,
"评论了你的笔记"
);
return ApiResponseUtil.success("评论成功");
} catch (Exception e) {
return ApiResponseUtil.error("评论失败");
}
}
@Override
@NeedLogin
public ApiResponse<EmptyVO> deleteComment(Integer commentId) {
Long userId = requestScopeData.getUserId();
// 查询评论
NoteComment comment = noteCommentMapper.findById(commentId);
if (comment == null || !comment.getUserId().equals(userId)) {
return ApiResponseUtil.error("无权删除该评论");
}
try {
// 软删除评论
comment.setIsDeleted(true);
comment.setUpdatedAt(new Date());
noteCommentMapper.update(comment);
return ApiResponseUtil.success("删除成功");
} catch (Exception e) {
return ApiResponseUtil.error("删除失败");
}
}
@Override
public ApiResponse<List<NoteComment>> getComments(Integer noteId) {
try {
List<NoteComment> comments = noteCommentMapper.findByNoteId(noteId);
return ApiResponseUtil.success("获取评论成功", comments);
} catch (Exception e) {
return ApiResponseUtil.error("获取评论失败");
}
}
}

View File

@ -5,72 +5,97 @@ import com.kama.notes.mapper.NoteLikeMapper;
import com.kama.notes.mapper.NoteMapper;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.base.EmptyVO;
import com.kama.notes.model.entity.Note;
import com.kama.notes.model.entity.NoteLike;
import com.kama.notes.scope.RequestScopeData;
import com.kama.notes.service.MessageService;
import com.kama.notes.service.NoteLikeService;
import com.kama.notes.utils.ApiResponseUtil;
import org.springframework.beans.factory.annotation.Autowired;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class NoteLikeServiceImpl implements NoteLikeService {
@Autowired
private NoteLikeMapper noteLikeMapper;
@Autowired
private NoteMapper noteMapper;
@Autowired
private RequestScopeData requestScopeData;
@Override
public Set<Integer> findUserLikedNoteIds(Long userId, List<Integer> noteIds) {
List<Integer> userLikedNoteIds = noteLikeMapper.findUserLikedNoteIds(userId, noteIds);
return new HashSet<>(userLikedNoteIds);
}
private final NoteLikeMapper noteLikeMapper;
private final NoteMapper noteMapper;
private final RequestScopeData requestScopeData;
private final MessageService messageService;
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> likeNote(Integer noteId) {
Long userId = requestScopeData.getUserId();
NoteLike noteLike = noteLikeMapper.findByUserIdAndNoteId(userId, noteId);
if (noteLike != null) {
return ApiResponseUtil.success("已经点赞过了");
// 查询笔记
Note note = noteMapper.findById(noteId);
if (note == null) {
return ApiResponseUtil.error("笔记不存在");
}
noteLike = new NoteLike();
noteLike.setUserId(userId);
noteLike.setNoteId(noteId);
noteLikeMapper.insert(noteLike);
try {
// 创建点赞记录
NoteLike noteLike = new NoteLike();
noteLike.setNoteId(noteId);
noteLike.setUserId(userId);
noteLike.setCreatedAt(new Date());
noteLikeMapper.insert(noteLike);
// 更新笔记点赞数
noteMapper.likeNote(noteId);
// 增加笔记点赞数
noteMapper.likeNote(noteId);
return ApiResponseUtil.success("点赞成功");
// 发送点赞通知
messageService.createMessage(
note.getAuthorId(),
userId,
"LIKE",
noteId,
"点赞了你的笔记"
);
return ApiResponseUtil.success("点赞成功");
} catch (Exception e) {
return ApiResponseUtil.error("点赞失败");
}
}
@Override
@NeedLogin
@Transactional
public ApiResponse<EmptyVO> unlikeNote(Integer noteId) {
Long userId = requestScopeData.getUserId();
NoteLike noteLike = noteLikeMapper.findByUserIdAndNoteId(userId, noteId);
if (noteLike == null) {
return ApiResponseUtil.success("已经取消点赞过了");
// 查询笔记
Note note = noteMapper.findById(noteId);
if (note == null) {
return ApiResponseUtil.error("笔记不存在");
}
noteLikeMapper.delete(noteLike);
try {
// 删除点赞记录
NoteLike noteLike = noteLikeMapper.findByUserIdAndNoteId(userId, noteId);
if (noteLike != null) {
noteLikeMapper.delete(noteLike);
// 减少笔记点赞数
noteMapper.unlikeNote(noteId);
}
return ApiResponseUtil.success("取消点赞成功");
} catch (Exception e) {
return ApiResponseUtil.error("取消点赞失败");
}
}
// 更新笔记点赞数
noteMapper.unlikeNote(noteId);
return ApiResponseUtil.success("取消点赞成功");
@Override
public Set<Integer> findUserLikedNoteIds(Long userId, List<Integer> noteIds) {
List<Integer> likedIds = noteLikeMapper.findUserLikedNoteIds(userId, noteIds);
return new HashSet<>(likedIds);
}
}

View File

@ -0,0 +1,123 @@
package com.kama.notes.service.impl;
import com.kama.notes.mapper.NoteMapper;
import com.kama.notes.mapper.UserMapper;
import com.kama.notes.model.base.ApiResponse;
import com.kama.notes.model.entity.Note;
import com.kama.notes.model.entity.User;
import com.kama.notes.service.SearchService;
import com.kama.notes.utils.ApiResponseUtil;
import com.kama.notes.utils.SearchUtils;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Log4j2
@Service
public class SearchServiceImpl implements SearchService {
@Autowired
private NoteMapper noteMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String NOTE_SEARCH_CACHE_KEY = "search:note:%s:%d:%d";
private static final String USER_SEARCH_CACHE_KEY = "search:user:%s:%d:%d";
private static final String NOTE_TAG_SEARCH_CACHE_KEY = "search:note:tag:%s:%s:%d:%d";
private static final long CACHE_EXPIRE_TIME = 30; // 分钟
@Override
public ApiResponse<List<Note>> searchNotes(String keyword, int page, int pageSize) {
try {
String cacheKey = String.format(NOTE_SEARCH_CACHE_KEY, keyword, page, pageSize);
// 尝试从缓存获取
List<Note> cachedResult = (List<Note>) redisTemplate.opsForValue().get(cacheKey);
if (cachedResult != null) {
return ApiResponseUtil.success("搜索成功", cachedResult);
}
// 处理关键词
keyword = SearchUtils.preprocessKeyword(keyword);
// 计算偏移量
int offset = (page - 1) * pageSize;
// 执行搜索
List<Note> notes = noteMapper.searchNotes(keyword, pageSize, offset);
// 存入缓存
redisTemplate.opsForValue().set(cacheKey, notes, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
return ApiResponseUtil.success("搜索成功", notes);
} catch (Exception e) {
log.error("搜索笔记失败", e);
return ApiResponseUtil.error("搜索失败");
}
}
@Override
public ApiResponse<List<User>> searchUsers(String keyword, int page, int pageSize) {
try {
String cacheKey = String.format(USER_SEARCH_CACHE_KEY, keyword, page, pageSize);
// 尝试从缓存获取
List<User> cachedResult = (List<User>) redisTemplate.opsForValue().get(cacheKey);
if (cachedResult != null) {
return ApiResponseUtil.success("搜索成功", cachedResult);
}
// 计算偏移量
int offset = (page - 1) * pageSize;
// 执行搜索
List<User> users = userMapper.searchUsers(keyword, pageSize, offset);
// 存入缓存
redisTemplate.opsForValue().set(cacheKey, users, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
return ApiResponseUtil.success("搜索成功", users);
} catch (Exception e) {
log.error("搜索用户失败", e);
return ApiResponseUtil.error("搜索失败");
}
}
@Override
public ApiResponse<List<Note>> searchNotesByTag(String keyword, String tag, int page, int pageSize) {
try {
String cacheKey = String.format(NOTE_TAG_SEARCH_CACHE_KEY, keyword, tag, page, pageSize);
// 尝试从缓存获取
List<Note> cachedResult = (List<Note>) redisTemplate.opsForValue().get(cacheKey);
if (cachedResult != null) {
return ApiResponseUtil.success("搜索成功", cachedResult);
}
// 处理关键词
keyword = SearchUtils.preprocessKeyword(keyword);
// 计算偏移量
int offset = (page - 1) * pageSize;
// 执行搜索
List<Note> notes = noteMapper.searchNotesByTag(keyword, tag, pageSize, offset);
// 存入缓存
redisTemplate.opsForValue().set(cacheKey, notes, CACHE_EXPIRE_TIME, TimeUnit.MINUTES);
return ApiResponseUtil.success("搜索成功", notes);
} catch (Exception e) {
log.error("搜索笔记失败", e);
return ApiResponseUtil.error("搜索失败");
}
}
}

View File

@ -14,6 +14,7 @@ import com.kama.notes.model.vo.user.RegisterVO;
import com.kama.notes.model.vo.user.LoginUserVO;
import com.kama.notes.model.vo.user.UserVO;
import com.kama.notes.scope.RequestScopeData;
import com.kama.notes.service.EmailService;
import com.kama.notes.service.FileService;
import com.kama.notes.service.UserService;
import com.kama.notes.utils.ApiResponseUtil;
@ -50,22 +51,43 @@ public class UserServiceImpl implements UserService {
@Autowired
private RequestScopeData requestScopeData;
@Autowired
private EmailService emailService;
@Override
@Transactional(rollbackFor = Exception.class)
public ApiResponse<RegisterVO> register(RegisterRequest request) {
// 检查账号是否已存在
// TODO: 可以优化
User existingUser = userMapper.findByAccount(request.getAccount());
if (existingUser != null) {
return ApiResponseUtil.error("账号重复");
}
// 如果提供了邮箱则进行邮箱相关验证
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
// 检查邮箱是否已存在
existingUser = userMapper.findByEmail(request.getEmail());
if (existingUser != null) {
return ApiResponseUtil.error("邮箱已被使用");
}
// 如果提供了邮箱但没有提供验证码
if (request.getVerifyCode() == null || request.getVerifyCode().isEmpty()) {
return ApiResponseUtil.error("请提供邮箱验证码");
}
// 验证邮箱验证码
if (!emailService.verifyCode(request.getEmail(), request.getVerifyCode(), "REGISTER")) {
return ApiResponseUtil.error("验证码无效或已过期");
}
}
// 创建新用户
User user = new User();
BeanUtils.copyProperties(request, user);
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setEmailVerified(request.getEmail() != null && !request.getEmail().isEmpty()); // 只有提供邮箱时才设置验证状态
try {
// 保存用户
@ -87,12 +109,20 @@ public class UserServiceImpl implements UserService {
@Override
public ApiResponse<LoginUserVO> login(LoginRequest request) {
User user = null;
User user = userMapper.findByAccount(request.getAccount());
// 根据账号或邮箱查找用户
if (request.getAccount() != null && !request.getAccount().isEmpty()) {
user = userMapper.findByAccount(request.getAccount());
} else if (request.getEmail() != null && !request.getEmail().isEmpty()) {
user = userMapper.findByEmail(request.getEmail());
} else {
return ApiResponseUtil.error("请提供账号或邮箱");
}
// 验证账号以及密码
if (user == null) {
return ApiResponseUtil.error("账号不存在");
return ApiResponseUtil.error("用户不存在");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {

View File

@ -14,18 +14,18 @@ public class ApiResponseUtil {
* @return ApiResponse
*/
public static <T> ApiResponse<T> success(String message) {
return new ApiResponse<>(HttpStatus.OK.value(), message, null);
return ApiResponse.success(null);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(HttpStatus.OK.value(), message, data);
return ApiResponse.success(data);
}
/**
* 构建参数错误的响应
*/
public static <T> ApiResponse<T> error(String msg) {
return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), msg, null);
return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), msg);
}
/**

View File

@ -0,0 +1,47 @@
package com.kama.notes.utils;
import com.huaban.analysis.jieba.JiebaSegmenter;
import com.huaban.analysis.jieba.SegToken;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
public class SearchUtils {
private static final JiebaSegmenter segmenter = new JiebaSegmenter();
/**
* 预处理搜索关键词
* 1. 去除特殊字符
* 2. 分词
* 3. 组合搜索词
*
* @param keyword 原始关键词
* @return 处理后的关键词
*/
public static String preprocessKeyword(String keyword) {
if (!StringUtils.hasText(keyword)) {
return "";
}
// 1. 去除特殊字符
keyword = keyword.replaceAll("[\\p{P}\\p{S}]", " ");
// 2. 分词
List<String> words = segmenter.sentenceProcess(keyword);
// 3. 组合搜索词
return String.join(" ", words);
}
/**
* 计算分页的偏移量
*
* @param page 页码从1开始
* @param pageSize 每页大小
* @return 偏移量
*/
public static int calculateOffset(int page, int pageSize) {
return Math.max(0, (page - 1) * pageSize);
}
}

View File

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

View File

@ -0,0 +1,101 @@
package com.kama.notes.websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kama.notes.model.vo.message.MessageVO;
import com.kama.notes.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket消息处理器
*/
@Slf4j
@RequiredArgsConstructor
public class MessageWebSocketHandler extends TextWebSocketHandler {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
// 用户ID -> WebSocket会话的映射
private static final Map<Long, WebSocketSession> USER_SESSIONS = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
Long userId = getUserIdFromSession(session);
if (userId != null) {
log.info("用户[{}]建立WebSocket连接", userId);
USER_SESSIONS.put(userId, session);
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// 处理接收到的消息
try {
String payload = message.getPayload();
log.debug("收到消息: {}", payload);
// 这里可以添加消息处理逻辑
} catch (Exception e) {
log.error("处理WebSocket消息时发生错误", e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
Long userId = getUserIdFromSession(session);
if (userId != null) {
log.info("用户[{}]断开WebSocket连接", userId);
USER_SESSIONS.remove(userId);
}
}
/**
* 发送消息给指定用户
*/
public void sendMessageToUser(Long userId, MessageVO message) {
WebSocketSession session = USER_SESSIONS.get(userId);
if (session != null && session.isOpen()) {
try {
String messageText = objectMapper.writeValueAsString(message);
session.sendMessage(new TextMessage(messageText));
log.debug("发送消息到用户[{}]: {}", userId, messageText);
} catch (Exception e) {
log.error("发送消息给用户[{}]时发生错误", userId, e);
}
}
}
/**
* 从WebSocket会话中获取用户ID
*/
private Long getUserIdFromSession(WebSocketSession session) {
String token = session.getHandshakeHeaders().getFirst("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
return jwtUtil.getUserIdFromToken(token);
}
return null;
}
/**
* 获取当前在线用户数
*/
public int getOnlineUserCount() {
return USER_SESSIONS.size();
}
/**
* 判断用户是否在线
*/
public boolean isUserOnline(Long userId) {
WebSocketSession session = USER_SESSIONS.get(userId);
return session != null && session.isOpen();
}
}

View File

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

View File

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

View File

@ -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='评论点赞表';

View File

@ -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='消息表';

View File

@ -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='邮箱验证码表';

View File

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

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kama.notes.mapper.CommentLikeMapper">
<!-- 评论点赞结果映射 -->
<resultMap id="commentLikeMap" type="com.kama.notes.model.entity.CommentLike">
<id property="commentLikeId" column="comment_like_id"/>
<result property="commentId" column="comment_id"/>
<result property="userId" column="user_id"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<!-- 插入评论点赞 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="commentLikeId">
INSERT INTO comment_like (
comment_id, user_id, created_at
) VALUES (
#{commentId}, #{userId}, #{createdAt}
)
</insert>
<!-- 删除评论点赞 -->
<delete id="delete">
DELETE FROM comment_like
WHERE comment_id = #{commentId} AND user_id = #{userId}
</delete>
<!-- 查询用户点赞的评论ID列表 -->
<select id="findUserLikedCommentIds" resultType="integer">
SELECT DISTINCT comment_id FROM comment_like
WHERE user_id = #{userId}
AND comment_id IN
<foreach collection="commentIds" item="commentId" open="(" separator="," close=")">
#{commentId}
</foreach>
</select>
</mapper>

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kama.notes.mapper.CommentMapper">
<!-- 评论结果映射 -->
<resultMap id="commentMap" type="com.kama.notes.model.entity.Comment">
<id property="commentId" column="comment_id"/>
<result property="noteId" column="note_id"/>
<result property="authorId" column="author_id"/>
<result property="parentId" column="parent_id"/>
<result property="content" column="content"/>
<result property="likeCount" column="like_count"/>
<result property="replyCount" column="reply_count"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<!-- 插入评论 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="commentId">
INSERT INTO comment (
note_id, author_id, parent_id, content,
like_count, reply_count, created_at, updated_at
) VALUES (
#{noteId}, #{authorId}, #{parentId}, #{content},
#{likeCount}, #{replyCount}, #{createdAt}, #{updatedAt}
)
</insert>
<!-- 更新评论 -->
<update id="update">
UPDATE comment
<set>
<if test="content != null">content = #{content},</if>
<if test="likeCount != null">like_count = #{likeCount},</if>
<if test="replyCount != null">reply_count = #{replyCount},</if>
updated_at = CURRENT_TIMESTAMP
</set>
WHERE comment_id = #{commentId}
</update>
<!-- 删除评论 -->
<delete id="deleteById">
DELETE FROM comment WHERE comment_id = #{commentId}
</delete>
<!-- 根据ID查询评论 -->
<select id="findById" resultMap="commentMap">
SELECT * FROM comment WHERE comment_id = #{commentId}
</select>
<!-- 查询评论列表 -->
<select id="findByQueryParam" resultMap="commentMap">
SELECT * FROM comment
<where>
<if test="params.noteId != null">AND note_id = #{params.noteId}</if>
<if test="params.parentId != null">AND parent_id = #{params.parentId}</if>
<if test="params.authorId != null">AND author_id = #{params.authorId}</if>
</where>
ORDER BY created_at DESC
LIMIT #{pageSize} OFFSET #{offset}
</select>
<!-- 统计评论数量 -->
<select id="countByQueryParam" resultType="int">
SELECT COUNT(*) FROM comment
<where>
<if test="params.noteId != null">AND note_id = #{params.noteId}</if>
<if test="params.parentId != null">AND parent_id = #{params.parentId}</if>
<if test="params.authorId != null">AND author_id = #{params.authorId}</if>
</where>
</select>
<!-- 增加评论点赞数 -->
<update id="incrementLikeCount">
UPDATE comment SET like_count = like_count + 1
WHERE comment_id = #{commentId}
</update>
<!-- 减少评论点赞数 -->
<update id="decrementLikeCount">
UPDATE comment SET like_count = like_count - 1
WHERE comment_id = #{commentId} AND like_count > 0
</update>
<!-- 增加评论回复数 -->
<update id="incrementReplyCount">
UPDATE comment SET reply_count = reply_count + 1
WHERE comment_id = #{commentId}
</update>
<!-- 减少评论回复数 -->
<update id="decrementReplyCount">
UPDATE comment SET reply_count = reply_count - 1
WHERE comment_id = #{commentId} AND reply_count > 0
</update>
</mapper>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kama.notes.mapper.EmailVerifyCodeMapper">
<insert id="insert" parameterType="com.kama.notes.model.entity.EmailVerifyCode" useGeneratedKeys="true" keyProperty="id">
INSERT INTO email_verify_code (
email, code, type, expired_at, created_at, used
) VALUES (
#{email}, #{code}, #{type}, #{expiredAt}, NOW(), FALSE
)
</insert>
<select id="findLatestValidCode" resultType="com.kama.notes.model.entity.EmailVerifyCode">
SELECT * FROM email_verify_code
WHERE email = #{email}
AND type = #{type}
AND used = FALSE
AND expired_at > NOW()
ORDER BY created_at DESC
LIMIT 1
</select>
<update id="markAsUsed">
UPDATE email_verify_code
SET used = TRUE
WHERE id = #{id}
</update>
</mapper>

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kama.notes.mapper.MessageMapper">
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.Message">
<id column="message_id" property="messageId"/>
<result column="receiver_id" property="receiverId"/>
<result column="sender_id" property="senderId"/>
<result column="type" property="type"/>
<result column="target_id" property="targetId"/>
<result column="content" property="content"/>
<result column="is_read" property="isRead"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<sql id="Base_Column_List">
message_id, receiver_id, sender_id, type, target_id, content, is_read, created_at, updated_at
</sql>
<insert id="insert" parameterType="com.kama.notes.model.entity.Message" useGeneratedKeys="true" keyProperty="messageId">
INSERT INTO message (
receiver_id, sender_id, type, target_id, content, is_read, created_at, updated_at
) VALUES (
#{receiverId}, #{senderId}, #{type}, #{targetId}, #{content}, #{isRead}, #{createdAt}, #{updatedAt}
)
</insert>
<select id="selectByParams" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM message
<where>
receiver_id = #{userId}
<if test="params.type != null">
AND type = #{params.type}
</if>
<if test="params.isRead != null">
AND is_read = #{params.isRead}
</if>
<if test="params.startTime != null">
AND created_at >= #{params.startTime}
</if>
<if test="params.endTime != null">
AND created_at &lt;= #{params.endTime}
</if>
</where>
ORDER BY ${params.sortField} ${params.sortOrder}
LIMIT #{params.pageSize}
OFFSET #{offset}
</select>
<select id="countByParams" resultType="int">
SELECT COUNT(*)
FROM message
<where>
receiver_id = #{userId}
<if test="params.type != null">
AND type = #{params.type}
</if>
<if test="params.isRead != null">
AND is_read = #{params.isRead}
</if>
<if test="params.startTime != null">
AND created_at >= #{params.startTime}
</if>
<if test="params.endTime != null">
AND created_at &lt;= #{params.endTime}
</if>
</where>
</select>
<update id="markAsRead">
UPDATE message
SET is_read = true,
updated_at = NOW()
WHERE message_id = #{messageId}
AND receiver_id = #{userId}
</update>
<update id="markAllAsRead">
UPDATE message
SET is_read = true,
updated_at = NOW()
WHERE receiver_id = #{userId}
AND is_read = false
</update>
<delete id="deleteMessage">
DELETE FROM message
WHERE message_id = #{messageId}
AND receiver_id = #{userId}
</delete>
<select id="countUnread" resultType="int">
SELECT COUNT(*)
FROM message
WHERE receiver_id = #{userId}
AND is_read = false
</select>
<select id="countUnreadByType" resultType="com.kama.notes.model.vo.message.UnreadCountByType">
SELECT
type,
COUNT(*) as count
FROM message
WHERE receiver_id = #{userId}
AND is_read = false
GROUP BY type
</select>
</mapper>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kama.notes.mapper.NoteCollectMapper">
<!-- 收藏结果映射 -->
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.NoteCollect">
<id column="collect_id" property="collectId"/>
<result column="note_id" property="noteId"/>
<result column="user_id" property="userId"/>
<result column="created_at" property="createdAt"/>
</resultMap>
<!-- 插入收藏记录 -->
<insert id="insert" parameterType="com.kama.notes.model.entity.NoteCollect" useGeneratedKeys="true" keyProperty="collectId">
INSERT INTO note_collect (note_id, user_id)
VALUES (#{noteId}, #{userId})
</insert>
<!-- 删除收藏记录 -->
<delete id="delete">
DELETE FROM note_collect
WHERE note_id = #{noteId}
AND user_id = #{userId}
</delete>
<!-- 根据用户ID和笔记ID查询收藏记录 -->
<select id="findByNoteIdAndUserId" resultMap="BaseResultMap">
SELECT *
FROM note_collect
WHERE note_id = #{noteId}
AND user_id = #{userId}
LIMIT 1
</select>
<!-- 查询用户收藏的笔记ID列表 -->
<select id="findNoteIdsByUserId" resultType="java.lang.Integer">
SELECT note_id
FROM note_collect
WHERE user_id = #{userId}
ORDER BY created_at DESC
</select>
</mapper>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kama.notes.mapper.NoteCommentMapper">
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.NoteComment">
<id column="id" property="id" />
<result column="note_id" property="noteId" />
<result column="user_id" property="userId" />
<result column="content" property="content" />
<result column="created_at" property="createdAt" />
<result column="updated_at" property="updatedAt" />
<result column="is_deleted" property="isDeleted" />
</resultMap>
<sql id="Base_Column_List">
id, note_id, user_id, content, created_at, updated_at, is_deleted
</sql>
<insert id="insert" parameterType="com.kama.notes.model.entity.NoteComment" useGeneratedKeys="true" keyProperty="id">
INSERT INTO note_comment (
note_id, user_id, content, created_at, updated_at, is_deleted
) VALUES (
#{noteId}, #{userId}, #{content}, #{createdAt}, #{updatedAt}, #{isDeleted}
)
</insert>
<update id="update" parameterType="com.kama.notes.model.entity.NoteComment">
UPDATE note_comment
SET updated_at = #{updatedAt},
is_deleted = #{isDeleted}
WHERE id = #{id}
</update>
<select id="findById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List" />
FROM note_comment
WHERE id = #{id}
</select>
<select id="findByNoteId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List" />
FROM note_comment
WHERE note_id = #{noteId}
AND is_deleted = false
ORDER BY created_at DESC
</select>
</mapper>

View File

@ -76,10 +76,20 @@
VALUES (#{questionId}, #{authorId}, #{content})
</insert>
<select id="findById" resultType="com.kama.notes.model.entity.Note">
SELECT *
FROM note
WHERE note_id = #{noteId}
<resultMap id="BaseResultMap" type="com.kama.notes.model.entity.Note">
<id column="note_id" property="noteId"/>
<result column="author_id" property="authorId"/>
<result column="question_id" property="questionId"/>
<result column="content" property="content"/>
<result column="like_count" property="likeCount"/>
<result column="comment_count" property="commentCount"/>
<result column="collect_count" property="collectCount"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<select id="findById" resultMap="BaseResultMap">
SELECT * FROM note WHERE note_id = #{noteId}
</select>
<update id="update">
@ -240,4 +250,47 @@
FROM note
</select>
<update id="incrementCommentCount">
UPDATE note
SET comment_count = comment_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE note_id = #{noteId}
</update>
<update id="decrementCommentCount">
UPDATE note
SET comment_count = CASE
WHEN comment_count > 0 THEN comment_count - 1
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP
WHERE note_id = #{noteId}
</update>
<!-- 搜索笔记 -->
<select id="searchNotes" resultType="com.kama.notes.model.entity.Note">
SELECT
n.*,
MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) as relevance
FROM note n
WHERE MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
ORDER BY relevance DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<!-- 根据标签搜索笔记 -->
<select id="searchNotesByTag" resultType="com.kama.notes.model.entity.Note">
SELECT DISTINCT
n.*,
MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) as relevance
FROM note n
LEFT JOIN note_tag nt ON n.id = nt.note_id
LEFT JOIN tag t ON nt.tag_id = t.id
WHERE
MATCH(n.search_vector) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE)
OR t.name LIKE CONCAT('%', #{tag}, '%')
ORDER BY relevance DESC
LIMIT #{limit} OFFSET #{offset}
</select>
</mapper>

View File

@ -9,8 +9,29 @@
</select>
<insert id="insert" keyProperty="userId" useGeneratedKeys="true">
INSERT INTO user (account, username, password)
VALUES (#{account}, #{username}, #{password})
INSERT INTO user (
account,
username,
password,
email,
email_verified,
gender,
is_banned,
is_admin,
created_at,
updated_at
) VALUES (
#{account},
#{username},
#{password},
#{email},
#{emailVerified},
3,
0,
0,
NOW(),
NOW()
)
</insert>
<select id="findById" resultType="com.kama.notes.model.entity.User">
@ -103,4 +124,28 @@
SELECT COUNT(*)
FROM user
</select>
<select id="findByEmail" resultType="com.kama.notes.model.entity.User">
SELECT * FROM user WHERE email = #{email}
</select>
<!-- 搜索用户 -->
<select id="searchUsers" resultType="com.kama.notes.model.entity.User">
SELECT * FROM user
WHERE
username LIKE CONCAT(#{keyword}, '%')
OR account LIKE CONCAT(#{keyword}, '%')
OR email LIKE CONCAT(#{keyword}, '%')
ORDER BY
CASE
WHEN username = #{keyword} THEN 1
WHEN account = #{keyword} THEN 2
WHEN email = #{keyword} THEN 3
WHEN username LIKE CONCAT(#{keyword}, '%') THEN 4
WHEN account LIKE CONCAT(#{keyword}, '%') THEN 5
WHEN email LIKE CONCAT(#{keyword}, '%') THEN 6
ELSE 7
END
LIMIT #{limit} OFFSET #{offset}
</select>
</mapper>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>验证码</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h2 style="color: #333; margin-bottom: 20px;">卡码笔记 - 验证码</h2>
<p style="color: #666; line-height: 1.6;">亲爱的用户:</p>
<p style="color: #666; line-height: 1.6;">您正在进行<span th:text="${operationType}">操作</span>,验证码为:</p>
<div style="background-color: #f8f8f8; padding: 15px; border-radius: 4px; text-align: center; margin: 20px 0;">
<span style="font-size: 24px; font-weight: bold; color: #1890ff;" th:text="${verifyCode}">123456</span>
</div>
<p style="color: #666; line-height: 1.6;">验证码有效期为15分钟请尽快完成验证。</p>
<p style="color: #666; line-height: 1.6;">如果这不是您的操作,请忽略此邮件。</p>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 12px;">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>© 2024 卡码笔记. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,99 @@
import React, { useState } from 'react'
import { Button, Input, message } from 'antd'
import { createComment } from '../../../../request/api/comment'
import { useUser } from '../../../../domain/user/hooks/useUser'
interface CommentInputProps {
noteId: number
parentId?: number
onSuccess?: () => void
onCancel?: () => void
placeholder?: string
}
export const CommentInput: React.FC<CommentInputProps> = ({
noteId,
parentId,
onSuccess,
onCancel,
placeholder = '写下你的评论...',
}) => {
const [content, setContent] = useState('')
const [submitting, setSubmitting] = useState(false)
const user = useUser()
const handleSubmit = async () => {
if (!content.trim()) {
message.warning('评论内容不能为空')
return
}
if (!user.userId) {
message.warning('请先登录后再发表评论')
return
}
try {
setSubmitting(true)
const { data: response } = await createComment({
noteId,
parentId,
content: content.trim(),
})
if (response.code === 200) {
setContent('')
message.success('评论成功')
onSuccess?.()
} else {
throw new Error(response.message || '评论失败')
}
} catch (error: any) {
console.error('评论失败:', error)
if (error.response?.status === 401) {
message.error('登录已过期,请重新登录')
} else {
message.error(error.message || '评论失败,请稍后重试')
}
} finally {
setSubmitting(false)
}
}
if (!user.userId) {
return (
<div className="comment-input">
<Input.TextArea
disabled
placeholder="请先登录后再发表评论"
autoSize={{ minRows: 2, maxRows: 6 }}
/>
</div>
)
}
return (
<div className="comment-input">
<Input.TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
autoSize={{ minRows: 2, maxRows: 6 }}
maxLength={500}
showCount
/>
<div className="mt-2 flex justify-end space-x-2">
{parentId && (
<Button onClick={onCancel} disabled={submitting}>
</Button>
)}
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</div>
</div>
)
}
export default CommentInput

View File

@ -0,0 +1,57 @@
import React from 'react'
import { Avatar, Button, List } from 'antd'
import { LikeOutlined, LikeFilled } from '@ant-design/icons'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { Comment } from '../../../../domain/comment/types'
interface CommentItemProps {
comment: Comment
onCommentSuccess: () => void
onLike: (isLiked: boolean) => void
}
const CommentItem: React.FC<CommentItemProps> = ({
comment,
onCommentSuccess,
onLike,
}) => {
return (
<List.Item
key={comment.commentId}
actions={[
<Button
key="like"
type="text"
icon={
comment.userActions?.isLiked ? <LikeFilled /> : <LikeOutlined />
}
onClick={() => onLike(comment.userActions?.isLiked || false)}
>
{comment.likeCount || 0}
</Button>,
<Button key="reply" type="text" onClick={onCommentSuccess}>
</Button>,
]}
>
<List.Item.Meta
avatar={<Avatar src={comment.author?.avatarUrl} />}
title={comment.author?.username}
description={
<div>
<div>{comment.content}</div>
<div className="text-sm text-gray-400">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</div>
</div>
}
/>
</List.Item>
)
}
export default CommentItem

View File

@ -0,0 +1,178 @@
import React, { useEffect, useState, useContext } from 'react'
import { Avatar, Button, List, message } from 'antd'
import {
LikeOutlined,
LikeFilled,
StarOutlined,
StarFilled,
MessageOutlined,
} from '@ant-design/icons'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import CommentInput from './CommentInput'
import {
getComments,
likeComment,
unlikeComment,
} from '../../../../request/api/comment'
import { UserContext } from '../../../../domain/user/context/UserContext'
import { Comment } from '../../../../domain/comment/types'
import CommentItem from './CommentItem'
interface CommentListProps {
noteId: number
onCommentCountChange?: () => void
}
const CommentList: React.FC<CommentListProps> = ({
noteId,
onCommentCountChange,
}) => {
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [replyTo, setReplyTo] = useState<Comment | null>(null)
const { currentUser } = useContext(UserContext)
const fetchComments = async () => {
if (!noteId) {
console.warn('noteId is required to fetch comments')
return
}
try {
setLoading(true)
const response = await getComments({
noteId,
page,
pageSize: 10,
})
if (response?.data?.code === 200) {
const newComments = response.data.data || []
setComments((prev) =>
page === 1 ? newComments : [...prev, ...newComments],
)
setHasMore(newComments.length === 10)
onCommentCountChange?.()
} else {
console.error('获取评论失败:', response)
message.error(response?.data?.message || '获取评论失败')
}
} catch (err) {
console.error('获取评论失败:', err)
message.error('获取评论失败,请稍后重试')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (noteId) {
setPage(1)
fetchComments()
}
}, [noteId])
useEffect(() => {
if (noteId && page > 1) {
fetchComments()
}
}, [page])
const handleLike = async (commentId: number, isLiked: boolean) => {
if (!currentUser) {
message.warning('请先登录后再点赞')
return
}
try {
const response = await (isLiked
? unlikeComment(commentId)
: likeComment(commentId))
if (response?.data?.code === 200) {
setComments((prev) =>
prev.map((comment) =>
comment.commentId === commentId
? {
...comment,
likeCount: isLiked
? Math.max(0, comment.likeCount - 1)
: comment.likeCount + 1,
userActions: {
...comment.userActions,
isLiked: !isLiked,
},
}
: comment,
),
)
} else {
message.error(
response?.data?.message || (isLiked ? '取消点赞失败' : '点赞失败'),
)
}
} catch (err) {
console.error(isLiked ? '取消点赞失败:' : '点赞失败:', err)
message.error(isLiked ? '取消点赞失败' : '点赞失败')
}
}
const handleReply = (comment: Comment) => {
if (!currentUser) {
message.warning('请先登录后再回复')
return
}
setReplyTo(comment)
}
const handleCommentSuccess = () => {
setPage(1)
fetchComments()
setReplyTo(null)
onCommentCountChange?.()
}
return (
<div className="comment-list">
<CommentInput
noteId={noteId}
parentId={replyTo?.commentId}
onSuccess={handleCommentSuccess}
onCancel={() => setReplyTo(null)}
placeholder={
replyTo ? `回复 ${replyTo.author.username}` : '写下你的评论...'
}
/>
<List
className="mt-4"
loading={loading}
itemLayout="horizontal"
dataSource={comments || []}
locale={{ emptyText: '暂无评论' }}
loadMore={
hasMore &&
!loading &&
comments.length > 0 && (
<div className="mt-4 text-center">
<Button onClick={() => setPage((p) => p + 1)} loading={loading}>
</Button>
</div>
)
}
renderItem={(comment) => (
<CommentItem
key={comment.commentId}
comment={comment}
onCommentSuccess={() => handleReply(comment)}
onLike={(isLiked) => handleLike(comment.commentId, isLiked)}
/>
)}
/>
</div>
)
}
export default CommentList

View File

@ -0,0 +1,37 @@
import React, { useEffect, useState } from 'react'
import { Badge } from 'antd'
import { BellOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { getUnreadCount } from '@/request/api/message'
const MessageBadge: React.FC = () => {
const [unreadCount, setUnreadCount] = useState(0)
const fetchUnreadCount = async () => {
try {
const { data } = await getUnreadCount()
if (data.code === 200) {
setUnreadCount(data.data)
}
} catch (err) {
console.error('获取未读消息数量失败:', err)
}
}
useEffect(() => {
fetchUnreadCount()
// 每分钟刷新一次未读消息数量
const timer = setInterval(fetchUnreadCount, 60000)
return () => clearInterval(timer)
}, [])
return (
<Link to="/messages">
<Badge count={unreadCount} overflowCount={99}>
<BellOutlined style={{ fontSize: '20px' }} />
</Badge>
</Link>
)
}
export default MessageBadge

View File

@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react'
import { Avatar, Button, List, message, Tag } from 'antd'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { getMessages, markAsRead, markAllAsRead } from '@/request/api/message'
import { Message } from '@/domain/message/types'
import { messageWebSocket } from '@/domain/message/service/messageWebSocket'
interface MessageListProps {
onUnreadCountChange?: () => void
}
const MessageList: React.FC<MessageListProps> = ({ onUnreadCountChange }) => {
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
// 处理新消息
const handleNewMessage = (newMessage: Message) => {
setMessages((prev) => [newMessage, ...prev])
message.info('收到新消息')
onUnreadCountChange?.()
}
useEffect(() => {
// 添加WebSocket消息处理器
messageWebSocket.addMessageHandler(handleNewMessage)
// 组件卸载时移除处理器
return () => {
messageWebSocket.removeMessageHandler(handleNewMessage)
}
}, [])
const fetchMessages = async () => {
try {
setLoading(true)
const { data } = await getMessages({
page,
pageSize: 10,
})
if (data.code === 200) {
setMessages((prev) =>
page === 1 ? data.data.list : [...prev, ...data.data.list],
)
setHasMore(data.data.list.length === 10)
}
} catch (err) {
console.error('获取消息失败:', err)
message.error('获取消息失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchMessages()
}, [page])
const handleMarkAsRead = async (messageId: number) => {
try {
const { data } = await markAsRead(messageId)
if (data.code === 200) {
setMessages((prev) =>
prev.map((msg) =>
msg.messageId === messageId
? {
...msg,
isRead: true,
}
: msg,
),
)
onUnreadCountChange?.()
}
} catch (err) {
message.error('标记已读失败')
}
}
const handleMarkAllAsRead = async () => {
try {
const { data } = await markAllAsRead()
if (data.code === 200) {
setMessages((prev) =>
prev.map((msg) => ({
...msg,
isRead: true,
})),
)
onUnreadCountChange?.()
message.success('已全部标记为已读')
}
} catch (err) {
message.error('标记全部已读失败')
}
}
const renderMessageContent = (msg: Message) => {
switch (msg.type) {
case 'COMMENT':
return (
<div>
<Tag color="blue"></Tag>
{msg.content}
</div>
)
case 'LIKE':
return (
<div>
<Tag color="red"></Tag>
{msg.content}
</div>
)
case 'SYSTEM':
return (
<div>
<Tag color="green"></Tag>
{msg.content}
</div>
)
default:
return msg.content
}
}
return (
<div className="message-list">
{messages.length > 0 && (
<div className="mb-4 flex justify-end">
<Button onClick={handleMarkAllAsRead}></Button>
</div>
)}
<List
loading={loading}
itemLayout="horizontal"
dataSource={messages}
locale={{ emptyText: '暂无消息' }}
renderItem={(msg) => (
<List.Item
actions={[
!msg.isRead && (
<Button
key="mark-read"
type="link"
onClick={() => handleMarkAsRead(msg.messageId)}
>
</Button>
),
]}
>
<List.Item.Meta
title={
<div className="flex items-center">
<span className="mr-2">{msg.sender.username}</span>
{!msg.isRead && (
<Tag color="red" className="ml-2">
</Tag>
)}
</div>
}
description={renderMessageContent(msg)}
/>
</List.Item>
)}
loadMore={
hasMore && (
<div className="mt-4 text-center">
<Button onClick={() => setPage((p) => p + 1)} loading={loading}>
</Button>
</div>
)
}
/>
</div>
)
}
export default MessageList

View File

@ -0,0 +1,15 @@
import React from 'react'
import { Card } from 'antd'
import MessageList from '../components/message/MessageList'
const MessagePage: React.FC = () => {
return (
<div className="message-page">
<Card title="消息中心">
<MessageList />
</Card>
</div>
)
}
export default MessagePage

View File

@ -31,3 +31,8 @@ export const USER_NOTE = '/user-center/note'
*
*/
export const QUESTION = '/questions'
/**
*
*/
export const MESSAGE_CENTER = '/messages'

View File

@ -11,6 +11,7 @@ import {
USER_HOME,
USER_INFO,
USER_NOTE,
MESSAGE_CENTER,
} from './config.ts'
import { NotFound } from '../../../base/components'
import HomePage from '../pages/home/HomePage.tsx'
@ -22,6 +23,7 @@ import QuestionSetPage from '../pages/questionSet/QuestionSetPage.tsx'
import QuestionPage from '../pages/question/QuestionPage.tsx'
import UserHomePage from '../pages/userHome/UserHomePage.tsx'
import QuestionListPage from '../pages/questionList/QuestionListPage.tsx'
import MessagePage from '../pages/MessagePage.tsx'
export const UserRouteConfig = (
<Route path={HOME} element={<UserApp />}>
@ -37,6 +39,7 @@ export const UserRouteConfig = (
<Route path={`${QUESTION}/:questionId`} element={<QuestionPage />} />
<Route path={`${USER_HOME}/:userId`} element={<UserHomePage />} />
<Route path={`${QUESTION_LIST}`} element={<QuestionListPage />} />
<Route path={MESSAGE_CENTER} element={<MessagePage />} />
<Route path="/*" element={<NotFound />} />
</Route>
)

View File

@ -0,0 +1,9 @@
// Token key in localStorage
export const TOKEN_KEY = 'token'
export const kamanoteUserToken = TOKEN_KEY
// API Host
export const kamanoteHost =
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
// 其他常量...

View File

@ -0,0 +1,13 @@
// 字母数字下划线
export const ALPHANUMERIC_UNDERSCORE = /^[a-zA-Z0-9_]+$/
// 字母数字下划线中文
export const ALPHANUMERIC_UNDERSCORE_CHINESE =
/^[\u4e00-\u9fa5_a-zA-Z0-9\-\.]+$/
// 密码允许的字符
export const PASSWORD_ALLOWABLE_CHARACTERS =
/^[a-zA-Z0-9!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+$/
// 邮箱正则表达式
export const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/

View File

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react'
import { Input, Button, message } from 'antd'
import { useUser } from '../domain/user/hooks/useUser'
import { TOKEN_KEY } from '../base/constants'
import { commentService } from '../domain/comment/service/commentService'
import { AxiosError } from 'axios'
const { TextArea } = Input
interface CommentInputProps {
noteId: number
parentId?: number
onCommentAdded?: () => void
}
const CommentInput: React.FC<CommentInputProps> = ({
noteId,
parentId,
onCommentAdded,
}) => {
const [content, setContent] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { currentUser } = useUser()
useEffect(() => {
console.log('CommentInput组件状态:', {
noteId,
parentId,
currentUser: currentUser ? '已登录' : '未登录',
token: localStorage.getItem(TOKEN_KEY) ? '存在' : '不存在',
})
}, [noteId, parentId, currentUser])
const handleSubmit = async () => {
const trimmedContent = content.trim()
if (!trimmedContent) {
message.warning('评论内容不能为空')
return
}
if (!currentUser) {
message.warning('请先登录后再发表评论')
return
}
console.log('准备提交评论:', {
noteId,
parentId,
content: trimmedContent,
token: localStorage.getItem(TOKEN_KEY) ? '存在' : '不存在',
})
setIsSubmitting(true)
try {
await commentService.createComment({
noteId,
parentId,
content: trimmedContent,
})
console.log('评论提交成功')
message.success('评论发表成功')
setContent('')
onCommentAdded?.()
} catch (error) {
console.error('评论提交失败:', error)
const axiosError = error as AxiosError
if (axiosError.response?.status === 401) {
message.error('登录已过期,请重新登录')
} else {
message.error('评论发表失败,请稍后重试')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className="comment-input">
<TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={currentUser ? '写下你的评论...' : '请先登录后再发表评论'}
disabled={!currentUser}
autoSize={{ minRows: 2, maxRows: 6 }}
style={{ marginBottom: '10px' }}
/>
<Button
type="primary"
onClick={handleSubmit}
loading={isSubmitting}
disabled={!currentUser || !content.trim()}
>
</Button>
</div>
)
}
export default CommentInput

View File

@ -0,0 +1,19 @@
import { http } from '../../../request/http'
export interface CreateCommentParams {
noteId: number
parentId?: number
content: string
}
class CommentService {
async createComment(params: CreateCommentParams) {
return http.post('/api/comments', params)
}
async getComments(noteId: number) {
return http.get(`/api/comments/${noteId}`)
}
}
export const commentService = new CommentService()

View File

@ -0,0 +1,58 @@
/**
*
*/
export interface CommentAuthor {
userId: number
username: string
avatarUrl: string
}
/**
*
*/
export interface CommentUserActions {
isLiked: boolean
}
/**
*
*/
export interface Comment {
commentId: number
noteId: number
content: string
likeCount: number
replyCount: number
createdAt: string
author: CommentAuthor
userActions?: CommentUserActions
replies?: Comment[]
}
/**
*
*/
export interface CreateCommentParams {
noteId: number
parentId?: number
content: string
}
/**
*
*/
export interface CommentQueryParams {
noteId: number
parentId?: number
page: number
pageSize: number
}
/**
* API响应
*/
export interface CommentResponse {
code: number
message: string
data: Comment[]
}

View File

@ -0,0 +1,96 @@
import { Message } from '../types'
import { getToken } from '@/utils/auth'
class MessageWebSocketService {
private ws: WebSocket | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectTimeout = 3000
private messageHandlers: ((message: Message) => void)[] = []
constructor() {
this.connect()
}
private connect() {
const token = getToken()
if (!token) {
console.warn('未登录无法建立WebSocket连接')
return
}
const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:8080'}/ws/message`
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket连接已建立')
// 发送认证信息
this.ws?.send(JSON.stringify({ type: 'AUTH', token }))
// 重置重连次数
this.reconnectAttempts = 0
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as Message
this.notifyHandlers(message)
} catch (error) {
console.error('处理WebSocket消息时发生错误:', error)
}
}
this.ws.onclose = () => {
console.log('WebSocket连接已关闭')
this.attemptReconnect()
}
this.ws.onerror = (error) => {
console.error('WebSocket发生错误:', error)
}
}
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket重连次数超过最大限制')
return
}
this.reconnectAttempts++
console.log(`尝试第${this.reconnectAttempts}次重连...`)
setTimeout(() => {
this.connect()
}, this.reconnectTimeout)
}
public addMessageHandler(handler: (message: Message) => void) {
this.messageHandlers.push(handler)
}
public removeMessageHandler(handler: (message: Message) => void) {
const index = this.messageHandlers.indexOf(handler)
if (index !== -1) {
this.messageHandlers.splice(index, 1)
}
}
private notifyHandlers(message: Message) {
this.messageHandlers.forEach((handler) => {
try {
handler(message)
} catch (error) {
console.error('执行消息处理器时发生错误:', error)
}
})
}
public disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
}
}
// 导出单例实例
export const messageWebSocket = new MessageWebSocketService()

View File

@ -0,0 +1,54 @@
/**
*
*/
export interface MessageSender {
userId: number
username: string
avatarUrl: string
}
/**
*
*/
export type MessageType = 'COMMENT' | 'LIKE'
/**
*
*/
export interface Message {
messageId: number
sender: MessageSender
type: MessageType
targetId: number
content: string
isRead: boolean
createdAt: string
}
/**
*
*/
export interface MessageQueryParams {
page: number
pageSize: number
type?: MessageType
isRead?: boolean
}
/**
* API响应
*/
export interface MessageResponse {
code: number
message: string
data: Message[]
}
/**
*
*/
export interface UnreadCountResponse {
code: number
message: string
data: number
}

View File

@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react'
import { Avatar, Button, Input, List, message } from 'antd'
import {
createComment,
deleteComment,
getComments,
} from '@/request/api/comment'
import { NoteComment } from '@/domain/note/types'
import { useUser } from '@/domain/user/hooks/useUser'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
interface NoteCommentsProps {
noteId: number
}
export function NoteComments({ noteId }: NoteCommentsProps) {
const [comments, setComments] = useState<NoteComment[]>([])
const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
const { currentUser } = useUser()
// 加载评论列表
const loadComments = async () => {
try {
const { data } = await getComments(noteId)
setComments(data)
} catch (error) {
message.error('加载评论失败')
}
}
// 提交评论
const handleSubmit = async () => {
if (!currentUser) {
message.warning('请先登录')
return
}
if (!content.trim()) {
message.warning('请输入评论内容')
return
}
setLoading(true)
try {
await createComment(noteId, content.trim())
message.success('评论成功')
setContent('')
loadComments()
} catch (error) {
message.error('评论失败')
} finally {
setLoading(false)
}
}
// 删除评论
const handleDelete = async (commentId: number) => {
try {
await deleteComment(commentId)
message.success('删除成功')
loadComments()
} catch (error) {
message.error('删除失败')
}
}
useEffect(() => {
loadComments()
}, [noteId])
return (
<div className="note-comments">
<div className="comment-input">
<Input.TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="写下你的评论..."
autoSize={{ minRows: 2, maxRows: 6 }}
maxLength={500}
showCount
/>
<Button
type="primary"
onClick={handleSubmit}
loading={loading}
style={{ marginTop: 8, float: 'right' }}
>
</Button>
</div>
<List
style={{ clear: 'both', marginTop: 16 }}
itemLayout="horizontal"
dataSource={comments}
renderItem={(comment) => (
<List.Item
actions={[
comment.userId === currentUser?.userId && (
<Button
type="link"
danger
onClick={() => handleDelete(comment.id)}
>
</Button>
),
]}
>
<List.Item.Meta
avatar={<Avatar src={currentUser?.avatarUrl} />}
title={currentUser?.username}
description={
<div>
<div>{comment.content}</div>
<div className="text-sm text-gray-400">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
locale: zhCN,
})}
</div>
</div>
}
/>
</List.Item>
)}
/>
</div>
)
}

View File

@ -21,6 +21,7 @@ interface NoteItemProps {
showAuthor?: boolean // 是否展示作者信息
showQuestion?: boolean // 是否展示题目信息
showOptions?: boolean // 是否展示点赞/收藏/评论等按钮
onRefresh?: () => void
}
const NoteItem: React.FC<NoteItemProps> = ({
@ -32,6 +33,7 @@ const NoteItem: React.FC<NoteItemProps> = ({
toggleIsModalOpen,
handleCollectionQueryParams,
handleSelectedNoteId,
onRefresh,
}) => {
const [isCollapsed, setIsCollapsed] = useState(false)
@ -69,6 +71,7 @@ const NoteItem: React.FC<NoteItemProps> = ({
toggleIsModalOpen={toggleIsModalOpen}
handleCollectionQueryParams={handleCollectionQueryParams}
handleSelectedNoteId={handleSelectedNoteId}
onRefresh={onRefresh}
/>
)}
</div>

View File

@ -1,10 +1,16 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { NoteWithRelations } from '../types/serviceTypes.ts'
import LikeButton from './LikeButton.tsx'
import CollectButton from './CollectButton.tsx'
import { Button, Drawer, message } from 'antd'
import {
LikeOutlined,
LikeFilled,
StarOutlined,
StarFilled,
MessageOutlined,
} from '@ant-design/icons'
import { useNoteLike } from '../../noteLike'
import { useApp } from '../../../base/hooks'
import { message } from 'antd'
import CommentList from '../../../apps/user/components/comment/CommentList'
interface OptionsCardProps {
note?: NoteWithRelations
@ -12,6 +18,7 @@ interface OptionsCardProps {
toggleIsModalOpen: () => void
handleCollectionQueryParams: (noteId: number) => void
handleSelectedNoteId: (noteId: number) => void
onRefresh?: () => void
}
const OptionsCard: React.FC<OptionsCardProps> = ({
@ -20,12 +27,20 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
toggleIsModalOpen,
handleCollectionQueryParams,
handleSelectedNoteId,
onRefresh,
}) => {
const { like, unLike } = useNoteLike()
const [commentDrawerVisible, setCommentDrawerVisible] = useState(false)
const [localCommentCount, setLocalCommentCount] = useState(0)
const app = useApp()
let likeLoading = false
useEffect(() => {
if (note?.commentCount !== undefined) {
setLocalCommentCount(note.commentCount)
}
}, [note?.commentCount])
/**
*
*/
@ -35,11 +50,7 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
return
}
// 处理没有传递函数的情况
if (!setNoteLikeStatus) return
// 处理没有 note 或 userActions 的情况
if (!note || !note.userActions) return
if (!setNoteLikeStatus || !note || !note.userActions) return
if (likeLoading) return
@ -47,7 +58,7 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
setNoteLikeStatus(note.noteId, !note.userActions.isLiked)
if (note.userActions.isLiked) {
await unLike(note!.noteId)
await unLike(note.noteId)
} else {
await like(note.noteId)
}
@ -63,30 +74,75 @@ const OptionsCard: React.FC<OptionsCardProps> = ({
message.info('请先登录')
return
}
// 处理没有 note 或 userActions 为空的情况
if (!note || !note.userActions) return
// 获取收藏列表
toggleIsModalOpen()
handleCollectionQueryParams(note.noteId)
handleSelectedNoteId(note.noteId)
}
/**
*
*/
const handleCommentClick = () => {
if (!app.isLogin) {
message.info('请先登录')
return
}
setCommentDrawerVisible(true)
}
// 评论成功后刷新笔记数据
const handleCommentSuccess = () => {
onRefresh?.()
}
return (
<div className="flex gap-4">
<LikeButton
key={`li${note?.noteId}`}
likeCount={note?.likeCount ?? 0}
currentUserLiked={note?.userActions?.isLiked ?? false}
clickHandle={likeButtonClickHandle}
></LikeButton>
<CollectButton
key={`c${note?.noteId}`}
collectCount={note?.collectCount ?? 0}
currentUserCollected={note?.userActions?.isCollected ?? false}
clickHandle={collectButtonClickHandle}
></CollectButton>
</div>
<>
<div className="flex items-center space-x-4">
<Button
type="text"
className="flex items-center"
icon={note?.userActions?.isLiked ? <LikeFilled /> : <LikeOutlined />}
onClick={likeButtonClickHandle}
>
{note?.likeCount || 0}
</Button>
<Button
type="text"
className="flex items-center"
icon={
note?.userActions?.isCollected ? <StarFilled /> : <StarOutlined />
}
onClick={collectButtonClickHandle}
>
{note?.collectCount || 0}
</Button>
<Button
type="text"
className="flex items-center"
icon={<MessageOutlined />}
onClick={handleCommentClick}
>
{localCommentCount}
</Button>
</div>
<Drawer
title="评论"
placement="right"
width={500}
onClose={() => setCommentDrawerVisible(false)}
open={commentDrawerVisible}
>
{note && (
<CommentList
noteId={note.noteId}
onCommentCountChange={handleCommentSuccess}
/>
)}
</Drawer>
</>
)
}

View File

@ -0,0 +1,12 @@
/**
*
*/
export interface NoteComment {
id: number
noteId: number
userId: number
content: string
createdAt: string
updatedAt: string
isDeleted: boolean
}

View File

@ -9,6 +9,7 @@ export const userApiList: ApiList = {
getUser: ['GET', '/api/users/{userId}'],
updateMe: ['PATCH', '/api/users/me'],
uploadImage: ['POST', '/api/upload/image'],
sendVerifyCode: ['GET', '/api/email/verify-code'],
}
// 管理端的 apiList

View File

@ -4,21 +4,61 @@ import {
ALPHANUMERIC_UNDERSCORE,
ALPHANUMERIC_UNDERSCORE_CHINESE,
PASSWORD_ALLOWABLE_CHARACTERS,
EMAIL_PATTERN,
} from '../../../base/regex'
import { useLogin } from '../hooks/useLogin.ts'
import { useRegister } from '../hooks/useRegister.ts'
import { userService } from '../service/userService.ts'
import { useForm } from 'antd/es/form/Form'
const LoginModal: React.FC = () => {
const [open, setOpen] = useState(false)
const [value, setValue] = useState('login')
const [loading, setLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
const { loginHandle } = useLogin()
const { registerHandle } = useRegister()
const [form] = useForm()
// 发送验证码
const handleSendVerifyCode = async () => {
try {
// 验证邮箱
await form.validateFields(['email'])
const email = form.getFieldValue('email')
if (!email) {
message.error('请输入邮箱')
return
}
setLoading(true)
await userService.sendVerifyCode({
email,
type: 'REGISTER',
})
message.success('验证码已发送')
// 开始倒计时
setCountdown(60)
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
} catch (e: any) {
message.error(e.message || '发送失败')
} finally {
setLoading(false)
}
}
async function onFinish(values: any) {
try {
setLoading(true)
@ -28,10 +68,7 @@ const LoginModal: React.FC = () => {
} else if (value === 'register') {
await registerHandle(values)
message.success('注册成功')
} else {
message.error('走到这个分支表示出大问题辣!')
}
setLoading(false)
setOpen(false)
} catch (e: any) {
message.error(e.message)
@ -52,44 +89,110 @@ const LoginModal: React.FC = () => {
layout={'vertical'}
form={form}
>
<Form.Item
label="账号"
name="account"
rules={[
{ required: true, message: '请输入账号' },
{
pattern: ALPHANUMERIC_UNDERSCORE,
message: '账号只能包含字母、数字和下划线',
},
{
min: 6,
max: 16,
message: '账号长度在 6 - 16 个字符',
},
]}
>
<Input autoComplete="off" />
</Form.Item>
{value === 'register' && (
{value === 'login' && (
<Form.Item
label="昵称"
name="username"
label="账号或邮箱"
name={form.getFieldValue('email') ? 'email' : 'account'}
rules={[
{ required: true, message: '请输入用户名' },
{ required: true, message: '请输入账号或邮箱' },
{
pattern: ALPHANUMERIC_UNDERSCORE_CHINESE,
message: '昵称只能包含中文、字母、数字和下划线',
},
{
min: 1,
max: 16,
message: '昵称长度在 1 - 16 个字符之间',
pattern: form.getFieldValue('email')
? EMAIL_PATTERN
: ALPHANUMERIC_UNDERSCORE,
message: form.getFieldValue('email')
? '邮箱格式不正确'
: '账号只能包含字母、数字和下划线',
},
]}
>
<Input autoComplete={'off'} />
<Input
autoComplete="off"
onChange={(e) => {
// 根据输入内容判断是邮箱还是账号
const value = e.target.value
if (value.includes('@')) {
form.setFieldsValue({ email: value, account: undefined })
} else {
form.setFieldsValue({ account: value, email: undefined })
}
}}
/>
</Form.Item>
)}
{value === 'register' && (
<>
<Form.Item
label="账号"
name="account"
rules={[
{ required: true, message: '请输入账号' },
{
pattern: ALPHANUMERIC_UNDERSCORE,
message: '账号只能包含字母、数字和下划线',
},
{
min: 6,
max: 16,
message: '账号长度在 6 - 16 个字符',
},
]}
>
<Input autoComplete="off" />
</Form.Item>
<Form.Item
label="昵称"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{
pattern: ALPHANUMERIC_UNDERSCORE_CHINESE,
message: '昵称只能包含中文、字母、数字和下划线',
},
{
min: 1,
max: 16,
message: '昵称长度在 1 - 16 个字符之间',
},
]}
>
<Input autoComplete={'off'} />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input autoComplete="off" />
</Form.Item>
<Form.Item
label="验证码"
name="verifyCode"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码长度必须为6位' },
]}
>
<Input
autoComplete="off"
suffix={
<Button
type="link"
size="small"
disabled={countdown > 0}
onClick={handleSendVerifyCode}
>
{countdown > 0 ? `${countdown}s后重试` : '发送验证码'}
</Button>
}
/>
</Form.Item>
</>
)}
<Form.Item
label="密码"
name="password"
@ -108,6 +211,7 @@ const LoginModal: React.FC = () => {
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
{value === 'register' ? '注册' : '登录'}
</Button>
@ -141,7 +245,7 @@ const LoginModal: React.FC = () => {
]}
value={value}
onChange={(value) => setValue(value)}
></Segmented>
/>
</div>
<div className="mt-4 flex justify-center pb-4">
<LoginForm />

View File

@ -1,7 +1,11 @@
import React from 'react'
import { NavLink } from 'react-router-dom'
import { Config, Logout, Permissions, User } from '@icon-park/react'
import { USER_CENTER, USER_HOME } from '../../../apps/user/router/config.ts'
import { Config, Logout, Permissions, User, Message } from '@icon-park/react'
import {
USER_CENTER,
USER_HOME,
MESSAGE_CENTER,
} from '../../../apps/user/router/config.ts'
import { useUser } from '../hooks/useUser.ts'
import { useApp } from '../../../base/hooks'
import { useLogout } from '../hooks/useLogout.ts'
@ -39,6 +43,14 @@ const ProfileMenu: React.FC = () => {
/>
</NavLink>
<NavLink className={itemCss} to={MESSAGE_CENTER}>
<Message
theme="multi-color"
size="18"
fill={['#333', '#8dbaf1', '#ffffff', '#e64155']}
/>
</NavLink>
{/* 只有管理员才能查看后台内容 */}
{user.isAdmin === Admin.ADMIN && (
<NavLink className={itemCss} to={app.isAdminApp ? '/' : '/admin'}>

View File

@ -0,0 +1,97 @@
import React, { createContext, useState, useEffect } from 'react'
import { User } from '../types/types'
import { userService } from '../service/userService'
import { TOKEN_KEY } from '../../../base/constants'
import { message } from 'antd'
interface UserContextType {
currentUser: User | null
setCurrentUser: (user: User | null) => void
reloadUser: () => Promise<void>
}
export const UserContext = createContext<UserContextType>({
currentUser: null,
setCurrentUser: () => {},
reloadUser: async () => {},
})
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [currentUser, setCurrentUser] = useState<User | null>(() => {
// 尝试从 localStorage 获取用户信息
const savedUser = localStorage.getItem('currentUser')
return savedUser ? JSON.parse(savedUser) : null
})
const reloadUser = async () => {
console.log('开始重新加载用户信息')
try {
const token = localStorage.getItem(TOKEN_KEY)
console.log('从localStorage获取的token:', token ? '存在' : '不存在')
if (!token) {
console.log('未找到token清除当前用户')
setCurrentUser(null)
localStorage.removeItem('currentUser')
return
}
const resp = await userService.whoamiService()
console.log('获取用户信息响应:', resp)
if (resp && resp.data) {
console.log('设置当前用户:', resp.data)
setCurrentUser(resp.data)
localStorage.setItem('currentUser', JSON.stringify(resp.data))
} else {
console.log('响应中没有用户数据,清除当前用户')
setCurrentUser(null)
localStorage.removeItem('currentUser')
localStorage.removeItem(TOKEN_KEY)
}
} catch (error) {
console.error('加载用户信息失败:', error)
setCurrentUser(null)
localStorage.removeItem('currentUser')
localStorage.removeItem(TOKEN_KEY)
}
}
// 组件挂载时,如果有 token 就自动加载用户信息
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY)
if (token && !currentUser) {
reloadUser()
}
}, [])
useEffect(() => {
console.log('用户状态更新:', currentUser)
if (currentUser) {
localStorage.setItem('currentUser', JSON.stringify(currentUser))
} else {
localStorage.removeItem('currentUser')
}
}, [currentUser])
const contextValue = {
currentUser,
setCurrentUser: (user: User | null) => {
console.log('设置用户状态:', user)
setCurrentUser(user)
if (!user) {
localStorage.removeItem('currentUser')
localStorage.removeItem(TOKEN_KEY)
} else {
localStorage.setItem('currentUser', JSON.stringify(user))
}
},
reloadUser,
}
return (
<UserContext.Provider value={contextValue}>{children}</UserContext.Provider>
)
}

View File

@ -4,6 +4,7 @@ import {
LoginBody,
RegisterBody,
RegisterData,
SendVerifyCodeBody,
UploadImageData,
UserListQueryParams,
} from '../types/serviceTypes.ts'
@ -63,7 +64,16 @@ export const userService = {
return httpClient.request<UploadImageData>(userApiList.uploadImage, {
body: body,
})
}
},
/**
*
*/
sendVerifyCode: (body: SendVerifyCodeBody) => {
return httpClient.request<void>(userApiList.sendVerifyCode, {
queryParams: body,
})
},
}
/**

View File

@ -0,0 +1,44 @@
/**
*
*/
export interface User {
userId: number
username: string
avatarUrl: string
email: string
isAdmin: boolean
}
/**
*
*/
export interface LoginParams {
username: string
password: string
}
/**
*
*/
export interface RegisterParams {
username: string
password: string
email: string
}
/**
* API响应
*/
export interface UserResponse {
code: number
message: string
data: User
}
/**
*
*/
export enum Admin {
ADMIN = 1,
USER = 0,
}

View File

@ -5,6 +5,8 @@ export type RegisterBody = {
username: string
account: string
password: string
email: string
verifyCode: string
}
export type RegisterData = {
@ -15,10 +17,19 @@ export type RegisterData = {
*
*/
export type LoginBody = {
account: string
account?: string
email?: string
password: string
}
/**
*
*/
export type SendVerifyCodeBody = {
email: string
type: 'REGISTER' | 'RESET_PASSWORD'
}
/**
*
*/

Some files were not shown because too many files have changed in this diff Show More