C#实现医保接口文件自动传输的Windows服务实战解析

📅 发布时间:2026/10/8 17:10:36
C#实现医保接口文件自动传输的Windows服务实战解析
简介针对医疗信息化场景的C#版医保接口自动传输程序以Windows服务方式在后台运行负责医疗机构与医保系统之间的定时数据交换适合从事HIS/医保对接的开发者、运维人员以及学习Windows服务编程的C#工程师。资源包共41个文件压缩后仅61KB主要包含C#源码cs、工程与运行配置config、可执行文件exe、说明文档txt以及批处理脚本bat等整体结构简洁便于快速查阅和二次开发。程序核心涵盖服务主类、医保接口通信类、数据模型、配置管理与错误日志模块能够帮助理解医保业务中患者信息、诊疗项目、药品费用等数据格式的编码与传输以及服务的启动、停止、定时触发等生命周期管理同时附带Visual Studio工程与编译输出可直接打开调试或在现成框架上扩展其他接口协议。已有217人学习对于需要搭建医保自动上报、定时数据交换类Windows服务项目的团队是一份能快速上手并落地的代码级参考。1. 医保接口自动传输程序不是黑匣子它只是一段常驻后台的 Windows 服务很多医院的医保结算数据不能实时推给医保系统。白天窗口忙HIS 只能在深夜导出当天的门诊结算流水、住院费用明细和日对账文件这些数据必须在次日凌晨前传到医保前置机或省级平台接收端否则第二天扣费、对账全部对不上。人工定时导文件节假日漏一次就够你悔半天。标题里的这个程序就是用 C# 写一个常驻后台的 Windows 服务winservic 是 Windows Service 的常见写法盯着指定目录有新文件就自动往医保接口传传成功归档传失败保留待重传。它解决的痛点是定时、自动、可重试、无人值守。适合医院信息科、HIS 厂商实施以及负责医保验收对接的集成工程师。2. 为什么选 Windows 服务而不是计划任务服务生命周期和医保接口的交互方式2.1 医保接口自动传输的三种常见对接方式医保接口在不同地市、不同医院的实现差异很大但按传输形态基本可以归成三类。第一类是文件传输医院主动把文本文件、加密压缩包推到前置机的 FTP、SFTP 或者某个 HTTP 上传地址医保端回传一个接收回执。这类方式最常见于日对账、出院结算明细、退费明细等批量任务也是“自动传输程序”这个标题最常对应的场景。第二类是 WebService/SOAP 或 REST 实时交易比如门诊挂号结算、冲正、退费这类请求对实时性要求高是 HIS 前台在业务线程里直接调的不适合用文件轮询去承担。第三类是数据库中间表医保前置机开放一个 SQL Server/Oracle 库医院往里插数据再由前置机同步这种模式在医院现场也大量存在。对接方式典型场景服务里怎么处理FTP/SFTP/HTTP 文件上传日对账、批量结算明细轮询目录 上传 归档WebService/REST 实时交易门诊结算、冲正、退费一般由 HIS 前台调用不做定时数据库中间表医保前置机同步轮询检查待处理记录 写入结果我做过的几个项目里真正需要“winservic”这种常驻服务的绝大多数落在第一类。一个医院信息科的朋友曾直接问我白天 HIS 已经能调医保实时交易了为什么还要一个服务半夜传文件因为实时交易接口有报文长度和并发限制批量明细走文件通道更稳定而且医保端第二天要做结算汇总文件必须在指定时间前到。2.2 为什么是 Windows 服务而不是控制台加计划任务有人会说C# 写个控制台程序再用 Windows 计划任务每天触发不就行了我也这么干过但踩过几次坑之后我一般更愿意直接上 Windows 服务。计划任务最大的问题在于执行环境。它虽然能以最高权限运行但网络驱动器映射、账户密码过期、任务执行时间重叠这些事每一样都能让传输静默失败。比如医院把医保上传目录映射成 Z 盘计划任务跑的时候 Z 盘没连上程序启动后找不到文件日志还没写任务就结束了。Windows 服务则由服务控制管理器SCM统一管理可以设开机自启进程崩溃后按“失败操作”自动重启登录账户独立于当前用户不会因为有人远程注销导致任务不跑。常驻内存还带来了一个额外好处服务启动后保持运行状态轮询间隔可以做到分钟级甚至秒级。HIS 导出的文件是分批落盘的控制台任务每天只跑一次漏掉晚到的文件就要等第二天服务每五分钟扫一次目录晚到的文件也能在当天补传。这一点在月度结算那天特别救命。2.3 服务内部的数据流动一个典型的医保文件自动传输服务内部逻辑可以分成四段定时触发、目录扫描、传输执行、结果归档。服务启动时只做初始化和校验然后交给后台定时器定时器到点后扫描源目录发现新文件先把文件名改掉或移动到处理目录避免同一个文件被两个轮询周期同时处理然后调用上传模块最后按结果把文件移到备份目录或错误目录。这个流程里最关键的设计决策是OnStart 里不能做耗时的同步操作。SCM 等待服务启动的超时默认是 30 秒你的 OnStart 里如果直接去连医保前置机、读大目录很可能服务刚启动就被 SCM 杀掉。后面第 3 章我会给出可运行的最小代码。3. 用 C# 实现医保接口自动传输的最小服务三个类加一个配置文件3.1 项目骨架ServiceBase、Worker 与安装器在 Visual Studio 里新建一个“Windows 服务”项目.NET Framework 4.x用 C#默认会生成一个继承自 ServiceBase 的类。这个类就是服务的入口SCM 通过它来启动和停止你的程序。我习惯把项目拆成三部分服务宿主类 MiTransferService 负责跟 SCM 打交道Worker 类 FileTransferWorker 负责业务逻辑和上传Program 类负责把服务宿主交给 SCM 运行。项目结构如下。文件作用Program.cs服务入口调用 ServiceBase.RunMiTransferService.cs继承 ServiceBase处理 OnStart/OnStopFileTransferWorker.cs扫描目录、上传文件、移动归档App.config所有可变参数的存放地最小可运行的 Program.cs 只有几行using System.ServiceProcess; namespace MiAutoTransfer { static class Program { static void Main() { ServiceBase.Run(new ServiceBase[] { new MiTransferService() }); } } }这段代码做的事情很单纯把服务宿主对象交给 Windows 服务控制管理器。ServiceBase.Run 会阻塞当前线程直到 SCM 发出停止命令。注意这里不要写任何业务初始化逻辑初始化放在服务类的构造函数或 OnStart 里更合适因为 Main 在服务模式下和调试模式下的执行时机不完全一致。3.2 用 System.Threading.Timer 驱动轮询别用 Sleep 死循环服务宿主类可以按下面这样写。我这里刻意不用 ServiceBase 自带的定时器而是用 System.Threading.Timer因为它在线程池线程上触发不影响 SCM 对服务状态的管理。using System; using System.Configuration; using System.ServiceProcess; namespace MiAutoTransfer { public partial class MiTransferService : ServiceBase { private System.Threading.Timer _timer; private FileTransferWorker _worker; public MiTransferService() { InitializeComponent(); ServiceName MiAutoTransfer; CanStop true; } protected override void OnStart(string[] args) { #if DEBUG System.Diagnostics.Debugger.Launch(); #endif int interval int.Parse( ConfigurationManager.AppSettings[pollIntervalMinutes] ?? 5); _worker new FileTransferWorker(); _timer new System.Threading.Timer( TimerCallback, null, TimeSpan.FromSeconds(15), // 启动后延迟 15 秒再开始扫描 TimeSpan.FromMinutes(interval)); } private void TimerCallback(object state) { try { _worker.ProcessAllFiles(); } catch (Exception ex) { Logger.Error(轮询任务异常, ex); } } protected override void OnStop() { _timer?.Dispose(); GC.SuppressFinalize(_timer); } } }这里有几个必须说清的点。System.Threading.Timer 会被垃圾回收器视为不可达对象如果你在 OnStart 里创建了 Timer 但没有保存到类字段下一次 GC 后回调就不会再触发服务表现为“启动成功但什么都不做”。所以要像上面这样把它放到 _timer 字段里持有引用。延迟 15 秒是为了给 HIS 导出任务留一点收尾时间服务刚启动时医保端未必就绪马上扫描容易把半截文件传上去。OnStart 里的 #if DEBUG 段是我做现场调试的惯用手段。DEBUG 编译时执行 Debugger.Launch()会弹出“选择调试器”的对话框可以把这个正在运行的服务附加到 Visual Studio 上直接下断点看 OnStart 和定时回调里的变量。Release 编译时这段代码不会被编译进去。3.3 传输主流程扫描、上传、归档、失败重试FileTransferWorker 是真正干活的类。我一般在构造函数里把配置读好ProcessAllFiles() 里做五个动作检查源目录、锁定待处理文件、移动文件到 processing 子目录、上传、按结果移动文件。using System; using System.Configuration; using System.IO; using System.Net; namespace MiAutoTransfer { public class FileTransferWorker { private string _srcDir; private string _processingDir; private string _backupDir; private string _errorDir; private string _endpointUrl; private int _timeoutSeconds; private int _retries; public FileTransferWorker() { _srcDir ConfigurationManager.AppSettings[srcDir]; _processingDir Path.Combine(_srcDir, processing); _backupDir ConfigurationManager.AppSettings[backupDir]; _errorDir ConfigurationManager.AppSettings[errorDir]; _endpointUrl ConfigurationManager.AppSettings[endpointUrl]; _timeoutSeconds int.Parse( ConfigurationManager.AppSettings[timeoutSeconds] ?? 120); _retries int.Parse( ConfigurationManager.AppSettings[retries] ?? 3); Directory.CreateDirectory(_srcDir); Directory.CreateDirectory(_processingDir); Directory.CreateDirectory(_backupDir); Directory.CreateDirectory(_errorDir); } public void ProcessAllFiles() { string[] files Directory.GetFiles(_srcDir, *.*, SearchOption.TopDirectoryOnly); foreach (string file in files) { if (file.EndsWith(.lock) || file.EndsWith(.processing)) continue; string fileName Path.GetFileName(file); string processingPath Path.Combine(_processingDir, fileName); File.Move(file, processingPath); if (!TryUpload(processingPath)) continue; } } private bool TryUpload(string filePath) { for (int i 0; i _retries; i) { try { using (var client new WebClient()) { client.Headers.Add(Content-Type, application/octet-stream); client.UploadFile(_endpointUrl, filePath); } string name Path.GetFileName(filePath); File.Move(filePath, Path.Combine(_backupDir, name)); Logger.Info($上传成功: {name}); return true; } catch (Exception ex) { Logger.Error($第 {i 1} 次上传失败: {filePath}, ex); System.Threading.Thread.Sleep( TimeSpan.FromSeconds(10 * (i 1))); } } string errName Path.GetFileName(filePath); File.Move(filePath, Path.Combine(_errorDir, errName)); return false; } } }这段逻辑有几个细节值得解释。先把文件 Move 到 processing 子目录是为了防止同一个文件被下一个轮询周期再次命中如果先上传再移动上传中途文件还在源目录下一轮 Timer 回调会在上传未完成时把这个文件又传一次。Move 是同一磁盘分区上的原语操作速度很快几乎不会出现竞态。上传失败时保留在 processing 里的文件重试次数耗尽后移动到 error 目录源目录不会再扫到它避免无限重试把医保端打挂。这里的 WebClient 是简化写法。真实医保接口如果走 HTTPS 并带客户端证书就要换成 HttpWebRequest 或 HttpClientHandler并在发起请求前加载 p12 证书。证书路径、密码这些同样放到配置里下面一节会专门讲参数化。3.4 安装服务的两条路InstallUtil 与 sc.exe编译产物是一个 exe但它不能双击运行必须注册到 SCM 里。常见做法是两种项目里加一个 ProjectInstaller 类然后跑 InstallUtil或者直接用 sc.exe 创建服务。我现场实施时更喜欢用 sc.exe因为它在没有 Visual Studio 的服务器上也能用。sc create MiAutoTransfer binPath C:\svc\MiAutoTransfer.exe start auto注意 sc 语法里 “binPath” 和 “start” 后面必须紧跟一个空格再加值这是最容易翻车的地方。如果写错sc 命令不会报友好错误而是直接提示参数格式不正确。创建成功后还需要设置服务失败后的自动重启让服务在崩溃时能自己拉起来sc failure MiAutoTransfer reset 86400 actions restart/60000/restart/120000/restart/300000这条命令的意思是服务失败后等 60 秒重启一次再失败等 120 秒重启第三次失败等 300 秒重启24 小时内计数重置。医保传输服务通常跑在凌晨半夜没有人盯着服务器这个自动重启策略是必须的。4. 把传输参数全部外置到 App.config间隔、超时、重试与防重复4.1 配置项设计不把任何现场变量写死在代码里医保接口服务交付到医院后最少要改的参数有五类目录位置、轮询间隔、接口地址、超时时间、重试次数。我一般把所有可变项全放进 App.config 的 appSettings 节点里方便实施工程师在不重新编译的情况下直接调整。?xml version1.0 encodingutf-8 ? configuration appSettings add keysrcDir valueD:\HISData\ToMi\/ add keybackupDir valueD:\HISData\MiBackup\/ add keyerrorDir valueD:\HISData\MiError\/ add keypollIntervalMinutes value5/ add keytimeoutSeconds value120/ add keyretries value3/ add keyretryBaseSeconds value15/ add keyendpointUrl valuehttp://192.168.3.10:8080/his/upload/ add keyclientCertPath value/ add keyclientCertPwd value/ /appSettings /configuration下面对照配置逐项说明。srcDir 是 HIS 导出的源文件目录必须确保服务启动账户对这个目录有读写权限backupDir 和 errorDir 分别放传输成功和重试失败的文件建议放在 D 盘而不是 C 盘避免系统盘写满pollIntervalMinutes 是轮询间隔医院门诊一般 5 分钟够了住院部夜间大文件导出时可以考虑 1 分钟但不要设成秒级否则文件还在写就扫到了timeoutSeconds 是整个上传请求的超时retries 建议 3retryBaseSeconds 是重试的基数间隔第一次失败后等 15 秒第二次 30 秒第三次 45 秒。endpointUrl 是医保接口的上传地址。很多项目的接口地址还会带签名参数或者请求头我建议有经验的实施人员把请求头也做成配置项比如add keyhttpHeaders valueX-Auth-Token:abc123;Channel:his/代码里用分号拆开再逐条加进 Header这样换科室、换医保前置机时不用动代码。至于医保端要求的国密 SM2/SM4 加签每家医院的接口文档差异很大无法用一个通用配置覆盖但设计原则是凡是接口文档里出现过的“可能变的值”都往配置里放。4.2 超时与重试的数值经验医保前置机跨运营商链路的时候慢是整个系统最头疼的问题。默认的 HttpWebRequest 超时是 100 秒但上传大文件时经常出现连接建立成功、数据慢慢传、最后差一点就超时的情况。我把超时拆成两个值会更稳连接超时和整体超时。连接超时设 15 秒整体超时设 120 秒这样排障时一眼就能看出是网络不通还是文件太大。另一个容易被忽视的参数是并发连接数。.NET 默认 ServicePointManager.DefaultConnectionLimit 是 2也就是说同时对医保端发起的 HTTP 连接最多两个。如果你的轮询周期里同时命中多个文件第二个文件之后的请求全部排队等前面的 120 秒超时结束才轮到它表现就是“传输特别慢、文件堆积”。我一般在服务启动时直接把它调大ServicePointManager.DefaultConnectionLimit 100; ServicePointManager.MaxServicePointIdleTime 10000;重试次数不是越多越好。凌晨断网半小时的情况下3 次重试远远不够而如果医保端正在重启连续 10 次重试会把对方打得更不稳定。我见过一个比较合理的方案普通异常重试 3 次间隔 15 秒起如果是 HTTP 503/504 这类服务端临时故障重试 6 次间隔指数级增加。指数级不能真按 2 的 n 次方去算那样 6 次就到 100 秒以上了一般按 1.5 倍或固定累加即可。4.3 防重复传输与并发保护医保端对重复文件的处理能力参差不齐。有的前置机按文件名去重同一个文件传两次会报“文件已存在”这种还好有的前置机是直接落库的重复传一次结算明细就多一笔这种灾难在月中对账时才暴露。所以在传输侧做防重是必要的。我常用的防重手段有三种组合使用。第一种是文件锁在源目录创建同名 .lock 文件处理完再删除服务启动时如果发现 .lock 文件存在就认为有未完成的传输直接把它对应的源文件挪到 error 目录由人工确认。第二种是 processing 子目录上一章代码里已经有所有待上传文件先移进去避免轮询窗口重叠。第三种是进程级互斥用 Mutex 确保同一台机器上只有一个服务实例在跑using (var mutex new Mutex(true, Global\MiAutoTransfer_Instance, out bool createdNew)) { if (!createdNew) { Logger.Warn(另一个实例已在运行本实例退出。); return; } ServiceBase.Run(new ServiceBase[] { new MiTransferService() }); }这三层防重做完剩下的就是文件名本身了。HIS 导出的文件命名一般带日期时间比如 settle_20250107_213000.txt服务端可以用日期做增量判断。我建议 Worker 里加一个配置项 lastProcessedFile记录上一次成功处理的文件名下次扫描只处理字典序大于它的文件这样即使服务连续重启也不会把历史文件重传一遍。5. 医保接口服务上线避坑5 条高频事故与排查思路5.1 服务“启动成功”但什么都不做定时器没有引用现象服务在 SCM 里显示“正在运行”事件日志没有错误但源目录里的文件一直不被处理。原因OnStart 里创建 System.Threading.Timer 时没有保存字段引用。前面说过Timer 是弱引用性质的它会随着方法返回变成不可达对象GC 几轮之后回调就不再触发但进程本身还在运行SCM 自然不会认为服务出错了。这是最典型的“黑匣子事故”日志没有异常可查服务状态还正常。解决把 Timer 对象存到类级字段除非服务停止否则既不 Dispose 也不重新赋值。更保险的做法是同时记录一个“上次执行时间”字段每次回调都更新到日志里排障时通过日志间隔判断定时器是否还在触发。5.2 本地跑能传做成服务后权限全无现象服务安装后日志里出现“对路径 D:\HISData\ToMi\ 的访问被拒绝”或者 WebClient 上传时报 401/403。原因Windows 服务默认以 LocalService 账户运行这个账户权限极低没有权限访问 D 盘目录也没有权限发送特定的 HTTP 头。而双击 exe 时用的是当前登录用户权限足够所以同一套代码在本地正常、注册成服务就翻车。解决在服务属性里把“登录”改成 NetworkService或者专用账号。医院现场我一般建议单独建一个 account密码设置为不过期并把源目录、备份目录的写权限授给这个账户。改完登录账户后重启服务再用 Process Explorer 确认服务进程里的用户确实变成新账户了。5.3 HTTPS 上传慢、卡住连接数限制和证书验证现象服务跑一会儿后文件开始堆积日志里全是 Task 超时或“The operation has timed out”但手工用浏览器访问接口地址是通的。原因两个坑叠在一起。第一是 DefaultConnectionLimit 默认为 2前面的连接未释放后面的请求全排队第二是医保端 HTTPS 证书如果不是正式 CA 签发的WebClient 会在验证证书链时抛异常而不是抛出清晰的“证书不受信任”提示。解决ServicePointManager.DefaultConnectionLimit 设大证书验证阶段做一次日志输出先把证书链问题暴露出来再由甲方确认是使用自签名证书还是在服务器上安装根证书。不要一上来就在代码里用 ServerCertificateValidationCallback 通配返回 true这会绕过整个加密验证医保验收时往往不接受这种行为。5.4 改了 App.config 重启不生效现象现场调试时改了文件目录指向新路径重启了服务发现还是往旧目录跑。原因Windows 服务的工作目录和配置文件查找顺序有讲究。如果服务是通过 sc create 创建的配置文件按 exe 所在目录读取但如果你把 exe 复制到 C:\svc\而 App.config 没有跟着复制并改名成 MiAutoTransfer.exe.config服务实际读到的还是旧文件。另一个原因是服务重启后进程没有真正退出SCM 的“重启”和“结束进程再启动”之间有时间差改动还没落盘就被进程又读了一遍。解决先确认 exe.config 文件存在且和 exe 同名改完配置后用 sc stop 加上稍等两秒再 sc start不要只点“重启”。更省事的方式是在代码里每次轮询都重新读一遍配置把 App.config 当热更新文件用代价是增加 IO 开销但对这种低频服务完全值得。我一般会让 Worker 在每次 ProcessAllFiles 开头重新读配置这样改完不用重启下个周期自然生效。5.5 服务重启后重复上传processing 目录没有善后现象服务在传输中途崩溃或被 sc stop 强杀重启后同一个文件传了两遍医保端出现重复明细。原因上一章提到文件从源目录移动到 processing 目录后再上传但如果服务在“移动文件”之后、“移动文件到 backup”之前被杀死processing 目录里就会留下一个半途而废的文件。重启后源目录不会再扫到它但如果下次轮询扫 processing 目录这个文件就会被当成新文件再传一次。解决服务启动时先扫 processing 目录对里面的文件二次尝试上传如果 retries 耗尽把它移动到 error 目录并写一条带原文件名的异常日志。处理原则是processing 目录里不允许存在无人认领的文件每次启动都要清空它。实现不复杂在 Worker 构造函数里多几行 Directory.GetFiles(_processingDir) 的逻辑即可。6. 让它自己照顾自己日志验证与自动重启的完整闭环6.1 从“能跑”到“敢不盯着”验证传输是否真正发生服务上线后我判断它是否可靠不看法宝和玄学只看三样东西日志、backup 目录、error 目录。日志里每一条上传记录必须包含文件名、耗时、服务器返回码backup 目录每天的文件数量应与 HIS 导出的数量一致error 目录一旦出现文件就说明有人工处理的必要。日志一般用 log4net 或 NLog 写文件按天滚动保留 30 天。我发现很多人只记录“上传成功/失败”不记录文件大小和耗时这对排查凌晨断网非常不利。建议把日志格式固定为2025-01-07 23:41:10.123 [INFO] 上传成功 filesettle_20250107_213000.txt size847213 cost3214ms respOKcost 字段能帮你区分是网络慢还是医保端处理慢。连续三天看到 cost 都在 3000ms 以上就该考虑联系医保前置机管理员了。6.2 从轮询升级到带日历的调度规则Timer 只能做固定间隔轮询但医保业务经常有日历规则比如“每月最后一个工作日的 22 点上传月度结算汇总”。这种诉求用 Timer 写 if 判断会很丑我一般直接引入 Quartz.Net把触发时间写进 cron 表达式。IScheduler scheduler await StdSchedulerFactory.GetDefaultScheduler(); IJobDetail job JobBuilder.CreateUploadJob().Build(); ITrigger trigger TriggerBuilder.Create() .WithCronSchedule(0 0 22 L * ?) // 每月最后一天 22:00 .Build(); await scheduler.ScheduleJob(job, trigger);Quartz 的好处是可以指定多个触发规则比如工作日每 5 分钟一次、月底最后一天额外执行一次互不干扰。代价是引入了一个第三方库打包体积变大部署时需要一并带上所有依赖。医保前置机所在服务器往往没有外网我建议在开发机把依赖全部复制到输出目录一起拷贝过去安装不要把 NuGet 还原依赖寄托在服务器环境上。最后讲一个我自己的教训。早期我写过一个类似的传输服务OnStart 里直接做了网络请求SCM 30 秒没等到响应把我的服务杀了 3 次我才意识到 OnStart 的正确姿势是只做轻量初始化然后立刻返回重活全部交给定时器回调。从那以后我写的每个服务都坚持同一个习惯OnStart 里只读配置、创建 Worker、启动 Timer任何可能阻塞的调用都不出现在启动路径上。这个原则比任何第三方库都管用医保接口传输这种“白天没人管、凌晨必须准”的场景耐住性子把日志、重试、防重做扎实服务就不会在关键时候给你添乱。希望这些经验能帮到你。本文还有配套的精品资源点击获取