Blazor WASM与.NET 6进销存系统实战:从实时库存到Docker部署
2026/9/16 7:03:50 网站建设 项目流程

简介:基于微软Blazor框架与.NET 6.0的新零售快消进销存系统源码包,主要面向.NET开发者和进销存系统学习者,提供一套以Dorisoy.POS为核心的完整跨平台Web应用工程,可帮助理解组件化界面设计、前后端分离思想以及快消行业的进销存业务流程。压缩包共包含951个文件,其中样式表有258个scss和82个css文件,逻辑脚本有161个js文件,C#源码有154个cs文件,界面主要由83个razor组件构成,另有少量图片、字体和数据库脚本,整体大小28.15MB,目录结构较规整。已有406人学习浏览,适合想掌握Blazor客户端交互、Entity Framework Core数据访问和.NET 6.0依赖注入、安全认证等机制的读者。通过源码可以研读商品管理、库存变动、采购销售、促销策略和会员管理等模块的具体实现,还能学习Razor组件间通信、全局状态同步、JWT或OAuth2授权以及容器化部署方式,对搭建高性能、可扩展的新零售业务系统有直接参考价值。

1. 收银台后台的实时库存:Blazor 与 .NET 6.0 进销存系统的现场视角

新零售快消门店的收银故障往往不是系统崩溃,而是点单时发现库存数据还是昨晚的。传统 Web 后端在每次收银、退货、盘点时都要全页面刷新或频繁拉扯服务端状态,终端设备多了以后服务器压力和服务中断问题很快暴露。Dorisoy.POS 这套基于 Blazor WASM 的进销存源码,把商品、库存、订单、会员和供应商管理全部跑在浏览器端渲染的交互组件上,服务端只承担 API 与业务校验,库存变动即时可见,离线弱网环境下仍然能完成扫码、开单、结账动作。对于正在评估 Blazor 是否足够成熟、或者想复用一个零售进销存底座的中高级 .NET 工程师,这套源码提供的不是 Demo,而是一套完整的端到端业务闭环。

2. Blazor WASM 托管模型与 Dorisoy.POS 工程分层

2.1 为什么这套系统选择了 WASM 而不是 Server 模式

Blazor 有两种托管模型。Server 模式下,Razor 组件在服务端渲染,UI 更新通过 SignalR 推送到浏览器,每个交互都要经过网络往返。在门店收银这种高频率、多人并发的局域网或低带宽场景,SignalR 的带宽占用和服务端压力会随在线收银台数量线性膨胀;网络抖动时 UI 直接无响应,收银员最不能接受的就是系统突然卡一下。

WASM 模式把 .NET 运行时编译后下载到客户端,在浏览器的 WebAssembly 沙箱里执行组件逻辑,UI 更新完全是本地绘制,只有业务数据才走 HTTP 请求。Dorisoy.POS 选择 WASM,本质上是把渲染压力从服务器搬到客户端,让服务端回归到纯粹的 API 网关与数据库操作职责。代价是首次加载需要下载 .NET WebAssembly 运行时和程序集,这在门店局域网内几乎不是问题。

对比维度Blazor WASMBlazor Server
UI 渲染位置浏览器本机服务器
网络依赖只在 API 请求时每次交互都要 SignalR
服务器负载低(只处理 API)高(维护所有连接状态)
首屏加载稍慢(下载运行时加程序集)快(几乎秒开)
离线容错可以部分离线断网即断线

表里的结论可以用一句话概括:门店进销存要求的是稳定、响应快、抗并发,WASM 牺牲首屏换取的是长期稳定性和更低的服务器开销,这对快消零售来说是划算的买卖。

2.2 从 .csproj 出发梳理 Client、Server、Shared 三层结构

打开源码先看解决方案结构,Dorisoy.POS 的 DCMS.SE 工程里,典型的 Blazor WASM 项目会包含三个职责明确的工程:Shared 存放 DTO、枚举和跨端共享的校验逻辑,比如销售单的 SalesOrderDto、库存事务的 TransactionType 枚举;Server 是 ASP.NET Core 6 Web API 宿主,负责 EF Core 数据访问、JWT 签发与验证、业务服务注册;Client 是 Blazor WASM 前端工程,包含页面、组件、服务类和模型。

Server 和 Shared 的引用关系需要特别留意:Client 不应该直接引用 Shared 以外的程序集,否则会把数据库上下文等服务端代码打进浏览器包。源码里通过项目引用来限定边界:

<ProjectReference Include="..\Shared\DCMS.SE.Shared.csproj" />

