资讯动态

ASP.NET(C#)三层架构CRM系统源码解析与二次开发

发布时间:2026/10/22 6:15:35 来源:尧图企业网站定制
简介一套面向ASP.NETC#初学者的客户关系管理系统完整源码适合学习网站分层架构、业务模块流转与数据库操作。压缩包共包含二百九十七个文件以一百个后台逻辑代码文件、三十八个页面文件、三十二个动态链接库为主并配有图片与样式资源整体约一点一五兆字节下载和部署都很快捷。系统实现了客户信息管理、流失客户分析、服务派单、反馈列表与处理列表等常用功能从登录页面到后台管理界面形成完整闭环可帮助理解企业级信息系统的设计思路。目前已有四百四十一人学习下载其中还附带数据库主文件和日志文件便于对照检查表结构、索引与业务关联非常适合课程设计、毕业设计或实际项目起步阶段参考使用。1. 客户关系管理系统一套能直接上手的 ASP.NET(C#) 源码一个销售小组想管住手里的客户跟进记录最省事的办法不是买一套现成的销售工具而是找一套能自己改的 CRM 源码。这套 ASP.NET(C#) 客户关系管理系统属于典型的 WebForms 老工程解决方案拆成了 Model、DAL、BLL、Web 四层功能覆盖客户档案、联系人、跟进记录、商机阶段和系统用户权限数据落在 SQL Server 里。适合两类人一类是想在真实工程里跑通 ASP.NET 三层架构的开发者另一类是业务规模不大、希望自己维护一套客户关系管理系统的团队。下面我从数据组织、核心代码、跑通步骤和改造思路四个方向把这份源码拆开看。2. 技术架构与数据组织三层架构 SQL Server 的核心设计拿到源码包先不用急着用 Visual Studio 打开先看它的解决方案结构。这套工程的解决方案里一共有四个项目分别是模型层、数据访问层、业务层和 Web 站点数据库脚本单独放在 DB 目录下。先搞清楚这一层后续定位问题会快很多。2.1 技术选型WebForms ADO.NET SQL Server这套源码基于 .NET Framework 4.5页面是 .aspx 加 .aspx.cs 的代码后置模式数据访问直接用 ADO.NET数据库首选 SQL Server 2008 R2 及以上版本。和现在流行的 ASP.NET Core MVC 相比WebForms 的事件驱动模型让“按钮点击 → 服务端事件 → 执行数据库操作”这条链路更容易在代码里找到对应关系同时用服务端控件绑定列表也减少了大量前端 JavaScript 的工作量。我一般建议看这类工程时不要因为它“不够现代”就跳过。SQLHelper 里对 Connection 和 Command 的封装、BLL 里对页面传来的参数做二次校验这些写法现在换到 EF Core 或者 Dapper 项目里依然通用。理解这套结构之后再迁移到 MVC只是把事件驱动改成了路由和控制器数据层的思路几乎不用变。2.2 目录结构四个项目各管一件事把 .sln 解决方案展开之后目录大概是这样的DemoCRM ├── DemoCRM.sln ├── Model │ ├── Customer.cs │ ├── FollowRecord.cs │ ├── Opportunity.cs │ └── UserInfo.cs ├── DAL │ ├── SQLHelper.cs │ ├── CustomerDAL.cs │ └── FollowRecordDAL.cs ├── BLL │ ├── CustomerBLL.cs │ └── FollowRecordBLL.cs ├── DB │ └── demo_crm.sql └── Web ├── Web.config ├── Global.asax ├── Login.aspx ├── Default.aspx ├── Customer │ ├── CustomerList.aspx │ └── CustomerEdit.aspx └── Sys └── UserManager.aspxModel 只放实体类属性对应数据库字段DAL 里没有业务判断只有 SQL 语句和参数BLL 拿到页面传来的数据先做校验再决定是否调用 DALWeb 层只负责渲染页面和读取表单。DB 目录下的 demo_crm.sql 是建库脚本跑源码之前必须先执行它这一点后面排障章节还会提到。2.3 数据库表关系客户是所有业务的主线这个系统的表不算多核心是四张用户表、客户表、跟进记录表和商机表。字段设计看下面这张表表关键字段说明UserInfoId, UserName, Password, RealName, RoleId系统登录用户与角色CustomerId, CustomerName, ContactPerson, Phone, Level, OwnerUserId, CreateTime, UpdateTime客户档案Level 表示客户等级FollowRecordId, CustomerId, Content, FollowType, NextFollowTime, CreateTime, CreateUserId跟进记录CustomerId 关联客户OpportunityId, CustomerId, OpportunityName, Amount, Stage, CloseDate, CreateTime商机Stage 表示销售阶段客户表是整个系统的主线跟进记录和商机都通过 CustomerId 外键指向 Customer 表。建表脚本里比较关键的一段是CREATE TABLE Customer ( Id INT IDENTITY(1,1) PRIMARY KEY, CustomerName NVARCHAR(100) NOT NULL, ContactPerson NVARCHAR(50), Phone NVARCHAR(20), Level INT DEFAULT 0, OwnerUserId INT, CreateTime DATETIME DEFAULT GETDATE(), UpdateTime DATETIME DEFAULT GETDATE() );Id 是自增主键Level 用数字 0/1/2/3 表示普通、重要、VIP 等客户等级OwnerUserId 关联 UserInfo.Id列表页按销售过滤客户时就要用这个字段。CreateTime 和 UpdateTime 分别记录建档和最后修改时间统计活跃客户时经常要用。实际跑数据之后我建议在 Customer.Phone 和 FollowRecord.CustomerId 上建索引因为这个系统按电话搜索客户、按客户查跟进记录的操作频率最高没索引时数据量过两万就会明显卡顿。3. 核心模块拆解客户建档、跟进与商机的完整代码链路这一章只看三个核心场景客户列表怎么查出来、新增客户怎么做服务端校验、跟进记录和商机怎么联动。这三个页面看懂之后这套 CRM 的骨架基本就掌握了。3.1 数据访问入口SQLHelper 的封装先看所有数据访问的基础 SQLHelper.cs。DAL 层所有方法最终都落到这里核心方法是 ExecuteDataTable 和 ExecuteNonQueryusing System; using System.Configuration; using System.Data; using System.Data.SqlClient; namespace DemoCRM.DAL { public class SQLHelper { private static readonly string connStr ConfigurationManager.ConnectionStrings[DemoCRMConn].ConnectionString; // 查询返回 DataTable适合列表页绑定 public static DataTable ExecuteDataTable(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); DataTable dt new DataTable(); SqlDataAdapter adapter new SqlDataAdapter(cmd); adapter.Fill(dt); return dt; } } // 增删改返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } } }上面这段代码用 using 包裹 SqlConnection 和 SqlCommand方法结束连接自动关闭避免老项目里常见的“连接没释放导致连接池满”的问题。关键点是所有参数都走 SqlParameter没有拼字符串这是防 SQL 注入的基本底线。ExecuteDataTable 返回整张表适合一次性绑定ExecuteNonQuery 返回受影响行数适合 INSERT、UPDATE、DELETE。params 可变参数让调用方可以灵活传入任意个参数这也是整套 DAL 最常用的入口。3.2 客户列表一次请求从 Page_Load 到数据库的完整链路客户列表页是整套系统的门面链路也最完整。先从 DAL 看起CustomerDAL.GetList 负责带搜索条件的分页查询public DataTable GetList(string keyword, int pageIndex, int pageSize, out int total) { string sql SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY UpdateTime DESC) AS RowNo, * FROM Customer WHERE 11; ListSqlParameter ps new ListSqlParameter(); if (!string.IsNullOrEmpty(keyword)) { sql AND (CustomerName LIKE kw OR ContactPerson LIKE kw OR Phone LIKE kw); ps.Add(new SqlParameter(kw, % keyword %)); } sql ) t WHERE RowNo BETWEEN start AND end; string countSql SELECT COUNT(1) FROM Customer WHERE 11; if (!string.IsNullOrEmpty(keyword)) { countSql AND (CustomerName LIKE kw OR ContactPerson LIKE kw OR Phone LIKE kw); } total Convert.ToInt32(SQLHelper.ExecuteDataTable(countSql, ps.ToArray()).Rows[0][0]); ps.Add(new SqlParameter(start, (pageIndex - 1) * pageSize 1)); ps.Add(new SqlParameter(end, pageIndex * pageSize)); return SQLHelper.ExecuteDataTable(sql, ps.ToArray()); }这里用了 SQL Server 的 ROW_NUMBER() 做分页而不是 OFFSET FETCH是为了兼容 2008 R2。keyword 为 null 时不会拼 LIKE 条件参数列表也不会添加 kw避免 SQL Server 对“参数声明了但没使用”报错。total 和分页数据两次查询分开做没有用 COUNT(*) OVER()是因为老版本 SQL Server 支持有限这个写法最稳。BLL 层做了一层参数兜底public DataTable GetList(string keyword, int pageIndex, int pageSize, out int total) { if (pageIndex 1) pageIndex 1; if (pageSize 1) pageSize 10; return dal.GetList(keyword, pageIndex, pageSize, out total); }BLL 在调 DAL 之前先修正非法页码防止页面直接传 page0 或负数导致 SQL 计算错误。业务层在这里还能扩展后续逻辑比如按当前登录用户的角色过滤客户归属需要加权限时改这一层就够了。最后是 Web 层的 Page_Load 绑定protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { BindList(); } } private void BindList() { string keyword txtKeyword.Text.Trim(); int pageIndex 1; if (!string.IsNullOrEmpty(Request[page])) pageIndex Convert.ToInt32(Request[page]); int total 0; DataTable dt new CustomerBLL().GetList(keyword, pageIndex, 10, out total); rptCustomer.DataSource dt; rptCustomer.DataBind(); }IsPostBack 判断是为了避免每次回发都重新绑定数据否则翻页按钮点击之后数据会被刷新两次搜索框的焦点也会丢失。页码从 Request[page] 读取搜索框用 txtKeyword 控件取值。对应的 Repeater 模板大致是这样asp:Repeater IDrptCustomer runatserver ItemTemplate tr td%# Eval(CustomerName) %/td td%# Eval(Phone) %/td tda hrefCustomerEdit.aspx?id%# Eval(Id) %编辑/a/td /tr /ItemTemplate /asp:Repeater整个链路就是 Page_Load → CustomerBLL.GetList → CustomerDAL.GetList → SQLHelper.ExecuteDataTable → Repeater.DataSource方向单一、层次清楚。排查问题时按这个方向从上往下查页面没数据先看 BLL 拿到的 dt 是否为 null再断点进 DAL 单独执行 SQL基本一轮就能定位。3.3 新增客户服务端校验与查重逻辑新增客户的页面重点不在界面而在设计保存按钮的服务端逻辑。下面这段是 CustomerEdit.aspx.cs 里的保存事件protected void btnSave_Click(object sender, EventArgs e) { string name txtCustomerName.Text.Trim(); string contact txtContactPerson.Text.Trim(); string phone txtPhone.Text.Trim(); int level Convert.ToInt32(ddlLevel.SelectedValue); // 客户名称必填 if (string.IsNullOrEmpty(name)) { lblMsg.Text 客户名称不能为空; return; } // 手机号非必填但填了就要校验格式 if (!string.IsNullOrEmpty(phone) !Regex.IsMatch(phone, ^1[3-9]\d{9}$)) { lblMsg.Text 手机号格式不正确; return; } int ownerUserId Convert.ToInt32(Session[UserId]); int result new CustomerBLL().AddCustomer(name, contact, phone, level, ownerUserId); if (result 0) lblMsg.Text 客户已存在请勿重复建档; else Response.Redirect(CustomerList.aspx); }Session[UserId] 是登录时写入的当前用户 ID新客户的 OwnerUserId 会自动归属到当前登录人。手机号正则只匹配大陆手机号如果只是内部演示可以放宽成更简单的校验。关键在 AddCustomer 的业务逻辑里做了查重public int AddCustomer(string name, string contact, string phone, int level, int ownerUserId) { if (dal.ExistsByName(name)) return -1; return dal.InsertCustomer(name, contact, phone, level, ownerUserId); }DAL 的 InsertCustomer 最终是这么一条参数化 SQLINSERT INTO Customer(CustomerName, ContactPerson, Phone, Level, OwnerUserId, CreateTime, UpdateTime) VALUES(name, contact, phone, level, owner, GETDATE(), GETDATE());这里返回负数表示失败0 或正数表示成功。页面拿到负数后不跳转直接在页面上提示“客户已存在”。为什么要在服务端查重而不是前端查因为前端查重只能拦正常操作绕过页面直接调接口的请求还是要靠服务端兜底。这个习惯在真实项目里非常重要。3.4 跟进记录与商机以客户为主线的数据联动跟进记录页按客户 ID 展示时间线核心查询很简单public DataTable GetListByCustomerId(int customerId) { string sql SELECT Content, FollowType, NextFollowTime, CreateTime FROM FollowRecord WHERE CustomerId customerId ORDER BY CreateTime DESC, Id DESC; return SQLHelper.ExecuteDataTable(sql, new SqlParameter(customerId, customerId)); }ORDER BY CreateTime DESC, Id DESC 是为了处理同一秒内多条跟进记录的场景只按 CreateTime 排序会有随机性。FollowType 在页面显示时需要转换成电话、拜访、微信、其他等文字转换逻辑放在页面里用 Enum.GetName 处理即可。商机阶段的更新则是直接 UPDATE Opportunity 表的 Stage 字段UPDATE Opportunity SET Stage stage, CloseDate closeDate WHERE Id id;Stage 的取值 0/1/2/3 分别对应初始、已联系、已报价、已成交统计报表里的漏斗图就是按这个字段分组 COUNT 出来的。列表页要显示“最近一条跟进内容”常用子查询实现SELECT c.Id, c.CustomerName, (SELECT TOP 1 Content FROM FollowRecord f WHERE f.CustomerId c.Id ORDER BY CreateTime DESC) AS LastFollow FROM Customer c WHERE c.OwnerUserId userId;这种子查询在数据量小的时候没问题数据量过五万之后会变慢。常见做法是给 FollowRecord.CreateTime 建索引或者直接在 Customer 表冗余一个 LastFollowTime 字段每次写入跟进记录时同步更新牺牲一点更新成本换查询速度。老工程项目里我会先选索引方案因为改动最小。4. 常见问题与排障四个高频翻车点及修复方法这套源码在 Visual Studio 里打开后真正让人多花时间的往往不是业务代码而是环境和配置。下面几条是实际运行中翻车频率最高的几个位置按出现概率排序。4.1 数据库连接失败先跑脚本再改连接串现象运行登录页直接报“无法打开登录所请求的数据库。登录失败”SQLException 的 ErrorNumber 通常是 4060 或 18456。原因三个地方不一致——SQL Server 实例名写错、数据库名和脚本里的库名不一致、登录账号权限不对。很多人先编译后跑结果卡在第一步。解决拿到源码第一件事先用 SSMS 打开 DB/demo_crm.sql 执行建库再改 Web.config 里的连接字符串。标准写法是add nameDemoCRMConn connectionStringServer.;DatabaseDemoCRM;User Idsa;Password123456;MultipleActiveResultSetsTrue providerNameSystem.Data.SqlClient/Server 是当前机器默认实例如果装了具名实例就写成 主机名\实例名。SQL Server 用 Windows 登录时把 User Id 改成 Integrated SecurityTrue。连接字符串改完之后用 SQLHelper 里的 ExecuteDataTable 跑一条 SELECT 1 验证连通性再继续。4.2 列表分页不生效GridView 和 Repeater 都要重新绑定数据现象GridView 把 AllowPaging 设为 True 之后底部分页条出现了但翻到第 2 页数据还是原来的内容或者干脆一次性渲染全部数据。原因WebForms 的分页是“假分页”。AllowPaging 只是让控件渲染分页条真正取数仍然要走 PageIndexChanging 事件重新查当前页。这套源码里的用户管理页就踩过这个坑。解决手动处理分页事件核心代码是protected void gvUser_PageIndexChanging(object sender, GridViewPageEventArgs e) { gvUser.PageIndex e.NewPageIndex; BindUserList(); }e.NewPageIndex 是用户点击的目标页码赋值给控件的 PageIndex 之后再重新调用数据绑定方法每次翻页都重新从数据库取当前页数据。不要用 ViewState 把整表数据存起来数据量一大页面回发体积会快速膨胀最终变成灾难。4.3 中文乱码varchar 和 nvarchar 的区别在哪现象录入“某科技有限责任公司”之后列表页显示成“???”或者页面源码里中文正常、数据库里已经成了乱码。原因涉及三个层面——页面编码不是 UTF-8、数据库字段用了 varchar、连接字符串没有走 Unicode。varchar 按数据库默认代码页存中文换实例或换排序规则就出问题。解决建表字段统一用 nvarchar页面 head 加 meta charsetutf-8已经建错的表用 SQL 改字段类型ALTER TABLE Customer ALTER COLUMN CustomerName NVARCHAR(100);ALTER COLUMN 会把已有数据重写为 Unicode如果旧数据已经乱码改了字段类型也救不回来只能清理后重录。这个问题的根因在“建表时没想清楚字符集”所以 DB 脚本里的字段类型比后续代码更重要属于最耗费时间的隐蔽坑。4.4 IIS 部署报 500 与样式丢失应用池和路径问题现象本机 IIS Express 跑得好好的发布到服务器 IIS 上打开页面报 500.19或者页面边框和样式全部丢失。原因500.19 多半是 web.config 里引用了当前 IIS 版本不认识的配置节或者应用池运行模式不对。样式丢失则是因为母版页嵌套目录时CSS 用相对路径计算错误。解决先把 IIS 站点应用池改为 Integrated 模式.NET CLR 版本选 v4.0.30319如果报 500.19注释掉 web.config 里报错那一行再逐段排除。样式路径问题用 ResolveUrl 解决link relstylesheet href% ResolveUrl(~/Content/site.css) % /ResolveUrl 从站点根目录计算路径不依赖当前页面所在目录母版页换目录、页面嵌多深都不会失效。同理图片和脚本地址也优先用这种方式不要写死相对路径。5. 二次开发实践从 WebForms 到接口化改造的最小路径拿到源码的人大多想干两件事加权限控制、对外提供接口。这里我建议不要上来就推倒重写 MVC而是先用最小的侵入式改造把边界划清楚。5.1 改造顺序先抽象接口再换数据层第一步是把 CustomerBLL 的方法签名抽成接口让 Web 层依赖接口而不是具体的 BLL 类public interface ICustomerService { DataTable GetList(string keyword, int pageIndex, int pageSize, out int total); int AddCustomer(string name, string contact, string phone, int level, int ownerUserId); } public class CustomerService : ICustomerService { private readonly CustomerBLL _bll new CustomerBLL(); public DataTable GetList(string keyword, int pageIndex, int pageSize, out int total) { // 可以在这里做缓存比如 MemoryCache 缓存热点客户列表 return _bll.GetList(keyword, pageIndex, pageSize, out total); } }改造完成后WebForm 页面的代码从 new CustomerBLL() 换成找容器拿 ICustomerService数据访问仍然走原来的 DAL。后续要把 ADO.NET 换成 Dapper 或 EF Core只需要新增一个实现类页面完全不用动。这个改造对老工程最合适因为风险点只在接口边界上不会波及整棵页面树。5.2 最小验证从登录到商机推进跑通一条客户链路改造完不要急着写新页面先按下面五步做最小功能验证重新执行 DB/demo_crm.sql 刷新数据库用脚本里初始化的管理员账号登录确认登录跳转到 Default.aspx新增客户确认返回列表且没有“客户已存在”提示给客户添加一条跟进记录到 FollowRecord 表里确认 CustomerId 关联正确把商机 Stage 从 0 改到 3确认列表页的统计数字变化。每步的检查点分别是数据库能否连上、登录凭据是否正确、插入数据是否带着 OwnerUserId、外键关联是否生效、状态流转是否被统计逻辑感知。这条链路通一遍就说明数据层和业务层的配合没有大问题接下去再动界面样式或再做权限体系都不会走偏。从那以后我每次接手这类源码都会先花十分钟确认数据库脚本和连接串再跑通“登录、建档、跟进、商机”这条最小闭环只有基础链路通了才敢继续动界面和接口。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