这不是一篇普通的 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 | ✅ |
八、给读者的建议
先跑通业务闭环:不要上来就搞分布式锁,先把销售→生产→入库跑通。
接口先行:先写接口定义(
IxxxService),再写实现,养成面向接口编程的习惯。事务是底线:涉及多表操作,永远用
BeginTransaction包裹。日志是命根子:审计日志 + 操作日志 + 异常日志,一个都不能少。
不要过度设计:小型项目不需要 CQRS 和发件箱,但在架构层面知道它们的存在是进阶的起点。