从手写 SQL 到 Windows 工具:致远 OA 文件清理实践

前言

在日常运维中,我们遇到过这样一个问题:致远 OA 中积累了大量不再需要的文件,只在系统界面里逐个处理效率很低,而直接进入服务器删除文件,又很容易因为逻辑文件名、数据库记录和磁盘物理文件之间的关系不清楚而误操作。

因此,这项工作的第一步不是写工具,而是手动查询数据库,把目录、文件记录和服务器物理文件之间的关系逐层确认。等删除逻辑经过实际数据验证后,再将整个过程封装成 Windows Server 桌面工具,提高重复操作的效率和可控性。

本文涉及数据库记录和服务器文件删除。所有操作都应在获得授权的环境中进行,并在生产操作前完成数据库及文件备份。不同致远 OA 版本的表结构可能不同,文中的字段关系需要结合实际环境核对。

一、从文件夹名称定位目录 ID

最开始只知道 OA 界面中显示的文件夹名称,例如"产品承认书"。第一步是在 DOC_RESOURCES 中确认它对应的目录记录:

sql 复制代码
SELECT
    ID,
    FR_NAME AS 目录名称,
    PARENT_FR_ID AS 父目录ID,
    LOGICAL_PATH AS 完整路径
FROM DOC_RESOURCES
WHERE FR_NAME = N'产品承认书'
  AND IS_FOLDER = 1;

这里有几个重要字段:

  • ID:当前资源或目录的逻辑 ID。
  • PARENT_FR_ID:父目录 ID,用于建立目录层级。
  • IS_FOLDER:是否为文件夹,值为 1 时表示目录。
  • LOGICAL_PATH:OA 内部保存的逻辑路径。

这条 SQL 很快暴露了第一个风险:同名文件夹可能出现在多个位置。只按 FR_NAME 查询,会把所有同名目录都找出来。如果继续批量删除,它们下面的文件可能一起被处理。

后续工具因此增加了多级目录查询。例如输入:

text 复制代码
文控中心/PCB图纸

程序会先把"PCB图纸"作为目标目录,再通过 PARENT_FR_ID 向上校验它的父目录必须是"文控中心",从而避免误匹配其他位置的同名文件夹。

二、先查询直属文件,再扩展到全部子目录

拿到文件夹 ID 后,可以先查询它的直属内容:

sql 复制代码
SELECT
    ID,
    FR_NAME AS 名称,
    IS_FOLDER AS 是否文件夹,
    PARENT_FR_ID AS 父目录ID,
    FR_SIZE AS 文件大小,
    CREATE_TIME AS 创建时间,
    LAST_UPDATE AS 最后修改时间
FROM DOC_RESOURCES
WHERE PARENT_FR_ID = 451823763545606013
ORDER BY IS_FOLDER DESC, FR_NAME;

这条 SQL 与 OA 当前目录列表比较接近:先显示文件夹,再按名称显示文件。但它只能查询一层。如果目标目录下面还有多层子目录,子目录中的文件不会被查出来。

为此,需要使用递归 CTE:

sql 复制代码
WITH FolderRoots AS (
    SELECT
        ID, FR_NAME, PARENT_FR_ID, IS_FOLDER, SOURCE_ID, FR_SIZE
    FROM DOC_RESOURCES
    WHERE ID = @目标文件夹ID
),
ResourceTree AS (
    SELECT
        ID, FR_NAME, PARENT_FR_ID, IS_FOLDER, SOURCE_ID, FR_SIZE
    FROM FolderRoots

    UNION ALL

    SELECT
        child.ID,
        child.FR_NAME,
        child.PARENT_FR_ID,
        child.IS_FOLDER,
        child.SOURCE_ID,
        child.FR_SIZE
    FROM DOC_RESOURCES AS child
    INNER JOIN ResourceTree AS parent
        ON child.PARENT_FR_ID = parent.ID
)
SELECT
    ID,
    FR_NAME,
    SOURCE_ID,
    FR_SIZE
FROM ResourceTree
WHERE IS_FOLDER = 0
OPTION (MAXRECURSION 32767);

这样就能从目标目录开始,递归遍历所有子文件夹,最终只返回文件记录。

