From 429e8a7bb7e63aaa8f5fb7bb46eeae8b12d80823 Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Tue, 22 Sep 2026 17:16:07 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=94=E8=AE=B0=E7=AE=A1=E7=90=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/accounting/config/SecurityConfig.java | 5 + .../com/accounting/config/WebMvcConfig.java | 35 + .../accounting/controller/NoteController.java | 132 ++++ .../dto/NoteAttachmentResponse.java | 17 + .../com/accounting/dto/NoteImportRequest.java | 38 + .../java/com/accounting/dto/NoteListItem.java | 30 + .../com/accounting/dto/NotePageResponse.java | 26 + .../java/com/accounting/dto/NoteRequest.java | 21 + .../java/com/accounting/dto/NoteResponse.java | 30 + src/main/java/com/accounting/entity/Note.java | 50 ++ .../com/accounting/mapper/NoteMapper.java | 9 + .../com/accounting/service/NoteService.java | 353 ++++++++++ src/main/resources/db/migration_add_note.sql | 29 + src/main/resources/db/schema.sql | 18 + src/main/resources/static/admin/index.html | 650 ++++++++++++++++++ 15 files changed, 1443 insertions(+) create mode 100644 src/main/java/com/accounting/config/WebMvcConfig.java create mode 100644 src/main/java/com/accounting/controller/NoteController.java create mode 100644 src/main/java/com/accounting/dto/NoteAttachmentResponse.java create mode 100644 src/main/java/com/accounting/dto/NoteImportRequest.java create mode 100644 src/main/java/com/accounting/dto/NoteListItem.java create mode 100644 src/main/java/com/accounting/dto/NotePageResponse.java create mode 100644 src/main/java/com/accounting/dto/NoteRequest.java create mode 100644 src/main/java/com/accounting/dto/NoteResponse.java create mode 100644 src/main/java/com/accounting/entity/Note.java create mode 100644 src/main/java/com/accounting/mapper/NoteMapper.java create mode 100644 src/main/java/com/accounting/service/NoteService.java create mode 100644 src/main/resources/db/migration_add_note.sql create mode 100644 src/main/resources/static/admin/index.html diff --git a/src/main/java/com/accounting/config/SecurityConfig.java b/src/main/java/com/accounting/config/SecurityConfig.java index 9c4610f..fdf9935 100644 --- a/src/main/java/com/accounting/config/SecurityConfig.java +++ b/src/main/java/com/accounting/config/SecurityConfig.java @@ -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); diff --git a/src/main/java/com/accounting/config/WebMvcConfig.java b/src/main/java/com/accounting/config/WebMvcConfig.java new file mode 100644 index 0000000..0577210 --- /dev/null +++ b/src/main/java/com/accounting/config/WebMvcConfig.java @@ -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; + +/** + * 把上传目录暴露成静态资源 + * + *

原有的 FileUtil 只负责把文件写进 {@code ${file.upload.path}}, + * 但项目里一直没有任何地方把它映射出去,所以历史上传的图片 + * 没有任何 URL 能访问到(OCR 场景只把图片喂给阿里云, + * 不需要回显,所以一直没暴露这个问题)。

+ * + *

笔记要在 Markdown 里引用图片,必须补上这层映射。

+ */ +@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); + } +} diff --git a/src/main/java/com/accounting/controller/NoteController.java b/src/main/java/com/accounting/controller/NoteController.java new file mode 100644 index 0000000..2d3a84c --- /dev/null +++ b/src/main/java/com/accounting/controller/NoteController.java @@ -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 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 detail(@PathVariable Long id, Authentication authentication) { + Long userId = getUserId(authentication); + return ResponseEntity.ok(noteService.detail(userId, id)); + } + + @Operation(summary = "新建笔记") + @PostMapping + public ResponseEntity create(@RequestBody NoteRequest request, Authentication authentication) { + Long userId = getUserId(authentication); + return ResponseEntity.ok(noteService.create(userId, request)); + } + + @Operation(summary = "更新笔记") + @PutMapping("/{id}") + public ResponseEntity 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> delete(@PathVariable Long id, Authentication authentication) { + Long userId = getUserId(authentication); + noteService.delete(userId, id); + + Map result = new HashMap<>(); + result.put("success", true); + return ResponseEntity.ok(result); + } + + @Operation(summary = "批量导入笔记") + @PostMapping("/import") + public ResponseEntity> importBatch( + @Valid @RequestBody NoteImportRequest request, + Authentication authentication) { + + Long userId = getUserId(authentication); + int imported = noteService.importBatch(userId, request.getNotes()); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("imported", imported); + return ResponseEntity.ok(result); + } + + @Operation(summary = "上传图片附件,返回可直接在 Markdown 中引用的地址") + @PostMapping("/attachments") + public ResponseEntity 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> 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().eq(User::getUsername, username) + ); + if (user == null) { + throw new IllegalArgumentException("用户不存在"); + } + return user.getId(); + } +} diff --git a/src/main/java/com/accounting/dto/NoteAttachmentResponse.java b/src/main/java/com/accounting/dto/NoteAttachmentResponse.java new file mode 100644 index 0000000..ea06a1f --- /dev/null +++ b/src/main/java/com/accounting/dto/NoteAttachmentResponse.java @@ -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; + } +} diff --git a/src/main/java/com/accounting/dto/NoteImportRequest.java b/src/main/java/com/accounting/dto/NoteImportRequest.java new file mode 100644 index 0000000..03716d3 --- /dev/null +++ b/src/main/java/com/accounting/dto/NoteImportRequest.java @@ -0,0 +1,38 @@ +package com.accounting.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * 批量导入笔记的请求体 + * + *