这样引用关系收敛在 Shared 一层,发布时 Client 的 wwwroot 体积可控,也避免了 Blazor WASM 加载 EF Core 原生数据库驱动这类运行时问题。如果后续要拆微服务,这个边界就是天然的服务划分依据:Shared 里只放契约,Server 里的仓储和业务服务按领域拆开即可。

2.3 obj 目录下那批 .cache 文件:增量编译告诉你的事

解压源码后很多人直接找 .sln,容易忽略 obj 目录里那些 .cache 和 .bin 产物。这些文件不是业务代码,但对排查构建问题很有参考价值:

文件作用异常含义
_IsIncrementalBuildMSBuild 标记本次是否走增量编译文件缺失或内容异常,说明上次构建失败
DCMS.SE.csproj.BuildWithSkipAnalyzers指示跳过静态分析器构建步骤存在不代表代码无警告,只是加速构建
fileList.bin记录上次增量编译输出的文件清单与当前 obj 不一致时触发全量重建
DCMS.SE.AssemblyReference.cache程序集引用哈希缓存引用的 NuGet 包变动后该缓存失效
DCMS.SE.assets.cacheproject.nuget.cacheNuGet 还原资产状态记录删除后执行 dotnet restore 自动重建
DCMS.SE.RazorAssemblyInfo.cacheRazor 组件编译时间戳修改 .razor 不生效时先看它
DCMS.SE.MvcApplicationPartsAssemblyInfo.cacheASP.NET Core MVC 应用部件发现缓存新增 Controller 后 Swagger 看不到,先查它

这些缓存文件的核心逻辑是:MSBuild 根据源文件时间戳、引用程序集哈希、Razor 文件修改时间做对比,任何一个输入发生变化,对应的 cache 就会失效。如果你改了 .razor 页面运行后没变化,多半是 RazorAssemblyInfo.cache 没识别到修改,删掉它重新 build 即可。日常开发中不要把这些文件提交到 Git,它们只是本机增量编译的临时状态,提交只会让团队里每个人收到一堆冲突。

3. EF Core 映射进销存数据关系:从实体设计到库存事务

3.1 商品、批次与库存数量:账、物、额对应的建模思路

进销存系统的数据建模核心不是订单表,而是怎么把每次进销存的变动留下痕迹。Dorisoy.POS 里商品与库存的关系,可以用三张表来表达:Product 是商品主数据,保存名称、条码、类别、品牌、售价、进价和默认供应商;Stock 保存当前可用量、锁定量和安全库存阈值;StockTransaction 记录每一笔出入库动作的来源单据、批次号和变动数量。

库存量不冗余在 Product 上,而是通过流水累计,这是仓库核算的基本纪律。账面库存来自流水累加,实际库存来自盘点结果,两者的差异由盘点单据冲平。这样设计的好处是每一笔错误的库存变动都能追溯到原始单据,不至于出现"库存突然涨了 10 件但没人知道是谁加的"。实体关系用 Fluent API 配置时,常见做法是对 Stock 建联合唯一索引:

builder.Entity<Stock>() .HasIndex(s => new { s.ProductId, s.WarehouseId }) .IsUnique();

联合唯一索引从数据层面兜底"一仓一商品只有一条当前库存"的约束。如果业务按批次管理,索引维度则扩展为(ProductId, BatchNo, WarehouseId),批次表还要记录生产日期和保质期——快消品做促销时按保质期先进先出(FEFO)是很常见的需求。

3.2 用 LINQ 算可用库存:不看字段,看流水

确定了流水累加的思路后,可用库存的查询直接在 StockTransaction 上做聚合。下面的方法从仓储层返回某个商品在某仓库的可用量:

public async Task<decimal> GetAvailableQuantityAsync(int productId, int warehouseId) { // 入库与盘点调整为正向数量 var inflow = await _db.StockTransactions .Where(t => t.ProductId == productId && t.WarehouseId == warehouseId && (t.TransactionType == StockTransactionType.PurchaseIn || t.TransactionType == StockTransactionType.Adjust)) .SumAsync(t => t.Quantity); // 销售出库与订单锁定都视为占用 var outflow = await _db.StockTransactions .Where(t => t.ProductId == productId && t.WarehouseId == warehouseId && (t.TransactionType == StockTransactionType.SaleOut || t.TransactionType == StockTransactionType.Locked)) .SumAsync(t => t.Quantity); return inflow - outflow; }

参数说明:PurchaseInAdjust增加可用量,SaleOutLocked减少可用量,两次聚合相减得到最终可用数。拆成两个SumAsync比用一个 GroupBy 加条件累加更稳妥,因为后者在 EF Core 表达式树里容易生成翻译受限的查询,两段聚合的 SQL 在几万行流水级别性能差异可以忽略。

需要提醒的是,这个查询适合数据量在几十万条流水以内的门店场景;流水过了百万后,每次查询现算就不合适了。常见做法是引入每日汇总表(DailyStockSnapshot),按商品和仓库维度预聚合当日发生额,报表和可用量查询走快照表,流水表只负责审计回溯。

3.3 扣减库存与生成销售单:让写入变成原子操作

进货、销售、退货、盘点都不是孤立的数据库操作,每一单都涉及减少库存、插入流水、更新记账等多个写入,必须用数据库事务包起来,任何一步失败都要回滚。以下是一次销售开单的扣减逻辑:

await using var tx = await _db.Database.BeginTransactionAsync(); try { var stock = await _db.Stocks.FirstOrDefaultAsync( s => s.ProductId == input.ProductId && s.WarehouseId == input.WarehouseId); if (stock is null || stock.AvailableQuantity < input.Quantity) throw new BusinessException($"库存不足,当前可用量:{stock?.AvailableQuantity ?? 0}"); stock.AvailableQuantity -= input.Quantity; _db.StockTransactions.Add(new StockTransaction { ProductId = input.ProductId, WarehouseId = input.WarehouseId, TransactionType = StockTransactionType.SaleOut, Quantity = input.Quantity, RefOrderNo = input.SaleOrderNo, CreatedAt = DateTime.Now }); await _db.SaveChangesAsync(); await tx.CommitAsync(); } catch { await tx.RollbackAsync(); throw; }

事务在这里保证了两点:并发下两个收银台同时扣减同一商品时,数据库的行锁让后一个请求必须等待前一个事务提交;写入过程中任何异常都会回滚,不会出现库存扣了但订单没生成的脏数据。

但这里仍有一个隐藏风险:如果两个请求并发执行FirstOrDefaultAsync读到同一个旧值,然后同时扣减,最终库存可能不准。应对方式有两层,第一层是给 Stock 实体加 rowversion:

public byte[] RowVersion { get; set; } builder.Entity<Stock>() .Property(s => s.RowVersion) .IsRowVersion();

SaveChangesAsync会生成带WHERE RowVersion = @originalVersion的 UPDATE,有人先提交后,后提交的人会抛DbUpdateConcurrencyException。第二层是捕获这个异常后刷新实体原值,把最新的库存数量展示给收银员确认,而不是静默重试覆盖。

注意:rowversion 冲突后的正确姿势是重新读取并让用户决定是否继续,直接重试同一事务在库存扣减场景有超卖和重复下单两个风险。

4. Blazor 组件通信、JWT 认证与权限边界

4.1 EventCallback 与服务注入:组件间数据流的两种姿势

Blazor 组件的数据流与 Vue/React 有区别:没有全局响应式代理,组件之间的数据传递靠参数绑定和事件回调。父子组件通信用[Parameter]EventCallback最直观。例如销售单页面里有一个商品选择子组件,选中商品后要把数据传回父组件生成订单明细:

// ProductSelector.razor.cs [Parameter] public EventCallback<ProductSelectedEventArgs> OnProductSelected { get; set; } private async Task HandleSelect(Product product) { // 组装选中的商品和当时的促销价格 var args = new ProductSelectedEventArgs { ProductId = product.Id, Sku = product.Sku, Quantity = 1, UnitPrice = product.SalePrice, PromotionId = await GetActivePromotionAsync(product.Id) }; await OnProductSelected.InvokeAsync(args); }

EventCallbackInvokeAsync会自动通知父组件重新渲染,框架会捕获回调中的异常并显示在错误边界内。另一种姿势是把共享业务逻辑放进注入的服务类里,组件只依赖服务接口。比如购物车这种跨页面状态就应该放进CartService,而不是用一堆EventCallback逐层向上传递。判断标准很简单:只影响父组件 UI 的数据用 EventCallback,影响多个页面或需要持久化的状态用注入服务,这是两个层级的复用。

4.2 HttpClient 调用 API:Token 挂载与认证状态通知

WASM 模式下浏览器不允许自动携带跨域 Cookie,JWT 是主流认证方式。核心步骤是:登录接口返回 token,前端写入 localStorage,之后每次请求在请求头手动挂载Bearer token。在 Blazor WASM 里封装一个 ApiService 比较稳妥:

public class ApiService { private readonly HttpClient _http; private readonly ILocalStorageService _storage; public ApiService(HttpClient http, ILocalStorageService storage) { _http = http; _storage = storage; } public async Task<HttpResponseMessage> GetAsync(string url) { var token = await _storage.GetItemAsStringAsync("access_token"); var req = new HttpRequestMessage(HttpMethod.Get, url); if (!string.IsNullOrEmpty(token)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return await _http.SendAsync(req); } }

注意这里不是把 token 直接放在 HttpClient 的默认请求头上,因为 WASM 模式下同一个 HttpClient 会被多个页面复用,token 过期或切换账号后默认头不会自动清理,手动构造 HttpRequestMessage 可以避免串号问题。登录成功后把 token 写进 localStorage,同时实现一个自定义AuthenticationStateProvider,从 token 里解析subrole声明构造ClaimsPrincipal。这一步做完后,AuthorizeView[Authorize]才能真正生效。

在 Program.cs 里注册:

builder.Services.AddAuthorizationCore(); builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>(); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

BaseAddress指向应用部署地址,这是 WASM 环境里获取服务端地址的标准姿势,无论部署在 IIS 还是 nginx 反代后面都不用改代码。

4.3 基于角色的界面授权:AuthorizeView 与后端强校验

服务端 API 的权限用[Authorize(Roles = "Admin,StoreManager")]控制接口访问,前端组件层的权限用AuthorizeView控制按钮和面板可见性。比如只有店长可以批量调价:

<AuthorizeView Roles="Admin,StoreManager"> <Authorized> <button class="btn btn-primary" @onclick="OpenPriceAdjustPanel">调价</button> </Authorized> <NotAuthorized> <span class="text-muted" title="需要管理员权限">调价不可用</span> </NotAuthorized> </AuthorizeView>

Authorized模板在用户角色命中时渲染,NotAuthorized模板用于降级展示。要明确的是,前端隐藏按钮只是交互体验设计,真正的权限判定永远在后端 API 上——前端没按钮不代表接口安全,服务端仍然要用[Authorize]做强制校验。两者结合使用时,建议把角色字符串声明为常量类,避免在页面里往返抄写。

5. 把 Dorisoy.POS 装进 Docker:部署与体积优化三板斧

5.1 多阶段构建一次出镜像

Blazor WASM 应用发布后,Server 项目会把编译出的 wwwroot 一起打包进行程序集。最干净的路径是用多阶段 Dockerfile 完成 publish 和运行环境组装:

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build WORKDIR /src COPY . . RUN dotnet restore Server/DCMS.SE.csproj RUN dotnet publish Server/DCMS.SE.csproj -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:6.0 WORKDIR /app COPY --from=build /app/publish . EXPOSE 80 ENTRYPOINT ["dotnet", "DCMS.SE.dll"]

这里的示例展示的是完整流程的简化形态。COPY . .会让 Docker 的层缓存失效,在团队规范里可以拆成先 COPY 项目文件再 restore 的缓存友好写法。publish 结束后 wwwroot 里的静态文件和 API 程序集在同一个输出目录,aspnet 镜像直接托管即可。

5.2 体积优化:先裁剪,再压缩,最后设置缓存

WASM 的体积瓶颈在_framework目录下的 dotnet.wasm 和各个程序集,优化按优先级分三步:

手段效果注意点
PublishTrimmed程序集裁剪体积减少 40% 到 60%反射、动态加载的代码需加标注
InvariantGlobalization开启减少全球化资源文件有日期格式定制需求时不适用
nginx 静态缓存加 Brotli 预压缩传输体积再降 20%index.html 不缓存,_framework 缓存一年

发布命令加上裁剪与压缩开关:

dotnet publish Server/DCMS.SE.csproj -c Release \ -p:PublishTrimmed=true \ -p:InvariantGlobalization=true \ -o out

PublishTrimmed=true时,EF Core 的延迟加载和 System.Text.Json 的反射序列化元数据可能被误裁,上线前要重点回归登录、拉取商品、开单、生成报表这条主链路。给相关属性加[DynamicallyAccessedMembers]标注,或只在 Client 端启用裁剪、Server 端保持完整发布,是更稳妥的折中方案。

最后落到 nginx 静态缓存:

location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } location /_framework/ { expires 1y; add_header Cache-Control "public, immutable"; } location /api/ { proxy_pass http://pos-backend:80; }

/_framework/下所有文件都经过 hash 命名,用immutable缓存一年没有副作用;index.html 不缓存,保证每次发版后浏览器拿到最新资源清单。这套组合落实后,门店终端第二次打开页面基本只剩 API 延迟。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询