开发过程中曾出现过两次典型错误:新增物理文件查询时忘记把 SOURCE_ID 放进递归结果;新增大小统计时又忘记传递 FR_SIZE。外层查询引用不到字段后,SQL Server 会报"列名无效"。递归 CTE 每一层的字段数量、顺序和类型必须完全对应,这是扩展查询时需要特别注意的地方。

三、从逻辑文件找到物理文件 ID

DOC_RESOURCES.ID 是 OA 资源记录的逻辑 ID,并不是服务器磁盘上的文件名。继续检查表结构后,我们确认了以下映射:

text 复制代码
DOC_RESOURCES.SOURCE_ID -> CTP_FILE.ID

于是可以关联 CTP_FILE 查询物理文件 ID 和源文件大小:

sql 复制代码
SELECT
    resource.ID AS 文件ID,
    resource.FR_NAME AS 文件名称,
    fileInfo.ID AS 物理文件ID,
    COALESCE(fileInfo.FILE_SIZE, resource.FR_SIZE, 0) AS 文件大小
FROM DOC_RESOURCES AS resource
LEFT JOIN CTP_FILE AS fileInfo
    ON fileInfo.ID = resource.SOURCE_ID
WHERE resource.ID = @文件ID;

这里最终在工具界面保留了三个最有用的字段:文件 ID、文件名称、物理文件 ID。文件大小作为隐藏字段用于生成删除统计,不占用列表显示空间。

四、确认服务器上的两份物理数据

数据库关系明确后,还需要到 OA 服务器上核对实际文件。我们的环境中有两处需要处理:

text 复制代码
upload 根目录
└─ 年
   └─ 月
      └─ 日
         └─ 物理文件ID

officetrans 根目录
└─ 年月日
   └─ 物理文件ID文件夹

upload 中保存源文件,文件名就是物理文件 ID。officetrans 中则是同名的物理 ID 文件夹,里面保存 OA 预览或转换产生的数据。

最初只删除 upload 源文件时,OA 中仍可能通过 officetrans 内容打开或显示文件。因此,一次完整清理需要处理三个位置:

  1. 删除 upload 下匹配物理 ID 的源文件。
  2. 递归删除 officetrans 日期目录下匹配物理 ID 的文件夹。
  3. 删除 DOC_RESOURCES 中对应的 OA 显示记录。

工具没有删除 CTP_FILE 记录。是否需要清理该表及其关联数据,应根据具体 OA 版本、外键关系和厂商建议单独评估,不能只凭字段名称直接操作。

五、从验证脚本到桌面工具

手动 SQL 可以验证逻辑,但面对几百甚至几千个文件时,人工复制 ID、查找目录和核对结果的成本很高,也容易遗漏。因此,在逻辑稳定后,我使用 .NET 8 和 Windows Forms 开发了一个可以直接在 Windows Server 上运行的工具。

工具的操作流程如下:

text 复制代码
连接 OA 数据库
    ↓
输入单级或多级目录路径
    ↓
递归查询所有文件及物理 ID
    ↓
单选、多选或选择全部结果
    ↓
二次确认删除范围
    ↓
清理 upload 和 officetrans
    ↓
分批删除 DOC_RESOURCES 记录
    ↓
生成 CSV 明细并推送企业微信

数据库账号、密码、企业微信 Webhook 和自定义存储路径都只保存在当前进程内存中,不写入本地配置文件。工具默认提供常用路径,也允许其他部署环境分别配置自己的 uploadofficetrans 根目录。

六、批量删除遇到的 SQL Server 参数限制

早期版本将所有文件 ID 都放入一条参数化 SQL:

sql 复制代码
DELETE FROM DOC_RESOURCES
WHERE IS_FOLDER = 0
  AND ID IN (@id0, @id1, @id2, ...);

一千多个文件可以正常执行,但增加到几千个后开始失败。原因是 SQL Server 单条命令最多只能接受大约 2100 个参数。

解决方式是将 ID 每 500 条分成一批,但所有批次仍放在同一个事务中:

csharp 复制代码
await using var transaction = await connection.BeginTransactionAsync();

foreach (var batch in resourceIds.Chunk(500))
{
    // 为当前批次生成参数并执行 DELETE。
}

await transaction.CommitAsync();

这样既避开参数数量上限,又能保证数据库操作的原子性:全部批次成功才提交,任何一批失败都回滚数据库事务。

