简介:基于.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 ICollection<WineSku> 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加促销权重排序时,数据库就喘了。
常见做法是两级缓存:列表页缓存加详情页缓存。列表页按筛选条件组合生成缓存Key,Value是渲染好的HTML片段或DTO列表;详情页按SKU Id缓存,Value是商品详情DTO。缓存失效控制在两个时机:后台改价或上下架时主动删Key,再配合五分钟的绝对过期兜底。
public async Task<IActionResult> Detail(int skuId) { string key = $"wine:sku:detail:{skuId}"; var dto = await _cache.GetAsync<WineSkuDetailDto>(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方案就不行了。我一般从第一天就放Redis,Key按用户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 Task<Result> 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。两个并发请求同时读到Stock=1,都判断可以卖,结果都执行了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做葡萄酒商城,建议把这三点写进项目计划的最前面。
希望帮到你。
本文还有配套的精品资源,点击获取