恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
档案管理系统开发实战:SpringBoot+Vue3+MyBatis全栈架构
首页
资讯中心
/
档案管理系统开发实战:SpringBoot+Vue3+MyBatis全栈架构
档案管理系统开发实战:SpringBoot+Vue3+MyBatis全栈架构
发布时间:2026/9/26 4:46:50
1. 档案管理系统到底在管什么从业务建模说起做这个项目之前我一直在思考一个问题市面上的档案管理系统不少为什么很多单位最后还是退回用 Excel 共享文件夹的模式答案很简单——通用系统没把档案这个词的业务特性吃透。档案管理系统和普通的 CRUD 后台有本质区别。普通管理系统的对象是一条记录删了也就删了但档案对象背后是真实性、完整性和可追溯性。所以动手写代码前我先把业务边界拆成了五块档案分类、档案登记入库、借阅归还、档案销毁、操作审计。每一块都不是简单的增删改查背后都有约束条件。比如档案编号必须全局唯一且不可重复利用——档案销毁后编号也不会释放给新档案用这是为了追溯。这套系统选择前后端分离架构也是业务决定的。档案管理通常涉及多个角色管理员、档案员、借阅人、审批人权限粒度要细到谁能看哪个类目下的哪些文件角色权限模型必须在后端做硬校验不能只靠前端按钮隐藏。确定了业务边界后我给系统画了一条主线档案生命周期。从归档到借阅到归还到销毁系统中的每一条数据都对应一个状态任何状态的变更都会写入操作日志。这样设计之后所有后来加的需求报表统计、到期提醒、催还通知都只是在这条生命周期线上做查询和事件触发不需要推翻重来。2. 为什么是 SpringBoot Vue3 MyBatis 这套组合选型这件事我建议用约束清单思维来做先把必要条件列出来再排优先级。我当时的约束条件有四条第一团队里 Java 基础最扎实Spring 生态能最大限度复用现有经验第二系统要能独立部署不能绑定特定的云厂商第三SQL 需要高可控性因为档案查询有大量多表关联和动态条件第四前端要能快速搭建后台管理界面且组内同学最好熟悉组件化开发。对照这个清单SpringBoot MyBatis Vue3 几乎是顺理成章的答案。2.1 SpringBoot 的价值不是减少代码量而是减少决策成本SpringBoot 的最大优势其实不在约定大于配置这句话本身而在于它把一套经过大规模验证的默认值直接给到你。连接池用 HikariCP、JSON 解析用 Jackson、内嵌容器用 Tomcat这些选型不需要你再纠结。档案管理系统这种典型业务系统里真正要写的代码是业务逻辑本身不是胶水代码。用 SpringBoot我可以把精力集中在档案业务规则和事务边界上。我在项目里开了这些基础配置统一返回结构Result 、全局异常处理器、CORS 跨域配置、JWT 拦截器。这些在 SpringBoot 里都是十几个注解和几十行代码的事。如果换成传统 SSM 手工装配光 web.xml、spring-mvc.xml、spring-dao.xml 三份配置就能耗掉半天。2.2 MyBatis 的取舍动态 SQL 是档案查询的刚需这个系统我没有用 MyBatis-Plus坚持手写 XML Mapper。原因不是 plus 不好而是档案管理系统的查询条件太不固定了按档案编号精确查、按类目层级模糊查、按归档日期区间查、按借阅状态查组合起来可能有几十种排列。此时 MyBatis 的where、if、foreach动态 SQL 处理这类场景非常自然且生成的 SQL 完全可控可以直接在日志里打印出来核对。举个例子档案列表查询接口的 Mapper 长这样select idselectArchivePage resultTypecom.archive.entity.ArchiveEntity SELECT a.*, c.category_name FROM archive a LEFT JOIN archive_category c ON a.category_id c.id where if testarchiveCode ! null and archiveCode ! AND a.archive_code #{archiveCode} /if if testcategoryId ! null AND a.category_id IN ( SELECT id FROM archive_category WHERE id #{categoryId} OR parent_id #{categoryId} ) /if if teststatus ! null AND a.status #{status} /if if testbeginDate ! null AND a.archive_date gt; #{beginDate} /if if testendDate ! null AND a.archive_date lt; #{endDate} /if /where ORDER BY a.create_time DESC /select这段 SQL 里有两处值得展开讲讲。第一类目查询用id #{categoryId} OR parent_id #{categoryId}是为了支持选了父类目就能看到所有子类目档案的场景这是档案分类最常见的检索诉求。第二动态条件拼接时 MyBatis 的where标签会自动去掉多余的AND省去了手写WHERE 11的脏做法。2.3 Vue3 不是必须但一旦用了就回不去Vue3 的 Composition API 对后台管理系统真正的价值不是响应式性能提升那是附带的而是逻辑组织方式的改变。档案管理页面有列表、查询表单、借阅对话框、批量操作按钮这些功能交错在一起用 Options API 写容易散落在 methods、watch、computed 里代码长了根本找不到逻辑。用 Composition API 按业务意图拆useArchiveList、useBorrowDialog、useBatchOperation组合式函数每个文件只关心一件事测试和维护都轻松很多。这套系统里前端和后端通过 RESTful JSON 交互前端不再关心页面渲染在哪台服务器只关心API_BASE_URL指向哪里。Nginx 直接前端静态资源挂根路径后端接口挂/api路径部署时候互不干扰这种形态就是前后端分离的日常写照。3. MySQL 数据库设计档案系统的地基不能糊弄业务定了、技术栈定了接下来最不能跳过的一步就是数据库建模。档案管理系统如果表结构设计有问题后期每个功能都隐身雷。我见过太多项目把档案信息堆到一张大宽表里字段几十个索引两三个最后查询慢到无法接受。所以这里我分享一下这套系统的核心表设计思路。3.1 权限模型RBAC 五张表不加戏用户、角色、菜单、用户角色关联、角色菜单关联这五张表是后台管理系统的底座。我见过有人为了简化把角色直接做成用户表里的一个字段当时省事后面每加一个功能都要改表结构。五张表的代价是多几次联查换来的是后续权限调整完全不用动代码。用户表里我只额外加了两个字段status启用/禁用和last_login_time最后登录时间。后者很有用能看到哪些账号长期未使用方便安全审计。3.2 档案核心表分类 主表 附件表档案业务最关键的是分类层级。我用一张自关联表表达树形结构字段类型说明idBIGINT主键category_nameVARCHAR(64)分类名称parent_idBIGINT父分类 ID顶级为 0sort_orderINT同层级排序statusTINYINT启用/停用树形结构用parent_id就够了吗对于档案分类一般建议再冗余一个ancestors字段存完整路径如0,1,12,35。这样查某分类下所有子分类档案时不需要递归查询一条FIND_IN_SET或LIKE就出来了。这个字段的维护成本低查询收益高建议保留。档案主表和附件表分开设计是这套系统里我认为最合理的决定。档案主表存元数据编号、标题、类别、密级、归档人、归档日期、状态附件表存文件元信息文件原名、存储路径、大小、MD5、上传时间。两者通过archive_id关联。为什么拆因为一个档案可能对应多个扫描件/电子文件而且文件上传和元数据保存是两步操作拆开后可以先传文件拿回 fileId再提交档案单流程上更顺。档案主表的核心字段如下字段类型说明备注archive_codeVARCHAR(32)档案编号唯一索引titleVARCHAR(200)档案标题普通索引category_idBIGINT分类 ID联合索引security_levelTINYINT密级公开/内部/秘密/机密statusTINYINT状态归档/借出/销毁borrow_statusTINYINT借阅状态可借/已借出archive_dateDATE归档日期联合索引create_byBIGINT创建人—create_timeDATETIME创建时间—这里我是刻意把status和borrow_status分成两个字段的。很多人会合并成一个状态字段用数字枚举扩展。但档案借出并不改变档案的存在状态一份借出去的档案依然是合法的归档档案只是当前处于借出环节。分两个字段含义清晰也方便统计在库档案数和在借档案数。3.3 借阅流水表状态机落地的关键借阅不是一次性操作它有生命周期申请 → 审批 → 借出 → 归还 → 可能续借。我把这些状态都放在borrow_record表里字段类型说明idBIGINT主键archive_idBIGINT档案 IDuser_idBIGINT借阅人borrow_typeTINYINT借阅类型整卷/复印/电子件apply_timeDATETIME申请时间approve_timeDATETIME审批时间approve_userBIGINT审批人expect_return_timeDATETIME预计归还时间actual_return_timeDATETIME实际归还时间statusTINYINT状态待审批/已借出/已归还/已拒绝remarkVARCHAR(255)备注同时在档案主表上建了索引(status, borrow_status, category_id)最常用的后台首页统计和列表过滤都靠这个组合索引撑住。3.4 操作日志表出问题时它是唯一真相档案系统的安全审计要求很重谁在什么时间对哪份档案做了什么操作必须留痕。我设计了一张统一的operation_log表字段极简id、module模块、action动作、content操作内容概述、operator_id、operator_ip、create_time。保存日志的时机放在 Service 层不用 AOP 切面原因下文会讲。4. 后端接口设计从登录鉴权到文件上传的完整链路后端接口设计我建议先定横切关注点再写业务接口。横切关注点包括认证方式、统一响应、异常映射、参数校验、跨域处理。这五样不解决业务接口写得再多也串不起来。4.1 登录鉴权JWT 拦截器不用 Spring Security技术选型时我考虑过 Spring Security OAuth2但一个内部档案系统的复杂度配不上那套体系我最终选了轻量的 JWT HandlerInterceptor。思路很简单用户登录成功后后端签发一个 24 小时有效的 JWT里面只放用户 ID、用户名、角色编码三样东西。后续请求在 Header 里携带Authorization: Bearer token拦截器解析 token 后检查有效性和过期时间再把用户信息放入 ThreadLocal供后续接口直接取用。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口和静态资源 if (isExcluded(request.getRequestURI())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { Claims claims JwtUtil.parseToken(token.substring(7)); if (claims ! null) { UserContext.set(claims); return true; } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }需要注意JWT 的好处是服务端无状态但代价是无法主动让 token 失效。如果用户修改密码或账号被禁用旧 token 依然有效。解决方案是每次请求时从数据库核对用户状态或者用 Redis 维护一个token 黑名单。我在系统里选择了前者因为查询用户表本来就轻且在拦截器里多一步数据库查询能避免很多权限漏洞。4.2 统一返回结构少踩前后端联调的坑前后端分离的核心痛点之一是沟通成本。前端问这个接口返回什么结构后端必须统一回答。我的做法是定义ResultTData public class ResultT { private Integer code; private String message; private T data; private Long timestamp; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); r.setTimestamp(System.currentTimeMillis()); return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.setCode(code); r.setMessage(message); r.setTimestamp(System.currentTimeMillis()); return r; } }同时配一个全局异常处理器把BusinessException、参数校验异常、数据库访问异常分别映射成不同的 code。前端 Axios 响应拦截器统一判断code 200否则弹出错误提示。这个约定的成本极低但能让几十个接口从未出现这个接口为什么返回 null的扯皮。4.3 档案登记与借阅归还事务边界怎么切档案登记的后端逻辑包含两步保存档案主表记录 解析并保存附件记录。这两步必须在同一个事务里否则可能出现档案数据存在但附件丢了的脏数据。Transactional(rollbackFor Exception.class) public void registerArchive(ArchiveCreateReq req) { ArchiveEntity archive new ArchiveEntity(); BeanUtils.copyProperties(req, archive); archive.setStatus(ArchiveStatus.ARCHIVED.getCode()); archive.setBorrowStatus(BorrowStatus.AVAILABLE.getCode()); archiveMapper.insert(archive); if (CollectionUtils.isNotEmpty(req.getFileIds())) { fileMapper.bindArchive(req.getFileIds(), archive.getId()); } }这里强调一个容易踩的坑Transactional注解要想生效必须满足三个条件方法必须是 public、异常必须从被代理的对象抛出、且默认只对 RuntimeException 回滚。我在借阅归还流程里就吃过亏——一个用户无法归还档案的问题排查半天发现是 Service 内部this调用导致事务代理未生效。借阅流程的事务边界要更细一点提交借阅申请只插入 borrow_record不改档案状态。审批通过修改 borrow_record 状态 更新档案 borrow_status 为借出。这两步在一个事务里。归还修改 borrow_record 状态 更新档案 borrow_status 为可借。为什么审批和改档案状态必须同一事务因为如果只改了记录状态档案主表的借出标记没变就会出现档案记录显示已借出实际已归还的错位。Transactional(rollbackFor Exception.class) public void approveBorrow(Long recordId, Integer approveResult) { BorrowRecordEntity record borrowRecordMapper.selectById(recordId); if (record null) { throw new BusinessException(借阅记录不存在); } if (!BorrowStatus.PENDING.getCode().equals(record.getStatus())) { throw new BusinessException(该记录不是待审批状态); } // 更新借阅记录 borrowRecordMapper.updateStatus(recordId, approveResult); if (approveResult.equals(BorrowStatus.BORROWED.getCode())) { // 更新档案借阅状态 archiveMapper.updateBorrowStatus(record.getArchiveId(), BorrowStatus.BORROWED.getCode()); } }4.4 文件上传下载从 MultipartFile 到断点续传档案附件以 PDF、JPG、Office 为主单个文件常见 10MB 到 100MB 不等。我的策略是本地磁盘存储 数据库存路径不上对象存储——因为这套系统定位是中小型单位内部使用本地存储足够控制成本。上传接口要点有三个限制文件类型和大小。类型白名单pdf、jpg、png、doc、docx、xls、xlsx比黑名单可靠黑名单永远堵不住。存储路径按日期分目录避免单目录文件过多。如/data/archive/2025/03/28/uuid.pdf。文件名用 UUID 重命名原始文件名单独存数据库。防止中文文件名和特殊字符导致路径问题。PostMapping(/upload) public ResultUploadFileVO upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(上传文件不能为空); } String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); if (!ALLOWED_TYPES.contains(ext.toLowerCase())) { throw new BusinessException(不支持的文件类型); } if (file.getSize() MAX_FILE_SIZE) { throw new BusinessException(文件大小超出限制); } String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String uuid UUID.randomUUID().toString().replace(-, ); String storePath BASE_PATH / datePath; File dir new File(storePath); if (!dir.exists()) { dir.mkdirs(); } File dest new File(storePath, uuid . ext); file.transferTo(dest); // 保存附件记录 UploadFileEntity fileEntity new UploadFileEntity(); fileEntity.setFileUuid(uuid); fileEntity.setFileName(originalFilename); fileEntity.setFileExt(ext); fileEntity.setFileSize(file.getSize()); fileEntity.setStorePath(datePath / uuid . ext); fileEntity.setMd5(FileDigestUtil.md5(file)); uploadFileMapper.insert(fileEntity); return Result.success(new UploadFileVO(fileEntity.getId(), originalFilename, file.getSize())); }超过 100MB 的文件我建议上分片 断点续传。前端把文件切成 2MB 一片后端接收每片后落盘所有片传完后前端调合并接口。档案管理系统里的扫描件动辄几十上百 MB这个能力是刚需不是炫技。关于合并拼接要注意按分片顺序拼接否则文件头错乱直接损坏。4.5 操作日志为什么放在 Service 层前面提到日志不用 AOP 切面是因为档案系统的日志不只是谁调用了什么接口还要记录业务上下文。比如审批借阅申请这个动作日志里必须包含哪份档案、借给谁、审批结果、审批意见而这些信息在进入 Controller 时是没有的只有在 Service 方法里拿到数据库实体后才能拼出有意义的日志内容。我在每个核心 Service 方法里手动调用OperationLogService.record(module, action, content, operatorId)同时查询列表接口统一不记日志避免产生大量无效数据。手动记录虽然多了几行代码但日志的可读性和完整性高了一个档次。安全审计要查的时候不需要翻代码推测某个日志字段的含义。5. 前端工程化Vue3 后台管理的目录组织与权限路由前端部分选型Vue3.4 Vite Pinia Vue Router Element Plus Axios。这个组合是近两年 Vue3 后台管理系统的标准配置。下面重点讲两个实践点目录结构和权限路由。5.1 目录结构按业务和类型双维度组织我见过很多前端项目把所有的 view 堆在views下面接口请求全部塞进api/index.js几千行一个文件这后面根本没法维护。这套系统的目录按照功能模块组织src/ ├── api/ │ ├── auth.ts # 登录认证 │ ├── archive.ts # 档案管理 │ ├── borrow.ts # 借阅管理 │ ├── category.ts # 分类管理 │ └── system.ts # 用户/角色 ├── components/ │ ├── common/ # 通用组件 │ └── business/ # 业务组件如档案上传弹窗 ├── composables/ │ ├── useTable.ts # 表格查询组合逻辑 │ ├── useBorrow.ts # 借阅逻辑 │ └── useUpload.ts # 文件上传逻辑 ├── router/ │ ├── index.ts # 基础路由 │ └── dynamic.ts # 动态路由生成 ├── stores/ │ ├── user.ts # 用户状态 │ └── permission.ts # 权限状态 └── views/ ├── login/index.vue ├── dashboard/index.vue ├── archive/ │ ├── list.vue │ ├── detail.vue │ └── register.vue └── borrow/ ├── apply.vue └── approval.vuecomposables目录是 Vue3 最有价值的地方。比如useTable这个组合式函数把分页、排序、查询条件、加载状态全部封装起来每个列表页只需要调用它并传入 API 方法就能获得完整的表格交互逻辑。一个后台系统里十几个列表页重复代码量大量减少。5.2 动态路由与按钮级权限权限控制在后台管理系统里的实现方式我建议走路由表由后端返回的方案。用户登录成功后后端根据角色返回可访问的菜单和路由信息前端用router.addRoute()动态添加。这样新角色或者角色权限调整不需要改前端代码重新部署。菜单信息用按钮权限码做补充。比如借阅审批按钮在前端显示前要检查当前用户是否拥有borrow:approve这个权限码。我封装了一个指令v-permission// 权限指令 app.directive(permission, { mounted(el, binding) { const requiredPerms binding.value as string[]; const userPerms useUserStore().permissions; const hasPermission requiredPerms.some(p userPerms.includes(p)); if (!hasPermission) { el.parentNode?.removeChild(el); } } });这种按钮级权限的意义在于体验优化真正防越权还是靠后端拦截器里对接口的权限校验。前端隐藏按钮只是让界面更干净合规性必须靠后端保证这个认知要贯穿整个开发过程。5.3 Axios 封装把错误提示这件事统一掉Axios 拦截器是我前端项目里最先写的代码。它的职责不只是带 token更重要的是统一处理错误码。http.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); http.interceptors.response.use( response { const res response.data; if (res.code 200) { return res.data; } // 401 跳回登录页 if (res.code 401) { router.push(/login); return Promise.reject(new Error(未登录或登录已过期)); } ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); }, error { ElMessage.error(error.response?.data?.message || 网络异常); return Promise.reject(error); } );这里有个细节登录接口本身不能用统一的错误拦截否则登录失败的提示会被拦截器吃掉后端返回的用户名或密码错误信息到不了登录页。我让登录接口单独走了一个不带拦截器的 Axios 实例等于把业务请求和认证请求分成两套通道。5.4 档案登记页面的表单 上传 预览联动档案登记是前端最复杂的页面因为它有组合交互用户在表单里填档案元数据同时可以多选上传附件附件上传后生成文件列表用户可以预览或删除删除时前端的困扰是——文件是否已经绑定到档案我这里的方案是上传接口只创建附件记录未绑定档案登记表单提交时才绑定。如果用户删除未绑定的附件前端只需调用一次删除接口如果已经绑定那就需要同时解除绑定关系再删除。所以上传返回的文件列表要立刻回传 fileId前端记录哪些 fileId 还未绑定。上传进度条也在这个页面里体现价值。用 Element Plus 的el-upload自带on-progress回调把进度存入响应式变量展示给用户。大文件上传时的卡顿感对用户体验的伤害非常大进度条是必须的。6. 跑通项目后要碰的五个坑每个都是实测出来的这里把我在开发这套档案管理系统时实际遇到的最有价值的问题和排查过程梳理出来。每个坑都不是网上随便搜到的都是我在这个项目里花时间验证过的。6.1 MyBatis 分页插件 PageHelper 的失效问题我用 PageHelper 做分页遇到过一个典型的错误用法在查询前先执行了一个无关的查询比如查询用户、查常量然后才调用分页查询结果分页参数被应用到了无关的查询上导致页面返回的数据莫名其妙。原因在于 PageHelper 的ThreadLocal会记住Page对象下一次查询会复用。正确姿势是PageHelper.startPage()之后立刻紧跟要分页的 Mapper 查询中间不要插入任何其他 SQL 操作。还有一个细节分页查询返回的总数是PageInfo.getTotal()但如果是多表 LEFT JOIN 的查询PageHelper 的 count 自动生成的语句可能和业务 SQL 合并执行出现性能问题。我最后是手写了 count 查询单独维护一套计数 SQL确保大表下总数统计稳定。6.2 跨域配置为什么前端请求总是 401前后端分离模式下的跨域是每个刚接触分离架构的人都会撞上的问题。前端跑在http://localhost:5173后端跑在http://localhost:8080浏览器默认会拦截跨域请求。我在 SpringBoot 里配置了全局 CORS 过滤器Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个坑allowCredentials(true)时allowedOriginPatterns(*)可以使用但如果用allowedOrigins(*)就会冲突浏览器会拒绝携带凭证的请求。如果前端请求总是报 CORS error先检查这里。另外预检请求 OPTIONS 必须放行否则前端自定义 Header比如Authorization会导致请求直接被拒。6.3 事务失效场景this 调用导致的代理穿透这个我前面提过实际排查过程再展开一下当时借阅归还功能报错我检查日志发现档案的borrow_status永远是借出但借阅记录确实更新了。单步调试发现我在BorrowServiceImpl内部直接调用了本类的另一个方法这等于绕过了 Spring AOP 代理Transactional没有触发第二步更新档案状态的操作没有和第一步一起提交或回滚。解决方案有三种把方法拆到另一个 Service 类里调用在类中注入自身的代理对象或者用TransactionTemplate手动管理事务。我最后选了拆 Service因为档案系统中借阅相关的操作比较多独立一个BorrowDomainService反而边界更清晰。6.4 LocalDateTime 序列化导致前端时间错乱Java 8 的LocalDateTime默认序列化成数组格式[2025, 3, 28, 14, 30, 0]前端直接拿到这种格式是无用的。我在 SpringBoot 里配置了全局 Jackson 序列化规则Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; }这个配置要在项目初始化时就加好不然后端一多前端每个页面都要单独处理时间格式既易漏又易错。6.5 MyBatis 缓存导致的数据可见性延迟MyBatis 默认开启了本地会话缓存一级缓存同一个 SqlSession 中两次相同查询会直接返回缓存结果。但档案系统里经常有先插入再立即查询的场景——比如档案登记完马上要列表刷新如果 Session 里缓存了旧的查询结果前端就看不到最新数据。我最终的方案是在application.yml里mybatis: configuration: cache-enabled: false local-cache-scope: statement同时所有写操作后手动清理缓存。对于档案管理系统这种读写频率不极端不对称的业务关闭二级缓存、把一级缓存作用域设为 statement是最省心的做法。如果你想在部分查询接口上加缓存建议直接用 Spring 的Cacheable Redis控制粒度比 MyBatis 自带的缓存机制灵活多了。6.6 权限字段遗漏导致的数据越权最后说一个最隐蔽的坑也最值得每个做管理系统的人警惕。有一次测试账号借阅档案发现它能搜索到所有分类下的档案。排查发现档案列表查询接口虽然在前端做了分类过滤但后端 Mapper 没有把当前用户可访问的分类 ID 集合作为查询条件拼进去只依赖前端传参。做一个越权测试后我立刻在 Mapper 里增加了一段强制权限过滤AND a.category_id IN ( SELECT category_id FROM user_category_perm WHERE user_id #{currentUserId} )这是档案管理系统区别于普通后台最关键的边界。档案数据的安全级别高于普通业务数据后端必须要校验这个用户能不能查这个分类的档案而不是信前端传的 categoryId。加上这一步之后系统才算真正达到了可用状态。我的实测体会这套档案管理系统从设计到跑通前后花了三周左右时间。最费时间的不是写代码而是梳理档案生命周期和权限模型。如果让我重新来一次我会在动手建表前先画出完整的档案状态流转图把每种操作对哪些字段产生影响、落在哪个事务边界里全部提前确认。另外前后端分离的项目接口文档一定要在联调前先固定下来哪怕只是用 Markdown 写清楚每个接口的入参、出参、错误码也能省下大量因字段名不一致带来的扯皮时间。最后再分享一个小技巧上线前把这个项目打成 jar 包用java -jar跑起来后把 MyBatis 的 SQL 日志级别调成 DEBUG打印所有执行 SQL。用真实数据跑一遍档案登记、查询、借阅、归还的完整流程看日志里的 SQL 是否和你预期完全一致联合查询的 JOIN 顺序、条件位置、索引命中情况一目了然。这一步花的时间不多但能提前发现很多在开发期 1000 条数据里感觉不出来的隐患。