由前端把 .md 文件读成字符串后一次性提交, + * 而不是走后端 multipart 解析 —— 前端可以在提交前先提取标题、 + * 去重、让用户改标签,比后端盲解析灵活得多。

+ */ +@Data +public class NoteImportRequest { + + @NotEmpty(message = "笔记列表不能为空") + @Valid + private List notes; + + @Data + public static class Item { + + /** 标题,为空时由正文首行提取 */ + private String title; + + /** Markdown 正文 */ + private String content; + + /** 导入来源文件名 */ + private String sourcePath; + + /** 标签,逗号分隔 */ + private String tags; + } +} diff --git a/src/main/java/com/accounting/dto/NoteListItem.java b/src/main/java/com/accounting/dto/NoteListItem.java new file mode 100644 index 0000000..a9f35af --- /dev/null +++ b/src/main/java/com/accounting/dto/NoteListItem.java @@ -0,0 +1,30 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 笔记列表项 —— 刻意不含 content 字段 + * + *

列表接口如果要带上正文,一页 20 条就可能传输几 MB, + * 而这个后端部署在公网服务器上,客户端跨网络拉取会明显变慢。

+ */ +@Data +public class NoteListItem { + + private Long id; + + private String title; + + /** 摘要,由正文自动提取 */ + private String summary; + + /** 标签列表 */ + private List tagList; + + private Integer isPinned; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/accounting/dto/NotePageResponse.java b/src/main/java/com/accounting/dto/NotePageResponse.java new file mode 100644 index 0000000..d35576d --- /dev/null +++ b/src/main/java/com/accounting/dto/NotePageResponse.java @@ -0,0 +1,26 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 笔记分页结果 + */ +@Data +public class NotePageResponse { + + private List list; + + /** 符合条件的总条数 */ + private long total; + + /** 当前页码,从 1 开始 */ + private int page; + + /** 每页条数 */ + private int size; + + /** 是否还有下一页,客户端做无限滚动时用 */ + private boolean hasMore; +} diff --git a/src/main/java/com/accounting/dto/NoteRequest.java b/src/main/java/com/accounting/dto/NoteRequest.java new file mode 100644 index 0000000..cee1a4a --- /dev/null +++ b/src/main/java/com/accounting/dto/NoteRequest.java @@ -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; +} diff --git a/src/main/java/com/accounting/dto/NoteResponse.java b/src/main/java/com/accounting/dto/NoteResponse.java new file mode 100644 index 0000000..4020e01 --- /dev/null +++ b/src/main/java/com/accounting/dto/NoteResponse.java @@ -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 tagList; + + private Integer isPinned; + + private String sourcePath; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/accounting/entity/Note.java b/src/main/java/com/accounting/entity/Note.java new file mode 100644 index 0000000..02bdcea --- /dev/null +++ b/src/main/java/com/accounting/entity/Note.java @@ -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; + +/** + * 笔记 + * + *

deleted 字段与 application.yml 中的 + * {@code mybatis-plus.global-config.db-config.logic-delete-field=deleted} 对应, + * 查询会自动追加 {@code deleted = 0},删除会自动变成 UPDATE。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/mapper/NoteMapper.java b/src/main/java/com/accounting/mapper/NoteMapper.java new file mode 100644 index 0000000..eaa0257 --- /dev/null +++ b/src/main/java/com/accounting/mapper/NoteMapper.java @@ -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 { +} diff --git a/src/main/java/com/accounting/service/NoteService.java b/src/main/java/com/accounting/service/NoteService.java new file mode 100644 index 0000000..b8f0e50 --- /dev/null +++ b/src/main/java/com/accounting/service/NoteService.java @@ -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 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 allTags(Long userId) { + List notes = noteMapper.selectList( + new LambdaQueryWrapper() + .select(Note::getTags) + .eq(Note::getUserId, userId) + ); + + Map 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.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 表示"本次不改这一项" + * + *

这样客户端可以只发 {@code {"isPinned":1}} 来切换置顶, + * 而不用担心把正文覆盖成空。要清空某个字段请显式传空串。

+ */ + @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 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 buildListWrapper(Long userId, String keyword, String tag) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 正文提取纯文本摘要 + * + *

