尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于ASP.NET Core 8.0的家具进销存系统设计与实践

基于ASP.NET Core 8.0的家具进销存系统设计与实践 简介这是一套基于ASP.NET Core 8.0构建的现代化家具电商后端系统面向中高级.NET开发者及电商项目团队解决高可用、可扩展Web API开发中的架构设计、支付集成与业务闭环等核心问题。资源包共245个文件含207个C#业务与实体类涵盖EF Core迁移快照、服务层与API控制器、21个SVG图标资源、6个配置与种子数据JSON、4个CSProj项目文件及Dockerfile、YML、License等工程化支撑文件整体仅326KB结构精炼、清洁架构分层清晰。已有72人下载学习适合深入理解领域驱动设计DDD实践、JWT/OAuth双模认证、Stripe支付流程编排、AI增强型Elasticsearch搜索集成以及实时通知与评分反馈机制。代码中大量采用时间戳命名的迁移文件如20250621144424_UpdateAddressFieldNames.Designer.cs直观体现迭代演进逻辑便于学习数据库演进策略与订单状态机建模。 最近在做一个家具经销商的内部管理系统技术栈定的是ASP.NET Core 8.0。本来以为就是个常规的进销存结果越做越发现家具这个品类在商品属性、库存核算、订单履约上的复杂度比想象中大得多。这篇文章把整个项目的设计思路、核心模块的建模方式、业务链路的代码落地、以及上线前后踩过的坑完整梳理一遍。如果你正要接类似的管理系统开发不一定是家具哪怕是建材、家电这类有SKU属性的行业这篇应该能帮你省不少试错时间。1. 别被“家具管理系统”这个看似简单的名字骗了先说一个很多人会有的误解家具管理系统嘛就是商品管理、库存管理、订单管理那一套CRUD。真做起来你才发现家具这个品类的复杂度在整个零售行业里都算高的它不像标准化的3C数码一个SKU对应一个产品就完了。1.1 家具行业的管理痛点不是所有商品都是“一件商品”家具行业有几个非常具体的业务特征直接影响系统建模商品是多维属性的组合。一张餐桌可以有原木色、胡桃色、黑色三种颜色桌面可以是岩板、实木、玻璃尺寸有1.2米、1.4米、1.6米坐凳数量有4把、6把、8把。如果每个组合都建一个独立商品商品表会爆炸式增长而且维护起来极其痛苦。更合理的做法是建立产品(Product) 变体(Variant) 属性(Attribute)的三层模型这才是家具管理系统的地基。成品与套件的关系。家具行业经常有“一桌四椅”这种套件销售。套件本身是一个可售商品但它又在库存层面关联着1张桌子和4把椅子。如果直接建一个“套件SKU”并单独维护库存就会出现套件卖完了但餐桌和椅子分别还剩很多的尴尬局面。系统必须支持组合商品(Bundle)与子件(Component)的库存联动。采购、库存、销售的价格链路。家具的采购价会随批次波动同一个商品不同批次的成本价不同。这就意味着库存账不能简单记一个“当前库存数量”而是要记录每一批入库的成本出库时按照移动加权平均或者先进先出计算成本。这是财务核算的硬需求不是可选项。定制与标品并存。很多家具门店支持尺寸定制定制订单不同于标准品订单它需要记录用户定制的尺寸参数并走“下单→拆单→生产/采购→发货”的不同流程。系统建设初期如果没考虑到后期补定制模块会很痛苦。1.2 面向门店与仓储的完整功能地图抛开概念这个系统实际需要覆盖的功能面是下面这些我按业务域拆开业务域核心功能说明商品中心产品、变体、属性、套件、价格策略支撑多维SKU与组合商品采购管理供应商、采购订单、到货质检、采购入库关联批次与成本价库存管理多仓库、出入库流水、盘点、调拨、库存预警库存账与财务账的基石销售管理客户、销售订单、出库发货、退货退款销售出库扣减库存并记录成本报表中心进销存报表、毛利报表、库存周转、销售排行老板最关心的一块系统管理用户、角色、权限、操作日志、数据字典每个管理系统的标配这套功能用传统单体应用完全扛得住关键是技术栈和架构别选错。1.3 “现代化”到底体现在哪个层面标题里的“现代化”不是营销词汇。它体现在几个具体的地方开发模式现代化ASP.NET Core 8.0的Minimal API配合EF Core 8的增强特性代码量比传统Web API少很多。前端交互现代化局部刷新、实时库存提醒、移动端适配不再是从前的服务端整页渲染。部署运维现代化Docker容器化一台2核4G的云服务器就能跑得很顺不再需要Windows Server IIS。认证授权现代化JWT无状态认证 RBAC权限模型天然适合前后端分离。提示如果你刚接触.NET不要被“现代化”吓到。ASP.NET Core 8.0依然是那个“写起来很顺手、坑也相对少”的框架只是入口更多了选自己熟悉的方式即可。2. 技术选型为什么锁定ASP.NET Core 8.0这套组合框架选型这件事我自己的原则是选长期支持(LTS)、生态成熟、团队上手成本可控的。.NET 8恰好就是当前阶段的LTS版本支持期到2026年11月比.NET 7这种STS版本省心得多。2.1 后端主框架ASP.NET Core 8.0的几个关键理由性能持续进化。.NET 8在JSON序列化、Kestrel服务器、反射调用等多个层面做了优化TechEmpower的基准测试里常年位居前列。对家具管理系统这种典型IO密集型场景来说性能冗余超过需求但带来的好处是——同样一台服务器可以同时扛住门店收银端的几十个并发操作和老板报表端的大查询。原生AOT提前编译。这个我实际没有在项目里启用因为家具管理系统的启动速度和内存占用在普通云服务器上根本不算瓶颈。但它提供了可能性如果以后要做一个面向门店的离线小工具AOT能帮上大忙。Minimal API让原型落地很快。一张采购入库单的提交接口用传统控制器写法要建类、标特性、注入服务用Minimal API则几行代码搞定。对于没有专职后端团队的场景交付效率差别很大。EF Core 8的增强特性正好命中业务需求。后面会详细提到比如复杂类型(Complex Types)、JSON列映射、以及批量更新操作的性能优化都是在实体建模和列表操作里用得上的实际能力。2.2 具体组件选型清单及理由我自己最终采用的技术栈组合领域选型理由ORMEF Core 8与ASP.NET Core同生态模型驱动开发效率高数据库PostgreSQL 16性能稳定、JSONB支持好、免费无授权压力实体关系复杂时PG的约束和索引能力足够认证JWT (Microsoft.AspNetCore.Authentication.JwtBearer)前后端分离的标准方案无状态易扩展校验FluentValidation把参数校验从控制器里解放出来规则清晰可复用日志Serilog结构化日志按天滚动方便排查线上问题映射Mapster比AutoMapper轻量编译期映射性能更好小项目手写也行缓存Redis (StackExchange.Redis)用户令牌黑名单、验证码、热门字典数据前端Vue 3 Element Plus管理后台生态成熟如果你不想引前端工程也可以选Blazor Server部署Docker Nginx统一环境迁移服务器成本极低这套组合里我特别想强调PostgreSQL。很多人一开始会惯性选SQL Server或者MySQL。SQL Server固然和.NET配合无缝但如果你部署在Linux服务器上用Docker跑PostgreSQL反而更轻、更省内存。EF Core对PG的支持已经非常完善我项目里用到的所有查询、事务、JSON操作都没有碰到兼容性障碍。2.3 关于前端Blazor还是前后端分离如果你是一个人开发或者团队里全是.NET工程师Blazor Server可能是效率最高的方案——可以完全不用写JavaScript组件复用好SignalR帮你在服务端和浏览器之间做实时同步。但它有明显短板服务器内存占用偏高每个连接都需要维持一个SignalR通道。门店这种几十人同时操作的场景问题不大但如果以后要接电商面向公众的C端Blazor Server的并发能力就有点捉襟见肘了。我最终选了Vue 3 Element Plus做管理后台。原因有三一是Element Plus的表格、表单、树形控件在后台系统里太好用二是前后端分离后以后如果要扩展一个小程序端或者门店POS端API直接复用三是前端工程量可控一个资深的.NET开发者转型写Vue一两周就能上手。注意选型没有绝对正确只有是否适合你的团队。如果团队都是老.NET坚持上Vue反而可能拖慢进度如果团队没有专职前端Blazor Server绝对值得优先考虑。3. 项目分层与代码结构让业务膨胀后不失控的设计中型管理系统的最大敌人不是性能而是业务逻辑纠缠。今天加一个“采购单审核后不允许修改”明天加一个“商品停售后自动冻结库存”如果所有逻辑都堆在控制器里三个月后没人敢动这些代码。3.1 模块化单体比微服务现实得多的选择先摆结论这种体量的系统不要用微服务。微服务解决的问题独立伸缩、独立部署、故障隔离在这个场景里通通不存在。微服务引入的问题分布式事务、服务发现、链路追踪倒是实实在在的。我采用的结构是模块化单体(Modular Monolith)一个ASP.NET Core Web项目作为宿主业务按领域垂直切分每个领域内部有自己清晰的分层Furniture.sln ├── src/ │ ├── Furniture.Api // API宿主层控制器、过滤器、中间件 │ ├── Furniture.Application // 应用层用例、DTO、命令/查询处理 │ ├── Furniture.Domain // 领域层实体、值对象、领域服务 │ ├── Furniture.Infrastructure // 基础设施层EF Core、仓储实现、Redis │ └── Furniture.Shared // 共享层通用工具、常量、扩展方法 └── tests/ ├── Furniture.UnitTests └── Furniture.IntegrationTests注意这里的“领域层”并不是说我要搞一套完整的DDD战术模式聚合、事件溯源那些。我采用的是简化版DDD架构实体和业务规则放Domain数据库访问和外部服务放Infrastructure用例编排放Application。这样做的核心目的只有一个——核心业务规则不依赖数据库细节。3.2 项目的实际目录拆解拿库存域举例实际的代码组织方式是这样// Furniture.Api/Controllers/InventoryController.cs [ApiController] [Route(api/inventory)] [Authorize(Roles 仓库管理员,系统管理员)] public class InventoryController : ControllerBase { private readonly IInventoryService _inventoryService; public InventoryController(IInventoryService inventoryService) { _inventoryService inventoryService; } // POST api/inventory/stock-in 采购入库 [HttpPost(stock-in)] public async TaskIActionResult StockIn([FromBody] StockInRequest request) { var result await _inventoryService.StockInAsync(request.ToCommand()); return Ok(result); } }Controller很薄只做参数接收和结果返回。真正的业务规则比如“入库单关联的采购单必须处于审核通过状态”“入库数量不能超过采购单未入库数量”沉淀在InventoryService或领域实体里。这也是我调整过好几轮才稳定下来的风格。早期图省事直接把入库逻辑写在Controller里后来要承接盘点、调拨、出库多个入口时才发现很多公共逻辑写流水、更新库存快照根本没有复用点重构成本一下子就上来了。3.3 依赖注入的生命周期最容易出隐性Bug的地方ASP.NET Core的DI容器人人会用但生命周期用错是这个项目里另一个排查很久的坑。三个关键规则DbContext必须注册为Scoped。这是EF Core的硬性要求因为DbContext内部有ChangeTracker跨请求复用会带来脏数据和并发问题。自定义Service无状态时注册为Scoped或Transient都行。但如果Service内部使用了DbContext注册为Singleton会导致DbContext被捕获后续请求复用同一个DbContext直接踩雷。Singleton服务里不能注入Scoped服务。如果你写了一个缓存服务想同时操作数据库不能直接把DbContext构造函数注入进来必须注入IServiceScopeFactory在方法内部创建Scope。// 正确示范Singleton服务访问DbContext的方法 public class DataDictionaryCacheService : IDataDictionaryCacheService { private readonly IServiceScopeFactory _scopeFactory; public DataDictionaryCacheService(IServiceScopeFactory scopeFactory) { _scopeFactory scopeFactory; } public async Task RefreshCacheAsync() { using var scope _scopeFactory.CreateScope(); var db scope.ServiceProvider.GetRequiredServiceAppDbContext(); // 操作数据库... } }这类问题编译期完全不会报错只会线上偶发出现数据异常排查起来极其痛苦。所以我在项目启动阶段就定了个规矩每一个Service的注册生命周期必须注释说明理由。3.4 配置与选项模式别在代码里满天飞地读配置家具管理系统的配置项其实不少数据库连接串、Redis地址、JWT密钥、文件存储路径、库存预警阈值。如果每处都用IConfiguration直接读配置管理会迅速失控。我用的方案是选项模式把配置强类型化为Configuration POCO类// Furniture.Shared/Options/JwtOptions.cs public class JwtOptions { public const string SectionName Jwt; public string Issuer { get; set; } string.Empty; public string Audience { get; set; } string.Empty; public string SigningKey { get; set; } string.Empty; public int ExpiresInMinutes { get; set; } 120; } // 在Program.cs注册 builder.Services.ConfigureJwtOptions(builder.Configuration.GetSection(JwtOptions.SectionName));这样做的收益配置项有了编译期类型检查改动一个配置项时所有引用点都能通过IDE找到同时方便引入配置校验比如密钥长度不够时应用启动就报错而不是运行时才报错。4. 核心数据模型拆解家具的SKU、库存、订单到底怎么建表这是全项目最值得反复推敲的部分。我先把最终的实体关系框架画出来用文字描述再逐个解释关键设计。产品表 Product ├── 产品变体表 ProductVariant (一个产品多个变体对应一个可售SKU) │ ├── 变体属性值表 VariantAttributeValue (存储颜色、材质、尺寸等) │ ├── 套件明细表 BundleItem (如果是套件存储子件SKU及数量) │ └── 批次库存表 StockBatch (每批采购入库形成一条记录) └── 供应商表 Supplier 采购单 PurchaseOrder → 采购单明细 PurchaseOrderItem → 采购入库单 StockInOrder 销售订单 SalesOrder → 销售订单明细 SalesOrderItem → 销售出库单 StockOutOrder 库存流水表 StockMovement (所有出入库都记录此表这是库存系统的账本) 仓库表 Warehouse 客户表 Customer4.1 商品中心产品/变体/属性三层模型的C#实现我从第一版“不同颜色桌子建不同商品”的教训中走出来后最终实体落地成这样public class Product { public Guid Id { get; set; } public string Name { get; set; } string.Empty; // 产品名如“北欧实木餐桌” public string? Description { get; set; } public ProductStatus Status { get; set; } // 草稿/上架/下架 public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public ICollectionProductVariant Variants { get; set; } new ListProductVariant(); } public class ProductVariant { public Guid Id { get; set; } public Guid ProductId { get; set; } public Product? Product { get; set; } public string SkuCode { get; set; } string.Empty; // SKU编码如 TBL-1400-WALNUT public string? Barcode { get; set; } public decimal SalePrice { get; set; } public decimal? CostPrice { get; set; } // 参考成本价实际成本按批次算 public bool IsBundle { get; set; } // 是否套件 public bool IsActive { get; set; } public ICollectionVariantAttributeValue AttributeValues { get; set; } new ListVariantAttributeValue(); public ICollectionBundleItem BundleItems { get; set; } new ListBundleItem(); } public class VariantAttributeValue { public Guid Id { get; set; } public Guid VariantId { get; set; } public ProductVariant? Variant { get; set; } public string AttributeName { get; set; } string.Empty; // 属性名颜色 public string AttributeValue { get; set; } string.Empty; // 属性值胡桃色 }属性值我没有做成独立的属性字典表而是直接用字符串冗余在变体上。原因在于家具管理系统的属性查询需求不复杂也不需要跨属性做复杂的规格筛选。如果以后要做“按材质颜色价格区间筛选商品”的C端商城再引入属性字典表也不迟。先保证主流程简单别过度设计。4.2 库存系统为什么“流水表”才是库存的灵魂家具行业的库存账和财务账紧密挂钩所以库存不能只记一个总数。我的设计里有两个关键表当前库存表(StockLevel)和库存流水表(StockMovement)。StockLevel记录每个仓库下每个SKU变体的当前可用数量和锁定数量。StockMovement记录每一次库存变动的明细类型、关联单号、变动前、变动后、发生时间、操作人。任何库存变动必须同时更新这两张表并且必须在同一个数据库事务里完成。有人会问既然每次变动都记录了流水那库存总数不是可以重算吗干嘛还要留一张StockLevel纯粹从数据完整性角度确实可以通过流水重算库存。但实际业务中库存数量被高频读取门店开单要实时看库存每次查询都聚合流水表是一个灾难级性能问题。所以保留冗余的库存快照表流水表作为审计和追溯依据是最务实的方案。public class StockMovement { public Guid Id { get; set; } public Guid VariantId { get; set; } public ProductVariant? Variant { get; set; } public Guid WarehouseId { get; set; } public StockMovementType Type { get; set; } // 采购入库/销售出库/盘点/调拨/退货入库... public int QuantityChanged { get; set; } // 正数入库负数出库 public int StockBefore { get; set; } public int StockAfter { get; set; } public string ReferenceNo { get; set; } string.Empty; // 关联业务单号如采购单号PO20250101001 public string? Remark { get; set; } public DateTime CreatedAt { get; set; } public Guid CreatedBy { get; set; } }记录StockBefore和StockAfter是个很有用的细节。排查库存异常时直接看流水里的前后值就能判断哪一笔操作写错了而不用拿着数量变化自己推算。4.3 成本核算批次库存与移动加权平均家具行业的采购价格是波动的同一款餐桌1月份从厂家A进货是800元一张3月份从厂家B进货是850元一张。如果系统里只存一个成本价字段月底核算毛利时完全对不上。我在项目中引入了批次库存表public class StockBatch { public Guid Id { get; set; } public Guid VariantId { get; set; } public Guid WarehouseId { get; set; } public Guid SupplierId { get; set; } public decimal UnitCost { get; set; } // 本批次采购单价 public int RemainingQuantity { get; set; } // 本批次剩余数量 public DateTime StockInDate { get; set; } }每次采购入库时按SKU供应商批次生成一条批次记录销售出库时按先进先出原则FIFO从最早的批次扣减数量并用扣减的批次成本计算本次销售成本。这套逻辑听起来不复杂但代码实现要仔细尤其是“一个批次不足、从多个批次扣减”的情况。我用了一个独立的CostCalculationService来集中处理成本计算和批次扣减业务入口销售出库、退货入库都调用它避免成本逻辑散落到多个Controller或Service里。4.4 EF Core 8的映射新特性JSON列让属性扩展不再痛苦家具商品属性的一个麻烦是不确定字段。比如餐桌有桌面材质沙发有填充物类型床有尺寸规格每个品类的特有属性各不相同。如果每个属性都建一张表表结构会非常僵化。EF Core 8的JSON列映射在这里派上了用场。PostgreSQL本身有JSONB类型EF Core 8可以原生支持将某个对象属性映射为JSON列public class ProductVariant { // 其他属性... public Dictionarystring, string ExtraAttributes { get; set; } new(); } // DbContext 配置 modelBuilder.EntityProductVariant() .OwnsOne(p p.ExtraAttributes, owned { owned.ToJson(); // 映射为JSONB列 });这样挂灯的商品可以存光源类型: LED色温: 3000K沙发的商品可以存填充物: 高回弹海绵框架: 实木。业务上需要精确检索某个属性时PostgreSQL的JSONB查询能力也足够。4.5 数据库索引与并发控制让老板的报表和大宗操作不“打架”索引设计库存流水表按(VariantId, CreatedAt)建复合索引支撑“查看某SKU的历史出入库”销售订单表按(CustomerId, CreatedAt)建索引支撑“查看客户历史订单”报表相关的聚合查询尽量走覆盖索引。并发控制下单扣库存时直接用UPDATE语句带条件原子更新避免并发超卖var affected await _db.StockLevels .Where(s s.VariantId variantId s.WarehouseId warehouseId s.AvailableQuantity requestedQuantity) .ExecuteUpdateAsync(setters setters .SetProperty(s s.AvailableQuantity, s s.AvailableQuantity - requestedQuantity) .SetProperty(s s.LockedQuantity, s s.LockedQuantity requestedQuantity));ExecuteUpdateAsync是EF Core 7开始提供的高效更新方法它会生成一条UPDATE语句并且通过WHERE条件天然实现乐观锁效果。如果affected 0说明库存不足或有并发冲突直接返回“库存不足”即可。5. 进、销、存三大核心业务链路的代码落地模型设计是图纸业务链路才是施工。这一节挑三条最核心的链路讲清楚它们的代码实现和容易踩的坑。5.1 采购入库链路从采购单到入库单到批次入库完整链路是创建采购单 → 提交审核 → 审核通过 → 到货验收 → 生成入库单 → 点击入库 → 增加当前库存 → 生成批次 → 写库存流水。每一步之间都有状态流转。public class InventoryService : IInventoryService { private readonly AppDbContext _db; public async TaskResult StockInAsync(StockInCommand command) { // 1. 校验采购单状态必须是审核通过 var po await _db.PurchaseOrders .Include(p p.Items) .FirstOrDefaultAsync(p p.Id command.PurchaseOrderId); if (po null || po.Status ! PurchaseOrderStatus.Approved) return Result.Fail(采购单不存在或未审核通过); // 2. 校验入库数量不能超过未入库数量 var alreadyStockedIn await _db.StockInOrders .Where(s s.PurchaseOrderId po.Id) .SelectMany(s s.Items) .SumAsync(it it.Quantity); var totalOrderQty po.Items.Sum(it it.Quantity); if (alreadyStockedIn command.TotalQuantity totalOrderQty) return Result.Fail(入库数量超过采购单剩余可入库数量); // 3. 使用事务执行多步写入 await using var transaction await _db.Database.BeginTransactionAsync(); // 3.1 创建入库单 var stockInOrder new StockInOrder { ... }; _db.StockInOrders.Add(stockInOrder); // 3.2 逐明细生成批次 更新当前库存 写流水 foreach (var item in command.Items) { var batch new StockBatch { Id Guid.NewGuid(), VariantId item.VariantId, WarehouseId command.WarehouseId, SupplierId po.SupplierId, UnitCost item.UnitCost, RemainingQuantity item.Quantity, StockInDate DateTime.UtcNow }; _db.StockBatches.Add(batch); // 更新或插入当前库存 var level await _db.StockLevels .FirstOrDefaultAsync(s s.VariantId item.VariantId s.WarehouseId command.WarehouseId); if (level null) { _db.StockLevels.Add(new StockLevel { ... AvailableQuantity item.Quantity }); } else { level.AvailableQuantity item.Quantity; } // 记录流水 _db.StockMovements.Add(new StockMovement { VariantId item.VariantId, WarehouseId command.WarehouseId, Type StockMovementType.PurchaseIn, QuantityChanged item.Quantity, StockBefore level?.AvailableQuantity ?? 0, StockAfter (level?.AvailableQuantity ?? 0) item.Quantity, ReferenceNo stockInOrder.OrderNo, CreatedAt DateTime.UtcNow, CreatedBy command.OperatorId }); } // 3.3 更新采购单入库状态 po.Status PurchaseOrderStatus.StockedIn; await _db.SaveChangesAsync(); await transaction.CommitAsync(); return Result.Ok(); } }这段代码里有几个细节值得单独说明整条链路必须包在显式事务里。EF Core的SaveChangesAsync默认是一个隐式事务但这里涉及多张表的多次写入任何一个环节失败都会导致数据不一致比如入库单建了库存没加上。显式事务保证要么全部成功要么全部回滚。入库单号要业务化、可读化。比如SI20250101001这种不能直接用数据库自增ID。业务员看到单号就知道是2025年1月1日的第几张入库单排查问题会方便很多。5.2 销售出库链路库存预占与确认扣减的双阶段设计销售出库我采用了一个很关键的设计先锁库存后实际扣减。门店销售员在开单时系统先尝试锁定库存上面提到的ExecuteUpdateAsync条件更新。订单提交后锁定库存变成“已锁定”状态。仓库发货时执行出库操作把已锁定库存真正扣掉。如果订单取消则释放锁定库存。这里的核心好处是开单时就能即时告知客户“库存够不够”而不是仓库发货时才发现缺货再回头通知客户。这在门店场景中体验差别巨大。public class SalesOrderService : ISalesOrderService { public async TaskResult LockStockAsync(Guid orderId) { var order await _db.SalesOrders .Include(o o.Items) .FirstOrDefaultAsync(o o.Id orderId); if (order null || order.Status ! SalesOrderStatus.Pending) return Result.Fail(订单不存在或状态不允许锁库); // 逐行锁定库存任何一个失败则整单失败 foreach (var item in order.Items) { var affected await _db.StockLevels .Where(s s.VariantId item.VariantId s.WarehouseId order.WarehouseId s.AvailableQuantity item.Quantity) .ExecuteUpdateAsync(setters setters .SetProperty(s s.AvailableQuantity, s s.AvailableQuantity - item.Quantity) .SetProperty(s s.LockedQuantity, s s.LockedQuantity item.Quantity)); if (affected 0) { // 回滚之前已锁定的库存 await ReleaseLockedStockAsync(orderId); return Result.Fail($SKU {item.SkuCode} 库存不足); } } order.Status SalesOrderStatus.StockLocked; await _db.SaveChangesAsync(); return Result.Ok(); } }这里还有一个需要注意的点套件商品锁库存时要递归锁定子件。一个“一桌四椅”的套件下单锁的不是套件本身的库存套件没有独立物理库存而是1个餐桌的库存和4把椅子的库存。我在LockStockAsync里对IsBundle的SKU做了递归展开处理这部分的单元测试写得最全因为最容易被业务方改来改去。5.3 盘点与调拨容易让库存账对不上的两个操作盘点仓库实际数量与账面数量有差异时通过盘点单调整。盘盈做入库调整盘亏做出库调整。关键点在于盘点期间库存可能还会变动所以盘点单通常会有一个“冻结时间点”只认可冻结时刻的账面数量之后的出入库不在本次盘点范围内。调拨A仓库调到B仓库涉及A仓出库减少、B仓入库增加、调拨在途三种状态。对于门店系统我采取了简化方案调拨单审核后A仓立刻出库B仓接收到货后确认入库。中间不搞“在途库存”这个状态把业务复杂度压在最低。盘点实现里有一个值得记录的教训盘亏出库也必须走库存流水。很多开发者在盘点时直接UPDATE库存数量不走流水结果就是库存表改动没有任何审计轨迹财务要求追溯时就傻眼了。所以盘盈盘亏我同样生成StockMovement记录类型标为StocktakeAdjustment流水里记录“账面数、实盘数、调整数字”。5.4 报表统计老板要的是毛利不是一堆数字报表中心是整个系统里看起来最简单、实际最难的部分。“本月销售额”谁都会写但“本月毛利”就涉及到销售出库成本和采购成本的对账。老板真正关心的报表有这么几张报表核心口径进销存汇总表期初库存 本期入库 - 本期出库 期末库存按SKU维度销售毛利表销售收入 - 销售出库成本按FIFO批次成本计算库存周转表出库成本 / 平均库存金额反映资金占用效率供应商采购汇总按供应商统计采购金额、到货及时率有采购单和入库单日期可以算这些报表如果用EF Core做复杂分组查询会很别扭我实际采用的是Dapper SQL视图的组合数据库里建好报表视图代码里用Dapper执行查询并映射到DTO。EF Core管业务写入Dapper管报表读取各司其职。public class ReportQueryService { private readonly string _connectionString; public async TaskIEnumerableInventorySummaryDto GetInventorySummaryAsync(DateTime start, DateTime end) { const string sql SELECT v.SkuCode, p.Name AS ProductName, COALESCE(SUM(CASE WHEN m.Type IN (PurchaseIn, ReturnIn, StocktakeIn) THEN m.QuantityChanged ELSE 0 END), 0) AS InQuantity, COALESCE(SUM(CASE WHEN m.Type IN (SaleOut, ReturnOut, StocktakeOut) THEN -m.QuantityChanged ELSE 0 END), 0) AS OutQuantity FROM StockMovements m JOIN ProductVariants v ON m.VariantId v.Id JOIN Products p ON v.ProductId p.Id WHERE m.CreatedAt start AND m.CreatedAt end GROUP BY v.Id, p.Id ORDER BY v.SkuCode; using var connection new NpgsqlConnection(_connectionString); return await connection.QueryAsyncInventorySummaryDto(sql, new { start, end }); } }提醒报表的SQL一定要在数据量增长之前就优化到位。家具管理系统虽然不像电商那样动辄百万订单但库存流水表一年下来几十万条很常见。如果报表查询没有索引支撑老板一查上月报表数据库CPU直接拉满前台开单就会卡。6. 用户与权限一张登录背后的认证授权设计管理系统的权限设计直接关系到门店的规范经营。我采用了经典的RBAC模型用户 → 角色 → 权限点菜单权限 按钮权限。6.1 为什么用JWT而不是Cookie认证前后端分离架构下JWT是事实标准。它的好处是服务端无状态不需要维护Session登录成功后前端拿到Token后续请求通过Authorization: Bearer token头携带即可。但这个项目里我额外做了一些JWT的安全加固Token有效期设短2小时同时引入刷新令牌(Refresh Token)避免用户频繁登录。密码哈希使用PBKDF2通过ASP.NET Core自带的PasswordHasher实现不存明文。退出登录时将Token加入Redis黑名单剩余有效期内的Token立即失效。这弥补了JWT“无法主动失效”的天然短板。6.2 权限数据表设计与按钮级权限控制权限模型涉及四张表Users、Roles、Permissions、UserRoleRolePermission中间表。public class Permission { public Guid Id { get; set; } public string Code { get; set; } string.Empty; // 如 product:add public string Name { get; set; } string.Empty; // 如 新增商品 public string Module { get; set; } string.Empty; // 所属模块 }按钮级权限的实现方式后端在接口上加[Authorize(Policy product:add)]策略前端根据用户权限列表动态控制按钮显隐。两件事都很重要——前端的按钮隐藏只是体验优化真正的安全边界永远在后端校验。6.3 ASP.NET Core 8中的JWT配置实测代码// Program.cs 认证与授权配置 var jwtOptions builder.Configuration.GetSection(JwtOptions.SectionName).GetJwtOptions(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer jwtOptions.Issuer, ValidateAudience true, ValidAudience jwtOptions.Audience, ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization(options { // 策略与权限点映射 options.AddPolicy(product:add, policy policy.RequireAssertion(ctx ctx.User.IsInRole(系统管理员) || ctx.User.HasClaim(Permission, product:add))); });ClockSkew这里我特意调成了30秒比默认的5分钟短很多。JWT服务的机器时钟同步正常的情况下30秒的偏移容忍已经足够还能减少Token过期后仍被接受的窗口期。6.4 登录安全限流、锁定、审计一个都不能少我在登录接口上做了三个防护措施登录失败限流同一IP 5分钟内最多试10次同一用户名连续失败5次锁定15分钟。操作日志审计记录所有敏感操作删除商品、调整库存、改权限的操作人、时间、IP和请求数据。领导问起来“这个库存为什么少了”打开日志一查便知。安全响应头开启X-Content-Type-Options、X-Frame-Options等基础安全头用几个中间件就能全局加上成本极低。7. 性能、托管与部署2核4G服务器上的实战检验开发环境下一切都很流畅一部署到生产环境就开始原形毕露。这个项目在性能方面踩了几个真实的坑值得写下来。7.1 EF Core查询性能三个最常见的坑坑一Select N1问题。列表页加载商品时每行商品还附带查一次库存和分类信息100个商品就变成1 100 100次查询。解决办法是Include或者直接Select投影成DTO一次查询把所有需要的数据查出来。坑二默认追踪查询的额外开销。查询只是展示数据、不需要修改时一律.AsNoTracking()。EF Core的ChangeTracker在追踪状态下会为每个实体生成快照几百行数据感觉不到几万行数据就是明显的性能差异。坑三大列表分页用Skip/Take的深分页性能问题。Skip(100000).Take(20)会让数据库扫到第100020行才停越翻越慢。小管理系统数据量一般不会走到这一步但如果你的老板特别爱看历史流水提前准备一套基于游标的分页方案也不亏。7.2 Redis缓存小系统也要有缓存思维家具管理系统里用户权限、数据字典、仓库列表这类数据被高频读取但很少变动是理想的缓存对象。我用Redis缓存了以下内容用户权限集合登录成功后一次性加载到Redis后续权限判断不再查数据库。数据字典品牌、材质、颜色、仓位类型缓存后查询性能提升明显。库存预警阈值配置。缓存的难点是失效时机。我的策略是在业务Service里所有涉及“修改字典”“修改权限”的操作在事务提交成功后主动删除对应缓存Key下次读取时再回源数据库加载。这和Cache-Aside模式一致实现简单、可控性强。7.3 Docker Compose一键部署把环境折腾降到最低我在项目根目录放了一个docker-compose.yml一次性启动API、PostgreSQL、Redis三个服务version: 3.8 services: api: build: context: . dockerfile: src/Furniture.Api/Dockerfile ports: - 8080:8080 environment: ConnectionStrings__DefaultConnection: Hostdb;Port5432;Databasefurniture;Usernameapp;Passwordsecret Redis__ConnectionString: redis:6379 depends_on: - db - redis restart: unless-stopped db: image: postgres:16-alpine environment: POSTGRES_DB: furniture POSTGRES_USER: app POSTGRES_PASSWORD: secret volumes: - pgdata:/var/lib/postgresql/data ports: - 5432:5432 redis: image: redis:7-alpine ports: - 6379:6379 volumes: pgdata:Dockerfile我单独说一句多阶段构建是必须的发布镜像里只包含publish输出体积从SDK镜像的几百MB降到几十MB部署拉取时间显著缩短。# 构建阶段 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet publish src/Furniture.Api/Furniture.Api.csproj -c Release -o /app/publish # 运行阶段 FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY --frombuild /app/publish . EXPOSE 8080 ENTRYPOINT [dotnet, Furniture.Api.dll]7.4 日志与监控出事的时候能救命Serilog配置上结构化日志所有业务操作尤其是库存变动都要记录关键业务字段。线上出问题时我第一件事就是打开日志系统按时间、操作人、单号过滤排查。同时配了健康检查接口/health在Docker Compose里可以用它做容器健康状态判断。服务器上再配一个简单的脚本每5分钟检查一次API存活情况挂了就自动重启容器。对中小型系统的运维来说这种轻量级的监控在投入产出比上刚刚好。8. 给打算开坑的人几个绕不开的注意事项整个项目从设计到上线走了不少弯路。把这些经验浓缩成几条给准备动手的朋友做个参考。8.1 数据库迁移策略别在生产环境乱来EF Core的迁移一定要纳入版本管理发布时通过自动迁移或者dotnet ef database update执行。我个人的建议是测试环境自动迁移生产环境由运维手动执行升级脚本。家具管理系统的表结构会随着业务迭代频繁变化手动把控生产变更步骤比让应用启动时自动建表改表安全得多。8.2 所有的时间字段一律存UTC这是分布式系统的常识但单体系统容易忽略。门店在不同时区虽然不多但日期转换的坑一旦埋下排查时极其隐蔽。我的做法是实体里所有DateTime用UTC存储API输出时统一转成北京时间东八区。前端的显示格式由前端控制后端始终给标准时间。8.3 软删除与唯一索引的冲突处理实体上加IsDeleted字段做软删除很常见但一旦和唯一索引比如SKU编码唯一相遇就会出问题删除一个SKU后想重新创建一个相同编码的SKU会被唯一索引挡住。解决方案有两个一是唯一索引带上IsDeleted条件PostgreSQL支持部分唯一索引二是删除时给SKU编码加前缀比如DEL20250101-TBL-1400。我选了后者实现简单而且保留了历史数据可追溯性。8.4 前端权限控制不能替代后端校验后台菜单隐藏、按钮灰置这些只是用户体验优化。真正要防的是绕过前端直接调API接口。所以后端每个接口都必须有对应的授权策略库存调整、价格修改这类操作还要额外做操作日志。安全这种事不能赌别人的技术能力。8.5 备份与恢复可以不备份但不能不演练数据库每天凌晨自动备份保留最近30天。但比备份更重要的事情是恢复演练。我见过太多系统备份文件一直有真到恢复的时候才发现备份文件是坏的、或者恢复步骤没人会。建议每季度做一次“模拟服务器烧毁从备份恢复”的演练半小时就能完成但能省掉的灾难远不止半小时。8.6 和业务方的沟通定义清楚“库存”的口径最后一条不是技术但比任何技术都重要。在家具管理系统里“库存”这个词在不同角色嘴里意思是不同的门店店员门店货架上有多少货。仓库管理员仓库里验收入库的货。财务付过款、有成本归属的货。老板整个公司能卖、能调拨的所有货。如果系统设计阶段没有跟业务方统一这些口径后面报表对不上、门店抱怨“明明有货系统说没货”你就会陷入无穷无尽的需求澄清会议中。我在项目启动阶段花了两周做业务调研其中一半时间就是在抠这些概念的范围和规则这笔时间花得值。写在最后的一点体会做管理系统的项目技术选型重要但从来不是决胜点。ASP.NET Core 8.0给了你一个稳定、高效、生态完善的基础设施真正决定项目成败的是对业务的理解深度以及建模时有没有把家具这类行业的特殊规则消化清楚。SKU的维度、批次的成本、库存的流水、权限的粒度、报表的口径每个决策背后都是业务方真实的管理诉求。如果你照着这篇文章的技术思路去搭一套大概率不会踩到特别离谱的坑。但有一点我必须提醒永远不要照搬别人的表结构。不同企业的采购流程、库存规则、销售打法各不相同把别人的模型生搬硬套过来表面上省了设计时间后面改起来会加倍偿还。拿这篇文章当参考框架结合自己客户的业务细节做调整才是正路。本文还有配套的精品资源点击获取
返回列表