C#二手交易平台实战:状态机与并发事务设计
简介面向本科毕业设计的二手闲置物品交易分享平台C#源码项目适合计算机相关专业学生、毕业设计选题者及需要完成类似Web系统的开发者。项目围绕大学生校园闲置物品流转场景涵盖搜索商品、商品展示、发布商品、添加收藏、用户管理、个人资料管理等核心模块可帮助理解ASP.NET MVC或.NET Web开发中的分层架构、数据访问与前后端协作方式。压缩包共1136个文件约75.1MB主要包含76个C#源码文件、60个cshtml视图页面、56个js脚本、98个css样式、100个dll引用程序集以及sql脚本与项目配置文件等便于直接还原项目结构并运行调试。已有1145人学习下载。配套文件含图片素材、字体资源与NuGet包等目录组织较清晰适合作为毕业设计参考、课程设计扩展或入门二手交易平台开发的学习案例。1. 这个 C# 毕设题目真正值钱的不是界面是交易状态怎么闭环每年毕业设计季「C# 二手闲置物品交易分享平台」这类题目都会批量出现。原因很实在C# 方向的学生需要一个人人用过、逻辑不复杂的业务域来装下自己的代码量二手交易刚好满足——用户、商品、订单、搜索、收藏全都有又不会像电商系统那样卷到库存、支付、物流的深水区。但我在帮人审这类源代码时发现大多数实现只停留在「能注册、能发帖、能留言」一旦被问到「商品被下单之后状态流转谁来保证」项目就塌了。所以这篇笔记不打算按功能清单流水账而是把这类平台从选型、建表、核心链路到踩坑完整走一遍让你拿到的不只是能跑的代码而是能在答辩现场讲清楚原理的代码。2. 技术选型与分层架构为什么我倾向用 ASP.NET Core MVC 而不是 WinForms2.1 三条 C# 技术路线里为什么先排掉 WinFormsC# 做这种平台常见路线有三条WinForms/WPF 桌面客户端、ASP.NET Core MVCRazor 视图、Blazor Server。很多模板源码包为了「跑起来简单」选了 WinForms两个 Form 拖一拖DataGridView 绑个表就当列表页用。但你要清楚毕设答辩时老师看的不是能不能点而是「这个系统别人怎么访问」。二手交易平台天生是 Web 场景桌面程序演示时你得搬着自己电脑跑数据都在本地 SQL Server老师一问「用户怎么注册」你就只能解释单机版这属于自己给自己挖坑。我一般会选 ASP.NET Core MVC EF Core SQL Server 这条组合。理由有三个一是控制器 视图的写法跟教材里三层架构对得上代码量够又不会失控二是 EF Core 的 Linq 查询写起来比手写 SqlConnection 省一大半篇幅你省下来的时间可以拿去写真正的业务约束三是 Razor 视图做列表、详情、分页都非常直观演示时浏览器一开就是完整站点。Blazor Server 虽然交互爽但实时连接的概念在毕设里不容易讲透老师追问 SignalR 回环时容易翻车。2.2 把项目拆成 Web Service Repository目录结构与泛型封装拿到一个新项目先做的事是定目录不要上来就在 Controllers 里堆五百行业务代码。我的习惯是分层拆Controllers 只做参数接收和视图返回业务规则放 Services数据访问放 Repositories这样每一层都能单独看、单独讲。下面这个结构是我给同类毕设推荐的骨架SecondHandPlatform/ ├── SecondHandPlatform.sln ├── src/ │ ├── SecondHandPlatform.Web/ │ │ ├── Controllers/ │ │ ├── ViewModels/ │ │ ├── Views/ │ │ └── wwwroot/ │ ├── SecondHandPlatform.Core/ │ │ ├── Entities/ │ │ └── Enums/ │ └── SecondHandPlatform.Data/ │ ├── Repositories/ │ ├── Migrations/ │ └── AppDbContext.cs这个结构的核心逻辑是把不稳定的东西往外放。实体类只放属性不放逻辑枚举单独建文件DbContext 只归 Data 层管。Web 层引用 Core 和 DataData 层不反向引用 Web否则项目之间的依赖就乱了。分层的好处是老师问「订单状态放在哪儿定义」时你能直接说出在 Core/Enums 下问「商品列表怎么查的」时你能指出 Repository 里那一个方法。这就是答辩时的「源代码管理」意识不是把文件堆在一起就叫管好了。2.3 从空项目跑通最小骨架命令与 EF Core 注册参数分层的项目别用 Visual Studio 向导一键生成那样会带一堆用不上的模板代码。我习惯用命令行初始化和添加包引用干净且每一步都知道发生了什么dotnet new sln -n SecondHandPlatform dotnet new mvc -n SecondHandPlatform.Web -o src/SecondHandPlatform.Web dotnet new classlib -n SecondHandPlatform.Core -o src/SecondHandPlatform.Core dotnet new classlib -n SecondHandPlatform.Data -o src/SecondHandPlatform.Data dotnet sln add src/SecondHandPlatform.Web src/SecondHandPlatform.Core src/SecondHandPlatform.Data dotnet add src/SecondHandPlatform.Web reference src/SecondHandPlatform.Core src/SecondHandPlatform.Data dotnet add src/SecondHandPlatform.Data package Microsoft.EntityFrameworkCore.SqlServer这里dotnet new mvc生成的 Program.cs 已经自带app.MapControllerRoute默认路由不需要手写路由表。给 Data 层添加 SqlServer 包时注意版本要和你的 .NET SDK 匹配.NET 6 对应 EF Core 6.NET 8 对应 EF Core 8混用会直接编译失败。DbContext 的注册放在 Web 层的 Program.cs 里因为只有 Web 项目是入口连接字符串也读它自己的 appsettings.jsonbuilder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default)));AddDbContext默认注册的生命周期是 Scoped也就是每个 HTTP 请求内复用同一个上下文实例。这个参数很多初学者不知道把它改成 Singleton 会在并发请求时报「DbContext 线程同时使用」的错误改成 Transient 又会让每次查询都新建连接性能浪费。所以保持默认 Scoped 即可这也是你在答辩时能主动讲出来的一个点。3. 数据模型与核心表设计把「闲置交易」落到 SQL Server 里3.1 商品表把「状态」当成一等公民来建模二手交易平台最容易被做砸的地方是商品表只存标题、描述、价格把「在售 / 已售 / 下架」这个字段完全交给页面逻辑判断。我在建表时会把状态和成色这两个枚举字段单独拎出来设计因为它们直接决定列表页、详情页、下单逻辑怎么写。下面是我常用的商品表结构SQL Server 语法直接可执行CREATE TABLE dbo.Products ( Id INT IDENTITY(1,1) PRIMARY KEY, SellerId INT NOT NULL, Title NVARCHAR(80) NOT NULL, Description NVARCHAR(2000) NULL, Price DECIMAL(10,2) NOT NULL, OriginalPrice DECIMAL(10,2) NULL, CategoryId INT NULL, ConditionLevel TINYINT NOT NULL DEFAULT 0, CoverUrl NVARCHAR(500) NULL, [Status] TINYINT NOT NULL DEFAULT 0, ViewCount INT NOT NULL DEFAULT 0, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME(), UpdatedAt DATETIME2 NULL, IsDeleted BIT NOT NULL DEFAULT 0 ); CREATE INDEX IX_Products_Status_CreatedAt ON dbo.Products ([Status], CreatedAt DESC);ConditionLevel我约定 0 代表「闲置」、1 代表「较新」、2 代表「全新」不直接存中文原因很简单中文枚举值一旦要改名你得 UPDATE 整张表而 TINYINT 改映射只动代码一处。[Status]同理0 在售、1 已售、2 下架列表页只查Status 0的数据。IsDeleted是软删除位用户删除商品时只置 1不物理删行这能保住历史订单对商品信息的引用。索引建在Status和CreatedAt的联合列上因为列表页最频繁的查询就是「筛选在售 按时间倒序」这个索引能让排序不走额外的 Sort 运算符。3.2 订单表与交易状态机从一个状态到另一个状态谁允许谁不允许商品表解决了「卖什么」订单表解决「怎么卖」。二手交易没有购物车一个订单只对应一个商品所以订单表直接引用 ProductId 即可。我见过很多实现把订单状态做成了字符串字段随程序里哪儿都能改最后数据乱成一锅粥。正确的做法是定死状态机0 待付款、1 待发货、2 已发货、3 已收货、4 已关闭并且约定跳转规则。CREATE TABLE dbo.Orders ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL, ProductId INT NOT NULL, SellerId INT NOT NULL, BuyerId INT NOT NULL, Amount DECIMAL(10,2) NOT NULL, [Status] TINYINT NOT NULL DEFAULT 0, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME(), PaidAt DATETIME2 NULL, ShippedAt DATETIME2 NULL, CompletedAt DATETIME2 NULL, CONSTRAINT UQ_Orders_ProductId UNIQUE (ProductId) );UQ_Orders_ProductId唯一约束是这整张表最关键的防线。它保证一个商品只能有一条订单记录哪怕代码里有人写了两遍下单逻辑数据库也会直接报错拒绝重复插入。状态流转我习惯放在 Service 层封装成方法不允许 Controller 直接改 Order.Status。比如「取消订单」只能从 0 或 1 跳到 4「确认收货」只能从 2 跳到 3。如果你用一个Dictionaryint, int[]把允许的迁移表写出来答辩时被问到就能直接展示这个约束表比嘴上说「我做了校验」有力得多。3.3 分类、收藏与留言用三张小表省掉后续无数 if商品表和订单表之外还有三个高频需求分类筛选、收藏夹、商品留言。它们可以分别用三张轻量表解决不需要引入复杂设计。分类表我用自关联结构允许一级分类挂二级分类CREATE TABLE dbo.Categories ( Id INT IDENTITY(1,1) PRIMARY KEY, ParentId INT NULL, Name NVARCHAR(50) NOT NULL, SortOrder INT NOT NULL DEFAULT 0 ); CREATE TABLE dbo.Favorites ( Id INT IDENTITY(1,1) PRIMARY KEY, UserId INT NOT NULL, ProductId INT NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT UQ_Favorites_User_Product UNIQUE (UserId, ProductId) );收藏表的唯一约束UQ_Favorites_User_Product是点睛之笔。没有它你需要在代码里先查一次有没有收藏过再决定是否插入有了它直接 INSERT如果违反唯一约束就说明已经收藏过代码里只要捕获DbUpdateException就能处理重复收藏。分类表里的ParentId为空表示顶级分类非空表示子分类筛选时先查子分类 ID 集合再查商品避免用 LIKE 匹配分类名这种性能陷阱。留言表结构更简单就是Id, ProductId, UserId, Content, CreatedAt四五个字段这里不展开但建表时记得给ProductId建普通索引详情页加载留言会用到。4. 核心功能实现注册、发帖、下单三条主链路怎么写出可复现代码4.1 注册登录密码不能明文存但毕设不需要上重武器很多毕设源码里密码直接明文存数据库老师打开 SSMS 一眼就能看到这一条就足够把分数拉到及格线以下。但也不需要引入 Identity 框架那种重武器自己实现一个 PBKDF2 哈希就够。我习惯单独写一个PasswordHasher静态类注册时生成盐和哈希登录时重新计算比对public static class PasswordHasher { private const int Iterations 10000; private const int SaltSize 16; private const int HashSize 32; public static (string Hash, string Salt) HashPassword(string password) { byte[] salt RandomNumberGenerator.GetBytes(SaltSize); byte[] hash Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); return (Convert.ToBase64String(hash), Convert.ToBase64String(salt)); } public static bool Verify(string password, string saltBase64, string hashBase64) { byte[] salt Convert.FromBase64String(saltBase64); byte[] expected Convert.FromBase64String(hashBase64); byte[] actual Rfc2898DeriveBytes.Pbkdf2( password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); return CryptographicOperations.FixedTimeEquals(actual, expected); } }这里几个参数是经过考量的SaltSize取 16 字节防止两个相同密码产生相同哈希Iterations取 10000 是性能和安全性的折中太大会让登录请求变慢太小又扛不住暴力破解答辩时说「迭代一万次 SHA256」是有说服力的HashSize取 32 字节对应 SHA256 的输出长度。验证时用FixedTimeEquals而不是是因为普通比较在第一个不匹配字节就返回存在时间侧信道攻击风险虽然毕设场景没人会真攻击你但这个细节写出来就是加分项。4.2 发布商品与图片上传IWebHostEnvironment 的正确用法发布商品是平台的核心写入操作。常见翻车点是图片上传后存了完整物理路径换台电脑跑项目图片全丢。正确做法是通过IWebHostEnvironment拿到 WebRootPath把文件存到wwwroot/uploads下数据库只存相对 URL 路径。Controller 里的上传核心代码我习惯这样写[HttpPost] public async TaskIActionResult Create(ProductCreateViewModel model) { string uploadDir Path.Combine(_env.WebRootPath, uploads); Directory.CreateDirectory(uploadDir); string ext Path.GetExtension(model.CoverImage.FileName).ToLowerInvariant(); string[] allowed { .jpg, .jpeg, .png, .webp }; if (!allowed.Contains(ext)) return ModelState.AddModelError(CoverImage, 仅支持 jpg/png/webp 图片); string fileName Guid.NewGuid().ToString(N) ext; string savePath Path.Combine(uploadDir, fileName); await using var stream new FileStream(savePath, FileMode.Create); await model.CoverImage.CopyToAsync(stream); var product new Product { SellerId _currentUser.Id, Title model.Title, Description model.Description, Price model.Price, CategoryId model.CategoryId, CoverUrl /uploads/ fileName, Status ProductStatus.OnSale }; _db.Products.Add(product); await _db.SaveChangesAsync(); return RedirectToAction(nameof(Detail), new { id product.Id }); }Guid.NewGuid().ToString(N)生成 32 位无连字符文件名主要防止两个用户上传同名文件互相覆盖。Directory.CreateDirectory放在方法里而不是构造函数里是因为目录可能在部署时被运维清掉运行时确保存在更稳妥。扩展名白名单校验是必须的否则用户传个.aspx文件到 wwwroot 下IIS 或 Kestrel 可能把它当脚本执行这是实打实的安全隐患。CoverUrl存相对路径/uploads/xxx.jpg页面直接img srcproduct.CoverUrl就能访问不需要拼服务器地址这样项目迁移到任何机器都不受影响。4.3 商品列表搜索、分页、排序一次写全顺便处理标题截断列表页看着简单但「搜索 分页」组合在一起时很多实现会把所有数据查出来再在内存里过滤。数据量小感觉不到答辩时老师如果有测试数据一页一页翻就能发现慢。正确做法是让 EF Core 的 IQueryable 把条件组合好最后只执行一次带 WHERE 和 ORDER BY 的 SQLpublic async TaskPagedResultProductListItemDto GetOnSaleProductsAsync( string keyword, int categoryId, int page, int pageSize) { var query _db.Products .AsNoTracking() .Where(p p.Status ProductStatus.OnSale !p.IsDeleted); if (!string.IsNullOrWhiteSpace(keyword)) query query.Where(p p.Title.Contains(keyword) || p.Description.Contains(keyword)); if (categoryId 0) query query.Where(p p.CategoryId categoryId); int total await query.CountAsync(); var items await query .OrderByDescending(p p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p new ProductListItemDto { Id p.Id, Title p.Title.Length 20 ? p.Title.Substring(0, 20) ... : p.Title, Price p.Price, CoverUrl p.CoverUrl }) .ToListAsync(); return new PagedResultProductListItemDto(items, total, page, pageSize); }AsNoTracking()告诉 EF 只读查询不需要做变更跟踪减少内存开销。CountAsync和ToListAsync会分别生成一条 SQL总记录数和当前页数据各查一次这是分页的标准姿势。Skip / Take的page参数一定要做边界保护前端传page-1时Skip会报错我一般在入口处先page Math.Max(1, page)。标题截断就是热搜里那个「C# 怎样截取字符串」的典型场景C# 的Substring按 UTF-16 字符截取中英文都算一个 char所以Substring(0, 20)不会把中文切半成乱码但必须先用Length判断是否超长否则会抛ArgumentOutOfRangeException。如果你要处理 Emoji 这类代理对字符就得用StringInfo但毕设场景到Substring加判断已经够用。4.4 下单闭环并发与事务的正确姿势下单是整张试卷的压轴题。最粗浅的实现是先查商品状态是「在售」就改成「已售」再插一条订单。这个「先查后改」在单用户演示时没问题一旦两个用户同时对同一商品下单两个请求都查到了「在售」然后都去更新最后商品被卖了两遍这就是经典的 check-then-act 并发缺陷。我在 C# 里一般用事务加条件更新来兜底using var tx await _db.Database.BeginTransactionAsync(); int updated await _db.Products .Where(p p.Id productId p.Status ProductStatus.OnSale) .ExecuteUpdateAsync(s s .SetProperty(p p.Status, ProductStatus.Sold) .SetProperty(p p.BuyerId, buyerId) .SetProperty(p p.UpdatedAt, DateTime.Now)); if (updated 0) { await tx.RollbackAsync(); return Json(new { ok false, msg 手慢了商品已被下单 }); } _db.Orders.Add(new Order { OrderNo DateTime.Now.ToString(yyyyMMddHHmmss) Random.Shared.Next(1000, 9999), ProductId productId, SellerId sellerId, BuyerId buyerId, Amount price, Status OrderStatus.PendingPayment }); await _db.SaveChangesAsync(); await tx.CommitAsync();这里的核心是ExecuteUpdateAsync带WHERE p.Status OnSale条件数据库在更新时会锁住匹配的行第二个并发请求的 UPDATE 影响行数为 0直接走「手慢了」分支。这比先查再改安全一个量级而且只发一条 SQL不需要在内存里判断状态。事务包裹了「更新商品 插入订单」两步任何一个失败整体回滚不会出现商品标记已售但订单没插进去的中间态。OrderNo用时间加随机数生成不依赖自增 ID这样订单号在外观上更像正式系统也避免自增 ID 被遍历猜测。EF Core 需要.ExecuteUpdateAsync时记得using Microsoft.EntityFrameworkCore;泛型委托s s.SetProperty(...)就是 C# 泛型和表达式树的组合用法老师如果追问你能讲出「表达式树被翻译成 SQL SET 子句」就已经超过大多数同学了。5. 常见问题排查图片丢失、中文乱码、并发下单翻车的五个现场5.1 图片上传后页面就 404wwwroot 与静态文件中间件现象上传时报错FileStream提示找不到目录或者上传成功但图片访问 404。 原因IWebHostEnvironment.WebRootPath指向的wwwroot/uploads目录不存在或者 Program.cs 里漏了app.UseStaticFiles()。ASP.NET Core 默认模板会调用静态文件中间件但有人会手滑删掉目录不存在则完全是运行时问题。 解决在Program.cs确保app.UseStaticFiles()存在上传方法开头先Directory.CreateDirectory(uploadDir)不要假设目录一定在。这两个动作加起来三行代码能消灭九成图片相关 bug。5.2 中文全部变成问号VARCHAR 与 NVARCHAR 的选择现象商品标题、留言里的中文存进去再读出来全是???。 原因SQL Server 里VARCHAR按代码页存储对中文支持差正确类型是NVARCHAR它使用 UCS-2 编码能直接存中文。很多人从 MySQL 的习惯带过来顺手写了VARCHAR于是翻车。 解决所有可能存中文的列统一用NVARCHAR在建库时指定排序规则Chinese_PRC_CI_AS连接字符串里不要额外加字符集参数保持默认。已经建错的表用ALTER TABLE ... ALTER COLUMN改列类型即可不需要重建库。5.3 同一商品被两个人同时下单成功线程安全与状态约束现象两个浏览器同时点「立即购买」两个订单都创建成功商品卖了两遍。 原因代码是「先查状态 → 内存判断 → 再更新」的三步两个线程都通过了判断没有在数据库层面加唯一约束或条件更新。 解决订单表加UNIQUE (ProductId)约束作为最后一道防线下单逻辑用第 4.4 节的ExecuteUpdateAsync条件更新让数据库来决定谁成功。这个 bug 是并发问题的典型样本也是 C# 线程知识在项目里的落地点答辩被问「多线程下你的系统怎么保证一致性」时直接讲这一段。5.4 一启动项目数据库就被清空重来EnsureCreated 与迁移的混乱现象每次运行程序之前注册的用户、发布的商品全部消失或者在DbInitializer里写Database.EnsureDeleted()一调试数据就没。 原因为了开发省事用的EnsureCreated或每次启动重建策略。它在模型变更时会删库重建生产环境这么干等于灾难。 解决开发期也不要走EnsureDeleted。用 EF Core Migration先dotnet ef migrations add Init再dotnet ef database update后续改模型再加新迁移。这样数据能跨版本保留。毕设如果只需要演示至少把初始化器改成「仅在空库时 Seed 数据」判断SetProduct().Any()为 false 才插入测试数据。5.5 搜索带中文的关键词直接报错 400URL 编码与手拼地址现象搜索框输入「自行车」点搜索页面报错或者后端拿到的 keyword 是乱码。 原因前端把中文关键词直接拼进 URL比如location.href /search?kw keyword浏览器虽然会自动编码部分字符但手拼时某些场景不会或者你用了Request.QueryString手动解析导致乱码。 解决前端用encodeURIComponent(keyword)编码后端用[FromQuery] string keyword让框架自动解码不要在 Controller 里手动HttpUtility.UrlDecode二次处理。这只是个小点但中文搜索是二手平台的刚需很多人卡在这一步很冤。6. 离「能答辩」还差一步用五个小验证手段自查这套平台代码写完不等于项目交付。我帮人审毕设代码时养成了一个习惯先不看功能清单而是直接跑一条完整的交易链路。一个平台只要「注册 → 发布商品 → 浏览详情 → 下单 → 修改状态 → 确认收货」能闭环走通骨架就是健康的。我建议你也按这个顺序自查它比任何模块列表都更能证明系统完整性。验证手段之一是准备一份造数脚本往商品表里插入五十到一百条测试数据专门用来验证列表页分页、搜索和排序。用 SQL 循环插入即可注意CreatedAt要错开时间否则按时间排序看不出效果。手段之二是并发下单验证开两个无痕浏览器窗口同时对同一商品下单正确的行为是只有一个成功另一个收到友好提示。手段之三是全局搜索源代码里的空catch块凡是catch (Exception ex)里没有Log或ModelState.AddModelError的地方都是黑匣子答辩时演示出错你会完全不知道发生了什么。手段之四是检查所有写操作是否都有「成功提示 失败提示」两条路径用户点击按钮后界面必须有反馈。手段之五是给商品详情页的 URL 加一层不可枚举的 ID 编码比如把自增 ID 转成短哈希防止别人通过改 URL 参数遍历平台商品这个小技巧在答辩演示时提一句效果很好。我自己的血泪教训是第一次帮人看毕设时登录注册看起来很完整但到下单环节连「商品已售空」的提示都没有页面直接报黄页异常。从那以后我要求自己任何演示都必须先走一遍主链路再谈功能多少。这个习惯救过我很多次。希望这篇笔记能让你把这份 C# 二手闲置交易平台源代码真正吃透在答辩台上讲到状态机、并发事务、数据库约束这些硬知识点时心里是有底的。希望帮到你。本文还有配套的精品资源点击获取