基于.NET的葡萄酒商城系统:架构设计与交易链路实战
发布时间:2026/10/18 15:15:15 锦皓数字建站

简介基于.NET与SQL Server的葡萄酒网上商城销售系统完整项目包面向ASP.NET WebForms初学者及毕业设计、课程设计人群助其理解电商角色权限与业务模块。系统划分普通会员、商户会员、管理员三类角色覆盖商品发布购买、订单处理、购物车、留言反馈、商户信息维护及资讯发布等功能为宁夏葡萄酒线上销售场景提供可参考的代码实现。压缩包共1277个文件、约56.25MB含aspx页面、cs后端代码、js/css前端脚本及gif/jpg/png图片素材另附SQL数据库脚本、配置文件和解决方案文件便于部署与二次开发。已有34人学习/浏览适合需要快速搭建可运行商城项目、对照学习ASP.NET三层架构的开发者。从压缩包中可获取完整页面与后台管理源码重点覆盖商户入驻、商品维护、会员订单、发货管理、购物车及注册登录等模块可直接部署试验或提取复用通用代码。1. 基于.NET的葡萄酒网上商城销售系统从哪来、解决什么、谁适合很多做酒水生意的团队第一次接触网上商城第一反应是套开源商城改模板。可真把葡萄酒这个品类放进去马上就发现不对劲同一款酒不同年份是不同的可售商品礼盒、整箱、单瓶三种包装各自独立库存满减按订单金额算还是按酒款算完全两套逻辑。基于.NET的葡萄酒网上商城销售系统核心价值就是把这类强SKU、重促销、高客单的品类逻辑理成一套可维护的代码。下面按我实际做过的技术路线——ASP.NET Core做前后台EF Core管数据Redis扛缓存把架构选型、数据模型、交易链路、后台权限、上线排坑完整过一遍适合准备自建商城的小团队也适合想搞懂.NET电商完整链路的开发者。2. 基于.NET的葡萄酒商城先定架构再建模2.1 技术栈选型MVC、Web API还是Blazor后台要不要拆这个标题下搜到最多的是各种基于.NET 某框架的组合实际上并没有官方标准架构常见路线是ASP.NET Core MVC EF Core SQL Server/PostgreSQL Redis。对于商品数量几百、日单量几千的小型商城这套组合无论成本还是维护难度都是最稳的。先回答第一个问题前台和后台要不要拆成两个独立应用。我的做法是不拆成两套部署而是用同一个解决方案下的三个项目——Web前台、Admin后台管理、Domain实体与业务逻辑。拆Web与Admin是为了权限隔离拆Domain是为了让促销、订单这类核心逻辑不被控制器绑架。如果团队前端功底强也可以改成Web API 独立前端但葡萄酒商城面向的是快速上线的中小团队服务端渲染对SEO和首屏更友好我一般默认推荐MVC。// 解决方案结构示意 // WMS.Domain - 葡萄酒商城实体、领域服务订单、库存、促销 // WMS.Web - 前台商城ASP.NET Core MVC Razor // WMS.Admin - 管理后台独立MVC区域独立登录与权限 // WMS.Infrastructure - EF Core DbContext、仓储实现、Redis封装选型时还有两个容易忽略的点。第一是.NET版本如果服务器自己能控制直接用当前LTS版本别停留在.NET Framework 4.x否则后面EF Core和依赖注入都别扭。第二是数据库生产环境用SQL Server或PostgreSQL都行开发环境用LocalDB或Docker起一个容器避免本地和线上行为不一致。ORM我固定EF Core不建议在这个量级引入其他ORM因为EF Core的LINQ查询和迁移对这类品类的CRUD已经足够团队上手也快。2.2 葡萄酒商品模型酒款、SKU、年份与多包装库存葡萄酒的商品模型比普通日用品多两层酒款和可售单元。不能只建一张商品表否则年份、容量、包装这些维度一进来表就炸了。酒款式一个名字、一个品牌、一个产区、一段品酒词它本身不参与交易。SKU级具体年份、具体容量、具体包装、条码、成本价、销售价、库存。这样设计的原因很具体同一款酒2018年份和2020年份价格可能差40%整箱和单瓶的促销折扣也可能完全不同。你要把年份做成商品的一个普通字段后面做筛选和价格策略会非常痛苦。public class Wine { public int Id { get; set; } public string Name { get; set; } // 酒款名 public string Winery { get; set; } // 酒庄/酒厂 public string Region { get; set; } // 产区 public string GrapeVariety { get; set; } // 葡萄品种 public string Description { get; set; } // 品酒词 public bool IsActive { get; set; } // 上下架 public ICollectionWineSku Skus { get; set; } } public class WineSku { public int Id { get; set; } public int WineId { get; set; } public int Vintage { get; set; } // 年份 public string Capacity { get; set; } // 容量750ml / 375ml public string PackType { get; set; } // 单瓶 / 礼盒 / 整箱 public string Barcode { get; set; } // 条码 public decimal CostPrice { get; set; } // 成本价 public decimal SalePrice { get; set; } // 销售价 public int Stock { get; set; } // 当前可用库存 public int LockedStock { get; set; } // 下单锁定库存 }这里有个很多新手看不懂的字段LockedStock。电商库存不是一段数字而是「可用库存 锁定库存 已售出」三段。用户下单但还没付款时库存不能直接扣掉否则用户取消订单后库存加回来会乱正确做法是把这部分库存放进LockedStock支付成功后再真正扣减。可用库存 总入库 - 锁定 - 已售。这个字段设计好了后面的并发扣减才会好写。产区、葡萄品种这类字段我建议用字典表加外键而不是直接存字符串。原因是一个产区的写法很容易漂移有人录某产区有人录该产区某子区搜索和统计时就会对不上。商品分类同理葡萄酒按「红/白/起泡/甜酒」和「旧世界/新世界」两个维度分类存两个分类表前台的筛选树照这个结构渲染即可。2.3 订单状态机从待支付到完成别用散落的if订单表是商城的主线。我的订单表字段固定为订单号、用户ID、订单金额、优惠金额、实付金额、状态、收货地址快照、下单时间、支付时间、发货时间、完成时间。注意是收货地址快照不是外键关联地址表——用户改地址不能影响历史订单这是电商常识但很多人会漏。订单状态不要散落在业务代码的if里直接做成状态机。葡萄酒订单因为有预售和整箱发货场景状态比普通电商多两个待支付、已支付待发货、部分发货、已发货、已完成、已取消、退款中、退款完成。public enum OrderStatus { PendingPayment 0, // 待支付订单已创建并锁定库存 Paid 1, // 已支付等待发货 PartiallyShipped 2, // 部分发货整箱拆单场景 Shipped 3, // 已发货 Completed 4, // 已完成 Canceled 5, // 已取消释放锁定库存 Refunding 6, // 退款中 Refunded 7 // 退款完成 }状态流转的规则放在一个独立的OrderStateMachine类里每个流转动作校验前置状态。比如「取消」只允许从待支付发起「发货」只允许从已支付或部分发货发起每笔流转都写一条状态变更历史表后台客服查单时才能回答为什么这单能退这类问题。这比在控制器里到处写 order.Status xxx 靠谱得多也不会出现同一个订单被两个操作改成不同状态的情况。事务边界上下单动作要含三件事创建订单主表与明细、把SKU的可用库存减掉并增加锁定库存、清空购物车中已勾选的项。这三件事必须在同一个数据库事务里做。有人图省事用多个SaveChanges中途一崩订单建了库存没锁就等着超卖。另一种常见错误是把发邮件、发短信这类外部调用也放进事务外部服务超时会导致事务悬挂我的习惯是事务只碰数据库外部通知全部放到事务提交后的消息或队列里。3. 商品浏览、购物车与库存扣减核心交易链路3.1 商品列表与详情页Redis缓存、搜索与酒标图片前台第一个要扛住的是商品列表页。葡萄酒商城的列表天然有筛选条件产区、年份、品种、价格区间、评分。每次筛选都查数据库几百个SKU时没问题到几千个SKU加促销权重排序时数据库就喘了。常见做法是两级缓存列表页缓存加详情页缓存。列表页按筛选条件组合生成缓存KeyValue是渲染好的HTML片段或DTO列表详情页按SKU Id缓存Value是商品详情DTO。缓存失效控制在两个时机后台改价或上下架时主动删Key再配合五分钟的绝对过期兜底。public async TaskIActionResult Detail(int skuId) { string key $wine:sku:detail:{skuId}; var dto await _cache.GetAsyncWineSkuDetailDto(key); if (dto null) { dto await _wineService.GetSkuDetailAsync(skuId); await _cache.SetAsync(key, dto, TimeSpan.FromMinutes(5)); } return View(dto); }缓存代码本身不复杂复杂的是Key的设计。凡是参与筛选、排序、促销计算的字段变化了Key必须变或必须主动删除。比如后台运营把某款酒的促销折扣从八折改成七折商品详情缓存不删前台用户看到的还是旧价这就是常见翻车点。葡萄酒图片方面酒标图、酒液图、瓶身图三张一套上传时用ImageSharp生成缩略图和WebP格式原图另存对象存储或本地磁盘不要把原图直接塞数据库。3.2 购物车Redis存什么、什么时候合并、怎么算钱购物车有两种实现路线。少量用户、不做登录态强制时放Cookie里最省事但只要上登录、换设备同步、促销价实时刷新Cookie方案就不行了。我一般从第一天就放RedisKey按用户ID或临时会话ID设计匿名用户用Guid会话ID登录后把匿名购物车合并进用户购物车。// 购物车条目结构Redis中存Hash方便单字段更新 public class CartLine { public int SkuId { get; set; } public int Qty { get; set; } public bool Selected { get; set; } // 是否勾选结算时只算勾选项 } // Hash结构cart:{userId} - field: skuId - value: {Qty:2,Selected:true}购物车的坑集中在价格计算。购物车里的单价不能直接存数据库里的销售价因为在结算那一刻价格要受促销影响。我的建议购物车只存SKU和数量不存单价快照每次展示购物车时实时计算单价、促销价、总价。这样运营改了价用户刷新购物车就是新价避免加购时200结算时变180的纠纷。数量上限也要限制单SKU限购9件或按库存上限否则整箱酒的库存容易被人恶意锁光。合并购物车的时机也要注意用户未登录加了几件酒再点登录登录成功后要把会话里的购物车与账号下的购物车merge。merge规则是SKU维度数量相加、勾选状态取或。这个逻辑写在登录回调里容易漏我建议写成独立的CartMergeService登录和切换账号两个入口都调它。3.3 库存扣减与支付回调锁、幂等和顺序库存扣减是整个商城最容易被并发打穿的地方。前面模型里分了可用库存和锁定库存扣减逻辑是下单时同时做两个操作——可用库存减N锁定库存加N。这个操作在SQL里是一句UPDATE完成的不能先查再减否则两个请求同时读到库存为1都判断够卖就超卖了。-- 原子扣减只有受影响行数为1才算扣减成功 UPDATE WineSku SET Stock Stock - qty, LockedStock LockedStock qty WHERE Id skuId AND Stock qty;受影响行数返回0说明库存不够或SKU已下架这次下单直接失败。这样就不需要分布式锁一般促销场景都能扛住。如果库存要放到Redis里做秒杀那又是另一套方案用Redis的Lua脚本保证原子性但常规商城我不建议把库存主数据放Redis数据库库存做主Redis做读缓存配合缓存击穿保护就够了。支付回调的坑是重复通知和乱序通知。第三方支付平台都可能把同一个支付成功结果推两遍回调处理逻辑必须幂等。做法是回调里先按订单号查支付流水表没有才插入流水并更新订单状态订单状态用状态机校验只有待支付才允许改成已支付。这样重复回调来了第二次查到流水已存在直接返回成功即可。// 支付回调处理同一订单只允许被成功处理一次 public async TaskResult HandlePayCallbackAsync(string orderNo, string payTradeNo) { // 支付流水表对orderNo建唯一索引重复插入会失败 var inserted await _payRepository.TryInsertPaymentAsync(orderNo, payTradeNo); if (!inserted) return Result.Ok(重复回调已忽略); // 只允许待支付订单进入已支付状态机拦掉乱序通知 var changed await _orderService.TransitionAsync(orderNo, OrderStatus.PendingPayment, OrderStatus.Paid); return changed ? Result.Ok(支付成功) : Result.Error(订单状态异常); }这段代码的关键是TryInsertPaymentAsync它在数据库层面靠唯一索引保证同一订单的支付流水只能插一次。先插流水再改状态两个操作放同一个事务回调的幂等就稳了。至于支付通知乱序比如退款通知先到、支付成功通知后到靠状态机就能拦住——退款完成后支付成功这种反向流转会被状态机判定为非法直接拒绝。提示库存扣减和支付回调的业务日志里必须带订单号和SKU ID排查超卖或重复入账时这两条日志能直接定位是扣减问题还是回调问题。4. 管理后台权限、审计与促销配置4.1 角色权限改价、发货、退款各归各人一个葡萄酒商城后台角色至少要分运营管商品与促销、客服管订单与售后、仓管管发货与库存、财务管报表与退款审批、超管。权限不只分菜单还要分操作按钮。ASP.NET Core自带的Identity加Policy机制可以覆盖这个需求不需要引入复杂的权限中间件。// Admin后台的Policy配置按操作粒度授权而不是只按菜单授权 builder.Services.AddAuthorization(options { options.AddPolicy(Product.Edit, policy policy.RequireRole(Operator, Admin)); options.AddPolicy(Order.Ship, policy policy.RequireRole(Warehouse, Admin)); options.AddPolicy(Order.Refund, policy policy.RequireRole(Finance, Admin)); });注意一个常见误区菜单隐藏不等于安全。很多后台只做前端按钮隐藏别人直接调接口一样能改价。我的习惯是每个写操作接口都加上对应的Policy校验前端隐藏只是体验优化后端接口才是安全底线。审计日志是最容易被漏掉的部分。谁把某款酒的价格从398改成298谁把某个订单标记成已发货这些操作必须记录。我习惯在Admin项目里做一个审计过滤器所有写操作的控制器方法自动记录操作人、操作时间、请求参数、操作前后对象快照。出问题的时候这层日志是唯一能还原现场的东西没有它客服和运营互相甩锅的画面会天天上演。4.2 促销配置满减、折扣、赠酒一张规则表搞定促销是葡萄酒商城最灵活也最容易写崩的部分。后台运营要能配三种基础促销满额减满600减80、折扣某酒款全场七五折、赠品买整箱赠开瓶器或赠小样酒。如果再叠加会员价和新人券促销类型能到五六种。我的经验是不搞复杂规则引擎用Promotion主表加PromotionRule明细表规则条件与优惠结果用JSON存下单时按顺序计算并叠加。public class Promotion { public int Id { get; set; } public string Name { get; set; } public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public bool IsActive { get; set; } public int Priority { get; set; } // 数值越小越先计算 } public class PromotionRule { public int Id { get; set; } public int PromotionId { get; set; } public string ConditionJson { get; set; } // {MinAmount:600,SkuIds:[1,2]} public string BenefitJson { get; set; } // {DiscountAmount:80,DiscountRate:0.75} }下单时促销计算顺序很重要先算单品折扣再算满减再算优惠券最后算赠品。顺序反了会出现满减后又打折导致实付为负的乌龙。价格计算全部在Decimal类型上进行严禁用double或float——葡萄酒单价几百元0.1的精度误差在报表里会被放大到很难看。促销配置在后台要做时间冲突校验不能让两个同优先级满减活动在同一时段生效。我踩过这种坑运营配了满600减80和满600赠酒杯两个活动同时启用下单计算时没有明确优先级结果有的订单算的是减钱有的是送杯子用户投诉一团乱。后来所有促销规则强制要求优先级不重复同优先级默认按创建时间排序并告警提示运营确认。5. 葡萄酒商城开发避坑指南索引、并发、存储与部署5.1 按产区筛选秒回加年份筛选就超时组合索引的锅现象商品列表页按产区筛选时响应很快再加一个年份条件就慢到3秒以上。原因直接在产区字段上建了单列索引年份、品种、价格区间各建各的索引数据库在多个索引之间做合并操作非常低效。加上葡萄酒SKU表动辄几千行组合条件的筛选就会触发全表扫描。解决把高频组合筛选条件建成组合索引比如(Region, Vintage, GrapeVariety)并调整查询写法避免在索引列上做函数运算比如WHERE YEAR(CreatedTime)这种写法会让索引失效。建好组合索引后再用EXPLAIN看执行计划确认走了索引而不是Seq Scan或Clustered Index Scan。这是SQL优化的老问题但在葡萄酒商城这种多条件筛选中特别容易踩。5.2 促销开抢就超卖先查库存再扣减的SQL写法现象某款限量酒在促销开抢一分钟内订单量超过库存后台显示库存为负。原因下单逻辑写成了先SELECT Stock再判断再UPDATE。两个并发请求同时读到Stock1都判断可以卖结果都执行了UPDATE库存变成-1。解决改成UPDATE ... WHERE Stock qty的原子扣减受影响行数为0就返回库存不足。这里还有第二个隐患扣减成功但订单创建失败会导致锁定库存增加而订单不存在所以扣库存和建订单必须在同一事务里任一步失败整体回滚。第三个隐患是有些团队为了性能把库存放在Redis里但数据库和Redis的库存同步时机没做对最后两边对不上更麻烦。5.3 服务器磁盘爆了酒标原图全部塞进了数据库现象运行两个月数据库文件从2GB涨到30GB备份一次要半小时磁盘告警。原因商品图片用varbinary直接存表。葡萄酒SKU动辄几百个每个SKU五六张图一张原图好几MB数据库就这么被撑爆了。解决图片文件落地到磁盘或对象存储数据库只存URL和尺寸信息同时限制后台单张图片不超过2MB并自动压缩。SQL备份文件太大恢复也痛苦这个坑越早改越省钱。如果已经踩了写一个数据迁移程序把历史图片导出去数据库字段改成URL别指望手工一条条改。5.4 部署到新服务器用户全部掉线机器密钥与Session的问题现象从开发机部署到生产服务器后用户重新登录时Session全部失效或者验证码一直校验失败。原因ASP.NET Core的防伪令牌、Session和加密Cookie默认使用机器级密钥换机器密钥就变了重启也会变。开发时没注意一上生产就暴露。解决在配置文件中固定DataProtection密钥存储路径用PersistKeysToFileSystem指定共享密钥目录多机部署时还要用相同的应用ID。这个问题只有上过生产才会遇到但遇到一次就能折腾一整天。我后来把这段配置写进了项目模板新项目默认就带不再每次手写。6. 上线前最后三件事压测、日志、备份三件事都是小事但任何一件没做上线第一周都会很难看。第一件压测。压测目标不是打到多少并发而是验证库存扣减和支付回调在多大并发下不超卖、不重复入账。我用k6或JMeter模拟下单流程看两个指标一个是创建订单接口的P95响应时间一个是库存为负的订单数量。压测时专门把限购酒款的库存设成100用200并发去抢看最终订单数和库存数是否对得上。第二件日志。上线前把关键业务日志全部结构化订单号、SKU ID、操作人、耗时、异常堆栈全部以JSON格式输出。排查线上问题的时候日志里没有订单号等于没有日志。我在支付回调、库存扣减、促销计算三个节点强制打了业务日志线上出问题靠日志基本能定位到具体是哪个环节。告警也要提前配好库存异常为负、支付回调失败率超过阈值这些都要第一时间通知到人。第三件备份。数据库每天全备日志每小时归档。备份要定期做恢复演练不能只看备份文件存在。我见过备份任务一直成功恢复时才发现文件损坏的案例被坑过一次以后我每周五自动恢复一次备份到测试库做校验。另外代码也要备份镜像和发布包保留最近五个版本方便紧急回滚。这三件事做完基本就可以安心上线了。我自己做过的几套商城项目中凡是在这三个环节省事的后面都回来补课了凡是一开始就做完整的运营期几乎没出过大乱子。如果你也打算基于.NET做葡萄酒商城建议把这三点写进项目计划的最前面。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。