笔记管理功能初始化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user