处理顺序很重要:先删代码块(里面可能有像 Markdown 的符号, + * 也最不该进摘要),再删图片(必须在链接之前,否则 ![](...) + * 会先被链接规则吃掉一半留下一个感叹号),最后清理行首标记和强调符号。

+ */ + 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 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 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; + } +} diff --git a/src/main/resources/db/migration_add_note.sql b/src/main/resources/db/migration_add_note.sql new file mode 100644 index 0000000..8ffb886 --- /dev/null +++ b/src/main/resources/db/migration_add_note.sql @@ -0,0 +1,29 @@ +-- =========================================================================== +-- 笔记模块建表脚本 +-- +-- 执行方式: +-- mysql -uroot -p accounting_db < migration_add_note.sql +-- +-- 说明: +-- - 本表沿用 schema.sql 的既有约定(utf8mb4_unicode_ci / create_time / user_id 外键) +-- - deleted 字段配合 application.yml 里的 +-- mybatis-plus.global-config.db-config.logic-delete-field=deleted +-- 自动实现逻辑删除,业务代码里不需要写 where deleted = 0 +-- =========================================================================== + +CREATE TABLE IF NOT EXISTS `note` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '笔记ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `title` VARCHAR(200) NOT NULL COMMENT '标题', + `content` LONGTEXT COMMENT 'Markdown 正文', + `summary` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '摘要(列表页展示,避免查询正文)', + `tags` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔', + `is_pinned` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶:0否 1是', + `source_path` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '导入来源文件名(便于追溯)', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表'; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index bd13c74..b0d71d8 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -123,6 +123,24 @@ CREATE TABLE IF NOT EXISTS `gold_price` ( INDEX `idx_gold_date` (`gold_id`, `price_date`) COMMENT '索引:用于按日期查询' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='黄金价格记录表'; +-- 笔记表 +CREATE TABLE IF NOT EXISTS `note` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '笔记ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `title` VARCHAR(200) NOT NULL COMMENT '标题', + `content` LONGTEXT COMMENT 'Markdown正文', + `summary` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '摘要(列表页展示,避免查询正文)', + `tags` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔', + `is_pinned` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶:0否 1是', + `source_path` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '导入来源文件名', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表'; + -- 插入预设分类(支出) INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES (NULL, '餐饮', '🍔', 1, 1), diff --git a/src/main/resources/static/admin/index.html b/src/main/resources/static/admin/index.html new file mode 100644 index 0000000..2e1f80d --- /dev/null +++ b/src/main/resources/static/admin/index.html @@ -0,0 +1,650 @@ + + + + + +笔记管理 + + + + + +
+ +
+ + + + + +
+
+
+ + +
+
+ +
+
+
+ + + + + +
+
+
+ +
+ + + + + +