从零构建一个企业级 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 项目分层(不是文件夹,是类库)

很多初学者把三层架构做成“文件夹”,那是伪分层。真正的分层必须通过项目引用强制隔离

text

MyERP.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 { Task<string> StockInAsync(StockInRequest request); Task<string> StockOutAsync(StockOutRequest request); } // ✅ 实现类 public class InventoryService : IInventoryService { ... } // ✅ Controller 依赖接口 public class InventoryController(IInventoryService inventoryService) { ... } // ✅ DI 注册 builder.Services.AddScoped<IInventoryService, InventoryService>();

价值:换实现只需改一行注册代码,符合依赖倒置原则(DIP),同时也是单元测试 Mock 的基础。


三、第二阶段:核心业务闭环(ERP 的灵魂)

3.1 数据库设计核心表

表名作用关键字段
Base_Item物料(成品/原材料)ItemCode,ItemType
Base_Warehouse仓库WarehouseCode
Base_Partner客户/供应商PartnerCode,Type
Inv_Stock实时库存快照AvailableQty,LockedQty,Version(乐观锁)
Inv_Transaction库存流水(只增不改)Direction,BeforeQty,AfterQty
Sales_Order销售订单Status(待审核/已审核/生产中/已发货/已完成)
Production_Order生产工单Status(待发料/进行中/已完工/已入库)
Production_OrderDetail工单 BOM 明细RequiredQty,IssuedQty

3.2 业务闭环流程(核心中的核心)

这是整个 ERP 最有价值的部分——需求驱动生产

text

销售下单(待审核) ↓ 审核通过 → 自动生成生产工单(待发料) ↓ 生产领料 → 扣减原材料库存,工单状态变为“进行中” ↓ 生产完工 → 成品入库,工单状态变为“已完工”

关键代码片段:销售审核自动生成工单

csharp

public async Task<bool> 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 最核心的技术难点——多个请求同时扣同一个物料的库存

csharp

public async Task<string> 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 自动记录

不写任何业务代码,自动捕获所有实体变更:

csharp

public class AuditInterceptor : SaveChangesInterceptor { public override async ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> 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)

乐观锁解决了并发冲突,但如果请求重试,会重复扣库存。需要幂等令牌

csharp

public async Task<string> 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(第三方物流、短信网关)时,必须加保护:

csharp

builder.Services.AddHttpClient("ExternalApi", client => ...) .AddPolicyHandler(HttpPolicyExtensions .HandleTransientHttpError() .WaitAndRetryAsync(3, retry => TimeSpan.FromSeconds(Math.Pow(2, retry)))) // 指数退避 .AddPolicyHandler(Policy<HttpResponseMessage> .Handle<Exception>() .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)主库(Master)EF Core
读取(Query)只读副本(Slave)Dapper + 原生 SQL

csharp

// 写入走 EF Core public class InventoryService(AppDbContext context) : IInventoryService // 查询走 Dapper public class ReportService(IDbConnection slaveConnection) : IReportService { public async Task<List<StockLedger>> GetLedgerAsync(...) { return await slaveConnection.QueryAsync<StockLedger>(sql, param); } }

5.2 冷热数据分离

Inv_Transaction会无限增长,超过千万级后报表超时。

自动归档策略

  • 保留 2 年在线数据

  • 每月凌晨将过期数据迁移到Inv_Transaction_Archive分区表

  • 报表查询时根据日期范围自动路由


六、第五阶段:容器化与可观测性

6.1 Docker Compose 一键启动

yaml

version: '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 - redis

6.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

八、给读者的建议

  1. 先跑通业务闭环:不要上来就搞分布式锁,先把销售→生产→入库跑通。

  2. 接口先行:先写接口定义(IxxxService),再写实现,养成面向接口编程的习惯。

  3. 事务是底线:涉及多表操作,永远用BeginTransaction包裹。

  4. 日志是命根子:审计日志 + 操作日志 + 异常日志,一个都不能少。

  5. 不要过度设计:小型项目不需要 CQRS 和发件箱,但在架构层面知道它们的存在是进阶的起点。