从零构建一个企业级 ERP 系统:.NET 8 + Vue 全栈实战指南
这不是一篇普通的 CRUD 教程而是一个完整的技术成长实录——从建表到分布式锁从三层架构到接口解耦从审计日志到熔断降级每一步都是真实踩坑后的沉淀。一、写在前面为什么要造这个轮子很多 .NET 开发者工作多年依然停留在“复制粘贴 Controller-Service-Repository”的层面对事务、并发、业务闭环、架构演进缺乏系统性认知。我决定从零写一个 Mini ERP 系统目标不是商业落地而是用代码打通整个后端知识体系。技术栈选型如下后端ASP.NET Core 8 EF Core SQL Server前端Vue 3 Element Plus辅助展示架构三层架构Domain / Application / Infrastructure / API 依赖注入解耦高级特性分布式锁Redis、审计日志Interceptor、发件箱模式Outbox、熔断重试Polly、CQRS 读写分离、容器化部署二、第一阶段打好地基三层架构 DI 解耦2.1 项目分层不是文件夹是类库很多初学者把三层架构做成“文件夹”那是伪分层。真正的分层必须通过项目引用强制隔离textMyERP.sln ├── MyERP.Domain # 实体、枚举纯 C#无任何外部依赖 ├── MyERP.Infrastructure # DbContext、Repository、EF Core 迁移 ├── MyERP.Application # DTO、Service 业务逻辑、接口定义 └── MyERP.API # Controller、JWT 鉴权、Program.cs 启动项依赖方向API → Application → Infrastructure → Domain依赖倒置2.2 最重要的一步面向接口编程DI 解耦绝大多数初学者写的 Service 是“裸类”csharp// ❌ 错误写法没有接口 public class InventoryService { ... } public class InventoryController(InventoryService svc) { ... }一旦需要换成另一个实现比如从 SQL Server 换成 Redis 缓存所有 Controller 都要改。正确写法提取接口csharp// ✅ 接口定义 public interface IInventoryService { Taskstring StockInAsync(StockInRequest request); Taskstring StockOutAsync(StockOutRequest request); } // ✅ 实现类 public class InventoryService : IInventoryService { ... } // ✅ Controller 依赖接口 public class InventoryController(IInventoryService inventoryService) { ... } // ✅ DI 注册 builder.Services.AddScopedIInventoryService, InventoryService();价值换实现只需改一行注册代码符合依赖倒置原则DIP同时也是单元测试 Mock 的基础。三、第二阶段核心业务闭环ERP 的灵魂3.1 数据库设计核心表表名作用关键字段Base_Item物料成品/原材料ItemCode,ItemTypeBase_Warehouse仓库WarehouseCodeBase_Partner客户/供应商PartnerCode,TypeInv_Stock实时库存快照AvailableQty,LockedQty,Version乐观锁Inv_Transaction库存流水只增不改Direction,BeforeQty,AfterQtySales_Order销售订单Status待审核/已审核/生产中/已发货/已完成Production_Order生产工单Status待发料/进行中/已完工/已入库Production_OrderDetail工单 BOM 明细RequiredQty,IssuedQty3.2 业务闭环流程核心中的核心这是整个 ERP 最有价值的部分——需求驱动生产text销售下单待审核 ↓ 审核通过 → 自动生成生产工单待发料 ↓ 生产领料 → 扣减原材料库存工单状态变为“进行中” ↓ 生产完工 → 成品入库工单状态变为“已完工”关键代码片段销售审核自动生成工单csharppublic async Taskbool ApproveAsync(long salesOrderId) { using var transaction await _context.Database.BeginTransactionAsync(); try { // 1. 更新销售订单状态 order.Status SalesOrderStatus.Approved; // 2. 自动生成生产工单 var prodOrder new ProductionOrder { OrderNo GenerateOrderNo(), ItemId order.ItemId, Quantity order.TotalQty, Status ProductionOrderStatus.PendingMaterial }; _context.ProductionOrders.Add(prodOrder); // 3. 展开 BOM 生成工单明细 foreach (var bom in GetBOM(order.ItemId)) { _context.ProductionOrderDetails.Add(new ProductionOrderDetail { ProductionOrderId prodOrder.Id, ItemId bom.MaterialItemId, RequiredQty bom.RequiredQty * order.TotalQty }); } await _context.SaveChangesAsync(); await transaction.CommitAsync(); return true; } catch { await transaction.RollbackAsync(); throw; } }3.3 库存扣减乐观锁防并发超卖这是 ERP 最核心的技术难点——多个请求同时扣同一个物料的库存。csharppublic async Taskstring StockOutAsync(StockOutRequest request) { // 1. 查询库存并记录旧版本号 var stock await _context.InvStocks.FindAsync(request.ItemId, request.WarehouseId); var oldVersion stock.Version; var beforeQty stock.AvailableQty; var afterQty stock.AvailableQty - request.Quantity; // 2. 乐观锁更新WHERE 条件带上 Version var rows await _context.InvStocks .Where(s s.Id stock.Id s.Version oldVersion) .ExecuteUpdateAsync(setters setters .SetProperty(s s.AvailableQty, afterQty) .SetProperty(s s.Version, oldVersion 1) ); // 3. 如果影响行数为 0说明被其他请求修改了 if (rows 0) throw new Exception(库存已被修改请重试); // 4. 写入流水审计 _context.InvTransactions.Add(new InvTransaction { ItemId request.ItemId, Direction Direction.Out, BeforeQty beforeQty, AfterQty afterQty }); await _context.SaveChangesAsync(); }面试常问为什么不用悲观锁SELECT ... FOR UPDATE因为乐观锁在冲突率低的场景下性能更好且不会产生死锁。四、第三阶段架构进阶从“能用”到“好用”4.1 审计日志利用 EF Core Interceptor 自动记录不写任何业务代码自动捕获所有实体变更csharppublic class AuditInterceptor : SaveChangesInterceptor { public override async ValueTaskInterceptionResultint SavingChangesAsync( DbContextEventData eventData, InterceptionResultint result, CancellationToken cancellationToken default) { var entries eventData.Context.ChangeTracker.Entries() .Where(e e.State EntityState.Modified || e.State EntityState.Added); foreach (var entry in entries) { // 记录旧值、新值、操作人、时间戳 var audit new AuditLog { TableName entry.Entity.GetType().Name, Action entry.State.ToString(), OldValues JsonSerializer.Serialize(entry.OriginalValues.Properties.ToDictionary(...)), NewValues JsonSerializer.Serialize(entry.CurrentValues.Properties.ToDictionary(...)), ChangedBy GetCurrentUserId(), ChangedAt DateTime.UtcNow }; eventData.Context.Add(audit); } return await base.SavingChangesAsync(eventData, result, cancellationToken); } }价值财务/老板最看重的功能——数据追溯。谁在什么时间改了哪个字段一目了然。4.2 分布式锁 幂等性Redis乐观锁解决了并发冲突但如果请求重试会重复扣库存。需要幂等令牌csharppublic async Taskstring StockOutAsync(StockOutRequest request) { // 1. 幂等性检查 var idempotentKey $Idempotent:{request.RequestId}; if (await _redis.StringGetAsync(idempotentKey) 1) return await GetCachedResult(request.RequestId); // 2. 分布式锁Key 物料仓库 var lockKey $Lock:Inventory:{request.ItemId}:{request.WarehouseId}; using var redisLock await _redis.LockTakeAsync(lockKey, ...); if (!redisLock.Acquired) throw new Exception(系统繁忙请稍后重试); // 3. 执行扣库存... await ExecuteStockOutAsync(request); // 4. 标记幂等 await _redis.StringSetAsync(idempotentKey, 1, TimeSpan.FromHours(24)); }价值工业级扣库存方案 幂等性 分布式锁 乐观锁三重保障。4.3 熔断与重试Polly当系统调用外部 API第三方物流、短信网关时必须加保护csharpbuilder.Services.AddHttpClient(ExternalApi, client ...) .AddPolicyHandler(HttpPolicyExtensions .HandleTransientHttpError() .WaitAndRetryAsync(3, retry TimeSpan.FromSeconds(Math.Pow(2, retry)))) // 指数退避 .AddPolicyHandler(PolicyHttpResponseMessage .HandleException() .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))); // 连续失败5次熔断30秒面试高频题熔断和重试的区别重试是“再试一次”熔断是“别试了赶紧失败”防止级联故障。4.4 发件箱模式Outbox Pattern—— 最终一致性如果用 MediatR 解耦业务可能出现“订单状态改了但事件没发出去”的问题。解决方案把“发事件”变成数据库事务的一部分sql-- 在同一事务中 UPDATE Sales_Order SET Status Approved WHERE Id 1; INSERT INTO Outbox_Messages (EventType, Payload, CreatedAt) VALUES (OrderApproved, {OrderId:1}, GETDATE());后台轮询扫描Outbox_Messages表发布成功后标记ProcessedAt。价值微服务最终一致性的黄金标准保证 100% 数据不丢。五、第四阶段数据库与性能优化5.1 CQRS 读写分离报表查询Group By几百万条流水会阻塞主库事务。引入 CQRS操作数据库ORM写入Command主库MasterEF Core读取Query只读副本SlaveDapper 原生 SQLcsharp// 写入走 EF Core public class InventoryService(AppDbContext context) : IInventoryService // 查询走 Dapper public class ReportService(IDbConnection slaveConnection) : IReportService { public async TaskListStockLedger GetLedgerAsync(...) { return await slaveConnection.QueryAsyncStockLedger(sql, param); } }5.2 冷热数据分离Inv_Transaction会无限增长超过千万级后报表超时。自动归档策略保留 2 年在线数据每月凌晨将过期数据迁移到Inv_Transaction_Archive分区表报表查询时根据日期范围自动路由六、第五阶段容器化与可观测性6.1 Docker Compose 一键启动yamlversion: 3.8 services: sqlserver: image: mcr.microsoft.com/mssql/server:2022-latest environment: SA_PASSWORD: Your_password123 ACCEPT_EULA: Y ports: - 1433:1433 redis: image: redis:alpine ports: - 6379:6379 myerp-api: build: . ports: - 8080:8080 depends_on: - sqlserver - redis6.2 可观测性三件套Metrics使用 Prometheus OpenTelemetry 收集接口耗时、错误率Tracing给每个请求生成 TraceId跨服务串联日志HealthCheck暴露/health接口配合云服务商实现自动告警七、写在最后我的技术栈清单CSDN 读者可自检分类技能点状态基础架构三层架构 DI 接口解耦✅数据库EF Core SQL Server 迁移管理✅认证授权JWT 角色权限 动态用户上下文✅并发控制乐观锁 分布式锁(Redis) 幂等性✅业务闭环销售→工单→领料→完工入库✅审计追溯EF Core Interceptor 自动审计日志✅系统弹性Polly 熔断/重试/超时✅最终一致性发件箱模式Outbox✅性能优化CQRS 读写分离 冷热数据归档✅测试Testcontainers 集成测试✅部署Docker K8s 编排✅可观测性OpenTelemetry Prometheus HealthCheck✅前端Vue 3 Element Plus✅八、给读者的建议先跑通业务闭环不要上来就搞分布式锁先把销售→生产→入库跑通。接口先行先写接口定义IxxxService再写实现养成面向接口编程的习惯。事务是底线涉及多表操作永远用BeginTransaction包裹。日志是命根子审计日志 操作日志 异常日志一个都不能少。不要过度设计小型项目不需要 CQRS 和发件箱但在架构层面知道它们的存在是进阶的起点。