笔记管理功能初始化

This commit is contained in:
2026-09-22 17:16:07 +08:00
parent a29a3979d7
commit 429e8a7bb7
15 changed files with 1443 additions and 0 deletions
@@ -37,6 +37,11 @@ public class SecurityConfig {
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/gold-price/**").permitAll()
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
// Web 后台管理页本身是静态页面,加载时不可能带 JWT;
// 页面里的数据请求仍然走 /api/notes/** 走正常鉴权
.requestMatchers("/admin/**").permitAll()
// 笔记附件(图片)。文件名是 UUID,不可枚举
.requestMatchers("/uploads/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -0,0 +1,35 @@
package com.accounting.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.file.Paths;
/**
* 把上传目录暴露成静态资源
*
* <p>原有的 FileUtil 只负责把文件写进 {@code ${file.upload.path}},
* 但项目里一直没有任何地方把它映射出去,所以历史上传的图片
* 没有任何 URL 能访问到(OCR 场景只把图片喂给阿里云,
* 不需要回显,所以一直没暴露这个问题)。</p>
*
* <p>笔记要在 Markdown 里引用图片,必须补上这层映射。</p>
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${file.upload.path}")
private String uploadPath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 用 toUri() 而不是直接拼 "file:" ,否则在 Windows 开发环境下
// 路径分隔符会出问题
String location = Paths.get(uploadPath).toUri().toString();
registry.addResourceHandler("/uploads/**")
.addResourceLocations(location);
}
}
@@ -0,0 +1,132 @@
package com.accounting.controller;
import com.accounting.dto.NoteAttachmentResponse;
import com.accounting.dto.NoteImportRequest;
import com.accounting.dto.NotePageResponse;
import com.accounting.dto.NoteRequest;
import com.accounting.dto.NoteResponse;
import com.accounting.entity.User;
import com.accounting.mapper.UserMapper;
import com.accounting.service.NoteService;
import com.accounting.util.FileUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Tag(name = "笔记管理", description = "笔记的增删改查、批量导入、附件上传")
@RestController
@RequestMapping("/api/notes")
public class NoteController {
@Autowired
private NoteService noteService;
@Autowired
private UserMapper userMapper;
@Autowired
private FileUtil fileUtil;
@Operation(summary = "分页查询笔记列表(不含正文)")
@GetMapping
public ResponseEntity<NotePageResponse> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String tag,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(noteService.list(userId, keyword, tag, page, size));
}
@Operation(summary = "查询笔记详情(含正文)")
@GetMapping("/{id}")
public ResponseEntity<NoteResponse> detail(@PathVariable Long id, Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(noteService.detail(userId, id));
}
@Operation(summary = "新建笔记")
@PostMapping
public ResponseEntity<NoteResponse> create(@RequestBody NoteRequest request, Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(noteService.create(userId, request));
}
@Operation(summary = "更新笔记")
@PutMapping("/{id}")
public ResponseEntity<NoteResponse> update(
@PathVariable Long id,
@RequestBody NoteRequest request,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(noteService.update(userId, id, request));
}
@Operation(summary = "删除笔记(逻辑删除)")
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id, Authentication authentication) {
Long userId = getUserId(authentication);
noteService.delete(userId, id);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
return ResponseEntity.ok(result);
}
@Operation(summary = "批量导入笔记")
@PostMapping("/import")
public ResponseEntity<Map<String, Object>> importBatch(
@Valid @RequestBody NoteImportRequest request,
Authentication authentication) {
Long userId = getUserId(authentication);
int imported = noteService.importBatch(userId, request.getNotes());
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("imported", imported);
return ResponseEntity.ok(result);
}
@Operation(summary = "上传图片附件,返回可直接在 Markdown 中引用的地址")
@PostMapping("/attachments")
public ResponseEntity<NoteAttachmentResponse> uploadAttachment(
@RequestParam("file") MultipartFile file) throws Exception {
String relativePath = fileUtil.saveFile(file);
return ResponseEntity.ok(new NoteAttachmentResponse("/uploads/" + relativePath));
}
@Operation(summary = "获取当前用户的所有标签,按使用次数排序")
@GetMapping("/tags")
public ResponseEntity<List<String>> tags(Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(noteService.allTags(userId));
}
private Long getUserId(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>().eq(User::getUsername, username)
);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
return user.getId();
}
}
@@ -0,0 +1,17 @@
package com.accounting.dto;
import lombok.Data;
/**
* 附件上传结果
*/
@Data
public class NoteAttachmentResponse {
/** 可直接用于 Markdown 引用的相对地址,如 /uploads/2026/09/22/xxx.png */
private String url;
public NoteAttachmentResponse(String url) {
this.url = url;
}
}
@@ -0,0 +1,38 @@
package com.accounting.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* 批量导入笔记的请求体
*
* <p>由前端把 .md 文件读成字符串后一次性提交,
* 而不是走后端 multipart 解析 —— 前端可以在提交前先提取标题、
* 去重、让用户改标签,比后端盲解析灵活得多。</p>
*/
@Data
public class NoteImportRequest {
@NotEmpty(message = "笔记列表不能为空")
@Valid
private List<Item> notes;
@Data
public static class Item {
/** 标题,为空时由正文首行提取 */
private String title;
/** Markdown 正文 */
private String content;
/** 导入来源文件名 */
private String sourcePath;
/** 标签,逗号分隔 */
private String tags;
}
}
@@ -0,0 +1,30 @@
package com.accounting.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 笔记列表项 —— 刻意不含 content 字段
*
* <p>列表接口如果要带上正文,一页 20 条就可能传输几 MB,
* 而这个后端部署在公网服务器上,客户端跨网络拉取会明显变慢。</p>
*/
@Data
public class NoteListItem {
private Long id;
private String title;
/** 摘要,由正文自动提取 */
private String summary;
/** 标签列表 */
private List<String> tagList;
private Integer isPinned;
private LocalDateTime updateTime;
}
@@ -0,0 +1,26 @@
package com.accounting.dto;
import lombok.Data;
import java.util.List;
/**
* 笔记分页结果
*/
@Data
public class NotePageResponse {
private List<NoteListItem> list;
/** 符合条件的总条数 */
private long total;
/** 当前页码,从 1 开始 */
private int page;
/** 每页条数 */
private int size;
/** 是否还有下一页,客户端做无限滚动时用 */
private boolean hasMore;
}
@@ -0,0 +1,21 @@
package com.accounting.dto;
import lombok.Data;
/**
* 新建 / 更新笔记的请求体
*/
@Data
public class NoteRequest {
private String title;
/** Markdown 正文 */
private String content;
/** 标签,逗号分隔(中英文逗号都支持) */
private String tags;
/** 是否置顶:0否 1是,不传按 0 处理 */
private Integer isPinned;
}
@@ -0,0 +1,30 @@
package com.accounting.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 笔记详情(含正文)
*/
@Data
public class NoteResponse {
private Long id;
private String title;
private String content;
/** 标签列表 */
private List<String> tagList;
private Integer isPinned;
private String sourcePath;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,50 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 笔记
*
* <p>deleted 字段与 application.yml 中的
* {@code mybatis-plus.global-config.db-config.logic-delete-field=deleted} 对应,
* 查询会自动追加 {@code deleted = 0},删除会自动变成 UPDATE。</p>
*/
@Data
@TableName("note")
public class Note {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String title;
/** Markdown 正文 */
private String content;
/** 摘要,列表页展示用,避免查询 content */
private String summary;
/** 标签,逗号分隔 */
private String tags;
/** 是否置顶:0否 1是 */
private Integer isPinned;
/** 导入来源文件名 */
private String sourcePath;
@TableLogic
private Integer deleted;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,9 @@
package com.accounting.mapper;
import com.accounting.entity.Note;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface NoteMapper extends BaseMapper<Note> {
}
@@ -0,0 +1,353 @@
package com.accounting.service;
import com.accounting.dto.NoteImportRequest;
import com.accounting.dto.NoteListItem;
import com.accounting.dto.NotePageResponse;
import com.accounting.dto.NoteRequest;
import com.accounting.dto.NoteResponse;
import com.accounting.entity.Note;
import com.accounting.mapper.NoteMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
public class NoteService {
@Autowired
private NoteMapper noteMapper;
/** 摘要最大长度 */
private static final int SUMMARY_MAX_LENGTH = 150;
/** 单页最大条数,防止客户端传个 size=99999 把数据库拖死 */
private static final int MAX_PAGE_SIZE = 100;
// ------------------------------------------------------------------
// Markdown 清洗用的正则,顺序有讲究,见 buildSummary 注释
// ------------------------------------------------------------------
private static final Pattern RE_CODE_FENCE = Pattern.compile("(?s)```.*?```");
private static final Pattern RE_CODE_FENCE_OPEN = Pattern.compile("(?s)```.*$");
private static final Pattern RE_IMAGE = Pattern.compile("!\\[([^\\]]*)\\]\\([^)]*\\)");
private static final Pattern RE_LINK = Pattern.compile("\\[([^\\]]*)\\]\\([^)]*\\)");
private static final Pattern RE_HEADING = Pattern.compile("(?m)^#{1,6}\\s*");
private static final Pattern RE_QUOTE = Pattern.compile("(?m)^\\s*>\\s*");
private static final Pattern RE_LIST_MARK = Pattern.compile("(?m)^\\s*(?:[-*+]|\\d+\\.)\\s+");
private static final Pattern RE_HR = Pattern.compile("(?m)^\\s*(?:[-*_]\\s*){3,}$");
private static final Pattern RE_EMPHASIS = Pattern.compile("[*_~`]+");
private static final Pattern RE_BLANK = Pattern.compile("\\s+");
// ==================================================================
// 查询
// ==================================================================
/**
* 分页查询笔记列表(不含正文)
*
* @param keyword 关键词,同时匹配标题和正文,可为空
* @param tag 标签精确匹配,可为空
*/
public NotePageResponse list(Long userId, String keyword, String tag, int page, int size) {
int safePage = Math.max(page, 1);
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
long total = noteMapper.selectCount(buildListWrapper(userId, keyword, tag));
List<Note> rows;
if (total == 0) {
rows = new ArrayList<>();
} else {
int offset = (safePage - 1) * safeSize;
rows = noteMapper.selectList(
buildListWrapper(userId, keyword, tag)
.orderByDesc(Note::getIsPinned)
.orderByDesc(Note::getUpdateTime)
// 项目里没有配置 MyBatis-Plus 的 PaginationInnerInterceptor,
// selectPage 不会真正生成 LIMIT,所以这里直接拼。
// offset/size 都是上面校验过的 int,不存在注入问题。
.last("LIMIT " + offset + "," + safeSize)
);
}
NotePageResponse response = new NotePageResponse();
response.setList(rows.stream().map(this::toListItem).collect(Collectors.toList()));
response.setTotal(total);
response.setPage(safePage);
response.setSize(safeSize);
response.setHasMore((long) safePage * safeSize < total);
return response;
}
/**
* 查询笔记详情(含正文)
*/
public NoteResponse detail(Long userId, Long id) {
return toResponse(requireOwnedNote(userId, id));
}
/**
* 某个用户用过的全部标签,按使用次数从多到少排序
*/
public List<String> allTags(Long userId) {
List<Note> notes = noteMapper.selectList(
new LambdaQueryWrapper<Note>()
.select(Note::getTags)
.eq(Note::getUserId, userId)
);
Map<String, Integer> counter = new HashMap<>();
for (Note note : notes) {
for (String tag : parseTags(note.getTags())) {
counter.merge(tag, 1, Integer::sum);
}
}
return counter.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue(Comparator.reverseOrder())
.thenComparing(Map.Entry.comparingByKey()))
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
// ==================================================================
// 写入
// ==================================================================
@Transactional
public NoteResponse create(Long userId, NoteRequest request) {
String content = request.getContent() == null ? "" : request.getContent();
Note note = new Note();
note.setUserId(userId);
note.setContent(content);
note.setTitle(resolveTitle(request.getTitle(), content, null));
note.setSummary(buildSummary(content));
note.setTags(normalizeTags(request.getTags()));
note.setIsPinned(toPinned(request.getIsPinned()));
noteMapper.insert(note);
return toResponse(note);
}
/**
* 更新笔记 —— 部分更新语义:字段为 null 表示"本次不改这一项"
*
* <p>这样客户端可以只发 {@code {"isPinned":1}} 来切换置顶,
* 而不用担心把正文覆盖成空。要清空某个字段请显式传空串。</p>
*/
@Transactional
public NoteResponse update(Long userId, Long id, NoteRequest request) {
Note note = requireOwnedNote(userId, id);
if (request.getContent() != null) {
note.setContent(request.getContent());
note.setSummary(buildSummary(request.getContent()));
}
if (request.getTitle() != null) {
note.setTitle(resolveTitle(request.getTitle(), note.getContent(), note.getSourcePath()));
} else if (request.getContent() != null) {
// 正文变了但没显式给标题,且原标题当初是自动推导的,这里跟着刷新
note.setTitle(resolveTitle(null, request.getContent(), note.getSourcePath()));
}
if (request.getTags() != null) {
note.setTags(normalizeTags(request.getTags()));
}
if (request.getIsPinned() != null) {
note.setIsPinned(toPinned(request.getIsPinned()));
}
noteMapper.updateById(note);
return toResponse(note);
}
@Transactional
public void delete(Long userId, Long id) {
Note note = requireOwnedNote(userId, id);
// 逻辑删除:MyBatis-Plus 会把它变成 UPDATE note SET deleted = 1
noteMapper.deleteById(note.getId());
}
/**
* 批量导入
*
* @return 实际写入的条数
*/
@Transactional
public int importBatch(Long userId, List<NoteImportRequest.Item> items) {
if (items == null || items.isEmpty()) {
return 0;
}
int count = 0;
for (NoteImportRequest.Item item : items) {
String content = item.getContent() == null ? "" : item.getContent();
Note note = new Note();
note.setUserId(userId);
note.setTitle(resolveTitle(item.getTitle(), content, item.getSourcePath()));
note.setContent(content);
note.setSummary(buildSummary(content));
note.setTags(normalizeTags(item.getTags()));
note.setIsPinned(0);
note.setSourcePath(trimTo(item.getSourcePath(), 500));
noteMapper.insert(note);
count++;
}
return count;
}
// ==================================================================
// 内部工具
// ==================================================================
private LambdaQueryWrapper<Note> buildListWrapper(Long userId, String keyword, String tag) {
LambdaQueryWrapper<Note> wrapper = new LambdaQueryWrapper<Note>()
.eq(Note::getUserId, userId);
if (StringUtils.hasText(keyword)) {
String kw = keyword.trim();
wrapper.and(q -> q.like(Note::getTitle, kw).or().like(Note::getContent, kw));
}
if (StringUtils.hasText(tag)) {
// FIND_IN_SET 是精确匹配单个标签,不会出现搜 "java" 命中 "javascript" 的问题
wrapper.apply("FIND_IN_SET({0}, tags) > 0", tag.trim());
}
return wrapper;
}
/**
* 取出属于该用户的笔记,找不到或不属于该用户都抛异常
*/
private Note requireOwnedNote(Long userId, Long id) {
Note note = noteMapper.selectById(id);
if (note == null || !note.getUserId().equals(userId)) {
throw new IllegalArgumentException("笔记不存在");
}
return note;
}
private static int toPinned(Integer value) {
return (value == null || value == 0) ? 0 : 1;
}
/**
* 标题解析:优先用显式标题,没有就取正文第一个非空行,再没有就用来源文件名
*/
private String resolveTitle(String explicitTitle, String content, String fallback) {
if (StringUtils.hasText(explicitTitle)) {
return trimTo(explicitTitle.trim(), 200);
}
for (String rawLine : content.split("\\r?\\n")) {
String line = rawLine.replaceAll("^\\s*#{1,6}\\s*", "").trim();
if (!line.isEmpty()) {
return trimTo(line, 200);
}
}
if (StringUtils.hasText(fallback)) {
String name = fallback.trim();
if (name.toLowerCase().endsWith(".md")) {
name = name.substring(0, name.length() - 3);
}
return trimTo(name, 200);
}
return "无标题笔记";
}
/**
* 从 Markdown 正文提取纯文本摘要
*
* <p>处理顺序很重要:先删代码块(里面可能有像 Markdown 的符号,
* 也最不该进摘要),再删图片(必须在链接之前,否则 ![](...)
* 会先被链接规则吃掉一半留下一个感叹号),最后清理行首标记和强调符号。</p>
*/
private String buildSummary(String content) {
if (!StringUtils.hasText(content)) {
return "";
}
String text = RE_CODE_FENCE.matcher(content).replaceAll(" ");
text = RE_CODE_FENCE_OPEN.matcher(text).replaceAll(" "); // 未闭合的代码块
text = RE_IMAGE.matcher(text).replaceAll("$1");
text = RE_LINK.matcher(text).replaceAll("$1");
text = RE_HR.matcher(text).replaceAll(" ");
text = RE_HEADING.matcher(text).replaceAll("");
text = RE_QUOTE.matcher(text).replaceAll("");
text = RE_LIST_MARK.matcher(text).replaceAll("");
text = RE_EMPHASIS.matcher(text).replaceAll("");
text = RE_BLANK.matcher(text).replaceAll(" ").trim();
if (text.length() <= SUMMARY_MAX_LENGTH) {
return text;
}
return text.substring(0, SUMMARY_MAX_LENGTH) + "…";
}
/**
* 标签解析,同时支持中英文逗号,去重且保持原有顺序
*/
private static List<String> parseTags(String tags) {
if (!StringUtils.hasText(tags)) {
return new ArrayList<>();
}
return Arrays.stream(tags.split("[,,]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.distinct()
.collect(Collectors.toList());
}
/** 归一化成存储用的逗号分隔串 */
private static String normalizeTags(String tags) {
List<String> list = parseTags(tags);
return trimTo(String.join(",", list), 200);
}
private static String trimTo(String value, int maxLength) {
if (value == null) {
return "";
}
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
private NoteListItem toListItem(Note note) {
NoteListItem item = new NoteListItem();
item.setId(note.getId());
item.setTitle(note.getTitle());
item.setSummary(note.getSummary());
item.setTagList(parseTags(note.getTags()));
item.setIsPinned(note.getIsPinned());
item.setUpdateTime(note.getUpdateTime());
return item;
}
private NoteResponse toResponse(Note note) {
NoteResponse response = new NoteResponse();
response.setId(note.getId());
response.setTitle(note.getTitle());
response.setContent(note.getContent());
response.setTagList(parseTags(note.getTags()));
response.setIsPinned(note.getIsPinned());
response.setSourcePath(note.getSourcePath());
response.setCreateTime(note.getCreateTime());
response.setUpdateTime(note.getUpdateTime());
return response;
}
}