简介:这份C# MES系统源码面向希望理解制造执行系统架构与实现方式的中级.NET开发者,围绕生产订单、工序、设备状态、实时数据采集与报表展示等场景,提供一套结构完整的示例工程。压缩包共95个文件,约1.81MB,以54个cs源码文件为核心,辅以24个resx界面资源、5个dll依赖库、3个sql建表与查询脚本,以及sln解决方案、csproj项目文件和说明文档,覆盖基础资料、基础模块、数据报表、数据采集等目录。已有3221人学习下载。读者可从中梳理三层架构分层、实体类与数据访问、串口/网络通信采集、工作流与权限控制等实现思路,并借助SQL脚本快速还原数据库结构,适合做课程设计、二次开发前的结构参考或MES入门研读。
1. 从一张手写派工单说起:C# MES 源码接住的是车间里最脏的那段流程
车间里最常见的画面是:计划员在 Excel 里排产,班组长拿打印单派工,操作工干完活回办公室补录,等数据汇总上来已经过去一天。这套 C# MES 源码要接的就是这段最脏的流程——工单下发、扫码过站、报工采集、看板展示,一条链路走完,数据秒级落库。它基于 ASP.NET Core 分层架构,后端 Web API、车间看板、工位端 WinForms 三端共存,业务表结构完整,既能当二次开发的底子,也能给中小车间撑起第一版数字化。下面按「能干什么、怎么分层、怎么跑、怎么改」的顺序拆开讲。
2. 技术栈与工程结构:从 .sln 到七个 csproj 的分层逻辑
2.1 选型:ASP.NET Core + EF Core + SQL Server 这套组合在车间里为什么够用
工厂现场的 IT 环境有个共同点:服务器是 Windows Server,客户端是装了七八年的工控机,网络是有线局域网,运维往往就一个人。这套源码的选型基本是照着这个约束倒推出来的,而不是追新。后端用 ASP.NET Core Web API 宿主,一个进程同时挂控制器和 SignalR Hub,部署时复制目录、注册个服务就能跑;数据访问用 EF Core,迁移脚本自带版本管理,现场升级不用手工比对表结构;数据库默认 SQL Server,Provider 在MesDbContext里是可替换的,换成 MySQL 只需要改一行UseSqlServer。
看板没走前后端分离,用的是 Blazor Server。理由很实际:车间看板的需求是「大字、实时、别崩」,不需要复杂交互,Blazor 把渲染放在服务端,工控机上有个浏览器就能看,改个字段重新编译 dll 即可,省掉一整套 npm 构建链。工位端坚持用 WinForms,因为扫码枪、电子秤、打印机这些外设的驱动大多是 Windows 原生库,WPF 也能做但 WinForms 上手成本更低,老师傅的机器上跑得更稳。
设备采集这块,源码里封装了 Opc.Ua.Client 和 NModbus 两套客户端,前者对接支持 OPC UA 的 PLC 和新一点的采集网关,后者对接还在跑 Modbus TCP 的老设备。后台采集任务挂在BackgroundService上,跟 Web 宿主同进程,不额外引入 Windows 服务,少一个部署环节就少一类故障。日志用 Serilog 写文件并按天滚动,现场出问题第一件事就是让运维把logs目录打包发过来。
至于为什么不做微服务:一个车间一天的报工记录也就几万条量级,单库单实例绰绰有余,拆成服务只会让现场多出几个要监控的端口。分层单体在这个场景下不是妥协,是正确解。
2.2 目录结构:六个业务项目各自的职责边界
拿到源码先看解决方案的目录,基本能判断这套东西能不能改。下面是主干结构,src下六个项目加一个测试工程,依赖方向是单向的,不允许反向引用。
Mes.sln ├── src/ │ ├── Mes.Domain/ 实体、枚举、领域常量,零第三方依赖 │ ├── Mes.Infrastructure/ DbContext、仓储实现、OPC/Modbus 客户端、种子数据 │ ├── Mes.Application/ 业务服务、DTO、过站规则引擎、状态机 │ ├── Mes.Api/ Web API 宿主、控制器、SignalR Hub、后台采集服务 │ ├── Mes.Web/ Blazor Server 车间看板 │ └── Mes.Station/ WinForms 工位端 └── tests/ └── Mes.Tests/ xUnit 单元测试,主要覆盖状态机和规则引擎Mes.Domain里放的是纯 POCO,没有任何[Key]、[Required]之类的框架特性,映射全部写在Mes.Infrastructure的 Fluent API 里。好处是领域层可以被单元测试直接 new 出来,也不用为了换个 ORM 去改实体。Mes.Application是唯一放业务逻辑的地方,控制器里只做参数校验和结果包装,超过十行的逻辑一律下沉到服务层。
Mes.Api和Mes.Web都引用Mes.Application,但两者之间不互相引用。看板要的数据通过IWorkOrderQueryService拿,走的是同一个服务层,不会出现「看板统计口径和接口对不上」这种经典问题。Mes.Station稍微特殊,它不直接引用 Application,而是通过 HTTP 调 API,这样工位端的升级和后端解耦,现场可以只换 dll 不重装客户端。
提示:如果打算把工位端改成浏览器方案,
Mes.Station可以整个去掉,把扫码逻辑搬到Mes.Web的一个 Razor 页面里,服务层一行不用动。
2.3 启动与配置:Program.cs里真正需要动的地方
入口文件不长,但每一行都对应一个现场的坑。下面这段是精简后的Mes.Api/Program.cs,保留了需要关注的部分。
var builder = WebApplication.CreateBuilder(args); // 日志:控制台 + 按天滚动的文件,现场排障主要靠后者 builder.Host.UseSerilog((ctx, cfg) => cfg .ReadFrom.Configuration(ctx.Configuration) .WriteTo.Console() .WriteTo.File("logs/mes-.log", rollingInterval: RollingInterval.Day)); // 数据库:开启重试,车间网络抖动时不至于整个请求失败 builder.Services.AddDbContext<MesDbContext>(opt => opt.UseSqlServer( builder.Configuration.GetConnectionString("Mes"), sql => sql.EnableRetryOnFailure(3, TimeSpan.FromSeconds(5), null))); // 业务服务与规则引擎 builder.Services.AddScoped<IWorkOrderService, WorkOrderService>(); builder.Services.AddScoped<IStationCheckService, StationCheckService>(); builder.Services.AddSingleton<IProcessRuleEngine, ProcessRuleEngine>(); // 设备采集常驻后台任务 builder.Services.AddHostedService<EquipmentCollectHostedService>(); builder.Services.AddSignalR(); builder.Services.AddControllers(); var app = builder.Build(); app.MapControllers(); app.MapHub<AndonHub>("/hubs/andon"); // 看板与 Andon 灯共用的推送通道 app.Run();EnableRetryOnFailure这三个参数分别是重试次数、最大退避间隔和可重试的错误号列表,留空表示用默认集合。车间里交换机重启、网线被叉车压断都遇到过,这个配置能把偶发的连接中断挡掉大半。IProcessRuleEngine注册成单例是因为它内部缓存了规则表,改规则时会通过IMemoryCache的 key 主动失效,不需要重启。
配置文件里真正要改的只有几项,其余保持默认即可。
{ "ConnectionStrings": { "Mes": "Server=.;Database=MesDb;Trusted_Connection=True;TrustServerCertificate=True" }, "Mes": { "SiteCode": "SITE01", "DisplayTimeZone": "Asia/Shanghai", "ScanDebounceMs": 300, "OfflineBufferSize": 5000 }, "Opc": { "Endpoint": "opc.tcp://127.0.0.1:4840", "PollIntervalMs": 1000, "ReconnectBackoffMs": 5000 } }SiteCode会写进每条报工记录,多厂区部署时靠它区分数据来源;ScanDebounceMs是前端防重复提交的窗口,后面避坑章节还会提到它;DisplayTimeZone只影响展示,库里统一存带偏移的时间。Opc节点在不需要采集设备的生产线上可以整段删掉,宿主服务会自己跳过。
3. 数据模型与核心链路:工单、过站、报工三张表怎么串起来
3.1 四张主表与字段取舍
MES 的表能铺到几十张,但真正决定系统能不能转起来的是四张:工单、过站记录、SN 追溯、物料库存。源码里这四张表的字段都很克制,没有一上来就堆自定义扩展字段。
| 表名 | 关键字段 | 说明 |
|---|---|---|
WorkOrder | OrderNo、ProductCode、PlanQty、FinishedQty、Status、RowVersion | 工单主表,OrderNo建唯一索引,RowVersion做并发令牌 |
OperationRecord | WorkOrderId、OperationCode、Sn、OkQty、NgQty、StationCode、ReportedAt | 报工与过站记录合一,一条记录代表一次过站动作 |
SnTrace | Sn、ProductCode、CurrentOperation、Status | 单件产品的当前工序快照,避免每次追溯都去扫全表 |
MaterialStock | MaterialCode、LocationCode、Qty、RowVersion | 线边库存,扣料走乐观锁 |
OperationRecord把「过站」和「报工」合成一张表,是因为车间里这两个动作本来就是同一个物理操作——操作工扫一下 SN,系统既记录了这件的过站,也累加了工单的完工数。分成两张表反而会出现「过站了但没报工」的对账麻烦。SnTrace是一张冗余表,作用是让追溯查询从「扫全表找这个 SN 的所有记录」变成「查一行拿当前状态」,代价是每次过站要同步更新一次,用事务包住就行。
注意:
FinishedQty是累加字段,不要拿它去做SUM(OperationRecord.OkQty)的实时计算,看板上万行数据时性能差一个数量级。
3.2 实体映射:唯一索引和并发令牌写在 Fluent API 里
实体类本身保持干净,映射集中在一个OnModelCreating里,方便一次性看全所有约束。
public class WorkOrder { public long Id { get; set; } public string OrderNo { get; set; } = default!; // 业务唯一键,对外暴露用这个 public string ProductCode { get; set; } = default!; public int PlanQty { get; set; } public int FinishedQty { get; set; } public WorkOrderStatus Status { get; set; } public DateTimeOffset CreatedAt { get; set; } // 带偏移,跨时区不出错 public byte[] RowVersion { get; set; } = default!; // 并发令牌 } public class MesDbContext : DbContext { public DbSet<WorkOrder> WorkOrders => Set<WorkOrder>(); public DbSet<OperationRecord> OperationRecords => Set<OperationRecord>(); protected override void OnModelCreating(ModelBuilder mb) { mb.Entity<WorkOrder>(e => { e.ToTable("WorkOrder"); e.HasIndex(x => x.OrderNo).IsUnique(); // 工单号不允许重复 e.Property(x => x.OrderNo).HasMaxLength(32).IsRequired(); e.Property(x => x.RowVersion).IsRowVersion(); // 映射到 SQL Server 的 rowversion e.Property(x => x.Status).HasConversion<int>(); // 枚举存 int,方便 SQL 里直接看 }); mb.Entity<OperationRecord>(e => { // 幂等的最后一道防线:同一工单 + 工序 + SN 只能有一条 e.HasIndex(x => new { x.WorkOrderId, x.OperationCode, x.Sn }).IsUnique(); e.HasIndex(x => new { x.StationCode, x.ReportedAt }); // 工位端补传时按时间捞 e.Property(x => x.Sn).HasMaxLength(64).IsRequired(); }); } }IsRowVersion()在 SQL Server 上会生成rowversion类型,EF Core 在SaveChanges时自动把原值放进WHERE条件,更新影响行数为 0 就抛DbUpdateConcurrencyException。这套机制不需要业务代码写任何版本号比较,比手写UPDATE ... WHERE Version = @v省心。
那个三列组合唯一索引是整个系统防重复报工的根本。应用层的AnyAsync判断只是提前返回友好提示,真正兜底的是索引——并发下两个请求同时通过检查的情况一定存在,这时候数据库会拒绝第二条插入,捕获DbUpdateException转成「重复报工」返回即可。
3.3 工单状态机:把 if-else 收进一张迁移表
工单状态如果用if (status == Created || status == Released)这种写法散在各个方法里,改一条规则要全项目搜。源码里的做法是把合法迁移写成字典,任何状态变更都必须过CanTransit。
public enum WorkOrderStatus { Created = 0, // 已创建,还没下发 Released = 10, // 已下发到车间 InProduction = 20, // 生产中 Paused = 30, // 暂停 Closed = 90, // 正常关闭 Cancelled = 99 // 作废 } public static class WorkOrderStateMachine { private static readonly Dictionary<WorkOrderStatus, WorkOrderStatus[]> Allowed = new() { [WorkOrderStatus.Created] = new[] { WorkOrderStatus.Released, WorkOrderStatus.Cancelled }, [WorkOrderStatus.Released] = new[] { WorkOrderStatus.InProduction, WorkOrderStatus.Cancelled }, [WorkOrderStatus.InProduction] = new[] { WorkOrderStatus.Paused, WorkOrderStatus.Closed }, [WorkOrderStatus.Paused] = new[] { WorkOrderStatus.InProduction, WorkOrderStatus.Cancelled } }; public static bool CanTransit(WorkOrderStatus from, WorkOrderStatus to) => Allowed.TryGetValue(from, out var next) && next.Contains(to); public static void EnsureCanTransit(WorkOrderStatus from, WorkOrderStatus to) { if (!CanTransit(from, to)) throw new BizException($"工单状态不允许从 {from} 变更为 {to}"); } }字典里没有Closed和Cancelled这两个 key,意味着终态不可逆,想返工只能新建一张工单。这条规则在现场引起过争议,但坚持下来是对的——允许回退的工单状态最后一定会有对不上账的记录。BizException是自定义的业务异常,API 层有个中间件专门捕获它,转成 400 加业务错误码,不会吐 500 堆栈给操作工看。
3.4 报工接口:幂等、事务、并发三件事一起处理
报工是整个系统调用最频繁的接口,工位端每扫一件就调一次。这段代码值得逐行看。
[HttpPost("report")] public async Task<ActionResult<ApiResult>> ReportAsync([FromBody] ReportRequest req) { // 1) 前置幂等检查,命中直接返回成功,工位端不会弹红色提示 var duplicated = await _db.OperationRecords.AnyAsync(x => x.WorkOrderId == req.WorkOrderId && x.OperationCode == req.OperationCode && x.Sn == req.Sn); if (duplicated) return Ok(ApiResult.Ok("重复报工,已忽略")); await using var tx = await _db.Database.BeginTransactionAsync(); // 2) 拿工单并校验状态,不在生产中一律拒绝 var order = await _db.WorkOrders.FirstOrDefaultAsync(x => x.Id == req.WorkOrderId) ?? return NotFound(ApiResult.Fail("工单不存在")); if (order.Status != WorkOrderStatus.InProduction) return BadRequest(ApiResult.Fail($"工单当前状态为 {order.Status},不能报工")); // 3) 写记录 + 累加完工数,同一事务内完成 _db.OperationRecords.Add(new OperationRecord { WorkOrderId = order.Id, OperationCode = req.OperationCode, Sn = req.Sn, OkQty = req.OkQty, NgQty = req.NgQty, StationCode = req.StationCode, ReportedAt = DateTimeOffset.Now, Result = req.NgQty > 0 ? RecordResult.Ng : RecordResult.Ok }); order.FinishedQty += req.OkQty; try { await _db.SaveChangesAsync(); // RowVersion 不匹配会在这里抛并发异常 await tx.CommitAsync(); } catch (DbUpdateConcurrencyException) { await tx.RollbackAsync(); return Conflict(ApiResult.Fail("工单已被其他工位更新,请重新扫码")); } catch (DbUpdateException) // 组合唯一索引冲突,并发下的兜底 { await tx.RollbackAsync(); return Ok(ApiResult.Ok("重复报工,已忽略")); } // 4) 推送看板,失败不影响主流程 await _hub.Clients.All.SendAsync("orderChanged", order.Id, order.FinishedQty); return Ok(ApiResult.Ok()); }请求体ReportRequest只有六个字段:WorkOrderId、OperationCode、Sn、OkQty、NgQty、StationCode。不带时间戳是因为时间以服务端为准,工位端的钟不可信——现场工控机的时间偏差一两个小时是常事,如果让客户端传时间,报表按小时统计会直接乱掉。
DbUpdateConcurrencyException和DbUpdateException要分开 catch。前者是工单被别的工位抢先更新(比如两个工位同时报同一张单),应该让操作工重扫;后者大概率是唯一索引冲突,本质是重复报工,返回成功反而更符合现场预期,因为那件产品确实已经报过了。
3.5 过站校验:用规则表代替硬编码的工序判断
过站校验要回答的问题是「这个 SN 现在能不能过这道工序」。硬编码的写法是在每个工位写一段if,产品一多就失控。源码里把规则抽成表,一条规则描述一个产品在某道工序的前置条件。
public class StationRule { public long Id { get; set; } public string ProductCode { get; set; } = default!; // 支持 "*" 通配 public string OperationCode { get; set; } = default!; public string PrevOperationCode { get; set; } = default!; // 必须已完成的工序 public bool RequireMaterialScan { get; set; } // 是否强制扫料 public int MaxNgRateBp { get; set; } // 不良率上限,万分比 public bool Enabled { get; set; } }校验服务的核心逻辑是先取规则、再逐条判定,没有匹配到规则时默认放行。
public async Task<CheckResult> CheckAsync(CheckRequest req, CancellationToken ct) { // 精确匹配优先于通配,避免通用规则盖住产品专属规则 var rule = await _db.StationRules .Where(r => r.Enabled && (r.ProductCode == req.ProductCode || r.ProductCode == "*") && r.OperationCode == req.OperationCode) .OrderByDescending(r => r.ProductCode != "*") .FirstOrDefaultAsync(ct); // 没配规则就放行,宁可少拦也不要卡住产线 if (rule is null) return CheckResult.Pass(); var prevDone = await _db.OperationRecords.AnyAsync(x => x.Sn == req.Sn && x.OperationCode == rule.PrevOperationCode && x.Result == RecordResult.Ok, ct); if (!prevDone) return CheckResult.Block($"SN {req.Sn} 未完成前道工序 {rule.PrevOperationCode}"); return CheckResult.Pass(); }「没配规则默认放行」这个设计在现场救过场。有次新产品试产,工艺还没来得及维护规则,如果默认拦截,整条线直接停摆;放行最多是漏检,事后能靠追溯补。当然这个策略要在上线前和品质部门对齐,不是所有厂都能接受。
MaxNgRateBp用的是万分比而不是百分比,因为double存 0.01% 这种值会有精度问题,存整数 1 表示万分之一,比较和统计都不会出错。这条经验来自于之前一个项目里不良率怎么算都对不上的血泪史。
4. 部署与跑通:本地起服务、连库、联调工位端
4.1 数据库迁移与种子数据
第一次跑起来最省事的路径是本地装一个 SQL Server Express,然后用 EF Core 迁移建库。先确认dotnet-ef工具装了,版本要和项目的 EF Core 包对齐。
# 安装或更新迁移工具,版本号跟 csproj 里的 EF Core 保持一致 dotnet tool install --global dotnet-ef --version 8.* # 还原依赖 dotnet restore Mes.sln # 生成初始迁移,注意 startup-project 要指到 Api,否则拿不到连接串 dotnet ef migrations add Init \ --project src/Mes.Infrastructure \ --startup-project src/Mes.Api # 应用到数据库 dotnet ef database update \ --project src/Mes.Infrastructure \ --startup-project src/Mes.Api--project和--startup-project这两个参数必须分开指。迁移文件生成在 Infrastructure 项目里,但连接串和DbContext的注册在 Api 项目里,只指一个会报「找不到设计时 DbContext」。这个问题在初次接触分层项目的人身上几乎必踩。
种子数据写得比较简单,只在表为空时插一张工单和两条规则,方便打开看板就能看到东西。
public static async Task SeedAsync(MesDbContext db) { if (await db.WorkOrders.AnyAsync()) return; // 已有数据不重复插 db.WorkOrders.Add(new WorkOrder { OrderNo = "WO-20240501-001", ProductCode = "P-1001", PlanQty = 500, Status = WorkOrderStatus.InProduction, CreatedAt = DateTimeOffset.Now }); db.StationRules.AddRange( new StationRule { ProductCode = "P-1001", OperationCode = "OP20", PrevOperationCode = "OP10", Enabled = true }, new StationRule { ProductCode = "*", OperationCode = "OP10", PrevOperationCode = "OP00", Enabled = true }); await db.SaveChangesAsync(); }4.2 后端与看板启动
数据库就绪后,两个进程分别起来。API 默认监听 5080,看板用 5081,端口写在各自的launchSettings.json里。
# 终端一:启动 Web API,看日志里有没有迁移和种子数据成功的提示 dotnet run --project src/Mes.Api --urls http://0.0.0.0:5080 # 终端二:启动 Blazor 看板 dotnet run --project src/Mes.Web --urls http://0.0.0.0:5081用0.0.0.0而不是localhost是为了让车间局域网里的工位机能直接访问。如果只在开发机上跑,改回localhost更安全。起来之后先拿 curl 验一下报工接口,比在工位端上盲试快得多。
curl -X POST http://localhost:5080/api/workorder/report \ -H "Content-Type: application/json" \ -d '{"workOrderId":1,"operationCode":"OP20","sn":"SN0001","okQty":1,"ngQty":0,"stationCode":"ST-01"}'第一次返回{"code":0,"message":"ok"},再发一次同样的请求会返回「重复报工,已忽略」——这说明幂等生效了,组合唯一索引和前置检查都在工作。如果第二次返回的是 500,说明前置检查那段逻辑被跳过了,去查一下OperationRecords的索引有没有真的建出来。
4.3 工位端联调:扫码枪输入与离线缓存
工位端的连接配置在App.config里,改完不用重新编译,现场运维自己就能改。
<!-- Mes.Station/App.config --> <appSettings> <add key="ApiBaseUrl" value="http://192.168.1.10:5080" /> <add key="StationCode" value="ST-01" /> <add key="OperationCode" value="OP20" /> <add key="ScanEndChar" value="13" /> <!-- 扫码枪以回车结尾,13 是 CR --> <add key="RequestTimeoutMs" value="3000" /> </appSettings>扫码枪在系统里表现为一个键盘,字符一个个进来,最后补一个回车。所以接收逻辑不能直接绑TextBox.TextChanged,那样每输入一个字符就触发一次,一个 20 位的 SN 会发 20 个请求。源码里的做法是攒够一个完整的扫描周期再提交。
private readonly StringBuilder _buffer = new(); private DateTime _lastKeyAt = DateTime.MinValue; private void OnKeyPress(object sender, KeyPressEventArgs e) { // 两次按键间隔过长,认为上一次是误触,清空重来 if ((DateTime.Now - _lastKeyAt).TotalMilliseconds > 120) _buffer.Clear(); _lastKeyAt = DateTime.Now; if (e.KeyChar == (char)13) // 回车 = 一次扫描结束 { var sn = _buffer.ToString().Trim(); _buffer.Clear(); if (sn.Length > 0) _ = SubmitAsync(sn); e.Handled = true; // 吃掉回车,防止触发默认按钮 return; } _buffer.Append(e.KeyChar); }120 毫秒这个阈值是根据扫码枪的字符间隔定的,一般扫码枪连续输出时字符间隔在 10 到 30 毫秒,人手敲键盘最快也要 100 毫秒以上。设得太小会把一次扫描拆成两次,设得太大又会把两次快速扫描粘在一起。
网络不通时的兜底是落盘重传,文件按行存 JSON,恢复后按顺序补。
public class OfflineQueue { private readonly string _file = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "pending.jsonl"); // 请求失败先落盘,避免现场因为网络问题停线 public void Enqueue(ReportRequest req) => File.AppendAllText(_file, JsonSerializer.Serialize(req) + Environment.NewLine); public IEnumerable<ReportRequest> Drain() { if (!File.Exists(_file)) yield break; foreach (var line in File.ReadAllLines(_file)) if (!string.IsNullOrWhiteSpace(line)) yield return JsonSerializer.Deserialize<ReportRequest>(line)!; File.Delete(_file); } }补传的时机放在工位端每次成功提交之后顺手跑一遍,不做定时器。这样网络恢复后第一批数据马上就能上去,不用等下一个轮询周期。补传失败时不要删文件,否则数据就真丢了。
5. 避坑与排查:上线前必须压一遍的 5 个坑
5.1 五个高频故障的现象、成因与处置
第一坑:报工数量翻倍。现象是工单完工数比实际产量多出百分之几,而且集中在网络慢的工位。原因多半是客户端超时重试:第一次请求其实已经到了服务端并写库成功,只是响应在路上丢了,工位端认为失败又发了一次。解决分两层,应用层的前置检查只能挡住串行重复,真正管用的是WorkOrderId + OperationCode + Sn的组合唯一索引,以及客户端RequestTimeoutMs不要设得太短——3 秒对局域网内的插入操作已经足够。
第二坑:看板越用越卡,重启就好。现象是早上刚上班时看板刷新很快,到下午要三四秒才响应一次。原因通常是 EF Core 的查询跟踪没关,加上循环里逐条查明细造成的 N+1,DbContext又因为被注册成 Scoped 而保持着大量实体引用。解决是所有只读查询加AsNoTracking(),列表页用Select投影到 DTO 而不是返回实体,看板接口单独走一套IDbContextFactory创建的短生命周期上下文。
第三坑:设备采集整点丢一段数据。现象是每小时的第 59 分钟到下一个整点之间的采集值是空的,其余时间正常。原因是 OPC UA 的会话有默认超时,长时间空闲后服务端会主动断开,而采集任务里只写了try没写重连,异常被最外层吞掉了,任务继续跑但会话已经废了。解决是把采集循环包在while里,捕获ServiceResultException后按ReconnectBackoffMs做指数退避重连,同时加一条心跳写入,连续三次失败就记 Error 日志并推送告警。
第四坑:报表时间对不上。现象是夜班 23 点到次日 1 点的产量在报表里被算到了前一天,跨月的时候还会出现整月的数字偏差。原因是数据库里混用了GETDATE()的服务器本地时间和代码里DateTime.Now的客户端时间,两边时区或者夏令时策略不一致。解决是全部换成DateTimeOffset,写入时统一取服务端时间,展示时再按DisplayTimeZone转换,SQL 里禁止出现GETDATE(),需要默认值时用SYSDATETIMEOFFSET()。
第五坑:并发扣料超卖。现象是线边库存偶尔变成负数,触发时机集中在换班前后多个工位同时开工。原因是「查库存 - 判断够不够 - 扣减」这三步没有原子性,两个请求都读到 10,都判断够,都扣 6,最后变成 -2。解决是给MaterialStock加RowVersion并发令牌,捕获DbUpdateConcurrencyException后重试一次;如果业务上允许,直接用一条UPDATE MaterialStock SET Qty = Qty - @n WHERE Qty >= @n更干脆,影响行数为 0 就是库存不足。
5.2 出问题先看哪三处:日志、执行计划、现场时序
现场报障时最忌讳的是凭感觉改代码。我的习惯是先按固定顺序看三样东西,八成的问题能在这三步内定位。
第一处是 Serilog 的日志文件。每个请求进来会打一条包含OrderNo和Sn的 Info 日志,异常会带完整堆栈。查「某件产品为什么没报上」时直接 grep 那个 SN,能看到它经过了哪几个接口、在哪一步返回了什么。
第二处是慢查询。把看板接口的 SQL 从日志里捞出来,丢进 SQL Server Management Studio 看实际执行计划,重点看有没有Table Scan和「估计行数与实际行数差异巨大」。后者通常意味着统计信息过期,UPDATE STATISTICS一下就能好,不用改代码。
第三处是现场时序。工位端的操作日志、API 的接收日志、数据库的写入时间,这三个时间点排在一起看,就能判断是网络延迟、客户端重试还是服务端处理慢。曾经有个「扫码没反应」的问题,最后发现是工位机的系统时间比服务器快了 40 分钟,日志时间一对就露馅了。
注意:上线前把日志级别设成 Information,不要图省事用 Debug。Debug 在高频报工场景下一天能写出几个 G,磁盘满了整个服务会挂。
6. 二次开发:三个最常见的改造点和一套验证方法
拿到这套源码的人,改造需求基本集中在三件事上:加新工序、对接上游系统、改看板样式。这三件事的落点完全不同,找错文件会浪费很多时间。
| 改造需求 | 主要落点 | 需要注意 |
|---|---|---|
| 新增一道工序 | StationRule表加一行 + 工位端App.config改OperationCode | 不需要改代码,但要确认前道工序的OperationCode拼写一致 |
| 对接 ERP 拉工单 | 新建ErpSyncHostedService,实现IWorkOrderService.ImportAsync | 外部单号要做映射,不能直接当OrderNo用 |
| 看板加图表 | Mes.Web下新建组件,数据走IWorkOrderQueryService | 查询必须AsNoTracking,别直接注入MesDbContext |
| 换数据库 | Program.cs里换 Provider,重做迁移 | rowversion在 MySQL 里要换IsRowVersion的等价写法 |
| 加新的过站规则类型 | StationRule加字段 +CheckAsync加分支 | 新字段要给默认值,历史数据不能因为空值被拦 |
加规则这件事值得单独说,因为它是最不需要写代码的扩展点。比如某个产品要求过站前必须扫物料条码,只要在StationRule上已经有RequireMaterialScan字段,插一条记录就完事。如果字段不存在,加了字段之后要记得给历史数据补默认值,ALTER TABLE加非空列时给DEFAULT 0,否则迁移会失败。
// 给已有规则表加一条「OP30 必须扫料」的规则,不需要改任何 C# 代码 db.StationRules.Add(new StationRule { ProductCode = "P-1001", OperationCode = "OP30", PrevOperationCode = "OP20", RequireMaterialScan = true, // 新开的开关 MaxNgRateBp = 500, // 不良率超过 5% 就拦 Enabled = true }); await db.SaveChangesAsync();改完之后怎么验证?我的做法是先跑单元测试确认状态机和规则引擎没被破坏,再用 curl 打三个用例:正常过站、跳过前道工序、重复报工。这三个用例覆盖了大部分回归场景,五分钟能跑完。别指望在工位端上手点,手点的路径永远覆盖不全。
有个教训印象很深:早期改规则引擎时为了图快,直接在CheckAsync里加了一段if (req.ProductCode == "P-1001")的特判,当时验证通过了,三个月后这个产品停产,那段特判没人记得删,新产品的编码规则一变,直接被这段历史代码拦在线上。从那以后我每次往业务服务里加特判之前,都会先问一句「这个条件能不能塞进规则表」,能塞就绝不写进代码。配置化的东西改错了能回滚,代码里的硬编码只能靠人记。
希望帮到你。
本文还有配套的精品资源,点击获取