需要注意,磁盘文件删除无法像数据库事务一样自动回滚。因此工具会先进行路径范围校验和二次确认,失败时也会明确提示立即核对服务器文件。生产环境中仍应依靠备份保证可恢复性。

七、路径安全比删除代码更重要

删除文件本身只需要一行代码,真正重要的是确保目标路径不会越界。工具对路径做了几层限制:

  • uploadofficetrans 必须分别配置,不能是同一个目录。
  • 配置的目录必须真实存在。
  • 目录末级名称必须分别是 uploadofficetrans
  • 每一个待删除路径都先转换为绝对路径。
  • 绝对路径必须位于对应的已配置根目录之下。
  • 删除前显示文件数量和影响范围,并要求二次确认。

这些限制会牺牲一点配置自由度,但对于运行在生产服务器上的删除工具,这种约束是必要的。

八、删除结果通过企业微信留痕

文件数量较少时,文本消息就能记录结果;一次删除几千个文件时,文本会超过企业微信消息长度限制。因此工具会在删除成功后生成 UTF-8 CSV 文档,上传给企业微信群机器人。

CSV 中包含:

  • 查询文件夹路径。
  • 删除文件总数。
  • OA 数据库记录数。
  • 源文件总大小。
  • OA 服务器名称和操作时间。
  • 每个文件的文件 ID、文件名称、物理文件 ID 和文件大小。

源文件总大小优先使用 CTP_FILE.FILE_SIZE 汇总,并明确备注:officetrans 被删除空间未计入该总大小。临时 CSV 上传完成后会立即从服务器删除。

企业微信文件上传还有一个实际兼容问题:普通的 .NET MultipartFormDataContent 请求曾返回 44001 empty media data。最终按照企业微信接口要求构造包含 filenamefilelength 和明确 boundary 的 multipart 请求体后,文件上传和群消息发送才都返回 errcode = 0

九、最终得到的不只是一个删除按钮

这个工具最初只是为了解决重复手工操作,但在开发过程中逐步补齐了真正影响生产可用性的细节:

  • 递归查询多层目录。
  • 用多级路径消除同名文件夹歧义。
  • 映射逻辑文件 ID 与物理文件 ID。
  • 同时清理源文件和预览转换目录。
  • 支持单选、多选和全部删除。
  • 分批处理数千条数据库记录。
  • 自定义不同服务器的存储路径。
  • 统计文件大小并生成完整删除明细。
  • 通过企业微信推送操作结果。

回顾整个过程,最关键的并不是一开始就写出完整程序,而是先用 SQL 和实际磁盘数据逐步验证每一层关系。只有目录层级、物理 ID、存储路径和数据库记录都对得上,自动化工具才有可靠的基础。

对于这类涉及生产数据的运维工具,效率应该建立在可验证、可审计和可恢复之上。先确认逻辑,再做自动化,最终得到的工具才真正有价值。

项目地址

本文中的工具和 SQL 仅供已授权的运维、测试和数据清理场景使用。请根据实际版本核对表结构,并在生产环境操作前完成备份。

相关推荐
笃行3501 小时前
OceanBaseVS金仓:一条 SQL 的两条路——KingbaseES 的性能竞争力从哪来
数据库
其实防守也摸鱼1 小时前
每天一个知识点——RCE漏洞
运维·服务器·数据库·windows·安全·github·漏洞
TDengine (老段)1 小时前
TDengine 常见问题 TOP2
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
笃行3501 小时前
数据迁移工具 KDMS 帮我把一本糊涂账算清了
数据库
笃行3502 小时前
SQLServer数据库迁移实录:十年老系统搬上 KingbaseES,T-SQL 基本没重写
数据库
落魄大学生之流水线上谋生计3 小时前
Java并发核心机制详解:线程池、CAS、AQS、锁升级
java·开发语言·数据库
Wang's Blog4 小时前
Java框架快速入门: Spring Security+OAuth2之邮件验证码发送(SMTP与API方式)
java·数据库·spring
砚底藏山河5 小时前
容错重试与指数退避:网络抖动手抖不再丢数据(魔码量化实战 #04)
java·数据库·python·金融
蓝速科技5 小时前
会议室门牌触控预约选型与落地实战指南丨蓝速科技
大数据·运维·数据库·人工智能·科技