SpringBoot2+Vue3+MyBatis-Plus 档案管理系统开发实战与踩坑记录 我前阵子刚把一个 Java Web 档案管理系统从零撸到交付技术栈用的是 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0整套源码和配套文档都整理好了。今天不打算按文档目录复述功能那没意思我想把这套系统从需求拆解到落地踩坑的整个过程、以及每个关键选择背后的思考原原本本分享出来。如果你正准备做类似的档案类管理系统或者刚想从 SSM/JSP 那套老东西切到 SpringBoot2 Vue3 的前后端分离方案这篇文章应该能帮你节省不少白嫖百度的时间。我先把这套系统的核心价值说清楚它不是一个玩具项目而是把机构档案管理中最高频的“收集—归档—保存—利用—销毁”全流程都给覆盖了后端基于 SpringBoot2 提供 RESTful API配合 MyBatis-Plus 操作 MySQL8.0前端用 Vue3 驱动页面渲染通过 Axios 交互。适合刚接触前后端分离的开发者直接参考也适合大学毕设、企业内训、小型政务系统二次开发拿去做底子。整个过程我会把能公开的表结构、接口设计、关键代码片段、部署步骤、以及那些文档里不会写的问题排查记录都摊开讲。1. 项目整体设计与技术架构1.1 为什么选这套技术组合先说说选型逻辑。档案管理系统听起来很垂直但实际抽出来就是一套典型的“增删改查 流程 权限”的 Web 系统难点在于数据结构复杂、关联多、操作需要留痕。我见过很多老系统还在用 JSP Servlet JDBC改一个查询条件要动大半页面维护成本极高。所以我在做这个项目时铁了心用前后端分离后端只做接口前端独立维护页面状态。后端选 SpringBoot2 而不是 SpringBoot3主要是生态兼容性。目前国内大部分中小项目、云服务器上的 JDK 还是以 8/11 为主SpringBoot2 对 JDK8 支持最顺滑且与 MyBatis-Plus 的集成资料最多踩坑了也容易搜到答案。MyBatis-Plus 则是 MyBatis 的增强工具不用写繁琐的 XML 映射也能完成单表 CRUD对于档案管理这种有大量固定表单的场景非常香复杂多表查询再用 XML 补充。MySQL8.0 是当下最主流的关系型数据库JSON 类型和窗口函数都很好用可以支撑档案元数据扩展和统计报表。前端 Vue3 是必选项。我在上一个小项目里还用 Vue2组件通信靠 $emit 和 EventBus遇到嵌套组件传递数据真是头大。Vue3 的 Composition API 把逻辑收纳到 setup 里配合响应式 API复用性比 Options API 强太多再加上 Vite 的极速冷启动开发体验好到回不去。实际项目我用的是 Vue3 Element Plus Pinia Vue Router。1.2 模块划分与核心需求解析档案管理系统不是简单的“文件列表”我按实际业务流程把它拆成六大模块档案采集入库支持单条录入、批量导入Excel、从临时表转入正式库。档案整理编目自动生成档号、分类号维护案卷与文件的关系。档案保管利用包括库存管理、出入库登记、借阅申请与审批、归还检查。档案检索统计支持多条件组合查询、全文检索、报表统计。系统权限管理用户、角色、菜单、数据权限细粒度到按钮级。日志与审计记录谁在什么时间对哪条档案做了什么操作。每个模块都有独立的 Controller、Service、Mapper 层但共享统一的响应体、异常处理、分页封装。这样做的关键好处是后期加功能时不会因为放在同一个类里导致代码爆炸。我见过很多项目把档案和用户写在同一个 Controller一旦权限逻辑变化就牵一发动全身所以在这里宁可多一点重复的 CRUD 模板也要把模块边界切开。1.3 数据库表设计思路档案管理系统的表设计是核心中的核心因为档案数据一旦入库表结构变更成本极高。我当时整理了一份比较完整的设计方案这里把关键表列出来sys_user用户表主键、用户名、密码BCrypt 加密、真实姓名、部门 ID、状态。sys_role 和 sys_user_role角色与用户多对多。sys_menu菜单表支持树形结构用于前端动态路由和按钮权限。archives_info档案信息主表档号、题名、责任者、形成日期、保管期限、密级、载体类型、存放位置、内容描述、状态。archives_file文件表一个档案下可以挂多个文件如 PDF 扫描件、图片、OFFICE 文档保存文件存储路径和大小。archives_borrow借阅表借阅人、档案 ID、借阅时间、预计归还时间、实际归还时间、审批人、审批状态。archives_log操作日志表操作人、操作类型、操作内容、操作时间、IP、请求方法。档案主表为什么不能把所有业务都塞进去因为一份档案可能有多个文件实体也可能存在多次借阅记录如果做成单表冗余查询时要用 find_in_set 或 JSON 数组后期统计你会哭着改 SQL。所以我坚持“一主多子”的范式结构只在必要字段做冗余。2. 后端核心实现与 MyBatis-Plus 整合细节2.1 MyBatis-Plus 的配置与分页插件MyBatis-Plus 虽然叫“增强工具”但如果配置不对会出现分页失效、逻辑删除不生效、字段自动填充不走等问题。我在项目里采用了以下几个配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/archive_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.archive.system.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里有个容易被坑的点url 里必须加serverTimezoneAsia/ShanghaiMySQL8.0 的驱动对时区非常敏感不加会报 CST 时区错误allowPublicKeyRetrievaltrue是因为 MySQL8.0 默认 caching_sha2_password 认证部分 JDBC 驱动版本需要显式允许获取公钥。分页插件必须写一个配置类否则selectPage返回的 total 永远是 0。很多新手在这里卡半天其实是没注册 PaginationInnerInterceptor。我在项目里是这样注册的Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }在 Service 里直接使用 LambdaQueryWrapper 就能避免把条件拼成字符串比如LambdaQueryWrapperArchivesInfo wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(title), ArchivesInfo::getTitle, title) .eq(ArchivesInfo::getStatus, status) .orderByDesc(ArchivesInfo::getCreateTime); PageArchivesInfo page archivesInfoMapper.selectPage(new Page(pageNum, pageSize), wrapper);这种方式最大的好处是字段引用是类型安全的如果我把ArchivesInfo::getTitle的字段名写错编译期就能发现而不是等到运行时拼命看 SQL。2.2 XML 与 Mapper 同目录配置有朋友问过“SpringBoot 项目使用 MyBatis-PlusXML 与 Mapper 在同一个文件夹下应该如何配置”。常规做法是把 XML 统一放到 resources/mapper 下然后通过mybatis-plus.mapper-locations指定。但如果你非要让 XML 跟 Mapper 接口同目录比如com/archive/system/mapper/ArchivesInfoMapper.xml也是可行的关键点是第一pom.xml 中需要配置 resources 包含 xml 文件因为 Maven 默认不会把 src/main/java 下的 xml 打包进目标目录build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build第二mybatis-plus.mapper-locations改成classpath*:com/archive/system/mapper/*.xml注意路径要用斜杠/而不是点。不过我用下来还是建议分开存放。把 XML 放在 resources 下打包后方便查看而且不会跟 Java 源码产生编译冗余如果团队有代码扫描工具也不会把 XML 当代码文件误报。除非你是开发一个公共 starter 包需要把 mapper 和 xml 打在一起否则真没必要冒这个配置风险。2.3 统一响应与全局异常处理为了前端好处理我设计了统一响应体Data public class ResultT { private Integer code; private String message; private T data; }成功时code200失败时返回业务异常码比如未登录 401、无权限 403、数据不存在 404。所有接口都返回这个结构前端只用判断 code 是否为 200 就能决定走向。全局异常处理我用RestControllerAdvice配合ExceptionHandler实现重点是区分业务异常和未知异常。业务异常如“档案编号已存在”需要弹出明确提示未知异常如空指针则统一记录日志并返回“系统繁忙”不要把堆栈直接抛给前端那样既暴露内部细节又让用户一脸懵。我还在异常处理器里把校验异常MethodArgumentNotValidException的字段错误信息给拼接好返回这样前端做表单校验都不用额外写提示逻辑。2.4 基于 JWT 的认证与权限控制档案管理系统涉及安全隐私权限不能只靠前端隐藏菜单后端接口必须能校验角色和数据范围。我用的方案是 JWT Spring Security但是并没有引入 Security 的完整过滤器链而是用自定义拦截器处理因为项目里不需要 OAuth2 那套复杂流程简单的 token 校验就够了。具体步骤是这样的登录成功后用用户 ID、用户名、角色编码生成 token设置过期时间 24 小时并加一个随机盐防伪造。后续请求在 Header 里带Authorization: Bearer token拦截器解析 token 拿到用户信息放到 ThreadLocal 里业务层就能随时获取当前操作人操作日志自动记录。权限控制我采用注解方式自定义RequirePermission(archive:add)在拦截器里读取注解检查当前用户角色是否拥有这个权限标识。菜单表里每个按钮都配了 permission 字段角色与菜单关联后前端从接口拉取可用按钮集合后端再验证一次双保险。实践证明光靠前端控制权限的后果很惨我一个朋友的公司系统被人改了按钮 id 就能调接口这是绝对要避免的。3. 前端架构与 Vue3 关键实现3.1 Vue3 项目初始化与目录组织前端我使用的是 Vue3 Vite Element Plus Pinia Vue Router Axios。初始化就一行命令npm create vitelatest archive-web -- --template vue然后安装依赖。这里我不建议用 create-vue 默认的模板直接开写因为默认模板里很多东西你用不上我一般会手动重新组织目录src/ api/ # 按模块拆分的接口请求 assets/ # 图片、全局样式 components/ # 公共组件 composables/ # 可复用的组合式函数 layout/ # 主布局侧边栏头部 router/ # 路由配置 stores/ # Pinia 状态 views/ # 页面 utils/ # 工具函数如 axios 封装每个页面对应一个 views 下的文件夹里面放 index.vue 和局部组件避免所有文件都堆在 views 平铺层。我在做档案录入页面时拆成了基本信息表单、文件上传、编目字段三个子组件页面代码只有渲染骨架和提交逻辑看起来很清爽。3.2 Vite 代理解决跨域Vue3 开发环境最常见的坑就是跨域。前端跑在 5173 端口后端跑在 8080 端口浏览器直接请求就跨域了。我不用后端加CrossOrigin解决因为生产环境前后端往往同一个域名加了反而有安全隐患。我的做法是在 vite.config.js 里配置代理export default defineConfig({ server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })这样前端请求/api/archive/list会被转发到http://localhost:8080/archive/list生产环境部署时用 Nginx 再做同样的转发规则代码里不需要区分环境。如果你拿到的是已经打包好的 dist也可以用nginx直接try_files $uri $uri/ /index.html配合location /api { proxy_pass }效果一样。3.3 Axios 封装与拦截器Axios 不封装直接裸用每一页都要重复写 loading、错误处理、token 头那是给自己挖坑。我封装了一个request.jsimport axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer userStore.token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } else if (res.code 401) { ElMessage.error(登录已过期请重新登录) userStore.logout() router.push(/login) return Promise.reject(new Error(unauthorized)) } else { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )这里的重点响应拦截器返回res而不是res.data这样调用方不用再解一层壳。登录过期后自动跳转登录页避免用户以为自己还登录着结果所有操作都报 401 却找不到原因。3.4 Pinia 存储用户信息与权限Vue3 项目我推荐用 Pinia 而不是 Vuex因为 Pinia 的 TypeScript 支持更好没有 mutations直接改 state 不会像 Vuex 那样报“不能直接修改 store 状态”的警告。我建立了一个 user storeexport const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: {}, permissions: [] }), actions: { setLogin(data) { this.token data.token this.userInfo data.userInfo this.permissions data.permissions localStorage.setItem(token, data.token) }, logout() { this.token this.userInfo {} this.permissions [] localStorage.removeItem(token) } } })路由守卫里做登录校验和菜单动态生成这也是前后端分离项目一道经典的坎。我实现的是登录后从后端获取当前用户的菜单树利用addRoute动态加入路由而不是在静态路由里写死所有页面这样不同角色进入系统看到的菜单天然不同也没有权限越级的入口。3.5 Vue3 组件设计细节档案管理有大量列表页列表页无非就是“搜索区 表格 分页 弹窗表单”我抽了个SearchPage.vue的公共组件用插槽接收搜索表单和表格列配置结果项目里四五个页面都能复用改动模板只改一处。这比我以前每个页面复制粘贴好多了。组合式函数我也用上了比如useArchiveTable封装了分页查询、重置搜索、刷新列表三个逻辑档案列表、借阅列表、日志列表全都调它。我强烈建议在 Vue3 项目中把这类高频逻辑提取成 composable而不是把所有状态都塞进页面里不然业务复杂后 setup 函数会长到几百行自己都看不下去。4. MySQL8.0 的准备与部署实战4.1 MySQL8.0 安装与环境配置整个系统最容易被低估的就是 MySQL8.0 的安装和配置。如果你是 Windows 开发机建议不要用安装包图形界面装直接下 zip 解压反而更干净。我写一下亲测可行的流程下载 mysql-8.0.x-winx64.zip解压到D:/mysql-8.0.36-winx64新建my.ini配置[mysqld] basedirD:/mysql-8.0.36-winx64 datadirD:/mysql-8.0.36-winx64/data port3306 character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci default-authentication-pluginmysql_native_password在 MySQL8.0 里default-authentication-pluginmysql_native_password这段很有用因为很多老版本 JDBC 驱动和 Navicat 连 caching_sha2_password 认证会报错。如果是新项目直接用最新的驱动其实不写这段也没事但为了兼容工具我保留了。然后以管理员身份打开 CMD进入 bin 目录执行mysqld --initialize-insecure mysqld --install net start mysql--initialize-insecure会生成一个 root 空密码的实例启动后自己登录改密码mysql -u root -p ALTER USER rootlocalhost IDENTIFIED BY 你的密码; FLUSH PRIVILEGES;如果你在 Linux 上装用包管理器装好后也要注意 root 默认 auth_socket 插件导致程序连接登录不上。我一般习惯建一个专门的业务账号并给数据库授权CREATE USER archive_user% IDENTIFIED BY archive_pass; GRANT ALL PRIVILEGES ON archive_db.* TO archive_user%; FLUSH PRIVILEGES;注意%代表允许任意主机访问生产环境应该指定应用服务器 IP不要偷懒。4.2 初始化数据库与导入 SQL设计完表结构后我直接生成了一份init_db.sql包含建库、建表、初始化管理员账号和测试数据。执行命令mysql -u root -p init_db.sql有了 SQL 脚本整个系统可以在一台新机器上 5 分钟复现出所有结构不用一点点点鼠标建表。这一点在部署到客户服务器时太省心了我遇到过客户要在一台内网机器部署不能联网下载工具直接一包 SQL 后端 jar 前端 dist 就搞定。4.3 Docker 方式部署 MySQL8.0现在很多团队习惯用 Docker 部署环境我也在项目里试过。一条命令就能拉起 MySQL8.0docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASEarchive_db \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0这里有个细节数据卷必须挂载到宿主机否则容器一旦被删数据全没了。还要注意时区可以在启动时加-e TZAsia/Shanghai。如果你在 Docker 里跑 MySQL 后发现后端连不上先确认容器端口是否映射成功、防火墙是否放行 3306然后看 MySQL 的 user 表里 root 的 host 是否包含%。Docker 容器里的 root 默认只允许 localhost 连接如果你要外部访问得进入容器执行授权命令docker exec -it mysql8 mysql -uroot -p GRANT ALL PRIVILEGES ON *.* TO root% IDENTIFIED BY root123; FLUSH PRIVILEGES;4.4 备份与恢复策略档案系统最怕丢数据我加入了简单的 mysqldump 定时备份机制。开发环境可以手动执行mysqldump -uroot -p archive_db archive_db_$(date %Y%m%d).sql为了自动化我还写了个 shell 脚本放在 cron 里每天凌晨执行保留最近 30 天的备份文件恢复时直接mysql -uroot -p archive_db archive_db_backup.sql这不是什么高深技术但很多人开发时根本不会建备份等出问题才悔得肠子青。至少要保证自己电脑上有初始化 SQL 和最近一次备份文件。5. 档案管理核心业务实现细节5.1 档案编号自动生成与流水号管理档案管理中档号是唯一标识编目规则一般按“分类号-年度-顺序号”生成比如DQ-2025-0001。这个不能直接依赖数据库自增因为自增列删了记录会补号会导致重号或者不连续合法性和审计上都很尴尬。我实现了一个独立的编号生成器public synchronized String generateArchiveCode(String categoryCode) { String date LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy)); QueryWrapperArchivesInfo wrapper new QueryWrapper(); wrapper.likeRight(archive_code, categoryCode - date -) .orderByDesc(archive_code) .last(limit 1); ArchivesInfo last archivesInfoMapper.selectOne(wrapper); int nextNum 1; if (last ! null) { String lastCode last.getArchiveCode(); nextNum Integer.parseInt(lastCode.substring(lastCode.lastIndexOf(-) 1)) 1; } return String.format(%s-%s-%04d, categoryCode, date, nextNum); }注意 synchronized 关键字保证并发下不会生成同样的编号虽然没有加锁到分布式层面但在单机部署下足够用了。后来我对这个逻辑做了改造把当前最大编号也存到一张archive_code_rule表里这样即使老档案的数据被删除序号也不会回退。这个细节让我在最终验收时拿到了加分。5.2 借阅流程的状态机设计借阅不是一个简单的 update 操作它存在明确的状态流转待审批 - 审批通过/驳回 - 已借出 - 已归还 - 逾期。我用一个status字段表示这些状态0-待审批1-已通过2-已驳回3-借出中4-已归还5-已逾期。所有状态变更都只允许从合法前置状态转移不能随便跳变。例如审批通过时代码里要判断当前状态必须是 0否则抛异常归还时判断必须当前状态是 3 或 5逾期归还也要允许但需要登记备注。这里用枚举定义状态让代码可读性好很多public enum BorrowStatus { PENDING(0), APPROVED(1), REJECTED(2), BORROWED(3), RETURNED(4), OVERDUE(5); }我还在借阅列表查询中用前后端联合处理了逾期计算后端从数据库查出应还日期前端用当前时间比对若超期则显示红色标记并支持一键催还。定时任务里我也加了扫描每天将超过应还日期仍未归还的记录自动置为逾期并给审批人发送站内消息提醒。5.3 文件上传与存储档案常伴随扫描件和电子文档文件上传功能我采用的方案是本地磁盘存储 MySQL 元数据。上传接口用 MultipartFile 接收文件重命名防止中文乱码和冲突String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String newNameId UUID.randomUUID().toString().replace(-, ); String relativePath /upload/ LocalDate.now() / newNameId suffix;这里我不建议直接把文件字节塞进数据库虽然好备份但数据库体积会飞快膨胀性能也差。文件路径记录到archives_file表里下载时根据路径读取。如果要支持更多类型预览可以在前端集成一个 PDF 预览组件图片直接imgOFFICE 文档可以转 PDF 再预览这个我作为扩展功能留在 TODO 里了。需要注意权限拦截上传目录必须放在 SpringBoot 项目外部比如D:/archive-data/upload然后用配置项设置存储根路径。同时要配置静态资源映射否则浏览器访问不到上传的文件。SpringBoot 中这样映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath /); } }5.4 多条件组合检索与全文检索档案检索是用户的刚需条件包括案卷号、题名、责任者、年度、密级、保管期限、全文关键词。我用 MyBatis-Plus 的 QueryWrapper 动态拼条件但遇到要跨越多个子表搜索时XML 自定义 SQL 更高效。比如按责任人查档案时因为责任人是子表字段需要 join 一下select idselectArchiveByKeyword resultTypecom.archive.system.entity.ArchivesInfo SELECT DISTINCT a.* FROM archives_info a LEFT JOIN archives_file f ON a.id f.archive_id where if testkeyword ! null and keyword ! AND (a.title LIKE CONCAT(%, #{keyword}, %) OR a.description LIKE CONCAT(%, #{keyword}, %) OR f.file_name LIKE CONCAT(%, #{keyword}, %)) /if if teststartDate ! null AND a.form_date gt; #{startDate} /if if testendDate ! null AND a.form_date lt; #{endDate} /if /where ORDER BY a.create_time DESC /select热点词里提到了 Elasticsearch如果你档案量到几十万条考虑引入 ES 做全文检索会更合适但本项目用 MySQL8.0 的全文索引和模糊查询足够支撑中等规模数据。我反向思考过引入 ES 的成本多一个中间件同步数据要设计运维复杂度增加对于几万条数据的系统纯属杀鸡用牛刀。优化 MySQL 的模糊查询用索引前缀匹配反而更实在。6. 前后端联调与部署6.1 接口文档约定与联调流程我用的是 RESTful 风格接口概览如下功能请求方式路径分页查询档案GET/api/archive/page档案详情GET/api/archive/{id}新增档案POST/api/archive修改档案PUT/api/archive/{id}删除档案DELETE/api/archive/{id}借阅申请POST/api/borrow审批借阅PUT/api/borrow/approve归还档案PUT/api/borrow/return登录POST/api/user/login制定好接口文档后前后端可以并行开发。我用 Swagger 注解自动生成在线文档但更推荐用 Apifox 管理接口因为可以自动 mock 数据前端不用等后端启动就能开测。实际开发中我先把接口定义好后端跑通前端直接用真实接口连调省去了 mock 的转换成本。6.2 生产环境部署步骤系统最终要跑到服务器上不能一直停留在 IDE 里。我把部署流程完整记录下来了照着做就能跑后端打包mvn clean package -DskipTests生成archive-system.jar。前端打包npm run build生成dist目录。在服务器上安装 JDK8、MySQL8.0、Nginx。导入数据库初始化脚本。创建/opt/archive目录放入 jar 包和 dist 包。配置 Nginxserver { listen 80; server_name archive.example.com; root /opt/archive/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /opt/archive-data/upload/; } }启动后端nohup java -jar archive-system.jar --server.port8080 /opt/archive/archive.log 21 这样前端和后端就通过 Nginx 的/api路径打通了不需要再单独开启跨域也是最稳的生产部署姿势。我还加了 systemd 服务脚本让 jar 包开机自启比 nohup 更可控。6.3 华为云/腾讯云等服务器注意事项如果你在云服务器上部署有几个坑我先踩为敬首先安全组必须放行 80/443 端口和 8080 端口如果用 Docker 装 MySQL3306 端口也要放行然后要注意云服务器默认 ufw 或 firewalld 可能拦截端口需要手动开启。我帮朋友排查过一次后端明明起来了外部访问就是不通结果发现是防火墙把 8080 挡住了。数据无价安全组和防火墙的配置一定要核对两遍。7. 常见问题与排查技巧实录7.1 MyBatis-Plus 分页 total 始终为 0这是最经典的问题。原因一般是忘记注册PaginationInnerInterceptor或者注册了但MapperScan扫描不到 Mapper 接口。检查三步第一MybatisPlusConfig是否存在第二MapperScan路径是否正确第三分页参数 Page 传入的页码是 1 开始还是 0 开始。MyBatis-Plus的页码是从 1 开始的如果前端传 0查询结果为空但 total 有值很多人在这里被绕晕。我在 frontend 统一处理current pageNum 1。7.2 MySQL8.0 连接时报 Public Key Retrieval is not allowed出现这个报错是因为 MySQL8.0 默认 caching_sha2_password 认证客户端无法获取服务器的公钥来加密密码。解决方案有两个url 参数加allowPublicKeyRetrievaltrue或者把用户的认证插件改为mysql_native_password。我推荐第一种不动数据库的认证插件很多云数据库可能不允许改全局插件。7.3 Vue3 项目在 Edge 浏览器中偶发无法关闭最小化按钮这个话题看起来和档案系统无关但确实是我实际遇到的一个前端浏览器兼容性问题。有位用户反馈系统在某个页面运行时Edge 浏览器右上角最小化按钮点击没有反应。排查后发现不是页面 JS 的问题而是系统里一条 console 报错导致渲染线程卡死最终让浏览器失去响应。解决办法检查代码里是否有死循环或超大列表渲染把分页每页条数限制在 100 以内同时避免在watch里做复杂计算。也要提醒用户更新浏览器版本老版本 Edge 内核 bug 也会导致这类问题。7.4 文件上传中中文名乱码SpringBoot 默认的 multipart 编码可能因未设置 UTF-8 导致中文文件名乱码。我在上传接口里指定spring.servlet.multipart.max-file-size100MB spring.servlet.multipart.max-request-size200MB server.servlet.encoding.charsetUTF-8 server.servlet.encoding.forcetrue再强调一次保存文件名时不要直接用原始文件名要用 UUID 重命名。这样既避免乱码也避免路径穿越漏洞。如果非常需要保留原始文件名可以在数据库字段里单独存一个原始名文件存储名仍用 UUID。7.5 动态路由刷新后白屏Vue3 动态路由容易出现刷新后所有菜单和路由全部丢失页面白屏。原因是 Pinia 里存的动态路由表是基于内存的刷新后就没了。我在路由守卫里做了判断如果 store 中没有菜单信息并且本地有 token则先调用后端获取菜单并 addRoute然后next({ ...to, replace: true })强制重进一次路由如果清空 store 还报循环那大概率是next()被反复调用需要加一个标志位防止递归。这个坑我调了一晚上写出来希望能帮大家少走弯路。7.6 Vue3 sortable 未生效在档案排序、菜单拖拽排序时用了 vuedraggable 却发现拖不动。现象是能拖但列表不变或者根本没反应。大多数情况是组件使用方式不对新版 vuedraggable 依赖 sortablejs安装时注意包含sortablejs并注册组件import Draggable from vuedraggable // 或者在具体组件中声明 components: { Draggable }如果列表项有key重复也会导致渲染异常拖拽后丢失状态。我后来直接用vuedraggable配合v-modellist不写多余的行内样式才正常。8. 项目文档与源码包含内容这次项目的“含文档”不是随便放个 README 完事我把实际交付的文档体系也捋了一下大概有需求说明书包含业务背景、术语定义、角色划分、功能清单、验收标准。数据库设计文档包含 E-R 图、表结构说明、字段字典、索引设计。接口文档包含每个接口的请求参数、响应示例、错误码。部署手册从服务器环境准备到 MySQL 安装、前后端部署、Nginx 配置、备份恢复。用户操作手册面向最终用户每一步操作都配有截图和说明。源码部分则严格区分模块后端有 yml 配置、Mapper 层、Service 层、Controller 层前端有组件和页面。所有代码都是可跑的不是那种复制过来就编译报错的半成品。我在源码里打了一定量的 TODO 注释方便二次开发的人知道哪些地方需要按自己的业务改造。8.1 二次开发扩展点我做这套系统的初衷是给自己手里几个档案相关项目打底所以特意留下了几个扩展点数据权限目前是按部门隔离扩展时可以细化到按人或者按密级。借阅审批流程目前是单级审批扩展时可以集成 Flowable 或 Activiti 做成多级工作流。格式转换目前图片和 PDF 是直接预览扩展时可以接入 Office 在线预览服务。消息通知借阅审批目前只做了站内信扩展时可以接入短信、企业微信或邮件通知。8.2 技术栈常见对比选型讨论很多时候会有人问 Spring Data JPA 和 MyBatis-Plus 到底选哪个。我不打算做出绝对的结论就说我自己的感觉如果项目以固定表单和业务逻辑为主MyBatis-Plus 的控制力更强SQL 排查直观复杂查询能兜底如果团队对 JPA 很熟并且业务模型相对稳定JPA 的缓存体系和实体关系映射可以省不少代码。档案管理系统里要写很多动态统计 SQL我用 MyBatis-Plus 心里更有底因为我知道 SQL 最终会变成什么样子。同样Vue2 转 Vue3 的核心差异不在模板语法而在数据流和逻辑复用方式。Vue3 的 Composition API 让我把“监听查询条件变化然后刷新列表”这种逻辑抽出来给多个页面共用代码量直接砍三分之一。如果你们团队还在纠结学 Vue2 还是 Vue3我的建议是直接学 Vue3就算公司老项目是 Vue2掌握 Vue3 之后再回头理解 options API 会很容易。9. 实测效果与个人使用体会系统在我这边的实际运行数据档案数约 5 万条文件扫描件 3 万多个同时在线约 50 人后端接口平均响应时间在 200ms 左右分页查询内存占用约 400MBMySQL 8.0 配置 2G 内存。这是很典型的轻量档案管理负载完全够用。我记忆最深刻的优化是借阅记录查询最初用了嵌套子查询借阅列表加载要 3 秒后来我改成只查借阅主表 关联档案表然后前端单独拉取档案详情缓存起来首次加载降到 800ms。这个经验就是不要什么都 join 到一条 SQL 里尤其是列表页大数据量的关联查询可以拆开用缓存或并行请求解决。不要小看这个思路很多页面慢不是数据库不行而是接口设计太贪心。还有一次我在测试并发借阅时发现同一份档案可以被两个人同时提交借阅申请导致重复借出。我加了唯一约束档案 ID 借阅状态为“借出中”的唯一索引并在业务上通过数据库行锁SELECT ... FOR UPDATE来保证同一时刻只有一个申请能转入借出状态。虽然项目规模不大但一些关键路径上必须用数据库约束兜底不能只依赖代码判断。我现在每次接手新项目都会先搭一套和这个档案系统一样的公共骨架统一的异常响应、统一分页返回、权限拦截、操作日志、代码生成器MyBatis-Plus Generator这样业务代码写起来飞快。毫不夸张地说这套骨架已经帮我干了三个不同行业的项目包括文物管理系统、设备台账系统都只是换表单字段和流程核心逻辑完全复用。如果你打算基于这套系统二次开发我个人建议先不要碰底层的 Mapper 和通用工具先梳理自己的档案分类和编号规则因为这是所有业务匹配度的源头。编号规则一旦变检索、打印、统计都会受影响。把基础编码研究明白后其他模块基本都是增删改查的重复劳动配合 MyBatis-Plus 的代码生成器一夜就能把 CRUD 搭完再花时间把借阅流程和权限模型调整到自己的业务上效率会成倍提升。最后再分享一个小技巧做档案管理系统一定要保留“数据回收站”功能不要让删除操作物理删库。我用的方案是逻辑删除MyBatis-Plus 的TableLogic删除时先进入回收站30 天后由定时任务自动物理删除。这样即使用户误删档案也有后悔药可吃。在档案行业里一份文件可能承载着远超想象的价值数据安全永远比页面炫酷重要得多。希望我这一路踩过的坑能让你在自己的项目里少熬几个夜。