简介:基于C#开发的在线二手商品交易平台项目,定位为计算机相关专业毕业设计或课程设计的完整参考案例,覆盖需求分析、系统设计、编码实现、数据库管理与测试部署等软件工程全流程。项目采用MVC分层架构,后台逻辑由C#实现,前端基于ASP.NET Web Forms/MVC构建,数据库可选用SQL Server或MySQL,并配有用户管理、商品发布、购物车、订单管理等典型电商模块。压缩包共包含572个文件,大小约6.31MB。其中57个.cs源码文件、51个aspx页面、27个css样式表、90个js脚本及大量GIF演示图、JPG界面截图和PNG图标,清晰呈现了项目结构、界面效果与操作流程;同时附带数据库文件与配置文件,便于快速部署运行。已有59人浏览/学习。对于希望系统掌握C# Web开发、MVC模式、关系型数据库设计以及整套项目落地过程的学生开发者而言,这套资料能提供从功能拆解到环境配置、再到上线部署的完整参考,具有较强的实践指导价值。
1. 在线二手交易平台为什么适合用 C# 做:从课程设计到可演示上线的完整闭环
每年毕设季,我都能见到一批“看起来功能齐全、一跑就露馅”的二手交易系统:商品能发布,但图片裂了;能下单,但库存能被扣成负数;订单状态随便改,老师一追问答不上来。这个标题给我的第一感觉是:它要的不是一个 CRUD Demo,而是一条用 C# 把“发布 → 检索 → 下单 → 支付 → 履约 → 沟通”走通的完整业务链。对做毕业设计或课程设计的学生来说,C# 生态里 ASP.NET Core + EF Core + SQL Server 的组合足够稳,也足够在答辩时讲出深度。这篇笔记就按这个方向,把技术选型、数据库设计、核心模块实现和常见坑一次讲透,让新手能照着复现,熟手能直接拿去替换自己的薄弱环节。
2. 技术选型与数据库设计:四张核心表和一个订单状态机
2.1 为什么是 ASP.NET Core + EF Core + SQL Server,而不是 Web Forms 或三层手动拼 SQL
很多课程教的是 ASP.NET Web Forms + ADO.NET,但近几年新项目基本都转向 ASP.NET Core MVC。对二手交易平台这个题目,核心诉求是三点:开发速度快、答辩能讲清架构、部署不折腾。Web Forms 的 ViewState 和事件驱动模型在讲“前后端交互”时很难自圆其说;而 ASP.NET Core MVC 的路由、模型绑定、依赖注入都是现代 Web 开发的通用概念,面试时也能复用。
EF Core 在这个项目里的价值不只是“少写 SQL”。更重要的是 Code First 模式:你定义好 C# 实体类,用迁移命令生成数据库,答辩时可以现场演示“给表加一个字段然后更新库结构”,这种可演示性在毕业设计里非常加分。SQL Server 则直接用 Visual Studio 自带的 LocalDB 或者 SQL Server Express 就够,不需要额外装庞大的企业版。
常见的项目分层我一般会拆成 Controller → Service → Repository 三层,再加一个 Entities 放实体类。Controller 只做参数接收和结果返回,业务规则放 Service,数据访问全走 Repository。好处是答辩被问到“如果以后要换数据库怎么办”时,你可以直接指向 Repository 接口说:换实现就行,上层不需要动。
2.2 数据库设计:五张表比四张表更完整,订单明细不能省
标题没有给数据库结构,但做二手交易平台,最稳的核心表设计是五张:用户表、商品表、订单表、订单明细表、站内消息表。很多人只做四张,把订单明细省掉,结果一个订单买两件不同商品时数据就乱了。用 EF Core 实体类来定义,代码块如下:
public class User { public int Id { get; set; } public string Username { get; set; } public string PasswordHash { get; set; } public string Nickname { get; set; } public string Phone { get; set; } public DateTime CreatedAt { get; set; } } public class Product { public int Id { get; set; } public int SellerId { get; set; } public string Title { get; set; } public string Description { get; set; } public decimal Price { get; set; } public int Stock { get; set; } public string ImageUrl { get; set; } public int Status { get; set; } // 0在售 1已下架 2已售出 public DateTime CreatedAt { get; set; } } public class Order { public int Id { get; set; } public string OrderNo { get; set; } // 业务订单号,如 yyyyMMddHHmmss+userId public int BuyerId { get; set; } public int SellerId { get; set; } public int ProductId { get; set; } public decimal TotalAmount { get; set; } public int Status { get; set; } // 对应下方状态机 public DateTime CreatedAt { get; set; } public DateTime? PaidAt { get; set; } public DateTime? ShippedAt { get; set; } public DateTime? CompletedAt { get; set; } } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } public string ProductTitle { get; set; } // 快照,防止商品改名后订单记录失效 public decimal Price { get; set; } public int Quantity { get; set; } } public class Message { public int Id { get; set; } public int FromUserId { get; set; } public int ToUserId { get; set; } public string Content { get; set; } public bool IsRead { get; set; } public DateTime CreatedAt { get; set; } }逻辑说明:OrderItem 里的ProductTitle和Price是刻意做的“快照”,因为二手商品可以随时编辑,下单后再回头查订单,必须以下单那一刻的信息为准,这是电商系统的基本常识,也是答辩加分点。Order 表直接冗余了SellerId,省去通过 Product 反查卖家的一步 JOIN,查询更快。
参数说明:Status全部用 int 而不是字符串枚举,存储更省,但代码里要用常量或枚举类对应。ImageUrl存相对路径而不是完整 URL,方便部署后换域名。PasswordHash绝不存明文,后面实现时会用哈希算法处理。
2.3 订单状态机:五个状态、五条合法迁移路径,杜绝随意改状态
订单状态是交易平台最容易翻车的领域。二手交易比普通电商多一个“买家确认收货”的环节,所以状态不能只有“待付款、已付款、已完成”三个。我常用的状态设计是五态:待支付(0)→ 已支付待发货(1)→ 已发货(2)→ 已完成(3),另外加一个取消(-1)作为终止态。迁移规则如下表:
| 当前状态 | 允许迁移到 | 触发动作 |
|---|---|---|
| 待支付(0) | 已支付待发货(1) | 买家支付成功 |
| 待支付(0) | 取消(-1) | 买家取消或超时未付 |
| 已支付待发货(1) | 已发货(2) | 卖家发货 |
| 已支付待发货(1) | 取消(-1) | 卖家同意退款 |
| 已发货(2) | 已完成(3) | 买家确认收货 |
实现上我不会让 Controller 直接order.Status = 2然后 SaveChanges,而是写一个订单状态机服务,把所有迁移规则收敛到一个方法里:
public class OrderStatusMachine { private static readonly Dictionary<int, List<int>> AllowedTransitions = new() { [0] = new() { 1, -1 }, [1] = new() { 2, -1 }, [2] = new() { 3 } }; public bool CanTransition(int currentStatus, int targetStatus) { return AllowedTransitions.ContainsKey(currentStatus) && AllowedTransitions[currentStatus].Contains(targetStatus); } }逻辑说明:这个状态机把非法操作挡在入口处。比如“已发货”状态不能被改成“待支付”,“已完成”不能跳回任何状态。
参数说明:如果后续要加“退款中”状态,只需在字典里增加一个 key 和对应的迁移路径,不需要改动 Controller。首次做这个项目时最容易忽略的是超时取消:待支付订单 30 分钟未支付要自动取消,这块在课程设计里不做是能过关的,但做了会明显提升完整度,建议用后台服务定时扫描。
3. 核心功能实现:注册登录、商品发布与检索分页
3.1 注册登录:用 BCrypt 哈希密码,Session 还是 JWT 先想清楚
二手平台必然有登录态。常见的做法有两种:Session 和 JWT。课程设计用 Session 更简单,后端代码少,前端拿 Cookie 就行;但如果想在答辩时讲“无状态认证”,JWT 更有说头。我给一个中间路线:登录接口发 JWT,前端存 localStorage,后端用中间件解析——代码量和 Session 差不多,但架构上更现代。
密码哈希不要用 MD5 或 SHA256 裸哈希,直接用 BCrypt:
// BCrypt.Net-Next 包 public class PasswordService { public string HashPassword(string rawPassword) { return BCrypt.Net.BCrypt.HashPassword(rawPassword, workFactor: 12); } public bool Verify(string rawPassword, string storedHash) { return BCrypt.Net.BCrypt.Verify(rawPassword, storedHash); } }逻辑说明:workFactor: 12表示 BCrypt 的计算轮数是 2 的 12 次方,这个值越高哈希越慢,暴力破解成本越大。课设里用 10 到 12 都合适,太低(如 4)在答辩演示机器上没问题,但被问安全加固时答不上来。
参数说明:登录成功后再签发 JWT,密钥放在appsettings.json的JwtSettings:SecretKey里,不要写死在代码中。JWT 过期时间建议设置 2 小时,配合前端 401 拦截跳回登录页。注意:JWT 无法主动失效,如果用户点了“退出登录”,只能前端删掉 Token,后端拦截器配合 Token 黑名单才能硬失效——这个坑在下面避坑章节会细说。
3.2 商品发布与图片上传:IFormFile 保存、文件名重命名与校验
商品发布表单包含文本字段和一张主图。图片上传最常见的坑有三处:文件名中文导致 URL 乱码、没有限制扩展名导致被传 exe、文件没有保存到可访问的静态目录。我一般用这样的写法:
[HttpPost] public async Task<IActionResult> Create(ProductCreateViewModel model) { if (!ModelState.IsValid) return View(model); var imageRelativePath = string.Empty; if (model.ImageFile != null) { var ext = Path.GetExtension(model.ImageFile.FileName).ToLower(); var allowedExts = new[] { ".jpg", ".jpeg", ".png", ".webp" }; if (!allowedExts.Contains(ext)) { ModelState.AddModelError("ImageFile", "仅支持 jpg/jpeg/png/webp 格式"); return View(model); } var fileName = $"{Guid.NewGuid():N}{ext}"; var uploadDir = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "products"); Directory.CreateDirectory(uploadDir); var fullPath = Path.Combine(uploadDir, fileName); using var stream = new FileStream(fullPath, FileMode.Create); await model.ImageFile.CopyToAsync(stream); imageRelativePath = $"/uploads/products/{fileName}"; } var product = new Product { SellerId = _currentUserId, Title = model.Title.Trim(), Description = model.Description?.Trim(), Price = model.Price, Stock = model.Stock, ImageUrl = imageRelativePath, CreatedAt = DateTime.Now }; _db.Products.Add(product); await _db.SaveChangesAsync(); return RedirectToAction("Detail", new { id = product.Id }); }逻辑说明:先用Path.GetExtension拿到扩展名并转小写,再做白名单校验,这是比黑名单更安全的方式。用Guid.NewGuid()生成文件名是为了彻底绕开中文文件名和重名覆盖问题。
参数说明:上传目录固定放在wwwroot/uploads/products下,这样浏览器直接通过/uploads/products/xxx.jpg就能访问,不需要额外写静态文件映射。还要留意图片大小,建议在 ViewModel 上用[MaxFileSize(2 * 1024 * 1024)]或后端手动判断model.ImageFile.Length,否则一本 200MB 的 PDF 伪装成 jpg 就能打满磁盘。
3.3 商品检索与分页:关键词模糊查询、排序与 EF Core 分页写法
商品列表页是平台的流量入口,必做搜索和分页。EF Core 的Skip/Take分页简单直接,数据量大后在千万级会慢,但对课设和毕设完全够用:
public async Task<(List<Product> Items, int TotalCount)> SearchProducts( string keyword, int pageIndex, int pageSize) { var query = _db.Products .Where(p => p.Status == 0); // 只查在售 if (!string.IsNullOrWhiteSpace(keyword)) { query = query.Where(p => p.Title.Contains(keyword) || p.Description.Contains(keyword)); } var totalCount = await query.CountAsync(); var items = await query .OrderByDescending(p => p.CreatedAt) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (items, totalCount); }逻辑说明:先查CountAsync拿到总条数,用于前端计算页码;再查当前页数据。Contains会被 EF Core 翻译成 SQL 的LIKE '%keyword%',虽然不会走索引,但数据量在几千到几万时性能毫无压力;如果以后要优化,可以换全文索引或 Elasticsearch,但这不是课程设计该背的包袱。
参数说明:pageIndex从 1 开始,pageSize每页常见值设 12 或 16(网格布局下视觉效果整齐)。OrderByDescending(p => p.CreatedAt)是把最新发布排在前面,二手平台的排序逻辑里,价格排序可以放在前端切换,但后端一定要有默认排序字段,否则分页结果不稳定。
4. 交易闭环与在线沟通:下单、库存扣减、支付模拟和 SignalR 聊天
4.1 下单与库存扣减:事务包住“查库存、扣库存、建订单”,并发用 UPDATE 语句解决
下单是整个系统最考功底的地方。新手最常见的写法是:先SELECT Stock判断是否大于 0,再UPDATE Stock = Stock - 1,再INSERT Order。这在单用户测试时没问题,但两个线程同时下单同一件库存只剩 1 的商品时,两个请求都读到Stock = 1,然后都执行扣减,最后库存变成 -1。这就是典型的并发翻车。正确做法是把扣减动作收进一条带条件的 UPDATE 语句:
public async Task<bool> TryCreateOrder(int productId, int buyerId, int quantity) { using var transaction = await _db.Database.BeginTransactionAsync(); // 条件扣库存:只有当前库存 >= 需要数量时才扣成功 var affected = await _db.Database.ExecuteSqlRawAsync( "UPDATE Products SET Stock = Stock - {0} WHERE Id = {1} AND Stock >= {0}", quantity, productId); if (affected == 0) { await transaction.RollbackAsync(); return false; // 库存不足,下单失败 } var product = await _db.Products.FindAsync(productId); var order = new Order { OrderNo = $"{DateTime.Now:yyyyMMddHHmmss}{buyerId}", BuyerId = buyerId, SellerId = product.SellerId, ProductId = productId, TotalAmount = product.Price * quantity, Status = 0, CreatedAt = DateTime.Now }; _db.Orders.Add(order); await _db.SaveChangesAsync(); await transaction.CommitAsync(); return true; }逻辑说明:核心在WHERE Stock >= {0}这个条件上。数据库层面保证“库存够才扣,扣了才返回 1 行受影响”,天然串行化了并发请求,不需要 C# 层加锁。如果两个线程同时执行这条 UPDATE,数据库的行锁会让后到的那个等待前一个提交或回滚,然后重新判断条件。
参数说明:ExecuteSqlRawAsync的参数用{0}占位符传参,而不是字符串拼接,防 SQL 注入。事务范围覆盖了扣库存和建订单,如果第二步SaveChangesAsync失败,整体回滚,不会出现“库存扣了但订单没建”的脏数据。异步方法的await保证每个步骤释放线程,不会在压测时耗尽线程池。
4.2 支付模拟:设计真实的支付流水表,支付宝微信接入留给生产
毕设里接真实支付宝或微信支付需要商户资质,学校项目基本走模拟支付。但“模拟”不代表“随便”——我见过太多项目在支付按钮里直接order.Status = 1,没有任何支付记录。正确做法是设计一张支付流水表(PaymentRecord),包含流水号、订单号、支付方式、支付金额、支付状态、回调时间,然后让“支付成功”走一遍完整的回调逻辑:
public class PaymentRecord { public int Id { get; set; } public string PaymentNo { get; set; } public string OrderNo { get; set; } public string PayMethod { get; set; } // alipay / wechat / balance public decimal Amount { get; set; } public int Status { get; set; } // 0待支付 1成功 2失败 public DateTime CreatedAt { get; set; } public DateTime? PaidAt { get; set; } } // 模拟支付回调 public async Task<bool> MockPayCallback(string paymentNo) { var payment = await _db.PaymentRecords.FirstOrDefaultAsync(p => p.PaymentNo == paymentNo); if (payment == null || payment.Status != 0) return false; payment.Status = 1; payment.PaidAt = DateTime.Now; var order = await _db.Orders.FirstOrDefaultAsync(o => o.OrderNo == payment.OrderNo); order.Status = 1; // 待支付 -> 已支付待发货 order.PaidAt = DateTime.Now; await _db.SaveChangesAsync(); return true; }逻辑说明:把“支付完成”抽象成一个回调方法,而不是在按钮点击事件里直接改状态。真实项目中这个回调是第三方支付服务器发来的,毕设里由自己调用来模拟,但代码结构完全一致——答辩时你就可以说“这里接支付宝只需替换成验签和异步通知”。
参数说明:PaymentNo用Guid.NewGuid():N生成,OrderNo业务上要可读,两个编号解耦。支付流水表一旦生成就不允许修改金额或订单号,要调整只能做冲正记录,这是支付系统的底线。模拟页面里给一个“确认支付”按钮,延迟 1 秒再跳转,演示效果更接近真实支付。
4.3 买卖双方站内沟通:用 SignalR 做实时聊天,连接管理要线程安全
商品详情页放一个“联系卖家”按钮,点开就是一个聊天窗口。传统做法是前端定时器轮询数据库,代码简单但体验差,而且答辩现场看不出技术含量。用 SignalR 做 WebSocket 实时通信,演示时两个浏览器窗口互发消息,效果非常直观。
public class ChatHub : Hub { private static readonly ConcurrentDictionary<int, string> _userConnections = new(); public override Task OnConnectedAsync() { var userId = int.Parse(Context.UserIdentifier); _userConnections[userId] = Context.ConnectionId; return base.OnConnectedAsync(); } public override Task OnDisconnectedAsync(Exception exception) { var userId = int.Parse(Context.UserIdentifier); _userConnections.TryRemove(userId, out _); return base.OnDisconnectedAsync(exception); } public async Task SendMessage(int fromUserId, int toUserId, string content) { _userConnections.TryGetValue(toUserId, out var connectionId); if (!string.IsNullOrEmpty(connectionId)) { await Clients.Client(connectionId).SendAsync("ReceiveMessage", fromUserId, content); } // 同时存入数据库做离线消息 var msg = new Message { FromUserId = fromUserId, ToUserId = toUserId, Content = content, IsRead = false, CreatedAt = DateTime.Now }; await _db.Messages.AddAsync(msg); await _db.SaveChangesAsync(); } }逻辑说明:ConcurrentDictionary是线程安全的字典,解决的是“多个连接同时在线时,读写连接表不冲突”的问题——这里就是热词“C# 线程”的真正落地场景,不能用普通Dictionary,否则并发下会抛异常或丢数据。Context.UserIdentifier来自 JWT 中间件,所以 SignalR 握手时要在查询串里带上 Token,并在后端配置 JWT 认证。
参数说明:消息先推给在线用户,再落库;对方不在线时,等下次登录拉取IsRead = false的消息。每次SendMessage里都做一次SaveChangesAsync性能有些浪费,课设量级无所谓,生产环境一般会改成批量落库或用内存队列缓冲。
5. 常见问题与避坑:EF Core 迁移、并发下单和图片上传的 5 个真实教训
5.1 改了实体类后一运行就报 “model backing the context has changed”
现象:在 Product 实体里加了一个字段,重新运行项目,所有涉及数据库的查询直接抛异常。
原因:EF Core Code First 维护了一张模型快照__EFMigrationsHistory,模型类与数据库结构不一致时会拒绝执行查询,防止隐式丢数据。
解决:在“包管理器控制台”执行迁移命令:
dotnet ef migrations add AddProductStatusField dotnet ef database update注意:先dotnet ef migrations add生成迁移文件,再database update应用变更。团队协作时,迁移文件要提交到版本库,别人拉代码后只需执行database update。
5.2 并发下单后库存变成负数
现象:用两个浏览器同时下单同一件库存为 1 的商品,两个订单都创建成功,库存变成 -1。
原因:代码里先查库存再扣减,两步之间有时间窗口,两个请求同时穿过“库存大于 0”的判断。
解决:用前面 4.1 里的条件 UPDATE 语句——把“查库存 + 扣库存”合并成一个原子操作。判断是否成功的标准不再是Stock > 0,而是UPDATE 影响行数 == 1。
5.3 图片上传成功但浏览器访问 404
现象:文件明明保存到了wwwroot/uploads/products下,浏览器访问http://localhost:5000/uploads/products/xxx.jpg却 404。
原因:三选一——项目没启用静态文件中间件;保存路径拼错,文件实际不在 wwwroot 下;文件名是中文导致 URL 编码不一致。
解决:在Program.cs里确认有app.UseStaticFiles();,并且该中间件放在UseRouting()之前;图片保存路径统一用Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", ...),不要手写相对路径;文件名一律用Guid重命名。排查时可以打开本地文件管理器看实际落盘位置,别只看代码。
5.4 JWT 退出登录后 Token 还能用
现象:用户点退出登录,把前端 Token 清了,但把旧 Token 塞回请求头,接口依然返回 200。
原因:JWT 是无状态的,服务端不记录“这个 Token 已经失效”,只要签名没过期就认可。
解决:简单做法是在用户表加一个TokenVersion,签发 JWT 时把这个版本号放进 Payload,登录时比对版本号,不一致就拒绝。退出登录时TokenVersion++即可。另一个更简单的思路是后端维护一个 Redis 黑名单,把退出的 Token 塞进黑名单直到过期——课程设计里用TokenVersion足够,还好讲。
5.5 订单状态被任意回改,买家已确认收货卖家还能改成待发货
现象:接口层没有做状态校验,前端暴露的更新订单接口收到什么状态就写什么状态,导致数据混乱。
原因:Controller 里直接order.Status = model.Status,没有经过状态机校验。
解决:所有状态变更都走 2.3 的OrderStatusMachine.CanTransition方法,非法迁移直接抛业务异常,由全局异常过滤器转成 400 响应返回给前端。答辩追问时,这一条体现的是“业务规则收敛”,比任何炫技都有说服力。
6. 验收与答辩:演示脚本、性能优化优先级,以及我最后悔没做的一件事
6.1 五分钟演示脚本:按业务链走,不按菜单走
很多同学答辩时习惯从“登录 → 用户管理 → 商品管理 → 订单管理”这种菜单顺序演示,老师看完只觉得是个后台管理系统。二手交易平台的核心是交易体验,演示要按业务链走:注册一个买家账号 → 在搜索框输入关键词 → 筛选出商品 → 查看详情联系卖家(打开聊天窗口互发两条消息)→ 下单 → 模拟支付 → 卖家发货 → 买家确认收货 → 订单状态变成已完成。这条链路走完,系统的业务完整性已经盖过大部分同期作品。
6.2 三个值得做的优化,按性价比排序
第一优先级是热门商品缓存:把商品详情页的访问用IMemoryCache缓存 60 秒,代码改动不超过 20 行,答辩能讲“缓存穿透和缓存雪崩”的概念。第二是后台定时任务:用BackgroundService每 5 分钟扫描一次待支付超时订单并自动取消,这是电商系统的标配能力。第三是接口幂等:下单接口加一个前端生成的requestId,防止用户双击提交产生两笔订单——这个问题我用条件 UPDATE 已经规避了库存超卖,但订单重复的问题同样值得在答辩时主动承认并说明方案。
6.3 一句心里话
做了这么多年 .NET 项目,我的体会是:课程设计最怕的不是功能少,而是“每个功能都浅”。与其做八个只能点按钮的模块,不如把“发布 → 下单 → 支付 → 履约 → 沟通”这条交易闭环做扎实,每一步都能说出为什么这么设计。我最后悔没做的一件事,是当初没保留早期的数据库设计草图和 ER 图,答辩时被老师问“你这表结构怎么演化过来的”,我只能现场硬讲。建议你在开发过程中把表结构变更记录在 README 里,哪怕只是三行文字,都能让答辩的“设计过程”变得有据可依。希望这份拆解能让你少踩几个我踩过的坑,把 C# 二手交易平台做成一个拿得出手的完整作品。
本文还有配套的精品资源,点击获取