简介面向Delphi初学者以及课程设计、毕业设计开发者的档案管理信息系统完整工程源码基于Delphi与SQL构建针对档案录入、条件检索、权限控制、版本追踪与统计报表等业务场景提供可落地的实现参考。压缩包共106个文件约1.51MB以24个dcu编译单元、22个pas源文件、18个dfm窗体文件为主体另含14个ddp工程模板、2个sql建库脚本以及mdf、ldf数据库文件与可执行exe覆盖界面窗体、数据模块与数据库结构可导入Delphi IDE编译调试。已有196人学习下载。借助其中的Object Pascal业务逻辑、窗体布局与建库脚本可对照理解ADO数据访问、档案编号与分类字段设计、用户角色权限划分等具体做法也适合作为功能扩展与二次开发的起点。1. 档案管理信息系统一份十年前的合同从三趟库房到一次检索去年帮一个基层单位整理档案室最麻烦的环节是找卷。纸质目录卡按年份排查一份十年前的合同要跑三趟库房先翻目录、再对盒号、最后逐件抽。后来我把这条链路整体搬进了一套档案管理信息系统里——著录、组卷、归档入库、组合检索、借阅审批、到期鉴定全在一条线上走完。它本质是一套 Spring Boot 加 Vue 的前后端分离后台核心就是四张主线表加一套基于部门的数据权限。适合拿来做单位内部系统的二次开发也适合想找一个业务闭环完整、代码不花哨的 CRUD 加权限加文件管理实战项目的人。下面按能跑、能读、能改的顺序往下拆。2. 技术选型与架构单体分层加数据权限为什么没上微服务2.1 技术栈清单与选型理由这套系统的技术栈很“稳”没有任何追新的成分后端 Spring Boot 2.7 MyBatis-Plus MySQL 8鉴权用 Spring Security 加 JWT缓存和登录态用 Redis没有的话可以关掉退化成纯 JWT前端 Vue 加 Element UI 组件库构建走 Vite 或 Vue CLI部署就是 Nginx 反代静态资源加一个 jar 包。层次技术说明后端框架Spring Boot 2.7单体内分模块不做服务拆分持久层MyBatis-Plus单表 CRUD 走 BaseMapper复杂检索写 XML数据库MySQL 8.0utf8mb4档号建唯一索引鉴权Spring Security JWT无状态token 存前端权限点存 Redis 可选前端Vue Element UI表格、表单、弹窗三件套覆盖 80% 页面文件存储本地磁盘 / MinIO抽象成 StorageService 接口实现可换为什么不上微服务这是我在类似项目里踩出来的判断。档案业务的并发量极低一个单位一天几十次操作就算高峰真正的难点在数据一致性著录一条案卷、挂接若干电子文件、写一条操作日志、更新全宗统计这四步必须在同一个事务里。拆成服务就得引入分布式事务或者最终一致为了一个日均 QPS 个位数的系统付这个代价不划算。所以架构上明确是“单体分模块”包结构按fonds / record / file / borrow / system切模块之间只通过 Service 接口调用不跨模块直接摸对方的 Mapper将来真要拆的时候有缝可切。另一个刻意的设计是把“数据权限”提到架构层。档案系统天然是多部门的一个部门的档案员不该看到别的部门的卷。这件事如果每个查询自己写where dept_id ?早晚会漏。项目里做成了注解加 AOP 的形式跟业务代码解耦。2.2 一次请求的完整链路从浏览器点到数据库一次列表查询要过这么几道浏览器 → Nginx(/api 反代) → JwtAuthenticationFilter(解析token,塞UserContext) → Controller(DataScope 注解) → DataScopeAspect(拼部门范围进ThreadLocal) → Service → Mapper(XML 里读 scopeDeptIds) → MySQL关键在于UserContext和DataScopeAspect这两个东西。前者是 ThreadLocal存当前用户 id、部门 id、角色后者在方法执行前根据角色算出“这次查询能看到哪些部门的档案”塞进另一个 ThreadLocalMapper 层再取出来拼进 SQL。Aspect Component public class DataScopeAspect { // 切所有标了 DataScope 的方法 Before(annotation(dataScope)) public void before(JoinPoint point, DataScope dataScope) { LoginUser user UserContext.get(); if (user null || user.isAdmin()) { // 超管不限制直接清空避免上一次请求的脏数据残留 DataScopeHolder.clear(); return; } ListLong deptIds; if (user.hasRole(DEPT_MANAGER)) { // 部门管理员本部门 所有下级部门 deptIds deptService.listSelfAndChildren(user.getDeptId()); } else { // 普通档案员只看本部门 deptIds Collections.singletonList(user.getDeptId()); } DataScopeHolder.set(deptIds); } After(annotation(dataScope)) public void after(JoinPoint point, DataScope dataScope) { // 必须清线程池复用下不清就是血泪事故 DataScopeHolder.clear(); } }这里的逻辑说明几句。Before里超管分支要显式clear()否则线程复用时会把上一个请求的范围带过来表现为“超管偶尔只能看到自己部门的数据”这种玄学 bug 排查起来非常费时间。After里无条件清理是兜底即使业务抛异常也要执行所以用的是After而不是AfterReturning。listSelfAndChildren走的是递归查部门树部门数量少的时候直接一次性查全表在内存里拼树更快别写成循环查库。对应的 XML 里这么用select idselectPageByScope resultTypecom.example.archive.entity.ArcRecord SELECT r.* FROM arc_record r where r.deleted 0 if testq.title ! null and q.title ! AND r.title LIKE CONCAT(%, #{q.title}, %) /if if testq.retention ! null and q.retention ! AND r.retention #{q.retention} /if !-- 数据权限由 AOP 写入绝不接受前端传值 -- if testscopeDeptIds ! null and scopeDeptIds.size() 0 AND r.dept_id IN foreach collectionscopeDeptIds itemd open( separator, close)#{d}/foreach /if /where ORDER BY r.archive_year DESC, r.record_no ASC /select参数上要注意scopeDeptIds的传法Controller 里调 Service 时用DataScopeHolder.get()取绝对不要从请求参数里读。我见过有实现图省事写成${scope.sql}字符串拼接那就等于把注入口子敞开了foreach虽然啰嗦但安全。2.3 表设计四张主线表撑起整个业务档案业务的实体关系其实很清晰全宗Fonds→ 案卷/件Record→ 电子文件File再加一张借阅单Borrow。最容易被设计坏的是档号。CREATE TABLE arc_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_no VARCHAR(64) NOT NULL COMMENT 档号全宗号-年度-保管期限-件号, title VARCHAR(255) NOT NULL COMMENT 题名, fonds_code VARCHAR(20) NOT NULL COMMENT 全宗号, archive_year INT NOT NULL COMMENT 归档年度, retention CHAR(4) NOT NULL COMMENT PERM永久 / Y30 / Y10, secret_level VARCHAR(16) DEFAULT INTERNAL COMMENT PUBLIC/INTERNAL/SECRET, dept_id BIGINT NOT NULL COMMENT 立卷部门数据权限过滤字段, file_count INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0草稿 1待审 2已入库, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_record_no (record_no), KEY idx_query (fonds_code, archive_year, retention), KEY idx_dept (dept_id) ) COMMENT 案卷/文件级著录表; CREATE TABLE arc_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL COMMENT 所属案卷, file_name VARCHAR(255) NOT NULL COMMENT 原始文件名, store_path VARCHAR(500) NOT NULL COMMENT 相对存储路径, file_size BIGINT, file_hash CHAR(64) COMMENT SHA-256用于秒传和去重, page_count INT, UNIQUE KEY uk_hash (file_hash, record_id), KEY idx_record (record_id) ) COMMENT 电子文件表;record_no建唯一索引是有必要的不只为了防止重复更因为档号一旦发出去就要写进实体盒标签改了就是事故。idx_query这个联合索引对应的是检索页最常用的三个条件全宗、年度、保管期限顺序按区分度从高到低排。file_count是冗余字段挂在案卷上省得列表页每行都count(*)著录时同步维护——这种冗余在档案系统里是可接受的因为文件挂接完基本不再变。deleted用逻辑删除而不是物理删除理由很实在档案的销毁是要走鉴定审批流程的删了得能捞回来。MyBatis-Plus 配TableLogic就能全局生效但要注意它对手写 XML 的select不生效得自己带deleted 0这一点在后面的排查章节还会提。3. 目录结构与启动从建库到前后端联调的完整路径3.1 目录结构逐层说明拿到源码包先别急着run花三分钟把目录看一遍后面报错能省一半时间。archive-system/ ├── archive-server/ # 后端单模块 Maven 工程 │ ├── src/main/java/com/example/archive/ │ │ ├── ArchiveApplication.java # 启动类MapperScan 在这里 │ │ ├── common/ # Result、分页、全局异常、常量 │ │ ├── framework/ # Security、JWT、DataScope、Redis │ │ ├── module/ │ │ │ ├── fonds/ # 全宗 │ │ │ ├── record/ # 著录与检索核心 │ │ │ ├── file/ # 电子文件上传/预览/下载 │ │ │ ├── borrow/ # 借阅申请与审批 │ │ │ └── system/ # 用户、角色、部门、字典、日志 │ │ └── ... │ ├── src/main/resources/ │ │ ├── application.yml │ │ ├── application-dev.yml │ │ ├── mapper/ # 所有 XML 放这别再散落 │ │ └── db/archive_db.sql # 建表 初始化数据 │ └── pom.xml ├── archive-web/ # 前端 │ ├── src/api/ # 一个模块一个文件对应后端 Controller │ ├── src/views/record/ # 著录列表、著录表单、检索页 │ ├── src/utils/request.js # axios 封装token 和错误码在这里 │ └── vite.config.js # 开发代理 /api → 8080 └── sql/ # 备份的初始化脚本module按业务分包、包内再分controller / service / mapper / entity / dto这套结构看着老派但好处是新人接手时找文件不用猜。framework单独拎出来是因为它里面的东西JWT 过滤器、数据权限切面不属于任何业务模块混在system里会和用户管理纠缠。3.2 后端配置与数据库初始化第一步永远是建库导数据顺序错了后面全是Table doesnt exist。# 建库字符集必须是 utf8mb4档案题名里有生僻字和繁体 mysql -uroot -p -e CREATE DATABASE archive_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 导入结构和初始数据管理员账号、部门树、保管期限字典都在这 mysql -uroot -p archive_db archive-server/src/main/resources/db/archive_db.sql # 验证一下应该能看到 arc_record 和 sys_user mysql -uroot -p archive_db -e SHOW TABLES;然后是配置文件。开发环境我一般单独放一份application-dev.yml主配置只留公共部分server: port: 8080 servlet: context-path: /api # 前端统一以 /api 前缀调Nginx 也只反代这个前缀 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # serverTimezone 不写就是经典的差 8 小时 url: jdbc:mysql://127.0.0.1:3306/archive_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: root servlet: multipart: max-file-size: 50MB # 单个电子文件上限 max-request-size: 200MB # 一次批量挂接的总上限 archive: storage: root: /data/archive/files # 文件落盘根目录别放项目目录里重新部署会丢 jwt: secret: change-me-in-prod # 上生产必须换 expire: 7200 # 秒 record-no: separator: -context-path设成/api是个很小的决定但影响前后端所有路径。前端 axios 的baseURL也要跟着写/api开发时靠 Vite 代理转发到 8080生产时靠 Nginxlocation /api/ { proxy_pass http://127.0.0.1:8080/api/; }。两边不一致就会出现“本地能跑、部署 404”的经典翻车。启动后端cd archive-server mvn clean package -DskipTests # 开发期直接跑改代码能热重启 mvn spring-boot:run -Dspring-boot.run.profilesdev # 或者跑打好的包 java -jar target/archive-server-1.0.0.jar --spring.profiles.activedev启动日志里看到Tomcat started on port(s): 8080 (http)且没有APPLICATION FAILED TO START后端就算起来了。如果连不上数据库日志最上面会打印 HikariPool 的初始化异常直接看那段Caused by。3.3 前端启动与接口联调cd archive-web npm install npm run dev启动后默认在 5173Vite或者 8080Vue CLI注意和后端端口撞车。代理配置必须是这个形状changeOrigin不写会在某些环境下被拦// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8080, changeOrigin: true // 不要写 rewrite 去掉 /api后端 context-path 已经带了 } } } })登录用初始化脚本里的管理员账号进去第一件事是去“系统管理-部门管理”确认部门树是不是完整的再去“角色管理”看一眼权限点有没有勾上。档案系统的页面权限是按菜单和按钮两级配的只配菜单不配按钮会出现“能进页面但新增按钮点了 403”。联调阶段最常改的是src/utils/request.js// 请求拦截统一挂 token service.interceptors.request.use(config { const token localStorage.getItem(archive_token) // 注意前缀是 Bearer 加一个空格后端解析时按这个切 if (token) config.headers[Authorization] Bearer token return config }) // 响应拦截401 统一跳登录别每个页面各写一遍 service.interceptors.response.use( res { const body res.data if (body.code 401) { localStorage.removeItem(archive_token); location.href /login } return body }, err Promise.reject(err) )这段代码看起来平平无奇但Bearer后面那个空格是新手最常漏的漏了就是登录成功、所有接口 401非常迷惑。4. 核心模块拆解档号生成、组合检索与借阅审批的代码实现4.1 著录与档号自动生成档号是档案系统的身份证格式一般是全宗号-年度-保管期限-件号比如A003-2023-Y30-00127。难点不在拼接在于件号必须按“同一全宗 同一年度 同一保管期限”这个维度独立递增而且并发下不能重号。Service public class RecordNoService { Autowired private ArcRecordMapper recordMapper; /** * 生成档号全宗号-年度-保管期限-4位件号 * 用数据库唯一索引兜底冲突时重试不做分布式锁 */ public String next(String fondsCode, Integer year, String retention) { String prefix String.join(-, fondsCode, String.valueOf(year), retention); // max(record_no) 在这个前缀下取最大比 count 安全删过数据也不会重号 String maxNo recordMapper.selectMaxNoByPrefix(prefix); int seq 1; if (maxNo ! null) { seq Integer.parseInt(maxNo.substring(maxNo.lastIndexOf(-) 1)) 1; } return prefix - String.format(%04d, seq); } }说明几个细节。用max(record_no)而不是count(*)是因为案卷可能被作废删除count 会算少导致重号。SQL 里selectMaxNoByPrefix要写成SELECT MAX(record_no) FROM arc_record WHERE record_no LIKE CONCAT(#{prefix}, -%)注意这里必须让record_no走索引LIKE前缀固定是可以走uk_record_no的。并发冲突靠数据库唯一索引兜底Service 层捕获DuplicateKeyException重试三次。这是单体系统的优势换成分布式就得引入号段或者 Redis 自增复杂度翻几倍。著录保存的主流程Service public class ArcRecordService { Transactional(rollbackFor Exception.class) public Long save(ArcRecordForm form) { ArcRecord record new ArcRecord(); BeanUtils.copyProperties(form, record); if (StringUtils.isBlank(form.getRecordNo())) { // 新建才生成档号编辑时档号不允许改 record.setRecordNo(recordNoService.next( form.getFondsCode(), form.getArchiveYear(), form.getRetention())); } record.setStatus(0); // 草稿态走完审批才置 2 record.setDeptId(UserContext.get().getDeptId()); // 部门从登录态取不接受前端传 recordMapper.insert(record); // 电子文件是先在临时目录传好这里只做绑定 if (CollectionUtils.isNotEmpty(form.getFileIds())) { fileService.bindToRecord(record.getId(), form.getFileIds()); recordMapper.updateFileCount(record.getId(), form.getFileIds().size()); } return record.getId(); } }deptId从登录态取而不是表单取是一个防御性设计。前端表单里也有这个字段用于显示“立卷部门”但后端一律覆盖不然改个请求参数就能把卷挂到别人部门下数据权限就破了。4.2 组合检索条件拼装与关键词匹配档案检索的用户习惯和电商搜索完全不同他们很少只输一个词更多是“2020 到 2023 年、Y30、题名里含‘采购’”。所以检索页要做成多条件组合而不是一个搜索框。后端接口设计成一个统一的RecordQuery对象参数类型说明titleString题名模糊匹配fondsCodeString全宗号精确匹配yearStart / yearEndInteger归档年度区间retentionString保管期限字典值secretLevelString密级普通用户强制过滤掉 SECRETkeywordString跨字段关键词匹配题名/文号/责任者pageNum / pageSizeInteger分页默认 1/10keyword这一项值得单独说。真正的全文检索方案是上 Elasticsearch 或者 MySQL 的全文索引但档案数据量通常在几十万条量级用LIKE加多字段OR完全扛得住省一套中间件的运维成本。if testq.keyword ! null and q.keyword ! AND ( r.title LIKE CONCAT(%, #{q.keyword}, %) OR r.doc_no LIKE CONCAT(%, #{q.keyword}, %) OR r.author LIKE CONCAT(%, #{q.keyword}, %) ) /if !-- 密级过滤非保密员只能看 PUBLIC 和 INTERNAL -- if testq.maxSecretLevel ! null AND r.secret_level IN foreach collectionq.maxSecretLevel items open( separator, close)#{s}/foreach /if密级过滤这个条件不能由前端传得在 Service 里根据当前用户角色算出maxSecretLevel再塞进查询对象。普通档案员拿到[PUBLIC,INTERNAL]保密员拿到全部三个。这类“看不见的过滤条件”最好写在 Service 的一处别散在十几个查询里。数据量大起来之后LIKE %x%是一定会扫全表的。我的做法是先靠全宗、年度、保管期限这三个走索引的条件把结果集压到几千条关键词过滤在这个小结果集上做实际响应基本在几百毫秒。真到了百万级再考虑 ES但要先把record_no和title的联合索引调好。4.3 借阅审批与状态机借阅是这套系统里唯一有“流程”的模块也是最容易写乱的地方。它的状态流转是待审批 → 已批准 / 已驳回 → 已归还 / 已逾期。写成一个简单的状态机比散落的 if-else 靠谱得多。Service public class BorrowService { Transactional(rollbackFor Exception.class) public void approve(Long borrowId, boolean pass, String opinion) { ArcBorrow borrow borrowMapper.selectById(borrowId); // 幂等保护已经审批过的不允许重复审批 if (!BorrowStatus.PENDING.getCode().equals(borrow.getStatus())) { throw new BizException(该申请已处理请刷新后重试); } // 不能审批自己提交的申请这条在档案系统里是硬要求 if (borrow.getApplyUserId().equals(UserContext.get().getUserId())) { throw new BizException(不能审批本人提交的借阅申请); } borrow.setStatus(pass ? BorrowStatus.APPROVED.getCode() : BorrowStatus.REJECTED.getCode()); borrow.setAuditUserId(UserContext.get().getUserId()); borrow.setAuditTime(LocalDateTime.now()); borrow.setAuditOpinion(opinion); borrowMapper.updateById(borrow); // 批准后扣减可借份数驳回不扣 if (pass) { recordMapper.decreaseAvailable(borrow.getRecordId()); } } }Transactional的rollbackFor Exception.class不能省Spring 默认只回滚RuntimeException业务里抛的受检异常如果不写这个前一步的 update 就提交了数据直接脏掉。幂等保护那三行看着啰嗦但没有它前端手快点两下“通过”可借份数就扣两次。归还和逾期用一个定时任务扫Scheduled(cron 0 0 2 * * ?) // 每天凌晨 2 点避开白天使用高峰 public void markOverdue() { // 只扫状态为“已批准”且应还日期已过的 ListArcBorrow list borrowMapper.selectOverdue(LocalDate.now()); list.forEach(b - { b.setStatus(BorrowStatus.OVERDUE.getCode()); borrowMapper.updateById(b); }); log.info(逾期标记完成共 {} 条, list.size()); }定时任务记得在启动类上加EnableScheduling另外多实例部署时要加分布式锁不然两台机器同时扫会重复处理。单体单实例部署就没这个问题这也是不做微服务的一个附带好处。4.4 电子文件上传与预览档案系统的文件有个特点量大、不改、要能长期读。所以存储层抽象成接口本地磁盘和对象存储两种实现切换public interface StorageService { /** 返回相对路径不返回绝对路径迁移存储时不至于全表改数据 */ String store(InputStream in, String originalName); InputStream load(String relativePath); void delete(String relativePath); }上传接口先算哈希再落盘哈希落库后同一个文件重复上传可以直接复用省磁盘也省时间PostMapping(/upload) public ResultFileVO upload(RequestParam(file) MultipartFile file, RequestParam(required false) Long recordId) throws IOException { // 1. 白名单校验别用后缀黑名单绕过方式太多 String ext FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(); if (!ALLOW_EXT.contains(ext)) { return Result.fail(不支持的文件类型 ext); } // 2. 算哈希命中已有记录直接返回实现秒传 String hash DigestUtils.sha256Hex(file.getInputStream()); ArcFile exist fileMapper.selectByHash(hash); if (exist ! null recordId ! null) { fileService.bindToRecord(recordId, Collections.singletonList(exist.getId())); return Result.ok(FileVO.of(exist, true)); } // 3. 落盘文件名用 UUID原始名单独存库避免中文和特殊字符踩坑 String relative storageService.store(file.getInputStream(), file.getOriginalFilename()); // ...落库 }文件名用 UUID 这个是硬经验。原始名里有中文、空格、括号、#直接拿去当路径在 Linux 上问题不大但拼进 URL 下载时不做编码就会 404。原始名单独存file_name字段下载时从Content-Disposition里带出去。5. 常见问题排查启动、登录、查不到数据与上传失败的几处坑5.1 启动即失败Table doesnt exist现象mvn spring-boot:run直接抛Table archive_db.arc_record doesnt exist但 Navicat 里明明能看到这张表。原因通常是三种初始化脚本没跑只建了库没导表Linux 上 MySQL 默认lower_case_table_names0表名大小写敏感而脚本里写的是Arc_Record或者连到了另一个库application-dev.yml里的库名和脚本里的不一致。解决先SHOW TABLES;确认表在再SELECT lower_case_table_names;看大小写策略。统一做法是建表全部小写下划线配置文件里的库名和脚本里的CREATE DATABASE保持完全一致。另外记得检查是不是 profile 没激活跑起来的是application.yml里的默认库。5.2 登录成功但所有接口 401现象登录接口返回了 token前端也存进去了可接下来请求全是 401控制台里Authorization头看着也有值。原因九成是Bearer前缀。后端过滤器一般这么解析String token header.substring(7);它是按Bearer 七个字符切的。前端如果写成Bearer token少个空格或者直接config.headers[Authorization] token切出来的就是错的。解决在过滤器里加一行日志打印header的原始值一眼就能看出来。另外要确认 Security 的放行名单里加了/auth/login、/auth/captcha以及静态资源路径否则连登录页都会被拦。5.3 库里有数据列表页就是查不出来现象用管理员账号查得到换成部门档案员账号一条都没有去数据库里select * from arc_record where dept_id 5明明有几十条。原因一般是数据权限切面取到的部门 id 为空。常见于两种场景一是登录时把deptId放进了 JWT 的 payload但解析时字段名对不上deptId和dept_id二是DataScopeHolder用了 ThreadLocal 但异步线程里取不到比如导出走了Async。解决先在DataScopeAspect里打日志把deptIds打出来如果是异步任务把当前用户上下文在提交任务前手动透传过去别指望 ThreadLocal 能跨线程。还有一种隐蔽情况手写 XML 的select忘了deleted 0逻辑删除的数据被当成有效的查出来了表现为“数量对不上”这时候补上条件即可。5.4 上传 50MB 以上的扫描件直接失败现象小文件正常传一个几十兆的 PDF 扫描件前端报 413 或者后端抛MaxUploadSizeExceededException。原因spring.servlet.multipart.max-file-size只改了应用层Nginx 那边的client_max_body_size默认是 1M请求在网关就被拒了。反过来也有只改 Nginx 没改应用的。解决两边都要改。Nginx 在server块里加client_max_body_size 200m;应用配置里max-file-size和max-request-size一起调。另外要看一眼是不是走了网关或反向代理的多层结构每一层都得放行。5.5 导出的 Excel 里中文乱码、长档号变科学计数法现象导出后用 Excel 打开题名全是问号或者A003-2023-Y30-00127这种档号显示成1.23E15。原因乱码多半是响应头里Content-Type丢了 charset或者写流的时候用了平台默认编码科学计数法是 Excel 自己的解析行为超过 11 位的纯数字串会被自动转成数值。解决响应头写死application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charsetutf-8用 POI 写XSSFWorkbook。档号这一类必须做文本格式处理把单元格样式设成文本格式或者写值时统一用setCellValue(String)并在样式里指定dataFormat。我习惯在导出工具类里对“编号类字段”统一套一个文本样式省得每处都记着设。6. 二次开发给著录表加一个自定义字段并打通导出实际交付里被问得最多的一句是“能不能再加个字段”。比如某个单位要求记录“电子档案的载体类型”光盘、硬盘、纸质扫描。这个改动能完整跑通一遍数据链路很适合作为二次开发的第一课。改法分四步数据库先行ALTER TABLE arc_record ADD COLUMN carrier_type VARCHAR(16) DEFAULT NULL COMMENT 载体类型OPTICAL/DISK/SCAN AFTER secret_level, ADD INDEX idx_carrier (carrier_type);接着在sys_dict里插两条字典数据dict_typecarrier_type前端下拉框就能直接读字典接口不用改代码。然后在ArcRecord实体加字段、ArcRecordForm加字段、ArcRecordVO加字段——这三个类一定要同时加只加实体不改 Form前端提交上来是 null改了实体不改 VO列表页看不到。Mapper 的resultType是自动映射字段名对上就行手写 XML 的select如果用了r.*也不用改。前端改三处著录表单里加一个el-select绑form.carrierType列表页el-table-column加一列检索表单里加一个筛选项。字典数据走已有的/sys/dict/{type}接口不新增接口。导出这块有个坑如果项目用的是注解式导出实体字段上打Excel(name载体类型)那只需在实体新字段上加注解但字典值导出的是OPTICAL这种码值得配一个readConverterExp OPTICAL光盘,DISK硬盘,SCAN纸质扫描。如果用的是手写 POI 的setCellValue那就得在导出方法里补一列并处理下标偏移后面所有列的索引都要往后挪一位这块最容易改漏。验证方法很朴素新建一条带载体类型的案卷保存后重新进详情页看值在不在列表页看列对不对检索页按载体筛一次最后导出一份 Excel 用肉眼对一遍码值有没有被转换。五步走完这个字段才算真正打通。从那以后我每次改这类“加字段”的需求都强制走一遍“建表脚本 → 实体/Form/VO → 前端表单、列表、检索 → 导出”的清单少一步就会在验收现场被问住。这套档案系统的结构足够规整按这个顺序改基本不会翻车。希望帮到你。本文还有配套的精品资源点击获取