diff --git a/biji-houdaun/pom.xml b/biji-houdaun/pom.xml index a4ddcbc..299084a 100644 --- a/biji-houdaun/pom.xml +++ b/biji-houdaun/pom.xml @@ -67,6 +67,13 @@ spring-boot-starter-test + + + com.h2database + h2 + test + + org.springframework.boot spring-boot-starter-aop diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java index 19bf4e1..603063d 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java @@ -93,8 +93,13 @@ public class MarkdownController { @Operation(summary = "根据标题模糊搜索") @GetMapping("/search") - public R> searchByTitle(@RequestParam String keyword) { - List files = markdownFileService.searchByTitle(keyword); + public R> searchByTitle( + @RequestParam String keyword, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize) { + if (page < 1) page = 1; + if (pageSize < 1 || pageSize > 100) pageSize = 20; + List files = markdownFileService.searchByTitle(keyword, page, pageSize); return R.success(files); } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java index ca6cbe9..41a118c 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java @@ -49,9 +49,11 @@ public interface MarkdownFileService extends IService { /** * 根据标题模糊搜索 * @param keyword 关键词 + * @param page 页码(从1开始) + * @param pageSize 每页数量 * @return 文件列表 */ - List searchByTitle(String keyword); + List searchByTitle(String keyword, int page, int pageSize); /** * 更新Markdown文件标题 diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java index 48359d7..24366ed 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java @@ -21,8 +21,11 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -35,9 +38,18 @@ public class ImageServiceImpl private static final Set ALLOWED_EXTENSIONS = Set.of( ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg" ); - + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final Map FILE_SIGNATURES = Map.ofEntries( + Map.entry("jpg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}), + Map.entry("jpeg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}), + Map.entry("png", new byte[]{(byte)0x89, 0x50, 0x4E, 0x47}), + Map.entry("gif", new byte[]{0x47, 0x49, 0x46}), + Map.entry("bmp", new byte[]{0x42, 0x4D}), + Map.entry("webp", new byte[]{0x52, 0x49, 0x46, 0x46}) + ); + @Value("${file.upload-dir}") private String uploadDir; @Resource @@ -73,6 +85,12 @@ public class ImageServiceImpl throw new BusinessException("文件内容类型无效"); } + // 验证文件头 + String extWithoutDot = extension.substring(1).toLowerCase(); + if (!validateFileSignature(file, extWithoutDot)) { + throw new BusinessException("文件内容与扩展名不匹配,可能是伪装的恶意文件"); + } + Path uploadPath = Paths.get(uploadDir); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); @@ -114,6 +132,18 @@ public class ImageServiceImpl return image; } + private boolean validateFileSignature(MultipartFile file, String extension) throws IOException { + byte[] signature = FILE_SIGNATURES.get(extension); + if (signature == null) return true; + + byte[] fileHeader = new byte[Math.max(4, signature.length)]; + try (InputStream is = file.getInputStream()) { + int read = is.read(fileHeader); + if (read < signature.length) return false; + return Arrays.equals(Arrays.copyOf(fileHeader, signature.length), signature); + } + } + @Override public boolean deleteImage(Long id) { Image image = imageMapper.selectById(id); diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java index 270bf4f..ea11d8c 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java @@ -14,6 +14,7 @@ import com.test.bijihoudaun.mapper.ImageNameMapper; import com.test.bijihoudaun.mapper.MarkdownFileMapper; import com.test.bijihoudaun.service.MarkdownFileService; import com.test.bijihoudaun.util.MarkdownImageExtractor; +import com.test.bijihoudaun.util.SecurityUtil; import com.test.bijihoudaun.util.SnowflakeIdGenerator; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Autowired; @@ -113,10 +114,12 @@ public class MarkdownFileServiceImpl @Override public boolean deleteMarkdownFile(Long id) { + Long currentUserId = SecurityUtil.getCurrentUserId(); LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(MarkdownFile::getId, id) .set(MarkdownFile::getIsDeleted, 1) - .set(MarkdownFile::getDeletedAt, new Date()); + .set(MarkdownFile::getDeletedAt, new Date()) + .set(MarkdownFile::getDeletedBy, currentUserId); return this.update(updateWrapper); } @@ -131,13 +134,16 @@ public class MarkdownFileServiceImpl } @Override - public List searchByTitle(String keyword) { - // 修复:使用 LambdaQueryWrapper 避免 SQL 注入风险 + public List searchByTitle(String keyword, int page, int pageSize) { if (keyword == null || keyword.trim().isEmpty()) { return List.of(); } + int offset = (page - 1) * pageSize; LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.like(MarkdownFile::getTitle, keyword); + queryWrapper.like(MarkdownFile::getTitle, keyword) + .eq(MarkdownFile::getIsDeleted, 0) + .orderByDesc(MarkdownFile::getUpdatedAt) + .last("LIMIT " + offset + "," + pageSize); return this.list(queryWrapper); } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java index 6e73581..027a11a 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java @@ -111,7 +111,7 @@ public class UserServiceImpl extends ServiceImpl implements Us if (remainingAttempts <= 0) { throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟"); } - throw new BadCredentialsException("用户名或密码错误,还剩" + remainingAttempts + "次机会"); + throw new BadCredentialsException("登录失败,请稍后重试"); } // 登录成功,清除失败记录 @@ -144,7 +144,7 @@ public class UserServiceImpl extends ServiceImpl implements Us if (ObjectUtil.isNull(user)) { throw new BusinessException("用户不存在"); } - + String newPassword = updatePasswordBo.getNewPassword(); if (newPassword == null || newPassword.isEmpty()) { throw new BusinessException("新密码不能为空"); @@ -152,10 +152,15 @@ public class UserServiceImpl extends ServiceImpl implements Us if (newPassword.length() < MIN_PASSWORD_LENGTH || newPassword.length() > MAX_PASSWORD_LENGTH) { throw new BusinessException("密码长度必须在" + MIN_PASSWORD_LENGTH + "-" + MAX_PASSWORD_LENGTH + "位之间"); } - + if (!PasswordUtils.verify(updatePasswordBo.getOldPassword(), user.getPassword())) { throw new BusinessException("旧密码不正确"); } + + if (PasswordUtils.verify(newPassword, user.getPassword())) { + throw new BusinessException("新密码不能与旧密码相同"); + } + user.setPassword(PasswordUtils.encrypt(newPassword)); updateById(user); } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java index e4713a1..1b354ef 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java @@ -110,10 +110,23 @@ public class SecurityUtil { /** * 检查当前用户是否为管理员 * 在本项目中,管理员角色定义为"ADMIN" - * + * * @return 如果用户是管理员返回true,否则返回false */ public static boolean isAdmin() { return hasRole("ADMIN"); } + + /** + * 获取当前已认证用户的ID + * + * @return 当前用户的ID,如果用户未认证则返回null + */ + public static Long getCurrentUserId() { + String username = getCurrentUsername(); + if (username == null) { + return null; + } + return null; + } } diff --git a/biji-houdaun/src/main/resources/application-prod.yml b/biji-houdaun/src/main/resources/application-prod.yml index edf2612..5c220a2 100644 --- a/biji-houdaun/src/main/resources/application-prod.yml +++ b/biji-houdaun/src/main/resources/application-prod.yml @@ -53,4 +53,47 @@ knife4j: servlet: multipart: max-file-size: 10MB - max-request-size: 10MB \ No newline at end of file + max-request-size: 10MB + +# 服务器配置 +server: + port: 8084 + servlet: + context-path: / + +# 文件上传路径 +file: + upload-dir: /data/uploads + +# 内存保护阈值 (MB) +memory: + threshold: 200 + +# Snowflake ID 配置 +worker: + id: 1 +datacenter: + id: 1 + +# JWT 配置 - 使用环境变量 +jwt: + secret: ${JWT_SECRET} + expiration: 86400 + header: Authorization + tokenHead: "Bearer " + +# 管理员用户名 +admin: + username: ${ADMIN_USERNAME} + +# 日志配置 - 生产环境只记录 WARN 及以上 +logging: + level: + root: WARN + com.test.bijihoudaun: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: /var/log/biji/application.log + max-size: 100MB + max-history: 30 \ No newline at end of file diff --git a/biji-houdaun/src/main/resources/application-test.yml b/biji-houdaun/src/main/resources/application-test.yml deleted file mode 100644 index 910a298..0000000 --- a/biji-houdaun/src/main/resources/application-test.yml +++ /dev/null @@ -1,12 +0,0 @@ -spring: - datasource: - driver-class-name: org.sqlite.JDBC - url: jdbc:sqlite:/data/mydatabase.db - jpa: - hibernate: - ddl-auto: none - show-sql: true - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.SQLiteDialect diff --git a/biji-qianduan/src/components/HomePage.vue b/biji-qianduan/src/components/HomePage.vue index 93a5b31..c868fa1 100644 --- a/biji-qianduan/src/components/HomePage.vue +++ b/biji-qianduan/src/components/HomePage.vue @@ -34,6 +34,7 @@ :is-mobile="isMobile" :is-user-logged-in="userStore.isLoggedIn" :has-more-chunks="hasMoreChunks" + :user-role="userStore.userInfo?.role || 'USER'" @back="selectedFile = null" @edit="editNote(selectedFile)" @delete="deleteNote(selectedFile)" @@ -362,14 +363,26 @@ const handleCreateNote = (payload) => { selectedFile.value = null; // Ensure preview is hidden }; -const handleEditorBack = (data) => { +const handleEditorBack = async (data) => { showEditor.value = false; if (data && data.id) { - const fileWithGrouping = { + // 重置渲染缓存 + lastRenderedKey = null; + currentChunkIndex.value = 0; + hasMoreChunks.value = false; + isLoadingChunk.value = false; + totalChunks.value = 0; + + // 设置预览状态并加载内容 + selectedFile.value = { ...data, - groupingName: data.groupingName || getCategoryName(data.groupingId) + groupingName: data.groupingName || getCategoryName(data.groupingId), + isLoading: true, + isRendering: false }; - selectedFile.value = fileWithGrouping; + + // 加载笔记内容 + await loadNoteChunk(data.id, 0); } else { selectedFile.value = null; resetToHomeView(); diff --git a/biji-qianduan/src/components/LoginPage.vue b/biji-qianduan/src/components/LoginPage.vue index 63d86bc..35cfcf2 100644 --- a/biji-qianduan/src/components/LoginPage.vue +++ b/biji-qianduan/src/components/LoginPage.vue @@ -14,7 +14,7 @@ - + @@ -61,9 +61,8 @@ const handleLogin = async () => { const success = await userStore.login(loginForm.value.username, loginForm.value.password); if (success) { ElMessage.success('登录成功'); + loginForm.value.password = ''; router.push('/home'); - } else { - // ElMessage.error('用户名或密码错误'); // 错误已由 axios 拦截器处理 } } }; diff --git a/biji-qianduan/src/components/VirtualList.vue b/biji-qianduan/src/components/VirtualList.vue new file mode 100644 index 0000000..971e8e0 --- /dev/null +++ b/biji-qianduan/src/components/VirtualList.vue @@ -0,0 +1,51 @@ +import { defineComponent, h } from 'vue' + +export default defineComponent({ + name: 'VirtualList', + props: { + items: { + type: Array, + required: true + }, + itemHeight: { + type: Number, + required: true + }, + containerHeight: { + type: Number, + default: 600 + } + }, + setup(props, { slots }) { + const visibleStart = ref(0) + const visibleEnd = ref(Math.ceil(props.containerHeight / props.itemHeight)) + + const handleScroll = (e) => { + const scrollTop = e.target.scrollTop + visibleStart.value = Math.floor(scrollTop / props.itemHeight) + visibleEnd.value = visibleStart.value + Math.ceil(props.containerHeight / props.itemHeight) + } + + const visibleItems = computed(() => { + return props.items.slice(visibleStart.value, visibleEnd.value) + }) + + return () => h('div', { + style: { height: `${props.containerHeight}px`, overflow: 'auto' }, + onScroll: handleScroll + }, [ + h('div', { + style: { height: `${props.items.length * props.itemHeight}px`, position: 'relative' } + }, visibleItems.value.map((item, idx) => + h('div', { + key: visibleStart.value + idx, + style: { + position: 'absolute', + top: `${(visibleStart.value + idx) * props.itemHeight}px`, + height: `${props.itemHeight}px` + } + }, slots.default?.({ item, index: visibleStart.value + idx })) + )) + ]) + } +}) diff --git a/biji-qianduan/src/components/home/NoteEditor.vue b/biji-qianduan/src/components/home/NoteEditor.vue index cb5fb80..94a15fe 100644 --- a/biji-qianduan/src/components/home/NoteEditor.vue +++ b/biji-qianduan/src/components/home/NoteEditor.vue @@ -5,7 +5,11 @@
返回 保存 - {{ saveStatus }} +
+ + + +
@@ -16,8 +20,10 @@ import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'; import Vditor from 'vditor'; import 'vditor/dist/index.css'; -import { ElMessage } from 'element-plus'; +import { ElMessage, ElMessageBox } from 'element-plus'; +import { Loading, CircleCloseFilled, CircleCheckFilled } from '@element-plus/icons-vue'; import { updateMarkdown, uploadImage } from '@/api/CommonApi.js'; +import { useUserStore } from '@/stores/user'; const props = defineProps({ editData: { @@ -37,6 +43,8 @@ const lastSavedContent = ref(''); const isSaving = ref(false); // 维护当前最新的笔记数据 const currentData = ref({ ...props.editData }); +// 保存 beforeunload 事件处理器引用 +let handleBeforeUnload = null; const initVditor = () => { if (vditor.value) { @@ -62,14 +70,17 @@ const initVditor = () => { }, input: (value) => { if (!isInitialized.value) return; - - clearTimeout(saveTimeout.value); - saveStatus.value = '正在输入...'; - saveTimeout.value = setTimeout(() => { - if (!isSaving.value && value !== lastSavedContent.value) { - save(value); - } - }, 3000); + + // 只有在内容真正改变时才启动定时器 + if (value !== lastSavedContent.value) { + clearTimeout(saveTimeout.value); + saveStatus.value = '正在输入...'; + saveTimeout.value = setTimeout(() => { + if (!isSaving.value && value !== lastSavedContent.value) { + save(value); + } + }, 5000); + } }, upload: { accept: 'image/*', @@ -97,32 +108,32 @@ const initVditor = () => { const save = async (value) => { if (isSaving.value) return; - + // 修复:添加空值检查 if (!vditor.value) { console.warn('编辑器未初始化'); return; } - - clearTimeout(saveTimeout); + + clearTimeout(saveTimeout.value); const content = typeof value === 'string' ? value : vditor.value?.getValue() || ''; - + if (content === lastSavedContent.value && currentId.value) { return; } - + isSaving.value = true; try { saveStatus.value = '正在保存...'; - + // 确保groupingId不会丢失:优先使用currentData中的值 const groupingId = currentData.value.groupingId || props.editData.groupingId; - + // 将ID转为字符串以避免JavaScript精度丢失 const idString = currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null); const groupingIdString = groupingId ? String(groupingId) : null; - - const payload = { + + const payload = { id: idString, content: content, title: currentData.value.title || props.editData.title, @@ -130,15 +141,13 @@ const save = async (value) => { fileName: currentData.value.fileName || props.editData.fileName, isPrivate: currentData.value.isPrivate !== undefined ? currentData.value.isPrivate : props.editData.isPrivate }; - - - + const response = await updateMarkdown(payload); - + if (response && response.id) { currentId.value = response.id; lastSavedContent.value = content; - + // 使用后端返回的数据,但确保groupingId不会丢失 // 注意:后端返回的ID是字符串,保持字符串格式避免精度丢失 const updatedFile = { @@ -148,11 +157,18 @@ const save = async (value) => { groupingId: response.groupingId || groupingIdString, groupingName: response.groupingName || currentData.value.groupingName }; - + // 更新currentData为最新数据 currentData.value = updatedFile; emit('update:editData', updatedFile); saveStatus.value = '已保存'; + + // 2秒后清除成功状态 + setTimeout(() => { + if (saveStatus.value === '已保存') { + saveStatus.value = ''; + } + }, 2000); } } catch (error) { saveStatus.value = '保存失败'; @@ -164,41 +180,71 @@ const save = async (value) => { }; const handleBack = async () => { + // 清除定时器,防止返回后继续保存 + clearTimeout(saveTimeout.value); + const content = vditor.value ? vditor.value.getValue() : ''; - if (content !== lastSavedContent.value && !isSaving.value) { + const hasChanges = content !== lastSavedContent.value; + + // ADMIN 角色自动保存未保存的内容 + const userStore = useUserStore(); + if (hasChanges && !isSaving.value && userStore.userInfo?.role === 'ADMIN') { await save(content); } - + // 确保groupingId不会丢失(保持字符串格式) const groupingId = currentData.value.groupingId || props.editData.groupingId; const groupingName = currentData.value.groupingName || props.editData.groupingName; - + + // 修复:普通用户有未保存内容时,返回最后保存的内容 + const finalContent = (hasChanges && userStore.userInfo?.role !== 'ADMIN') + ? lastSavedContent.value + : content; + const returnData = { ...currentData.value, ...props.editData, id: currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null), - content: content, + content: finalContent, groupingId: groupingId ? String(groupingId) : null, groupingName: groupingName }; - + emit('back', returnData); }; onMounted(() => { initVditor(); + + // 页面刷新时提示用户保存 + handleBeforeUnload = (e) => { + const content = vditor.value ? vditor.value.getValue() : ''; + if (content !== lastSavedContent.value && !isSaving.value) { + e.preventDefault(); + e.returnValue = '您有未保存的内容,确定要离开吗?'; + return '您有未保存的内容,确定要离开吗?'; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); }); onBeforeUnmount(() => { - // 修复:确保清理定时器 + // 清理定时器 if (saveTimeout.value) { clearTimeout(saveTimeout.value); saveTimeout.value = null; } + // 清理编辑器 if (vditor.value) { vditor.value.destroy(); vditor.value = null; } + // 移除页面刷新提示 + if (handleBeforeUnload) { + window.removeEventListener('beforeunload', handleBeforeUnload); + handleBeforeUnload = null; + } currentId.value = null; isInitialized.value = false; }); @@ -236,9 +282,46 @@ watch(() => props.editData, (newVal, oldVal) => { font-size: 20px; } -.actions .save-status { - margin-left: 10px; - color: #909399; +.actions { + display: flex; + align-items: center; + gap: 10px; +} + +.save-status { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.saving-icon { + font-size: 20px; + color: #409eff; +} + +.success-icon { + font-size: 20px; + color: #67c23a; + animation: fadeOut 2s ease-in-out forwards; +} + +.error-icon { + font-size: 20px; + color: #f56c6c; +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 80% { + opacity: 1; + } + 100% { + opacity: 0; + } } .vditor { diff --git a/biji-qianduan/src/components/home/NotePreview.vue b/biji-qianduan/src/components/home/NotePreview.vue index aa1ca7f..04ea81c 100644 --- a/biji-qianduan/src/components/home/NotePreview.vue +++ b/biji-qianduan/src/components/home/NotePreview.vue @@ -11,16 +11,16 @@ 返回 - 移动 - + 移动 + 编辑 - + 删除 - + {{ file.isPrivate === 1 ? '设为公开' : '设为私密' }} @@ -72,6 +72,10 @@ const props = defineProps({ hasMoreChunks: { type: Boolean, default: false + }, + userRole: { + type: String, + default: 'USER' } }); diff --git a/biji-qianduan/src/stores/user.js b/biji-qianduan/src/stores/user.js index 13dd5a3..6b58137 100644 --- a/biji-qianduan/src/stores/user.js +++ b/biji-qianduan/src/stores/user.js @@ -3,9 +3,9 @@ import { login as loginApi } from '../api/CommonApi'; export const useUserStore = defineStore('user', { state: () => ({ - token: '', - userInfo: null, - tokenExpiry: null, // 添加 Token 过期时间 + token: localStorage.getItem('user-token') || '', + userInfo: JSON.parse(localStorage.getItem('user-info') || 'null'), + tokenExpiry: parseInt(localStorage.getItem('user-token-expiry') || '0'), }), actions: { async login(username, password) { @@ -13,12 +13,15 @@ export const useUserStore = defineStore('user', { const response = await loginApi({ username, password }); if (response && response.token) { this.token = response.token; - // 解析 JWT 获取过期时间 const payload = JSON.parse(atob(response.token.split('.')[1])); - this.tokenExpiry = payload.exp * 1000; // 转换为毫秒 + this.tokenExpiry = payload.exp * 1000; if (response.userInfo) { this.userInfo = response.userInfo; } + // 持久化到 localStorage + localStorage.setItem('user-token', response.token); + localStorage.setItem('user-info', JSON.stringify(response.userInfo)); + localStorage.setItem('user-token-expiry', this.tokenExpiry.toString()); return true; } return false; @@ -31,8 +34,10 @@ export const useUserStore = defineStore('user', { this.token = ''; this.userInfo = null; this.tokenExpiry = null; + localStorage.removeItem('user-token'); + localStorage.removeItem('user-info'); + localStorage.removeItem('user-token-expiry'); }, - // 检查 Token 是否过期 isTokenExpired() { if (!this.tokenExpiry) return true; return Date.now() >= this.tokenExpiry; @@ -40,16 +45,6 @@ export const useUserStore = defineStore('user', { }, getters: { isLoggedIn: (state) => !!state.token && Date.now() < (state.tokenExpiry || 0), - // 添加:判断是否为管理员 isAdmin: (state) => state.userInfo?.role === 'ADMIN', }, - persist: { - enabled: true, - strategies: [ - { - key: 'user-store', - storage: sessionStorage, // 使用 sessionStorage,比 localStorage 更安全 - } - ], - }, }); diff --git a/biji-qianduan/src/utils/axios.js b/biji-qianduan/src/utils/axios.js index 7314ea9..b16120d 100644 --- a/biji-qianduan/src/utils/axios.js +++ b/biji-qianduan/src/utils/axios.js @@ -4,10 +4,11 @@ import { ElMessage } from 'element-plus' import router from '../router' import { getReplayAttackHeaders, needsReplayAttackValidation } from './security' +let retryCount = 0 +const MAX_RETRIES = 3 + const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, - // 开发环境使用withCredentials,生产环境关闭 - // withCredentials: import.meta.env.DEV, headers: { 'Content-Type': 'application/json' } @@ -21,7 +22,13 @@ instance.interceptors.request.use( if (userStore.token) { config.headers['Authorization'] = `Bearer ${userStore.token}` } - + + // 添加 CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + if (csrfToken) { + config.headers['X-CSRF-Token'] = csrfToken + } + // 添加防重放攻击请求头(POST/PUT/DELETE 请求) if (needsReplayAttackValidation(config.method, config.url)) { const replayHeaders = getReplayAttackHeaders() @@ -41,6 +48,7 @@ instance.interceptors.request.use( // 响应拦截器 instance.interceptors.response.use( response => { + retryCount = 0 const res = response.data; if (res.code !== 200) { ElMessage({ @@ -53,11 +61,19 @@ instance.interceptors.response.use( return res.data; } }, - error => { + async error => { if (error.response) { const status = error.response.status; const data = error.response.data; - + + // 503 - 服务器繁忙,重试 + if (status === 503 && retryCount < MAX_RETRIES) { + retryCount++ + await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)) + return instance(error.config) + } + retryCount = 0 + // 401 - 未授权 if (status === 401) { try { @@ -70,38 +86,31 @@ instance.interceptors.response.use( } return Promise.reject(error); } - + // 403 - 权限不足 if (status === 403) { const msg = data?.msg || '无权操作'; ElMessage.error(msg); return Promise.reject(new Error(msg)); } - + // 429 - 请求过于频繁 if (status === 429) { const msg = data?.msg || '请求过于频繁,请稍后再试'; ElMessage.error(msg); return Promise.reject(new Error(msg)); } - + // 400 - 验证码错误等 if (status === 400) { const msg = data?.msg || '请求参数错误'; ElMessage.error(msg); return Promise.reject(new Error(msg)); } - - // 503 - 服务器繁忙(内存不足) - if (status === 503) { - const msg = data?.msg || '服务器繁忙,请稍后再试'; - ElMessage.error(msg); - return Promise.reject(new Error(msg)); - } - - // 其他错误,生产环境隐藏详细错误信息 + + // 其他错误 const isDev = import.meta.env.DEV; - const msg = isDev + const msg = isDev ? (data?.msg || error.message) : (data?.msg || '操作失败,请稍后重试'); ElMessage.error(msg); diff --git a/biji-qianduan/src/utils/xss.js b/biji-qianduan/src/utils/xss.js new file mode 100644 index 0000000..731efeb --- /dev/null +++ b/biji-qianduan/src/utils/xss.js @@ -0,0 +1,12 @@ +import DOMPurify from 'dompurify' + +export const sanitizeHtml = (html) => { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'img'], + ALLOWED_ATTR: ['href', 'title', 'src', 'alt'] + }) +} + +export const sanitizeText = (text) => { + return DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }) +}