我前阵子刚把一个 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true 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 里必须加serverTimezone=Asia/Shanghai,MySQL8.0 的驱动对时区非常敏感,不加会报 CST 时区错误;allowPublicKeyRetrieval=true是因为 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 就能避免把条件拼成字符串,比如:
LambdaQueryWrapper<ArchivesInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(title), ArchivesInfo::getTitle, title) .eq(ArchivesInfo::getStatus, status) .orderByDesc(ArchivesInfo::getCreateTime); Page<ArchivesInfo> page = archivesInfoMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这种方式最大的好处是字段引用是类型安全的,如果我把ArchivesInfo::getTitle的字段名写错,编译期就能发现,而不是等到运行时拼命看 SQL。
2.2 XML 与 Mapper 同目录配置
有朋友问过“SpringBoot 项目使用 MyBatis-Plus,XML 与 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> <directory>src/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 Result<T> { private Integer code; private String message; private T data; }成功时code=200,失败时返回业务异常码,比如未登录 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 vite@latest 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.js:
import 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 store:
export 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] basedir=D:/mysql-8.0.36-winx64 datadir=D:/mysql-8.0.36-winx64/data port=3306 character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci default-authentication-plugin=mysql_native_password在 MySQL8.0 里,default-authentication-plugin=mysql_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 'root'@'localhost' 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.0:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=archive_db \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0这里有个细节:数据卷必须挂载到宿主机,否则容器一旦被删,数据全没了。还要注意时区,可以在启动时加-e TZ=Asia/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")); QueryWrapper<ArchivesInfo> 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 预览组件,图片直接<img>,OFFICE 文档可以转 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 id="selectArchiveByKeyword" resultType="com.archive.system.entity.ArchivesInfo"> SELECT DISTINCT a.* FROM archives_info a LEFT JOIN archives_file f ON a.id = f.archive_id <where> <if test="keyword != null and keyword != ''"> AND (a.title LIKE CONCAT('%', #{keyword}, '%') OR a.description LIKE CONCAT('%', #{keyword}, '%') OR f.file_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="startDate != null"> AND a.form_date >= #{startDate} </if> <if test="endDate != null"> AND a.form_date <= #{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 包。 - 配置 Nginx:
server { 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.port=8080 > /opt/archive/archive.log 2>&1 &这样前端和后端就通过 Nginx 的/api路径打通了,不需要再单独开启跨域,也是最稳的生产部署姿势。我还加了 systemd 服务脚本,让 jar 包开机自启,比 nohup 更可控。
6.3 华为云/腾讯云等服务器注意事项
如果你在云服务器上部署,有几个坑我先踩为敬:首先安全组必须放行 80/443 端口和 8080 端口;如果用 Docker 装 MySQL,3306 端口也要放行;然后要注意云服务器默认 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 参数加allowPublicKeyRetrieval=true,或者把用户的认证插件改为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-size=100MB spring.servlet.multipart.max-request-size=200MB server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force=true再强调一次:保存文件名时不要直接用原始文件名,要用 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-model="list",不写多余的行内样式才正常。
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 左右(分页查询),内存占用约 400MB,MySQL 8.0 配置 2G 内存。这是很典型的轻量档案管理负载,完全够用。
我记忆最深刻的优化是借阅记录查询,最初用了嵌套子查询,借阅列表加载要 3 秒,后来我改成只查借阅主表 + 关联档案表,然后前端单独拉取档案详情缓存起来,首次加载降到 800ms。这个经验就是:不要什么都 join 到一条 SQL 里,尤其是列表页;大数据量的关联查询可以拆开,用缓存或并行请求解决。不要小看这个思路,很多页面慢不是数据库不行,而是接口设计太贪心。
还有一次我在测试并发借阅时发现,同一份档案可以被两个人同时提交借阅申请,导致重复借出。我加了唯一约束:档案 ID + 借阅状态为“借出中”的唯一索引,并在业务上通过数据库行锁SELECT ... FOR UPDATE来保证同一时刻只有一个申请能转入借出状态。虽然项目规模不大,但一些关键路径上必须用数据库约束兜底,不能只依赖代码判断。
我现在每次接手新项目都会先搭一套和这个档案系统一样的公共骨架:统一的异常响应、统一分页返回、权限拦截、操作日志、代码生成器(MyBatis-Plus Generator),这样业务代码写起来飞快。毫不夸张地说,这套骨架已经帮我干了三个不同行业的项目,包括文物管理系统、设备台账系统,都只是换表单字段和流程,核心逻辑完全复用。
如果你打算基于这套系统二次开发,我个人建议先不要碰底层的 Mapper 和通用工具,先梳理自己的档案分类和编号规则,因为这是所有业务匹配度的源头。编号规则一旦变,检索、打印、统计都会受影响。把基础编码研究明白后,其他模块基本都是增删改查的重复劳动,配合 MyBatis-Plus 的代码生成器一夜就能把 CRUD 搭完,再花时间把借阅流程和权限模型调整到自己的业务上,效率会成倍提升。
最后再分享一个小技巧:做档案管理系统一定要保留“数据回收站”功能,不要让删除操作物理删库。我用的方案是逻辑删除(MyBatis-Plus 的@TableLogic),删除时先进入回收站,30 天后由定时任务自动物理删除。这样即使用户误删档案,也有后悔药可吃。在档案行业里,一份文件可能承载着远超想象的价值,数据安全永远比页面炫酷重要得多。希望我这一路踩过的坑,能让你在自己的项目里少熬几个夜。