MD编辑器更新
UI更新
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.NoteAttachmentResponse;
|
||||
import com.accounting.dto.NoteFolderAssignRequest;
|
||||
import com.accounting.dto.NoteImportRequest;
|
||||
import com.accounting.dto.NotePageResponse;
|
||||
import com.accounting.dto.NoteRequest;
|
||||
@@ -45,10 +46,14 @@ public class NoteController {
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String tag,
|
||||
@RequestParam(defaultValue = "all") String scope,
|
||||
@RequestParam(required = false) Long folderId,
|
||||
@RequestParam(defaultValue = "updated") String sort,
|
||||
Authentication authentication) {
|
||||
|
||||
Long userId = getUserId(authentication);
|
||||
return ResponseEntity.ok(noteService.list(userId, keyword, tag, page, size));
|
||||
return ResponseEntity.ok(
|
||||
noteService.list(userId, scope, folderId, keyword, tag, sort, page, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询笔记详情(含正文)")
|
||||
@@ -94,7 +99,7 @@ public class NoteController {
|
||||
Authentication authentication) {
|
||||
|
||||
Long userId = getUserId(authentication);
|
||||
int imported = noteService.importBatch(userId, request.getNotes());
|
||||
int imported = noteService.importBatch(userId, request.getNotes(), request.getFolderId());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
@@ -102,6 +107,21 @@ public class NoteController {
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "把笔记移动到某个目录(folderId 为 null 表示移到未分类)")
|
||||
@PutMapping("/{id}/folder")
|
||||
public ResponseEntity<Map<String, Object>> assignFolder(
|
||||
@PathVariable Long id,
|
||||
@RequestBody NoteFolderAssignRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
Long userId = getUserId(authentication);
|
||||
noteService.assignFolder(userId, id, request.getFolderId());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "上传图片附件,返回可直接在 Markdown 中引用的地址")
|
||||
@PostMapping("/attachments")
|
||||
public ResponseEntity<NoteAttachmentResponse> uploadAttachment(
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.FolderMoveRequest;
|
||||
import com.accounting.dto.FolderNode;
|
||||
import com.accounting.dto.FolderRequest;
|
||||
import com.accounting.dto.FolderTreeResponse;
|
||||
import com.accounting.entity.User;
|
||||
import com.accounting.mapper.UserMapper;
|
||||
import com.accounting.service.NoteFolderService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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 java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 笔记目录
|
||||
*
|
||||
* <p>路径挂在 {@code /api/notes/folders} 下,和 {@code /api/notes/{id}} 并存 ——
|
||||
* Spring 的路径匹配对字面量段的优先级高于变量段,所以 {@code folders} 不会被
|
||||
* 当成笔记 id,这一点和已有的 {@code /api/notes/tags} 是同一个机制。</p>
|
||||
*/
|
||||
@Tag(name = "笔记目录", description = "笔记文件夹的增删改查与移动")
|
||||
@RestController
|
||||
@RequestMapping("/api/notes/folders")
|
||||
public class NoteFolderController {
|
||||
|
||||
@Autowired
|
||||
private NoteFolderService folderService;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Operation(summary = "获取目录树(含各目录的笔记数,数量包含子目录)")
|
||||
@GetMapping
|
||||
public ResponseEntity<FolderTreeResponse> tree(Authentication authentication) {
|
||||
return ResponseEntity.ok(folderService.tree(getUserId(authentication)));
|
||||
}
|
||||
|
||||
@Operation(summary = "新建目录")
|
||||
@PostMapping
|
||||
public ResponseEntity<FolderNode> create(
|
||||
@RequestBody FolderRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
Long userId = getUserId(authentication);
|
||||
return ResponseEntity.ok(
|
||||
folderService.create(userId, request.getName(), request.getParentId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "重命名目录")
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> rename(
|
||||
@PathVariable Long id,
|
||||
@RequestBody FolderRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
folderService.rename(getUserId(authentication), id, request.getName());
|
||||
return ResponseEntity.ok(ok());
|
||||
}
|
||||
|
||||
@Operation(summary = "移动目录到新的父目录(parentId 为 null 表示移到根目录)")
|
||||
@PutMapping("/{id}/move")
|
||||
public ResponseEntity<Map<String, Object>> move(
|
||||
@PathVariable Long id,
|
||||
@RequestBody FolderMoveRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
folderService.move(getUserId(authentication), id, request.getParentId());
|
||||
return ResponseEntity.ok(ok());
|
||||
}
|
||||
|
||||
@Operation(summary = "删除目录(子目录与笔记上移到父目录,不删除笔记)")
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> delete(
|
||||
@PathVariable Long id,
|
||||
Authentication authentication) {
|
||||
|
||||
folderService.delete(getUserId(authentication), id);
|
||||
return ResponseEntity.ok(ok());
|
||||
}
|
||||
|
||||
private Map<String, Object> ok() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
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,14 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 移动目录(改变它的父目录)
|
||||
*
|
||||
* <p>{@code parentId} 为 null 表示移动到根目录下。</p>
|
||||
*/
|
||||
@Data
|
||||
public class FolderMoveRequest {
|
||||
|
||||
private Long parentId;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 目录树节点
|
||||
*/
|
||||
@Data
|
||||
public class FolderNode {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
/** 父目录 ID,null = 根目录下的一级目录 */
|
||||
private Long parentId;
|
||||
|
||||
/**
|
||||
* 该目录下的笔记数(**含所有子目录**)
|
||||
*
|
||||
* <p>这样父目录的数量不会和"点进去看到的条数"对不上:
|
||||
* 列表默认就是按"当前目录含子目录"来查的,两边口径一致。</p>
|
||||
*/
|
||||
private int noteCount;
|
||||
|
||||
private List<FolderNode> children = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 新建 / 重命名目录
|
||||
*/
|
||||
@Data
|
||||
public class FolderRequest {
|
||||
|
||||
private String name;
|
||||
|
||||
/** 新建时指定父目录;null = 建在根目录下 */
|
||||
private Long parentId;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 目录树接口的返回体
|
||||
*/
|
||||
@Data
|
||||
public class FolderTreeResponse {
|
||||
|
||||
/** 目录树(只含真实目录,根层级) */
|
||||
private List<FolderNode> folders = new ArrayList<>();
|
||||
|
||||
/** 未分类的笔记数 */
|
||||
private int uncategorizedCount;
|
||||
|
||||
/** 全部笔记数,含未分类 */
|
||||
private int totalCount;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 把笔记移动到某个目录
|
||||
*
|
||||
* <p>单独开一个接口而不是复用 {@code PUT /api/notes/{id}},是因为后者是
|
||||
* 部分更新语义(字段为 null 表示"不改动"),没法表达"把笔记移回未分类"。
|
||||
* 这里 folderId 为 null 是明确的"未分类"。</p>
|
||||
*/
|
||||
@Data
|
||||
public class NoteFolderAssignRequest {
|
||||
|
||||
/** 目标目录 ID,null = 移到未分类 */
|
||||
private Long folderId;
|
||||
}
|
||||
@@ -20,6 +20,9 @@ public class NoteImportRequest {
|
||||
@Valid
|
||||
private List<Item> notes;
|
||||
|
||||
/** 导入到哪个目录;null = 未分类。由前端按当前所在目录传入 */
|
||||
private Long folderId;
|
||||
|
||||
@Data
|
||||
public static class Item {
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ public class NoteListItem {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 所属目录 ID,null = 未分类 */
|
||||
private Long folderId;
|
||||
|
||||
private String title;
|
||||
|
||||
/** 摘要,由正文自动提取 */
|
||||
|
||||
@@ -13,6 +13,9 @@ public class NoteResponse {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 所属目录 ID,null = 未分类 */
|
||||
private Long folderId;
|
||||
|
||||
private String title;
|
||||
|
||||
private String content;
|
||||
|
||||
@@ -24,6 +24,9 @@ public class Note {
|
||||
|
||||
private Long userId;
|
||||
|
||||
/** 所属目录 ID,null = 未分类 */
|
||||
private Long folderId;
|
||||
|
||||
private String title;
|
||||
|
||||
/** Markdown 正文 */
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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>树形结构,parentId 为 null 表示根目录下的目录。</p>
|
||||
*/
|
||||
@Data
|
||||
@TableName("note_folder")
|
||||
public class NoteFolder {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
/** 父目录 ID,null = 位于根目录 */
|
||||
private Long parentId;
|
||||
|
||||
private String name;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.accounting.mapper;
|
||||
|
||||
import com.accounting.entity.NoteFolder;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface NoteFolderMapper extends BaseMapper<NoteFolder> {
|
||||
}
|
||||
@@ -3,7 +3,26 @@ package com.accounting.mapper;
|
||||
import com.accounting.entity.Note;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
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.Map;
|
||||
|
||||
@Mapper
|
||||
public interface NoteMapper extends BaseMapper<Note> {
|
||||
|
||||
/**
|
||||
* 按目录统计笔记数(只统计当前目录直接挂的,不含子目录)
|
||||
*
|
||||
* <p>用注解写原生 SQL 是为了能 group by 聚合 —— BaseMapper 不带这个能力。
|
||||
* 这里显式带上 {@code deleted = 0},因为自定义 SQL 不会走 MyBatis-Plus
|
||||
* 的逻辑删除自动拼接。</p>
|
||||
*
|
||||
* <p>folder_id 为 NULL 的那一行表示未分类笔记。</p>
|
||||
*/
|
||||
@Select("SELECT folder_id, COUNT(*) AS cnt FROM note "
|
||||
+ "WHERE user_id = #{userId} AND deleted = 0 "
|
||||
+ "GROUP BY folder_id")
|
||||
List<Map<String, Object>> countGroupByFolder(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.FolderNode;
|
||||
import com.accounting.dto.FolderTreeResponse;
|
||||
import com.accounting.entity.Note;
|
||||
import com.accounting.entity.NoteFolder;
|
||||
import com.accounting.mapper.NoteFolderMapper;
|
||||
import com.accounting.mapper.NoteMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
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.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 笔记目录(文件夹)
|
||||
*
|
||||
* <p>树的深度和节点数在个人使用场景下都很小(几百个顶天),
|
||||
* 所以不做递归 SQL,一次性把该用户的目录全查出来在内存里组树。</p>
|
||||
*/
|
||||
@Service
|
||||
public class NoteFolderService {
|
||||
|
||||
@Autowired
|
||||
private NoteFolderMapper folderMapper;
|
||||
|
||||
@Autowired
|
||||
private NoteMapper noteMapper;
|
||||
|
||||
private static final int NAME_MAX_LENGTH = 100;
|
||||
|
||||
// ==================================================================
|
||||
// 查询
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* 目录树 + 各目录的笔记数
|
||||
*
|
||||
* <p>目录上的笔记数是**含子目录**的累计值,和列表接口
|
||||
* 「按当前目录含子目录查询」的口径保持一致,
|
||||
* 不会出现"父目录显示 5、点进去只有 2"的困惑。</p>
|
||||
*/
|
||||
public FolderTreeResponse tree(Long userId) {
|
||||
List<NoteFolder> folders = folderMapper.selectList(
|
||||
new LambdaQueryWrapper<NoteFolder>()
|
||||
.eq(NoteFolder::getUserId, userId)
|
||||
.orderByAsc(NoteFolder::getSortOrder)
|
||||
.orderByAsc(NoteFolder::getId));
|
||||
|
||||
Map<Long, Integer> directCounts = new HashMap<>();
|
||||
int uncategorized = 0;
|
||||
int total = 0;
|
||||
|
||||
for (Map<String, Object> row : noteMapper.countGroupByFolder(userId)) {
|
||||
int count = toInt(row.get("cnt"));
|
||||
total += count;
|
||||
|
||||
Object folderId = row.get("folder_id");
|
||||
if (folderId == null) {
|
||||
uncategorized = count;
|
||||
} else {
|
||||
directCounts.put(((Number) folderId).longValue(), count);
|
||||
}
|
||||
}
|
||||
|
||||
Map<Long, FolderNode> nodeById = new LinkedHashMap<>();
|
||||
for (NoteFolder folder : folders) {
|
||||
FolderNode node = new FolderNode();
|
||||
node.setId(folder.getId());
|
||||
node.setName(folder.getName());
|
||||
node.setParentId(folder.getParentId());
|
||||
node.setNoteCount(directCounts.getOrDefault(folder.getId(), 0));
|
||||
nodeById.put(folder.getId(), node);
|
||||
}
|
||||
|
||||
List<FolderNode> roots = new ArrayList<>();
|
||||
for (FolderNode node : nodeById.values()) {
|
||||
FolderNode parent = node.getParentId() == null ? null : nodeById.get(node.getParentId());
|
||||
// 父目录查不到(数据异常)时当根节点处理,避免整棵子树丢失
|
||||
if (parent == null) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
parent.getChildren().add(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 自底向上把子目录的数量累加到父目录
|
||||
for (FolderNode root : roots) {
|
||||
accumulateCount(root);
|
||||
}
|
||||
|
||||
FolderTreeResponse response = new FolderTreeResponse();
|
||||
response.setFolders(roots);
|
||||
response.setUncategorizedCount(uncategorized);
|
||||
response.setTotalCount(total);
|
||||
return response;
|
||||
}
|
||||
|
||||
private int accumulateCount(FolderNode node) {
|
||||
int sum = node.getNoteCount();
|
||||
for (FolderNode child : node.getChildren()) {
|
||||
sum += accumulateCount(child);
|
||||
}
|
||||
node.setNoteCount(sum);
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个目录及其所有子孙目录的 id 集合(含自身)。
|
||||
* NoteService 按目录查询笔记时用它拼 IN 条件。
|
||||
*/
|
||||
public Set<Long> subtreeIds(Long userId, Long rootId) {
|
||||
if (rootId == null) {
|
||||
return new LinkedHashSet<>();
|
||||
}
|
||||
|
||||
List<NoteFolder> folders = folderMapper.selectList(
|
||||
new LambdaQueryWrapper<NoteFolder>()
|
||||
.select(NoteFolder::getId, NoteFolder::getParentId)
|
||||
.eq(NoteFolder::getUserId, userId));
|
||||
|
||||
Map<Long, List<Long>> childrenOf = new HashMap<>();
|
||||
for (NoteFolder folder : folders) {
|
||||
childrenOf.computeIfAbsent(folder.getParentId(), k -> new ArrayList<>()).add(folder.getId());
|
||||
}
|
||||
|
||||
Set<Long> result = new LinkedHashSet<>();
|
||||
collect(rootId, childrenOf, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collect(Long id, Map<Long, List<Long>> childrenOf, Set<Long> result) {
|
||||
// 正常情况下不会有环(move 里做了校验),这里再兜一层防止脏数据把请求拖死
|
||||
if (!result.add(id)) {
|
||||
return;
|
||||
}
|
||||
for (Long child : childrenOf.getOrDefault(id, List.of())) {
|
||||
collect(child, childrenOf, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 写入
|
||||
// ==================================================================
|
||||
|
||||
@Transactional
|
||||
public FolderNode create(Long userId, String name, Long parentId) {
|
||||
String cleanName = requireName(name);
|
||||
|
||||
if (parentId != null) {
|
||||
requireOwned(userId, parentId);
|
||||
}
|
||||
ensureNameNotTaken(userId, parentId, cleanName, null);
|
||||
|
||||
NoteFolder folder = new NoteFolder();
|
||||
folder.setUserId(userId);
|
||||
folder.setName(cleanName);
|
||||
folder.setParentId(parentId);
|
||||
folder.setSortOrder(0);
|
||||
folderMapper.insert(folder);
|
||||
|
||||
FolderNode node = new FolderNode();
|
||||
node.setId(folder.getId());
|
||||
node.setName(folder.getName());
|
||||
node.setParentId(folder.getParentId());
|
||||
node.setNoteCount(0);
|
||||
return node;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void rename(Long userId, Long id, String name) {
|
||||
NoteFolder folder = requireOwned(userId, id);
|
||||
String cleanName = requireName(name);
|
||||
ensureNameNotTaken(userId, folder.getParentId(), cleanName, id);
|
||||
|
||||
folder.setName(cleanName);
|
||||
folderMapper.updateById(folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动目录到新的父目录
|
||||
*/
|
||||
@Transactional
|
||||
public void move(Long userId, Long id, Long newParentId) {
|
||||
NoteFolder folder = requireOwned(userId, id);
|
||||
|
||||
if (newParentId != null) {
|
||||
requireOwned(userId, newParentId);
|
||||
// 不能把目录挂到自己的子目录下,否则会形成一个断开的环
|
||||
if (subtreeIds(userId, id).contains(newParentId)) {
|
||||
throw new IllegalArgumentException("不能把目录移动到它自己的子目录里");
|
||||
}
|
||||
}
|
||||
|
||||
// 用 UpdateWrapper 而不是 updateById:后者会忽略 null 字段,
|
||||
// 导致"移动到根目录"(parentId = null)静默失败
|
||||
folderMapper.update(null, new LambdaUpdateWrapper<NoteFolder>()
|
||||
.eq(NoteFolder::getId, id)
|
||||
.eq(NoteFolder::getUserId, userId)
|
||||
.set(NoteFolder::getParentId, newParentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录
|
||||
*
|
||||
* <p>子目录和笔记**上移到被删目录的父级**,不跟着删除 —— 先保数据,
|
||||
* 用户真要把笔记一起清了应该去删笔记。</p>
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(Long userId, Long id) {
|
||||
NoteFolder folder = requireOwned(userId, id);
|
||||
Long newParentId = folder.getParentId();
|
||||
|
||||
// 1. 子目录上移
|
||||
folderMapper.update(null, new LambdaUpdateWrapper<NoteFolder>()
|
||||
.eq(NoteFolder::getUserId, userId)
|
||||
.eq(NoteFolder::getParentId, id)
|
||||
.set(NoteFolder::getParentId, newParentId));
|
||||
|
||||
// 2. 笔记上移(目录本身只是归类,笔记内容不受影响)
|
||||
noteMapper.update(null, new LambdaUpdateWrapper<Note>()
|
||||
.eq(Note::getUserId, userId)
|
||||
.eq(Note::getFolderId, id)
|
||||
.set(Note::getFolderId, newParentId));
|
||||
|
||||
// 3. 目录本身走逻辑删除
|
||||
folderMapper.deleteById(id);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 内部工具
|
||||
// ==================================================================
|
||||
|
||||
public NoteFolder requireOwned(Long userId, Long id) {
|
||||
NoteFolder folder = folderMapper.selectById(id);
|
||||
if (folder == null || !folder.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("目录不存在");
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
/** 目标父目录在移动前也需要校验,供别的服务复用 */
|
||||
public void requireOwnedIfPresent(Long userId, Long id) {
|
||||
if (id != null) {
|
||||
requireOwned(userId, id);
|
||||
}
|
||||
}
|
||||
|
||||
private String requireName(String name) {
|
||||
if (!StringUtils.hasText(name)) {
|
||||
throw new IllegalArgumentException("目录名不能为空");
|
||||
}
|
||||
String clean = name.trim();
|
||||
if (clean.length() > NAME_MAX_LENGTH) {
|
||||
clean = clean.substring(0, NAME_MAX_LENGTH);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一层级下不允许重名,否则用户分不清点哪个。
|
||||
* [excludeId] 用于重命名时排除自己。
|
||||
*/
|
||||
private void ensureNameNotTaken(Long userId, Long parentId, String name, Long excludeId) {
|
||||
LambdaQueryWrapper<NoteFolder> wrapper = new LambdaQueryWrapper<NoteFolder>()
|
||||
.eq(NoteFolder::getUserId, userId)
|
||||
.eq(NoteFolder::getName, name);
|
||||
|
||||
if (parentId == null) {
|
||||
wrapper.isNull(NoteFolder::getParentId);
|
||||
} else {
|
||||
wrapper.eq(NoteFolder::getParentId, parentId);
|
||||
}
|
||||
if (excludeId != null) {
|
||||
wrapper.ne(NoteFolder::getId, excludeId);
|
||||
}
|
||||
|
||||
if (folderMapper.selectCount(wrapper) > 0) {
|
||||
throw new IllegalArgumentException("同一层级下已经有叫「" + name + "」的目录了");
|
||||
}
|
||||
}
|
||||
|
||||
private static int toInt(Object value) {
|
||||
return value instanceof Number ? ((Number) value).intValue() : 0;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.accounting.dto.NoteResponse;
|
||||
import com.accounting.entity.Note;
|
||||
import com.accounting.mapper.NoteMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -19,6 +20,7 @@ import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -28,6 +30,16 @@ public class NoteService {
|
||||
@Autowired
|
||||
private NoteMapper noteMapper;
|
||||
|
||||
@Autowired
|
||||
private NoteFolderService folderService;
|
||||
|
||||
/** 查询范围:全部笔记 */
|
||||
private static final String SCOPE_ALL = "all";
|
||||
/** 查询范围:未分类(folder_id 为空) */
|
||||
private static final String SCOPE_UNCATEGORIZED = "uncategorized";
|
||||
/** 查询范围:指定目录(含子目录) */
|
||||
private static final String SCOPE_FOLDER = "folder";
|
||||
|
||||
/** 摘要最大长度 */
|
||||
private static final int SUMMARY_MAX_LENGTH = 150;
|
||||
|
||||
@@ -55,14 +67,18 @@ public class NoteService {
|
||||
/**
|
||||
* 分页查询笔记列表(不含正文)
|
||||
*
|
||||
* @param scope all(默认,全部笔记)/ uncategorized(未分类)/ folder(指定目录)
|
||||
* @param folderId 仅 scope=folder 时有意义,查询含该目录及其所有子目录
|
||||
* @param keyword 关键词,同时匹配标题和正文,可为空
|
||||
* @param tag 标签精确匹配,可为空
|
||||
* @param sort updated(默认)/ created / title
|
||||
*/
|
||||
public NotePageResponse list(Long userId, String keyword, String tag, int page, int size) {
|
||||
public NotePageResponse list(Long userId, String scope, Long folderId, String keyword,
|
||||
String tag, String sort, 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));
|
||||
long total = noteMapper.selectCount(buildListWrapper(userId, scope, folderId, keyword, tag));
|
||||
|
||||
List<Note> rows;
|
||||
if (total == 0) {
|
||||
@@ -70,9 +86,7 @@ public class NoteService {
|
||||
} else {
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
rows = noteMapper.selectList(
|
||||
buildListWrapper(userId, keyword, tag)
|
||||
.orderByDesc(Note::getIsPinned)
|
||||
.orderByDesc(Note::getUpdateTime)
|
||||
applySort(buildListWrapper(userId, scope, folderId, keyword, tag), sort)
|
||||
// 项目里没有配置 MyBatis-Plus 的 PaginationInnerInterceptor,
|
||||
// selectPage 不会真正生成 LIMIT,所以这里直接拼。
|
||||
// offset/size 都是上面校验过的 int,不存在注入问题。
|
||||
@@ -180,16 +194,39 @@ public class NoteService {
|
||||
noteMapper.deleteById(note.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把笔记移动到某个目录
|
||||
*
|
||||
* <p>单独一个方法而不是走 {@link #update},是因为后者是部分更新语义
|
||||
* (字段为 null 表示不改动),没法表达"移回未分类"。
|
||||
* 这里的 folderId 为 null 是明确的「未分类」。</p>
|
||||
*/
|
||||
@Transactional
|
||||
public void assignFolder(Long userId, Long noteId, Long folderId) {
|
||||
requireOwnedNote(userId, noteId);
|
||||
// 目标目录必须存在且属于当前用户;null 表示未分类,允许
|
||||
folderService.requireOwnedIfPresent(userId, folderId);
|
||||
|
||||
// 用 UpdateWrapper:updateById 会忽略 null 字段,
|
||||
// 导致"移回未分类"静默失败
|
||||
noteMapper.update(null, new LambdaUpdateWrapper<Note>()
|
||||
.eq(Note::getId, noteId)
|
||||
.eq(Note::getUserId, userId)
|
||||
.set(Note::getFolderId, folderId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入
|
||||
*
|
||||
* @param folderId 导入到哪个目录,null = 未分类
|
||||
* @return 实际写入的条数
|
||||
*/
|
||||
@Transactional
|
||||
public int importBatch(Long userId, List<NoteImportRequest.Item> items) {
|
||||
public int importBatch(Long userId, List<NoteImportRequest.Item> items, Long folderId) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
folderService.requireOwnedIfPresent(userId, folderId);
|
||||
|
||||
int count = 0;
|
||||
for (NoteImportRequest.Item item : items) {
|
||||
@@ -197,6 +234,7 @@ public class NoteService {
|
||||
|
||||
Note note = new Note();
|
||||
note.setUserId(userId);
|
||||
note.setFolderId(folderId);
|
||||
note.setTitle(resolveTitle(item.getTitle(), content, item.getSourcePath()));
|
||||
note.setContent(content);
|
||||
note.setSummary(buildSummary(content));
|
||||
@@ -213,10 +251,26 @@ public class NoteService {
|
||||
// 内部工具
|
||||
// ==================================================================
|
||||
|
||||
private LambdaQueryWrapper<Note> buildListWrapper(Long userId, String keyword, String tag) {
|
||||
private LambdaQueryWrapper<Note> buildListWrapper(Long userId, String scope, Long folderId,
|
||||
String keyword, String tag) {
|
||||
LambdaQueryWrapper<Note> wrapper = new LambdaQueryWrapper<Note>()
|
||||
.eq(Note::getUserId, userId);
|
||||
|
||||
if (SCOPE_UNCATEGORIZED.equals(scope)) {
|
||||
wrapper.isNull(Note::getFolderId);
|
||||
} else if (SCOPE_FOLDER.equals(scope) && folderId != null) {
|
||||
// 目录查询默认含子目录:点开「工作」应该看到「工作/服务器」里的东西,
|
||||
// 而不是一片空白。目录上显示的笔记数也是这个口径。
|
||||
Set<Long> ids = folderService.subtreeIds(userId, folderId);
|
||||
if (ids.isEmpty()) {
|
||||
// in() 传空集合会生成非法的 `IN ()`,这里直接让它查不到数据
|
||||
wrapper.apply("1 = 0");
|
||||
} else {
|
||||
wrapper.in(Note::getFolderId, ids);
|
||||
}
|
||||
}
|
||||
// scope=all(默认)或没传 folderId 时不做目录过滤
|
||||
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
String kw = keyword.trim();
|
||||
wrapper.and(q -> q.like(Note::getTitle, kw).or().like(Note::getContent, kw));
|
||||
@@ -230,6 +284,25 @@ public class NoteService {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序
|
||||
*
|
||||
* <p>置顶永远是第一排序键 —— 那是用户显式做的选择,
|
||||
* 不该因为切了排序方式就被冲掉。</p>
|
||||
*/
|
||||
private LambdaQueryWrapper<Note> applySort(LambdaQueryWrapper<Note> wrapper, String sort) {
|
||||
wrapper.orderByDesc(Note::getIsPinned);
|
||||
|
||||
if ("created".equals(sort)) {
|
||||
wrapper.orderByDesc(Note::getCreateTime);
|
||||
} else if ("title".equals(sort)) {
|
||||
wrapper.orderByAsc(Note::getTitle);
|
||||
} else {
|
||||
wrapper.orderByDesc(Note::getUpdateTime);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出属于该用户的笔记,找不到或不属于该用户都抛异常
|
||||
*/
|
||||
@@ -330,6 +403,7 @@ public class NoteService {
|
||||
private NoteListItem toListItem(Note note) {
|
||||
NoteListItem item = new NoteListItem();
|
||||
item.setId(note.getId());
|
||||
item.setFolderId(note.getFolderId());
|
||||
item.setTitle(note.getTitle());
|
||||
item.setSummary(note.getSummary());
|
||||
item.setTagList(parseTags(note.getTags()));
|
||||
@@ -341,6 +415,7 @@ public class NoteService {
|
||||
private NoteResponse toResponse(Note note) {
|
||||
NoteResponse response = new NoteResponse();
|
||||
response.setId(note.getId());
|
||||
response.setFolderId(note.getFolderId());
|
||||
response.setTitle(note.getTitle());
|
||||
response.setContent(note.getContent());
|
||||
response.setTagList(parseTags(note.getTags()));
|
||||
|
||||
Reference in New Issue
Block a user