简介:这份 Word 文档围绕综合档案管理系统的建设与功能展开,面向政府机关、企事业单位的档案管理人员及信息化建设负责人。文档系统梳理了系统定位、功能特点与实施思路,涵盖文书、科研、产品、设备仪器、基建、会计、声像等十余类档案库的建立方式,并给出分类表维护、四级类目结构、条件组卷与手工组卷、档案全文检索浏览、用户化国标报表打印等具体做法。资源包内共 1 个 doc 文件,约 70KB,为可离线查阅的文档资料,适合在系统选型与需求调研时参考。目前已有 66 人学习下载。文中还涉及主题词自动标引、光盘存储、多媒体档案管理、与协同办公系统对接等集成要点,以及数据备份恢复、TXT/EXCEL/DBF 等多格式导入导出和按库分配角色、按角色分配库的权限设计,便于读者较快形成对档案管理流程与系统边界的整体认识。
1. 档案管理系统里,doc 不是一个后缀,而是一条数据链路
验收一套档案管理系统,最快的办法不是压测,而是丢一个叫「××年度工作总结.doc」的旧文件进去,点预览、搜正文。浏览器打不开它,转换队列里它排在最后,全文检索能命中标题却搜不到一个字。多数团队把这当成兼容性小问题,实际上它牵连的是接入、识别、抽取、转换、索引、长期保存整条链路。
档案管理系统和普通网盘的分水岭在这里:文件不是存下来给人下载,而是要变成可检索、可校验、可追溯的档案条目。doc 和 docx 横跨两个时代——OLE2 复合文档和 OOXML 压缩包,WPS 还有自己的格式,只看后缀名迟早翻车。
下面按落地顺序讲:怎么识别真实格式,怎么让 docx 在浏览器里稳定预览并排查「无法预览 doc」,怎么把正文抽出来进检索,以及批量迁移时该固定哪些参数。
2. 档案管理系统的文件接入与 doc/docx 真实格式识别
2.1 为什么只看后缀名判断 doc 与 docx 一定会出事
档案系统的入库来源很杂:共享盘整体搬迁、外包扫描件重命名、邮件附件、老 OA 导出、文档分享站点下载的副本。这些文件里后缀被改过、大小写混用、甚至干脆没有后缀的比例,在历史档案里往往超过百分之五。一旦按后缀分流,后面的抽取器和转换器就会拿着错误的输入干活:把 docx 交给只认 OLE2 的解析器,抛异常;把伪装成 docx 的 zip 丢给 python-docx,直接解包失败;把老 WPS 文档当成 Word 处理,正文抽出来是乱码。
更麻烦的是这类错误不会立刻暴露。转换失败的条目躺在队列里,检索库里少了一批正文,等到别人来查一份十年前的批复文件查不到,才发现是入库那天格式判错了。所以格式识别要放在入库前置校验里,识别结果落库,作为后续所有处理的分支依据。
提示:菜单里「新建」默认给的是 docx,很多单位的存量 doc 只减不增。存量 doc 恰恰是识别环节最容易出错的一批,别只拿新文件测。
2.1.1 四种常见归档文件的魔数与判断特征
| 格式 | 典型后缀 | 头部魔数(hex) | 判断依据 | 常用抽取器 |
|---|---|---|---|---|
| Word 97-2003 | .doc | D0 CF 11 E0 A1 B1 1A E1 | 前 8 字节,且内部含 WordDocument 流 | Apache Tika、antiword |
| Office Open XML | .docx | 50 4B 03 04 | ZIP 包内存在 word/document.xml | python-docx、Tika |
| WPS 旧格式 | .wps | D0 CF 11 E0 A1 B1 1A E1 | 同样是 OLE2,需看流名区分 | Tika |
| 改了名的压缩包 | .doc/.docx | 50 4B 03 04 | ZIP 内无 word/document.xml | 不做正文抽取 |
OLE2 是个"外壳",doc、xls、ppt、老 WPS 都套这个壳,光看魔数只能判断"这是个复合文档",必须再往里看一眼流名,才能确定走哪条抽取路线。
2.2 入库前做一次格式嗅探:最小可用的 Python 实现
import zipfile from pathlib import Path OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" ZIP_MAGIC = b"PK\x03\x04" def sniff_office(path: Path) -> str: """返回 doc / docx / ole_other / zip_other / unknown""" with path.open("rb") as f: head = f.read(8) # 只读头部 8 字节,几百 MB 的扫描件也不吃亏 if head.startswith(OLE2_MAGIC): try: import olefile ole = olefile.OleFileIO(str(path)) names = {"/".join(s) for s in ole.listdir()} except Exception: return "unknown" # 文件损坏或被截断,进人工复核队列 if any(n.startswith("WordDocument") for n in names): return "doc" return "ole_other" # xls / ppt / 老 WPS,交给各自的处理分支 if head.startswith(ZIP_MAGIC): try: with zipfile.ZipFile(path) as z: names = set(z.namelist()) except zipfile.BadZipFile: return "unknown" if "word/document.xml" in names: return "docx" return "zip_other" # 把 zip 改成 docx 的情况,别当文档解析 return "unknown" # PDF、图片、音视频走别的分支,不进这里这段函数只做一件事:把"该用哪个解析器"这件事在入库前定死。参数与逻辑上有几点值得说明。第一,只读 8 字节头部而不是全量加载,是因为归档里常有几百兆的复合文档,全量读取会把内存打满。第二,OLE2 分支必须借助 olefile 一类的库看内部流名,不能只看魔数就认定是 doc。第三,unknown 不要抛异常中断入库,应该带着原始文件进人工复核队列,避免一批有问题文件卡死整条导入流水线。
实际落地时,把嗅探结果和原始后缀分别落两个字段。两者不一致时打一个冲突标记,审计时能查到"这份档案入库时后缀是 doc、实际是 docx"。冲突数量本身也是一个健康度指标,持续偏高说明上游某个来源的系统在乱改后缀。
2.3 档案条目表怎么设计才能同时装下 doc 与 docx
CREATE TABLE archive_file ( id BIGSERIAL PRIMARY KEY, archive_no VARCHAR(64) NOT NULL, -- 档号,业务唯一键,也是检索索引的文档 id title VARCHAR(512) NOT NULL, ext_name VARCHAR(16), -- 原始后缀,仅作展示与审计 detected_type VARCHAR(16) NOT NULL, -- sniff 结果:doc / docx / ole_other ... size_bytes BIGINT NOT NULL, sha256 CHAR(64) NOT NULL, -- 秒传、去重、长期完整性校验 storage_key VARCHAR(256) NOT NULL, -- 对象存储 key,不存绝对路径 convert_status SMALLINT NOT NULL DEFAULT 0, -- 0 待转换 1 成功 2 失败 index_status SMALLINT NOT NULL DEFAULT 0, -- 0 待抽取 1 成功 2 失败 created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX uk_archive_sha ON archive_file (archive_no, sha256); CREATE INDEX idx_convert_pending ON archive_file (id) WHERE convert_status = 0;唯一索引建在「档号 + 内容哈希」上,而不是只建在哈希上。同一个文件出现在不同档号下是合法的,比如正文相同的两份批文分属不同年度卷宗,直接按哈希全局去重会把它们吃掉。idx_convert_pending是部分索引,只覆盖待转换的行,队列扫描时索引体积远小于全表,这点在几十万条档案的库上差别很明显。
ext_name和detected_type分开存,是这套设计里最有价值的一条。前端展示"原始文件名:总结.doc"用的是前者,转换和抽取调度用的是后者。日后想统计"系统里到底还有多少存量 doc 没有完成格式迁移",一条WHERE detected_type = 'doc'就能出数。
3. 档案管理系统的 docx 在线预览与「无法预览 doc」排查
3.1 浏览器不认 docx,预览必须选一条链路
浏览器原生能渲染的只有 PDF、图片和部分音视频,docx 是一堆 XML 打包,doc 更是二进制流,都不能直接丢给<iframe>。常见的三条路线差异很大。
| 方案 | 前端 docx-preview | mammoth 转 HTML | 服务端 LibreOffice 转 PDF |
|---|---|---|---|
| 样式保真度 | 中 | 低,表格与页眉易丢 | 高 |
| 是否支持 doc | 不支持 | 不支持 | 支持 |
| 计算位置 | 浏览器 | 浏览器 | 服务端 |
| 水印与权限控制 | 容易被绕过 | 容易被绕过 | 渲染结果固定,可控 |
| 适用场景 | 轻量在线阅读 | 只看正文 | 档案系统正式预览 |
档案系统基本都会走第三条,原因是两条:一是存量 doc 在前端方案里完全打不开,而档案系统恰恰是存量最多的场景;二是档案要求"看到的和归档时一致",渲染结果必须固定下来,不能随浏览器版本变化。音视频类档案则另走转码链路,和文档不是一回事。
注意:前端方案对 doc 无能为力,如果系统里预览按钮对 docx 正常、对 doc 报错或空白,先确认是不是压根没接服务端转换。
3.2 用 soffice 无头模式批量转 PDF:命令、参数与并发隔离
# 单文件转换。并发时必须给每个任务独立的 UserInstallation 目录 soffice --headless --norestore --invisible \ -env:UserInstallation=file:///var/lib/lo-profile/job-1024 \ --convert-to pdf:writer_pdf_Export \ --outdir /data/convert/out \ /data/archive/2024/0001.doc参数逐个说清楚。--headless --invisible --norestore三个一起用,让进程没有界面、不恢复上次会话,容器化部署下必须带。-env:UserInstallation是最容易漏的一个,第二个并发进程会去抢默认的用户配置目录,直接静默退出,表现为"部分文件转换成功率莫名只有一半",所以每个任务给一个按 job id 命名的独立目录,跑完删掉。--convert-to pdf:writer_pdf_Export显式指定 Writer 的 PDF 导出过滤器,比只写pdf更稳,避免过滤器推断错。--outdir指向临时目录,转换成功后再按内容哈希改名入库,临时文件不留在归档区。
# 检查中文字体是否装全,少于 3 说明镜像里字体不够 fc-list :lang=zh | wc -l老 doc 里如果嵌入了特殊字体,镜像里没有对应字体会被替换,排版整体偏移,表格线错位。归档镜像里至少装上两三套常用中文字体并执行一次fc-cache -fv。字体变化会让已缓存的 PDF 全部作废,处理办法见下一节。
3.3 「无法预览 doc」的排查顺序:一张对照表
| 现象 | 常见根因 | 验证方式 |
|---|---|---|
| 所有 doc 预览都报 500 | soffice 未安装,或 profile 目录被并发抢占 | 容器内执行soffice --version,看转换 job 日志尾部 |
| 部分 doc 转出来是空白页 | 文档含宏、加密或结构损坏,转换器拒绝 | 单独跑一次转换,看 stderr 输出 |
| PDF 中文显示成方块 | 镜像缺中文字体 | `fc-list :lang=zh |
| 转换成功但前端一直转圈 | convert_status 未回写,或缓存 key 拼错 | 查库中convert_status,再查对象存储里有没有对应 PDF |
| 预览按钮是灰的 | 格式白名单没放 doc,或权限未通过 | 查 MIME 白名单与档号权限配置 |
排查顺序建议固定成:先看单个文件能否手工转换成功,再看并发下是否稳定,最后才怀疑前端和缓存。反过来查会浪费大量时间在明明没问题的地方。
3.4 预览缓存 key 与失效策略
import hashlib # 换字体、升级 LibreOffice 或调整导出参数时,必须改这个值 CONVERTER_VERSION = "lo-font-zh-2024-05" def preview_cache_key(sha256: str) -> str: raw = f"{sha256}:{CONVERTER_VERSION}:pdf" return "preview/" + hashlib.sha256(raw.encode()).hexdigest()[:32] + ".pdf"key 只跟内容哈希和转换器版本有关,跟文件名、档号无关,所以同一份文件被多次归档只转一次,改个名也不会重复转换。把版本号写进 key 是个低成本高收益的做法:镜像换了字体,只要把CONVERTER_VERSION往上提一版,新旧缓存自然分开,旧的走 TTL 正常过期,线上不会出现"一半新排版一半旧排版"的混乱。反之如果把archive_no也拼进 key,同内容不同档号会各转一份,几十万条档案下磁盘占用能翻好几倍。
4. 档案管理系统的正文抽取、全文检索与检索权限
4.1 doc 与 docx 的抽取路线不一样
docx 本质是 zip 里的 XML,python-docx 能拿到段落、表格、页眉页脚,结构可控。doc 是复合二进制流,python 生态里没有可靠的纯 Python 解析器,实际项目里一般交给 Apache Tika,它底层用 POI,能同时吃 doc、docx、老 WPS 和 PDF,代价是启动慢、内存占用高。混合方案的做法是:docx 走轻量解析器拿结构化内容,doc 和其他格式统一走 Tika,接口层做成一个extract(path, detected_type),上层调度不关心底下用谁。
要提前定的一件事是抽哪些部分。表格内容一般要进索引,因为档案里大量信息在表格里;页眉页脚通常不进,否则每个文件都被单位名称和密级字样命中,检索噪音极大;批注和修订记录建议单独存字段,不进正文索引。这些决定一旦上线再改,需要全量重建索引。
4.2 用 Tika 抽取正文并写入检索索引
# Tika 以服务方式部署,应用侧只发 HTTP 请求,避免每个服务都装一套解析依赖 docker run -d --name tika -p 9998:9998 apache/tikaimport requests from elasticsearch import Elasticsearch TIKA = "http://tika:9998" es = Elasticsearch("http://es:9200") def extract_text(path: str) -> str: # /tika 端点用 PUT 上传,Accept 决定返回纯文本还是 HTML with open(path, "rb") as f: r = requests.put(f"{TIKA}/tika", data=f, headers={"Accept": "text/plain"}, timeout=120) r.raise_for_status() return r.text doc = { "archive_no": "2024-WS-0001", "title": "2024年度工作总结", "detected_type": "doc", "archive_year": 2024, "text": extract_text("/data/archive/2024/0001.doc"), } # 用档号做文档 id,任务重跑时是覆盖而不是重复插入 es.index(index="archive_doc", id=doc["archive_no"], document=doc)几点说明。Accept: text/plain拿纯文本,/rmeta/text端点则能同时返回作者、创建时间等元数据,想要元数据就用后者。timeout给 120 秒是给大文档留余量,太短会出现超时后重试、重试又超时的死循环。索引 id 用档号而不是自动生成的 id,是为了让抽取任务天然幂等,失败重跑不会产生重复文档。正文特别长的条目可以只索引前若干万字,超出部分不参与检索,避免单个文档把索引撑爆。
4.3 检索字段设计表
| 字段 | 类型 | 说明 |
|---|---|---|
| archive_no | keyword | 档号,作为文档 id,精确匹配 |
| title | text,中文分词 | 标题,查询权重设为 3 |
| text | text,中文分词 | 正文,不返回原文,只用于命中与高亮 |
| detected_type | keyword | doc / docx / pdf,用于筛"只看 doc" |
| dept_code | keyword | 部门代码,权限过滤 |
| archive_year | short | 归档年度,范围查询 |
| security_level | short | 密级,过滤条件 |
4.4 检索时的权限过滤与 doc 结果高亮
{ "query": { "bool": { "must": [ { "multi_match": { "query": "工作总结", "fields": ["title^3", "text"] } } ], "filter": [ { "terms": { "dept_code": ["D01", "D07"] } }, { "term": { "security_level": 1 } } ] } }, "highlight": { "fields": { "text": { "fragment_size": 120, "number_of_fragments": 3 } } } }权限条件放filter而不是must,这是检索侧最关键的一条约定。filter不参与打分,还能被 ES 缓存,部门范围固定时后续查询几乎不额外消耗 CPU;放进must则每次都要算一次相关性,权限越复杂越慢。title^3让标题命中排在正文命中前面,档案检索里这个排序差异很直观。highlight返回片段给前端,用户点进预览时拿片段里的关键词去 PDF 里做定位,老 doc 转出的 PDF 也照样能用。
注意:正文
text字段建议设置"index": true, "store": false,只索引不存原文,索引体积能降一大截,原始内容始终以归档文件为准。
5. 档案管理系统的 doc 长期保存:PDF/A 转换与完整性校验
长期保存的核心原则是"三份副本各司其职":原始 doc 原样保存,一个字节都不动,作为法律效力凭证;PDF/A 作为提供利用的阅览副本,格式自包含、不依赖字体环境;抽取出的纯文本进检索库,只服务于查询。三者通过档号关联,任何一份出问题都能定位到另外两份。
批量转换建议走离线任务而不是在线预览那条链路,参数上要显式指定 PDF/A 过滤器:
# 归档副本转换:目标格式是 PDF/A-2b,适合长期保存 soffice --headless --norestore --invisible \ -env:UserInstallation=file:///var/lib/lo-profile/archive-job-7 \ --convert-to 'pdf:writer_pdf_Export:{"SelectPdfVersion":{"type":"long","value":"1"}}' \ --outdir /data/pdfa /data/archive/2024/0001.doc这里的SelectPdfVersion值是 PDF/A 的档位开关,配合--convert-to的过滤器参数串使用,比转换后再用工具二次处理可靠。转完之后按内容哈希重命名文件,同时把convert_status置为 1,失败置 2 并保留 stderr 摘要,方便成批重试。
完整性校验要定期跑,不要只在入库那天算一次:
import hashlib, psycopg2 from pathlib import Path def file_sha256(p: Path) -> str: h = hashlib.sha256() with p.open("rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): # 1MB 分块,避免大文件吃内存 h.update(chunk) return h.hexdigest() with psycopg2.connect(dsn) as conn, conn.cursor() as cur: cur.execute("SELECT archive_no, sha256, storage_key FROM archive_file WHERE detected_type='doc'") for archive_no, expect, key in cur.fetchall(): real = file_sha256(Path("/data/archive") / key) if real != expect: # 不覆盖、不删除,只标记并进人工确认队列 cur.execute("UPDATE archive_file SET convert_status=2 WHERE archive_no=%s", (archive_no,))校验脚本的关键在最后一行:发现哈希不匹配时只做标记,不自动重算、不覆盖原记录。档案场景里"文件变了"这件事本身就是需要留痕的事件,可能是介质老化、可能是拷贝出错、也可能是有人动过,自动"修复"会把证据抹掉。实际运维中这台校验任务放年度周期跑,跑完统计不匹配条数,为零才算通过。
最后一件事,转换任务跑完先别急着关掉批次,把convert_status = 2的数量点开看一遍,再从不同年份各抽三份 doc 转出的 PDF 手工打开,确认表格线、签章位置、页眉页码没跑偏——这比盯着日志里的退出码可靠得多,因为退出码只说明进程正常结束,不说明版面对。
本文还有配套的精品资源,点击获取