
简介基于微软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 ServerUI 渲染位置浏览器本机服务器网络依赖只在 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.cache与project.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.EntityStock() .HasIndex(s new { s.ProductId, s.WarehouseId }) .IsUnique();联合唯一索引从数据层面兜底一仓一商品只有一条当前库存的约束。如果业务按批次管理索引维度则扩展为(ProductId, BatchNo, WarehouseId)批次表还要记录生产日期和保质期——快消品做促销时按保质期先进先出FEFO是很常见的需求。3.2 用 LINQ 算可用库存不看字段看流水确定了流水累加的思路后可用库存的查询直接在 StockTransaction 上做聚合。下面的方法从仓储层返回某个商品在某仓库的可用量public async Taskdecimal 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; }参数说明PurchaseIn和Adjust增加可用量SaleOut和Locked减少可用量两次聚合相减得到最终可用数。拆成两个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 实体加 rowversionpublic byte[] RowVersion { get; set; } builder.EntityStock() .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 EventCallbackProductSelectedEventArgs 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); }EventCallback的InvokeAsync会自动通知父组件重新渲染框架会捕获回调中的异常并显示在错误边界内。另一种姿势是把共享业务逻辑放进注入的服务类里组件只依赖服务接口。比如购物车这种跨页面状态就应该放进CartService而不是用一堆EventCallback逐层向上传递。判断标准很简单只影响父组件 UI 的数据用 EventCallback影响多个页面或需要持久化的状态用注入服务这是两个层级的复用。4.2 HttpClient 调用 APIToken 挂载与认证状态通知WASM 模式下浏览器不允许自动携带跨域 CookieJWT 是主流认证方式。核心步骤是登录接口返回 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 TaskHttpResponseMessage 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 里解析sub和role声明构造ClaimsPrincipal。这一步做完后AuthorizeView和[Authorize]才能真正生效。在 Program.cs 里注册builder.Services.AddAuthorizationCore(); builder.Services.AddScopedAuthenticationStateProvider, 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 RolesAdmin,StoreManager Authorized button classbtn btn-primary onclickOpenPriceAdjustPanel调价/button /Authorized NotAuthorized span classtext-muted title需要管理员权限调价不可用/span /NotAuthorized /AuthorizeViewAuthorized模板在用户角色命中时渲染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 --frombuild /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:PublishTrimmedtrue \ -p:InvariantGlobalizationtrue \ -o outPublishTrimmedtrue时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 延迟。本文还有配套的精品资源点击获取