简介:档案管理信息系统是一套面向档案数字化管理学习与桌面应用开发实践的Delphi+SQL项目源码,适合计算机专业学生、Delphi初学者及需要了解档案管理业务建模的开发者参考。压缩包共106个文件,约1.51MB,包含22个pas源文件、24个dcu编译单元、18个dfm窗体文件,以及ddp、cfg、dof等Delphi工程配置,另有2个sql脚本、mdf/ldf数据库文件、dpk组件包和dpr工程入口,可较完整地还原界面设计、业务逻辑与数据库连接思路。资源已有196人学习,内容围绕档案录入、检索、权限管理、版本控制、统计报表与导出打印等模块展开,并涉及ADO数据访问、用户权限、数据备份与系统维护等要点。读者可借此梳理Delphi桌面系统的目录结构、窗体组织和SQL表设计方法,对照源码理解档案管理信息系统的功能划分与实现流程,作为课程设计、毕业设计或类似管理信息系统开发的参考素材。
1. 档案管理信息系统不是“网盘加个上传按钮”:先分清全宗、门类和档号
很多单位做档案信息化的第一步,是把扫描件按文件夹拷进服务器,再套一个上传下载页面,结果第一批数据入库就卡住:同一份会议纪要,办公室按“件”著录,业务处室按“案卷”装盒,两边档号规则还不一致,检索时谁也查不全。档案管理信息系统真正要解决的不是存文件,而是把全宗、门类、案卷、件这四层关系固化成数据结构,让档号唯一、元数据可校验、借阅留痕、移交能对账。这套源码包按四级层级建模,后端 Spring Boot 跑著录、归档、借阅、鉴定四条链路,前端 Vue 做工作台,MySQL 存元数据、对象存储放电子文件。适合给单位档案室搭系统、手里有一批历史台账要迁、又不想从零推演表结构的人。
2. 拆开源码包:四级层级模型与核心表的落地方式
2.1 目录结构与模块边界
拿到包,先不要急着运行mvn spring-boot:run,先看目录怎么切。档案系统并发不高,几十到几百人的单位,峰值也就几十个请求同时进来,做成微服务纯属给自己找运维活。这个包的结构是单体多模块,边界靠 Maven 模块划,不靠服务拆。
archive-server/ # 后端,单体多模块 ├── archive-common/ # 统一返回体、业务异常、日期与摘要工具 ├── archive-domain/ # 实体、Mapper、DTO,不含业务逻辑 ├── archive-service/ # 著录、归档、借阅、鉴定四条链路的实现 ├── archive-admin/ # 启动模块,Controller、拦截器、配置 └── archive-job/ # 定时任务:逾期催还、孤儿文件清理、摘要复核 archive-web/ # 前端 Vue3 + Vite ├── src/api/ # 按业务域拆的请求封装 ├── src/views/archive/ # 著录、案卷、借阅、鉴定页面 └── src/components/MetaForm/ # 配置驱动的元数据表单模块划分对应的是改动频率,不是技术分层。archive-domain稳定,加了字段才动;archive-service是血泪集中区,业务规则全在这;archive-admin只做参数接收和结果包装。这样改一个校验规则,只动 service,编译范围和回归范围都可控。
| 模块 | 主要职责 | 常见改动频率 |
|---|---|---|
| archive-common | 返回体、异常码、SHA-256 工具 | 低 |
| archive-domain | 实体与 Mapper,字段映射 | 中 |
| archive-service | 档号生成、元数据校验、归档事务 | 高 |
| archive-admin | 接口、鉴权、跨域、文件上传 | 中 |
| archive-job | 逾期借阅、文件摘要复核 | 低 |
前端这边有个容易忽略的点:著录表单不能写死。文书档案要题名、责任者、成文日期,科技档案要项目号、图号、阶段,会计档案要凭证号、册号。写死一个页面的结果是每加一个门类就复制一份代码,半年后没人敢动。包里用MetaForm组件读字段配置渲染,页面只负责取配置和提交。
2.2 从全宗到件:表结构与索引取舍
核心表不多,但关系必须摆正。全宗是顶层,一个立档单位一个全宗;门类挂在全宗下,决定元数据模板和保管期限规则;案卷是可选的中间层,文书档案可以件级管理,科技档案往往还得有案卷;件级主表是检索和借阅的实际落点。
| 表名 | 关键字段 | 说明 |
|---|---|---|
sys_fonds | fonds_code、fonds_name、parent_id | 全宗,支持上下级 |
arc_category | category_code、retention_rule | 门类:文书、科技、会计、照片 |
arc_volume | volume_code、fonds_id、archive_year | 案卷,可为空 |
arc_item | item_code、title、retention、security_level | 件级主表 |
arc_metadata | item_id、meta_key、meta_value | 门类专属扩展元数据 |
arc_file | item_id、storage_key、sha256、mime | 电子文件 |
arc_borrow | borrow_no、item_id、status、due_date | 借阅单 |
arc_appraisal | item_id、result、destroy_date | 鉴定与销毁 |
扩展元数据为什么单独一张表,而不是把所有门类的字段摊成一张宽表?因为文书档案字段二十来个,科技档案加上项目号、图号、设计阶段、密级能到六七十个,会计档案又是一套,宽表最后会膨胀到两三百列,其中大半长期是 NULL。用主表加扩展表,字段配置表决定表单渲染和服务端校验规则,加门类只是加数据,不是改表结构。
代价也要认:扩展表是纵向存储,按元数据值检索会慢。我的处理是把高频检索字段冗余回主表——题名、责任者、归档年度、保管期限、密级、档号,这六个字段直接落在arc_item上并建索引,只有冷门字段才去扩展表捞。这是典型的用一点冗余换查询确定性,档案系统查询模式稳定,值得。
提示:
arc_item上不要建太多单列索引。archive_year、category_id、retention这类选择性低的字段,单列索引基本用不上,组联合索引才有效果。
2.3 档号生成:并发生成器与校验规则
档号是整个系统的主键语义,重复或跳号都是返工级事故。规则一般由全宗号、门类代码、年度、保管期限、件号拼成,中间用分隔符。生成逻辑必须放在服务端,并且用数据库行锁兜底。
// ArchiveCodeGenerator.java —— 按「全宗-门类-年度-期限」分段取号,件号四位补零 public String nextCode(String fondsCode, String categoryCode, int year, String retention) { String prefix = String.join("-", fondsCode, categoryCode, String.valueOf(year), retention); // selectForUpdate 拿行锁,保证同一前缀下取号串行,避免并发下重号 CodeSequence seq = sequenceMapper.selectForUpdate(prefix); if (seq == null) { // 唯一索引兜底:并发首次插入时只有一个能成功,失败的走一次重试 sequenceMapper.insertIgnore(prefix, 0); seq = sequenceMapper.selectForUpdate(prefix); } int next = seq.getCurrentNo() + 1; sequenceMapper.updateCurrentNo(prefix, next); return prefix + "-" + String.format("%04d", next); }几个参数值得说清楚。prefix用短横线拼接,是为了让人一眼读出来,也方便后续按前缀做统计和分段导出。件号补零到四位是硬要求,1和01在字符串排序里位置完全不同,导出清册时序号会乱。insertIgnore依赖prefix上的唯一索引,没有这个索引并发首次取号必出双份,这一点在部署新环境时最容易被漏掉。
服务端还要有一道格式校验,别指望前端。常见做法是用正则把合法格式固化下来,录入、导入、迁移三处共用一套规则:
// archive-code.js —— 与后端 ArchiveCodeRule 保持一致,改一边必须改另一边 const CODE_RE = /^[A-Z0-9]{2,8}-[A-Z]{2,8}-\d{4}-(永久|30年|10年)-\d{4}$/; export function checkCode(code) { if (!CODE_RE.test(code)) { return '档号格式应为 全宗-门类-年度-期限-四位件号'; } return null; // 返回 null 表示通过,调用方直接判断即可 }2.4 电子文件落盘:storage_key 设计与摘要留存
电子文件不能按原始文件名存盘。原始名里带空格、括号、斜杠的情况太常见,跨平台一拷就出问题,更别说路径穿越。包里统一用storage_key,规则是全宗/门类/年度/月/uuid.ext,原始文件名只作为元数据存库。
// LocalStorageService.java —— 落盘只认 storage_key,原始文件名永不参与路径拼接 public StoredFile save(InputStream in, String originalName, String fondsCode, String categoryCode) { String ext = FilenameUtils.getExtension(originalName).toLowerCase(); String key = String.format("%s/%s/%d/%02d/%s.%s", fondsCode, categoryCode, LocalDate.now().getYear(), LocalDate.now().getMonthValue(), UUID.randomUUID(), ext); Path target = root.resolve(key).normalize(); if (!target.startsWith(root)) { // 归一化后仍在根目录下才允许写入 throw new BizException("非法存储路径"); } String sha256 = DigestUtils.sha256Hex(in); // 落盘同时算摘要,只读一遍流 // 写文件 + 校验长度,长度不一致说明传输被截断 return new StoredFile(key, sha256, size); }sha256有两个用处:一是同一份文件被重复上传时可以直接复用,省存储;二是归档时的“真实性”检查有了最便宜的抓手——用户端声明的摘要和服务端算出来的对不上,直接拒收。UUID 保证并发上传不撞名,扩展名保留是为了后续按格式做可用性检查时不用回读文件头。
3. 本地跑通:环境清单、初始化 SQL 与前后端联调
3.1 版本清单与两个必须锁死的依赖
环境不对,后面全是玄学报错。包里用的是 Spring Boot 3.2 系列,意味着 JDK 必须是 17,继续用 8 会直接编译不过。
| 组件 | 版本要求 | 说明 |
|---|---|---|
| JDK | 17 | Spring Boot 3.x 的最低要求 |
| Maven | 3.8+ | 低版本解析不了新的仓库元数据 |
| MySQL | 8.0.28+ | 需要utf8mb4_0900_ai_ci排序规则 |
| Redis | 6.0+ | 存会话与验证码,非必须但建议起 |
| Node.js | 18 LTS | Vite 5 的最低要求 |
第二个必须锁死的依赖是 MyBatis-Plus 的版本。3.5.x 之间对Interceptor接口签名改过,版本飘了会出现编译通过但拦截器不生效的情况——查询照常返回全量数据,权限过滤形同虚设,这种问题在测试环境很难被发现。
注意:Spring Boot 3 之后
javax.servlet全部换成jakarta.servlet。从旧项目拷过来的 Controller 如果还引javax,报错是“找不到 HttpServletRequest”,不是缺依赖,是包名换了。
3.2 建库与初始化数据
建库这一步不要图省事用默认字符集。档案题名里生僻字多,utf8三字节存不下,入库时会被截断成问号,事后补数据非常痛苦。
-- 01_schema.sql —— 字符集与排序规则必须显式指定 CREATE DATABASE archive_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE archive_db; CREATE TABLE arc_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, item_code VARCHAR(64) NOT NULL COMMENT '档号,全宗-门类-年度-期限-件号', fonds_id BIGINT UNSIGNED NOT NULL, category_id BIGINT UNSIGNED NOT NULL, title VARCHAR(512) NOT NULL COMMENT '题名', responsible VARCHAR(128) DEFAULT NULL COMMENT '责任者', archive_year SMALLINT NOT NULL COMMENT '归档年度,只存年份不带时间', retention VARCHAR(8) NOT NULL COMMENT '永久/30年/10年', security_level TINYINT NOT NULL DEFAULT 1, volume_id BIGINT UNSIGNED DEFAULT NULL, deleted TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_item_code (item_code), KEY idx_year_cat (archive_year, category_id), KEY idx_title (title(64)) ) ENGINE=InnoDB COMMENT='件级档案主表';archive_year用SMALLINT而不是DATETIME,是为了让“按年度统计”这类查询不受时区影响,也不会出现 12 月 31 日归档的文件因为时区偏移被算进下一年。idx_title只取前 64 个字符做前缀索引,因为题名查询绝大多数是前缀匹配,整列索引在 512 长度上太占空间。初始化脚本里还会写入默认全宗、四个门类的字段配置和一个管理员账号,第一次登录后先把这几项核对一遍再录数据。
3.3 后端配置:四个决定成败的参数
# application.yml —— 只列会直接影响行为的项 server: port: 8080 servlet: context-path: /api # 所有接口带 /api 前缀,前端代理要与之一致 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/archive_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: archive password: change_me servlet: multipart: max-file-size: 200MB # 单个电子文件上限,扫描件合订本容易超 max-request-size: 500MB # 批量上传的总上限,要大于单文件上限 archive: storage: type: local # local | oss,切对象存储只改这里和实现类 local-path: /data/archive/files security: token-ttl: 7200 # 会话有效期(秒) borrow-max-days: 30 # 借阅最长天数,超过要走延期serverTimezone不写会和服务器本地时区打架,表现是新建记录的时间比实际早八小时。max-request-size小于max-file-size是经典翻车点:单文件传得上去,一次传三个就报“请求体过大”,而且报错在网关层,后端日志里什么都看不到。local-path一定要指向挂载卷,不能是容器内的临时目录。
3.4 前端启动与代理配置
cd archive-web npm install --registry=https://registry.npmmirror.com npm run dev// vite.config.js —— 开发环境把 /api 原样转发给后端,不做路径重写 export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true // 注意:这里不要加 rewrite,后端 context-path 已经是 /api // 加了会把路径改成 /archive/... 导致全部 404 } } } })代理这块最常见的错就是“双重前缀”和“路径被吃掉”。判断方法很简单:浏览器开发者工具里看实际请求路径,如果变成/api/api/xxx或者/xxx,就是 rewrite 配错了,跟后端没关系。
4. 三条主链路:著录校验、归档入库、借阅审批
4.1 著录:配置驱动表单,服务端才是底线
著录页面的字段由门类配置决定,前端读配置渲染控件,但校验必须在服务端再跑一遍。前端校验只是体验优化,改个请求就能绕过,档案数据一旦脏了,后面所有检索和统计都不可信。
// MetadataValidator.java —— 按门类字段配置逐项校验,返回全部错误而不是遇到第一个就抛 public List<String> validate(Long categoryId, Map<String, Object> meta) { List<CategoryField> fields = fieldMapper.selectByCategory(categoryId); List<String> errors = new ArrayList<>(); for (CategoryField f : fields) { Object v = meta.get(f.getFieldKey()); if (f.getRequired() == 1 && (v == null || String.valueOf(v).trim().isEmpty())) { errors.add(f.getFieldLabel() + "不能为空"); continue; // 必填缺失就没必要再跑格式判断 } if (v == null) continue; String s = String.valueOf(v).trim(); if ("DATE".equals(f.getFieldType()) && !s.matches("\\d{4}-\\d{2}-\\d{2}")) { errors.add(f.getFieldLabel() + "应为 yyyy-MM-dd"); } if (f.getMaxLength() != null && s.length() > f.getMaxLength()) { errors.add(f.getFieldLabel() + "超过 " + f.getMaxLength() + " 字"); } } return errors; // 空列表表示通过,Controller 直接判空返回 }这个校验器的关键点是“一次返回所有错误”。档案著录动辄二三十个字段,每提交一次只报一个错,录入员要点二十次才能存上一条,怨气全撒在系统上。continue的位置也有讲究:必填缺失时跳过格式校验,否则“日期为空”会同时报两条错,用户反而不知道先改哪个。
4.2 归档入库:四性检测的最小实现
真实性、完整性、可用性、安全性这四项,落到代码上不需要多复杂,但每一道闸都得有。
// ArchiveService.java —— 归档是一个事务,文件、主表、元数据、文件表一起成一起败 @Transactional(rollbackFor = Exception.class) public Long archive(ArchiveSubmitDTO dto) { // 1. 先落文件,摘要由服务端计算,不信任前端声明 StoredFile file = storage.save(dto.getInputStream(), dto.getOriginalName(), dto.getFondsCode(), dto.getCategoryCode()); // 2. 真实性:前端声明的摘要与服务端算出的比对,不一致直接拒 if (StringUtils.hasText(dto.getDeclaredSha256()) && !file.getSha256().equalsIgnoreCase(dto.getDeclaredSha256())) { throw new BizException("文件摘要不一致,可能在传输中被截断"); } // 3. 可用性:格式白名单,白名单之外一律不收,别做"先收下再转格式" if (!ALLOWED_MIME.contains(file.getMime())) { throw new BizException("不支持的格式:" + file.getMime()); } // 4. 完整性:必填元数据必须齐,缺一项就不让归档 List<String> errors = validator.validate(dto.getCategoryId(), dto.getMeta()); if (!errors.isEmpty()) { throw new BizException(String.join(";", errors)); } // 5. 档号在这里才取,避免校验失败白白消耗号段 String code = codeGenerator.nextCode(dto.getFondsCode(), dto.getCategoryCode(), dto.getArchiveYear(), dto.getRetention()); Long itemId = itemMapper.insertFrom(dto, code, file); metaMapper.batchInsert(itemId, dto.getMeta()); fileMapper.insert(itemId, file); return itemId; }顺序是有讲究的。档号生成放在最后一步,是因为号段一旦消耗就不该回退,校验失败还去取号会造成大面积跳号。文件先落盘再写库,失败时靠事务回滚加定时任务清理孤儿文件——archive-job里那个清理任务扫的就是存储目录里存在、arc_file表里查不到的 key。安全性这块,包里留了病毒扫描的扩展点,接入单位现有的扫描服务即可,不接也不影响主流程。
4.3 借阅:状态机与数据权限
借阅单有明确的状态迁移:草稿、待审批、已批准、已借出、已归还,另外还有驳回和逾期两个分支。状态字段用枚举而不是数字,用数字时间一长没人记得3是批准还是驳回。
// BorrowStateMachine.java —— 状态迁移集中管理,别在 Controller 里散着写 if private static final Map<BorrowStatus, Set<BorrowStatus>> ALLOWED = Map.of( BorrowStatus.DRAFT, Set.of(BorrowStatus.PENDING), BorrowStatus.PENDING, Set.of(BorrowStatus.APPROVED, BorrowStatus.REJECTED), BorrowStatus.APPROVED, Set.of(BorrowStatus.BORROWED), BorrowStatus.BORROWED, Set.of(BorrowStatus.RETURNED, BorrowStatus.OVERDUE), BorrowStatus.OVERDUE, Set.of(BorrowStatus.RETURNED) ); public void transfer(Long borrowId, BorrowStatus target, String operator) { BorrowDO b = borrowMapper.selectById(borrowId); if (!ALLOWED.getOrDefault(b.getStatus(), Set.of()).contains(target)) { throw new BizException("当前状态不允许该操作:" + b.getStatus() + " -> " + target); } borrowMapper.updateStatus(borrowId, target, operator, LocalDateTime.now()); }比状态机更容易出事的是数据权限。很多实现只在前端把菜单藏起来,接口本身没校验,改个 URL 就能看到别的部门的档案。正确做法是在持久层拦截,把部门范围和密级上限拼进 SQL。
// DataScopeInterceptor.java —— 服务端拼条件,前端藏按钮只是辅助 @Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rb, ResultHandler rh, BoundSql boundSql) { LoginUser user = SecurityUtils.getUser(); if (user == null || user.isArchivist()) return; // 档案员与管理员不受部门限制 // 只对声明了数据权限的语句生效,导出、统计类 SQL 走白名单排除 String scope = " AND dept_id IN (" + user.getDeptScopeIds() + ")" + " AND security_level <= " + user.getMaxLevel(); // 反射改写 SQL 略,关键是白名单:不在名单内的 MappedStatement 一律跳过 }4.4 检索:先上 MySQL 全文,再谈 ES
一上来就架 Elasticsearch 是过度设计。件级数据在百万以内,MySQL 的 ngram 全文索引足够用,少一个组件就少一份运维负担。
-- 中文全文索引必须指定 ngram 解析器,默认解析器按空格切词,中文全废 ALTER TABLE arc_item ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram; SELECT id, item_code, title, archive_year FROM arc_item WHERE MATCH(title) AGAINST ('+会议 +纪要' IN BOOLEAN MODE) AND archive_year BETWEEN 2015 AND 2024 AND deleted = 0 ORDER BY archive_year DESC LIMIT 20;ngram_token_size默认是 2,也就是按两字切分,搜“会议”能命中,搜单个“档”字命中不了。这个参数在服务端配置里改,改完必须重建索引,光改参数不重建是无效操作。真到了百万级以上、或者需要按元数据做多维聚合筛选,再考虑引入外部检索引擎,那时数据同步的一致性又是一笔账。
5. 避坑:五个把项目拖进返工的细节
5.1 数据层的三个坑
档号重号,导入时偶发,重跑又正常。现象是批量导入几百条记录后,uk_item_code报主键冲突,但单条手工录入从来不出问题。原因是导入任务开了多线程,每个线程独立取号,行锁没有被包在同一个事务里,锁在语句结束就释放了。解决是把取号和写入放进同一个事务,或者干脆把导入改成单线程分批提交——档案导入的吞吐瓶颈本来就在文件解析,不在取号。
删掉的档号再也建不回来。现象是删除一条错误著录,再用同一个档号重新录入,提示唯一键冲突。原因是逻辑删除只改了deleted = 1,唯一索引仍然包含这行。解决办法有两个:一是删除时把item_code改写为原档号#id#时间戳,保留可追溯性;二是加一个deleted_at字段,未删除时为 NULL,让唯一索引变成(item_code, deleted_at),利用 NULL 不参与唯一约束的特性。我一般用第二种,查询时不用做字符串还原。
按年度统计总是差几条。现象是年底对账,系统里的 2023 年件数比纸质清册少三条。原因是当年用DATETIME存归档时间,12 月 31 日晚上录入的记录因为时区偏移被算进了 2024 年。解决是拆出独立的archive_year字段,由业务在归档确认时按人工认定的年度写入,与服务器时区彻底解耦。
5.2 部署与权限层的两个坑
服务重启后所有附件打不开。现象是本地开发一切正常,上了容器环境后过几天图片和 PDF 全部 404。原因是local-path指向容器内的临时目录,容器重建文件就没了,而数据库里的storage_key还在。解决是把存储目录挂成持久卷,或者直接切对象存储;storage_key里永远只存相对路径,不存绝对路径和访问域名,否则换环境就要批量改库。
换个账号就能看到别的部门的档案。现象是审计时发现,普通用户手工拼一个接口地址,就能查到全量数据。原因是权限只做在路由和按钮上,接口层没有校验。解决是把数据范围条件下推到持久层拦截器,同时对导出、打印这类高风险接口单独复核一次权限;前端隐藏菜单只是减少误操作,从来不是安全边界。
6. 历史台账迁移:分批幂等导入与一次对账
历史台账基本都是一张 Excel,列名五花八门,“题名”可能写成“文件标题”,“责任者”可能写成“拟稿人”。我的做法是先做一层列名映射,映射关系写死在脚本里,不做自动猜列——自动猜列猜错一次,后面几万条数据全歪,排查成本远超手工核对三十个列名。
模板里每列对应一个元数据 key,必填列在表头标色。导入脚本用source_no做幂等键,重跑不会产生重复档号,这是后悔药。
# import_ledger.py —— 分批提交 + source_no 幂等,中断后从任意批次重跑 import pandas as pd, requests BATCH = 500 URL = "http://127.0.0.1:8080/api/archive/import" def push(rows, token): r = requests.post(URL, json={"items": rows}, headers={"Authorization": "Bearer " + token}, timeout=180) r.raise_for_status() return r.json() # {"success": n, "skipped": m, "errors": [...]} df = pd.read_excel("ledger.xlsx", dtype=str).fillna("") rows = df.to_dict("records") for i in range(0, len(rows), BATCH): res = push(rows[i:i + BATCH], token) print(f"批次{i}-{i + BATCH}: 成功{res['success']} 跳过{res['skipped']} 失败{len(res['errors'])}")参数上,BATCH取 500 是个经验值:太大容易撞上请求超时和事务过长,太小则提交次数多、日志难对。skipped是幂等的直接体现,重跑时它应该等于已导入条数,如果不等于,说明source_no映射有问题。
导完必须对账,别信“成功 N 条”这句回显。
-- 导入后对账:四个数字对不上就别急着上线 SELECT (SELECT COUNT(*) FROM tmp_ledger) AS 源表行数, (SELECT COUNT(*) FROM arc_item WHERE deleted = 0) AS 入库件数, (SELECT COUNT(DISTINCT item_id) FROM arc_file) AS 挂文件件数, (SELECT COUNT(*) FROM arc_item WHERE item_code IS NULL OR item_code = '') AS 缺档号;源表行数减去入库件数,差值应该正好等于 Excel 里的空行和重复行;挂文件件数小于入库件数,说明有一批只有目录没有电子文件,这在历史台账里极常见,要单独出一张缺文件清单交给业务确认,而不是让技术去补。缺档号必须是 0,出现一条就说明取号链路有分支漏了。
这类迁移我踩过最疼的一次,是先用小样本试跑通过就全量灌,结果正式数据里有三千多条带全角空格的题名,入库后检索一律搜不到。从那以后我每次导入前都强制走一遍字段清洗和对账,先跑 500 条看四个数字,再放全量。希望帮到你。
本文还有配套的精品资源,点击获取