☰
ASP.NET C#自研CRM系统:架构选型、权限设计与性能优化
2026/9/25 4:52:15 网站建设 项目流程

简介:面向需要学习ASP.NET(C#)Web开发与CRM业务流程的开发者,这套客户关系管理系统完整源码以典型企业级场景为依托,覆盖客户档案管理、服务派发、流失分析与反馈处理等核心环节,可直接用于课程设计、毕业设计或项目起步参考。压缩包共297个文件,约1.15MB,其中包含100个C#业务逻辑文件、38个页面文件、32个动态链接库,以及界面图片、数据库主文件与日志文件、解决方案文件等,从登录验证到后台管理层次分明,结构清晰便于按模块查阅。目前已有439人学习下载,适合具备一定C#与SQL基础的读者深入研习。通过源码可快速掌握权限控制、数据绑定、页面跳转、常用控件使用等技巧,尤其适合围绕客户关系管理主题进行二次开发与功能扩展,是一份难得的实战型参考资料。

1. 用 ASP.NET(C#) 自研 CRM:先想清楚它到底解决什么问题

用 ASP.NET(C#) 做 CRM(客户关系管理系统),是很多内部系统团队都动过的念头。公司销售把客户名单存在 Excel 和微信聊天记录里,人一走客户也跟着走;管理者想知道本周新增了多少客户、哪个客户该跟进,只能等人工拉表。CRM 要做的就是把这些数据从个人手里收归公司层面,靠权限、跟进记录和报表让业务过程透明。我用这套技术栈给两家公司交付过内网 CRM,最深的体感是:增删改查永远不是难点,权限边界、数据审计和多用户同时操作才是。这篇笔记适合会一点 C#、想独立交付一套 ASP.NET CRM 的开发者,也适合正在评估自研而不是直接注册一个免费 CRM 账号的团队——本质区别就一句话:数据在谁手里,以及能不能按业务改。

2. 架构与数据访问选型:别急着写页面,先把三层拆明白

2.1 Web Forms 还是 MVC:先回答三个问题再动手

很多人在写 CRM 第一行代码前,卡在框架选型上。我的建议是先回答三个问题:团队手上有没有跑了几年的老系统?用户量是几十人还是上千人?后续要不要接移动端或对外接口?

如果老系统是 Web Forms,改造时继续用 Web Forms 最划算。内网 CRM 的典型形态就是一堆表单加一堆列表页,GridView 自带分页排序,Button 直接绑事件,开发效率在.net Framework 时代是被验证过的。如果团队更熟 MVC、要做前后端分离、要预留 Web API 给手机端,那就走 ASP.NET Core MVC 或者 Razor Pages。企业级要直接上微软 Dynamics CRM 本地部署,实施和定制成本都很高,小团队才考虑自研这套东西。

我自己倾向的判断是:用户量几十到几百、页面以表单和列表为主、开发周期两三个月的 CRM,用 Web Forms 完全够用;新项目且团队愿意学新东西,用 ASP.NET Core MVC。下面这张表是我的经验值,不是绝对答案。

方案适合场景不适合场景我的建议
Web Forms(.NET Framework 4.x)内网小团队、表单+列表为主、要快速交付要接口优先、要做单元测试老团队不要为了新而新
ASP.NET Core MVC / Razor Pages新项目、团队熟路由和中间件只有 Windows 服务器没有 Linux 需求也可以选新项目优先考虑

无论选哪条路,解决方案里都要把 UI、业务、数据访问三层拆开。我见过太多 CRM 项目把 SQL 写在页面后台代码里,三个月后改一个字段名要全局搜索十几个页面。拆三层不是架构洁癖,是给你自己留后悔药。

2.2 DAL 层选型:EF、Dapper、存储过程怎么搭配

数据访问层是 CRM 最容易被写坏的地方。EF 的跟踪机制在单表操作时很省事,但延迟加载在列表页容易搞出 N+1 查询;Dapper 的 SQL 完全可控,性能扎实,但每张表的增删改查都要手写;存储过程派适合有专职 DBA 的公司,小团队别硬上。

我的常见做法是混合用:复杂报表查询走 Dapper,单表增删改走 EF Core。下面这段是一个客户分页查询的典型写法,SQL 里直接用 OFFSET/FETCH 分页,比先把整表查出来再在内存里 Skip/Take 靠谱得多。

using Dapper; using System.Collections.Generic; using System.Data; using System.Data.SqlClient; public class CustomerRepository { private readonly string _connStr; public CustomerRepository(string connStr) { _connStr = connStr; } public (List<Customer> list, int total) GetPage(int pageIndex, int pageSize, string keyword) { using (IDbConnection conn = new SqlConnection(_connStr)) { string countSql = @" SELECT COUNT(*) FROM Customer WHERE IsDeleted = 0 AND (CustomerName LIKE @kw OR Mobile LIKE @kw)"; int total = conn.ExecuteScalar<int>(countSql, new { kw = "%" + keyword + "%" }); string dataSql = @" SELECT CustomerId, CustomerName, Mobile, LevelType, OwnerUserId, CreateTime FROM Customer WHERE IsDeleted = 0 AND (CustomerName LIKE @kw OR Mobile LIKE @kw) ORDER BY CreateTime DESC OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY"; var list = conn.Query<Customer>(dataSql, new { kw = "%" + keyword + "%", offset = (pageIndex - 1) * pageSize, pageSize = pageSize }).AsList(); return (list, total); } } }

这里有两个参数要特别说明。第一,OFFSET ... FETCH NEXT要求 SQL Server 2012 及以上版本,老库还在用 2008 的话,要改成ROW_NUMBER() OVER (ORDER BY CreateTime DESC)的写法。第二,new { kw = "%" + keyword + "%" }这个匿名对象的属性名必须和 SQL 里的参数名@kw一致,Dapper 靠这个做参数映射,拼错一个字母查询就直接报错。

EF Core 那边则要注意 Include 的用法。查询客户列表时如果要显示所属销售的名字,千万不能在循环里访问c.Owner.Name,否则每个客户多一条 SQL。正确写法是查询时一次性 Include 进去:

using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System.Linq; public List<Customer> GetCustomersWithOwner() { return _ctx.Customers .Where(c => c.IsDeleted == false) .Include(c => c.Owner) // 一次性 LEFT JOIN 用户表,避免 N+1 .OrderByDescending(c => c.CreateTime) .ToList(); }

Include(c => c.Owner)会让 EF 生成一条带 JOIN 的 SQL,把客户和负责人一起查出来。等值条件、排序、分页这些能下推到数据库的,就不要在内存里做。

2.3 权限模型:把页面权限和归属权限分开建

CRM 的权限和普通后台管理系统不一样。普通后台只要分管理员和普通用户,CRM 还必须管数据归属:老板看全部客户,主管看本组客户的跟进,销售只能看自己名下的客户。这个模型要在一开始就定好,后面改起来成本极高。

我的做法是两张基础表加一个归属字段。用户表、角色表、用户角色关联表管页面权限,Customer表上放一个OwnerUserId字段管数据归属。页面权限靠基类统一拦截,数据归属靠每个查询强制带过滤条件。先看基类:

using System; using System.Collections.Generic; using System.Web.UI; public class BasePage : Page { protected int CurrentUserId { get; private set; } protected bool IsAdmin { get; private set; } protected override void OnInit(EventArgs e) { base.OnInit(e); if (Session["UserId"] == null) { Response.Redirect("~/Login.aspx"); return; } CurrentUserId = (int)Session["UserId"]; IsAdmin = Session["IsAdmin"] != null && (bool)Session["IsAdmin"]; } protected bool HasPermission(string permissionCode) { // 简化写法:登录时把权限点集合放进 Session var perms = Session["UserPermissions"] as HashSet<string>; return perms != null && perms.Contains(permissionCode); } }

所有业务页面继承BasePage而不是直接继承Page,登录校验就全覆盖了。数据归属的过滤不能写在页面里,要下沉到 DAL 层,每个查询方法都强制带ownerUserId参数,避免某个程序员在某次迭代里漏掉条件,把全公司的客户暴露给所有销售。

3. 客户管理、跟进记录和报表:CRM 的核心模块这样落地

3.1 客户资料维护:唯一性校验和软删除一个都不能少

客户表是 CRM 的地基。我见过最惨的翻车现场是:没做唯一性校验,同一个手机号被不同销售建了三条客户记录,月底对账时吵成一团。基础表结构建议这样设计:

CREATE TABLE Customer ( CustomerId INT IDENTITY(1,1) PRIMARY KEY, CustomerName NVARCHAR(100) NOT NULL, Mobile NVARCHAR(20) NOT NULL, LevelType TINYINT NOT NULL DEFAULT 0, -- 0普通 1重要 2VIP OwnerUserId INT NOT NULL, -- 归属销售 IsDeleted BIT NOT NULL DEFAULT 0, -- 软删除标记 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), UpdateTime DATETIME NOT NULL DEFAULT GETDATE() );

软删除是为了给误删操作留后悔药,但软删除和唯一索引有个经典冲突:手机号建了普通唯一索引后,删掉一条记录再新增同手机号的客户,会直接撞索引。SQL Server 2008 之后的版本可以用过滤唯一索引解决:

CREATE UNIQUE INDEX UX_Customer_Mobile_Active ON Customer(Mobile) WHERE IsDeleted = 0;

这个索引只在IsDeleted = 0的行上生效,被软删除的记录不参与唯一性约束,既防重复建档又不挡恢复。保存客户的 C# 代码里还要加一层业务校验,不能只靠数据库兜底:

public void SaveCustomer(Customer c) { string sql = @" SELECT COUNT(1) FROM Customer WHERE Mobile = @mobile AND IsDeleted = 0 AND CustomerId <> @excludeId"; int exists = _conn.ExecuteScalar<int>(sql, new { mobile = c.Mobile, excludeId = c.CustomerId }); if (exists > 0) throw new BusinessException("该手机号已存在,不能重复建档"); if (c.CustomerId == 0) _repository.Insert(c); else _repository.Update(c); }

excludeId是编辑场景的关键参数:新增时传 0,编辑时传当前客户 ID,否则修改客户资料时会被自己的手机号拦住。BusinessException在页面层统一 catch,弹出提示,不要把异常堆栈直接甩给用户。

3.2 跟进记录:谁、何时、做了什么,三个字段一个都别省

客户资料是静态数据,跟进记录才是 CRM 里最有业务价值的动态数据。这条表的设计原则是:每条跟进必须能回答"谁在什么时间对哪个客户做了什么"。只记一句内容不记人的跟进表,复盘时就是一笔糊涂账。

CREATE TABLE FollowLog ( FollowId INT IDENTITY(1,1) PRIMARY KEY, CustomerId INT NOT NULL, -- 客户ID Content NVARCHAR(500) NOT NULL, -- 跟进内容 NextFollowTime DATETIME NULL, -- 下次跟进时间 CreateBy INT NOT NULL, -- 跟进人 CreateTime DATETIME NOT NULL DEFAULT GETDATE() );

写入逻辑我用了一个带事件的服务类。这样做的好处是:保存跟进这个动作本身只做两件事,写日志和更新客户表的最新跟进时间。其他业务想监听跟进动作(比如写审计、发通知),订阅事件就行,不污染主流程。

public class FollowLogService { public event EventHandler<FollowLogSavedEventArgs> Saved; public void Save(FollowLog log) { if (string.IsNullOrWhiteSpace(log.Content)) throw new BusinessException("跟进内容不能为空"); log.CreateTime = DateTime.Now; _repository.Insert(log); // 同步更新客户表的最新跟进时间,首页"今日待跟进"依赖这个字段 _repository.UpdateLastFollowTime(log.CustomerId, log.CreateTime); Saved?.Invoke(this, new FollowLogSavedEventArgs(log)); } }

这里用事件而不是直接调审计方法,是 C# 事件和委托的典型应用场景。以后要加"跟进后自动给主管发提醒"的功能,只需要在Application_Start或模块初始化时订阅Saved事件,不需要改动Save方法内部一行代码。

录入体验也要注意:跟进记录的录入页要短、打开要快。销售是在通话刚结束的空隙里记跟进的,页面跳三次、字段有八个,他就回去用 Excel 了。

3.3 列表报表:GridView 分页、排序与导出 Excel

列表页是 Web Forms 做 CRM 最顺手的地方,GridView 自带分页排序,但直接用 SqlDataSource 绑定会有个坑:翻页和排序是独立事件,处理不好会出现"翻到第三页后点排序,结果只有第三页的数据参与排序"。常见做法是手动绑定,翻页和排序都重新查一次数据:

protected void gvCustomers_PageIndexChanging(object sender, GridViewPageEventArgs e) { gvCustomers.PageIndex = e.NewPageIndex; BindCustomerList(); // 重新执行带分页的查询,而不是从 ViewState 里取旧数据 } protected void gvCustomers_Sorting(object sender, GridViewSortEventArgs e) { ViewState["SortExpression"] = e.SortExpression; BindCustomerList(); }

绑定数据的逻辑里拼排序字段,注意排序字段不能直接拼接用户输入,要用白名单映射。GridView 本身的分页排序对新手够用,但表格交互(行高亮、删除确认框)需要 jQuery 辅助,这就是很多人找 ASP.NET 的 GridView 的 jQuery 插件的原因。其实不用插件也行,一个事件委托就能解决:

$(document).on('click', '#gvCustomers tr', function () { $('#gvCustomers tr').removeClass('row-selected'); $(this).addClass('row-selected'); });

导出 Excel 是这个模块里最容易翻车的地方。很多人的第一版是直接把 GridView 渲染成 HTML 表格塞给 Response,结果用户用 Excel 打开全是乱码。问题的根源有两个:编码没带 BOM,以及文件扩展名和内容格式不匹配。下面这段是带 BOM 的导出写法:

protected void btnExport_Click(object sender, EventArgs e) { DataTable dt = GetReportData(); Response.Clear(); Response.Buffer = true; Response.Charset = "UTF-8"; Response.ContentEncoding = Encoding.UTF8; Response.ContentType = "application/vnd.ms-excel"; Response.AddHeader("Content-Disposition", "attachment;filename=CustomerReport.xls"); // 写入 UTF-8 BOM,否则 Excel 打开 UTF-8 无 BOM 文件会按 ANSI 解析,中文变乱码 Response.BinaryWrite(Encoding.UTF8.GetPreamble()); StringWriter sw = new StringWriter(); HtmlTextWriter htw = new HtmlTextWriter(sw); htw.RenderBeginTag(HtmlTextWriterTag.Table); // 这里遍历 DataTable 按单元格写 <tr><td>,比直接 RenderControl(gvCustomers) 更可控 htw.RenderEndTag(); Response.Write(sw.ToString()); Response.End(); }

Response.BinaryWrite(Encoding.UTF8.GetPreamble())这一行是关键,在输出 HTML 内容之前先写入 BOM 字节。另外要注意,Response.End()会抛出 ThreadAbortException,在老代码里属于正常行为,别在 catch 里把它当错误吞掉。

4. CRM 项目避坑:会话过期、导出乱码、权限漏检这几处最值得记

4.1 编辑页面写到一半会话过期,保存时直接回到登录页

现象:销售在客户编辑页面停留了 20 分钟,填完资料点保存,页面跳回登录页,刚才输入的内容全部丢失。这种问题最像玄学,有时候 10 分钟就出现,有时候一小时也没事。

原因:ASP.NET 默认 Session 超时是 20 分钟,IIS 应用池回收也会清空内存中的 Session。内网 CRM 用户习惯开着页面去干别的,回来继续填,正好踩中这个时间点。

解决:Web.config里调长超时时间,并确认应用池的固定回收间隔不要设太短。

<system.web> <sessionState mode="InProc" timeout="120" /> </system.web>

这只是一种缓解。真正靠谱的做法是把 Session 存到 SQL Server 或 Redis,mode="StateServer"或mode="SQLServer",这样 IIS 应用池回收也不丢会话。更实用的兜底方案是编辑页定时自动保存草稿,哪怕会话丢了,刷新页面还能把草稿捞回来。CRM 的会话过期问题在传统 Web Forms 项目里会伴随整个生命周期,我建议上线前就把 Session 持久化配好,别等项目跑起来再改。

4.2 GridView 导出的 Excel 中文乱码,与 2003/2007 格式选择

现象:导出客户名单后,Excel 打开全是乱码,或者中文全变成问号。同一份代码在开发机上正常,放到服务器上就乱。

原因:开发机浏览器直接用 Excel 打开,服务器环境下 Response 的编码没设置,默认按 ANSI 发送,UTF-8 的中文内容自然被拆坏。

解决:设置Response.Charset = "UTF-8"和Response.ContentEncoding = Encoding.UTF8,并且输出前写入 BOM。如果是生成真正的.xlsx文件,不要用 HTML 表格方案,直接用 NPOI 或 ClosedXML 这类库生成二进制文件。HTML 表格导出经过 Excel 打开时会弹"文件格式与扩展名不匹配"的警告,用户会认为是系统坏了。这里的原则是:数据量大、格式要求高,就用 NPOI;临时报表、能接受警告,再用 HTML 表格。

4.3 EF 延迟加载导致的 N+1 查询,列表页一次打开几十条 SQL

现象:客户列表页只有 50 条数据,打开却花了三秒,SQL Server Profile 一看执行了几十条甚至上百条 SQL。

原因:EF 的延迟加载(Lazy Loading)在访问导航属性时才去查数据库。列表页循环渲染每个客户的负责人姓名,循环 50 次就触发 50 次查询。

解决:查询时用Include预先加载导航属性,上面 2.2 节已经给过写法。如果用的是 EF Core,多级导航要用ThenInclude。这个问题的隐蔽之处在于:本地测试数据量小看不出性能问题,生产环境几百个客户就开始雪崩。还有一个判断技巧:打开 SQL Server Profile 看NumberOf Statements,如果远大于页面上的记录数,基本就是 N+1。

4.4 只做了菜单级权限,直接输入 URL 照样能进编辑页

现象:管理员把某销售账号的"客户删除"菜单权限去掉了,但销售直接把删除页面的 URL 记下来,在浏览器地址栏输入后照样能删数据。

原因:权限判断只写在菜单渲染逻辑里,页面本身的Page_Load没有做二次校验。ASP.NET 的页面只要能被 URL 访问到,就会执行生命周期,菜单隐藏根本不拦请求。

解决:所有受保护页面必须继承BasePage,在OnInit里做登录和角色校验,删除、审批这类高风险操作还要在后端方法里再校验一次HasPermission("Customer_Delete")。前端按钮隐藏只是体验优化,后端校验才是安全边界。

4.5 定时任务调外部接口报"无法将数据写入传输连接:远程主机强迫关闭了"

现象:CRM 的定时任务给外部系统推送数据,运行一段时间后开始抛WebException,消息是"无法将数据写入传输连接:远程主机强迫关闭了连接"。重启站点后又正常,过一阵再犯。

原因:服务端主动断开了空闲的 Keep-Alive 连接,客户端还复用在旧连接上继续写数据,就会触发这个异常。定时任务场景下连接复用率高,出现频率尤其高。通常不是防火墙问题,而是连接池中空闲连接被服务端回收。

解决:短请求场景关闭 Keep-Alive,并加重试。代码里要控制好重试次数,CRM 推送通知这类接口要保证幂等,建议带一个唯一消息 ID 让接收方去重。

HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url); req.KeepAlive = false; // 定时任务场景关闭连接复用,减少被服务端主动断开的概率 req.Timeout = 30000; req.ReadWriteTimeout = 30000; for (int i = 0; i < 3; i++) { try { using (var resp = (HttpWebResponse)req.GetResponse()) { // 正常处理响应 } break; } catch (WebException ex) { if (i == 2) throw; // 第三次还失败就放弃,记日志交给后续补偿任务 Thread.Sleep(2000 * (i + 1)); // 第一次等2秒,第二次等4秒 } }

这个坑的排查方法比较枯燥:先看异常发生的时间点是不是集中在长空闲之后,再看代码里有没有把HttpWebRequest当静态对象复用。如果是 .NET Core 的HttpClient,问题会以SocketsHttpHandler连接池空闲超时的形式出现,处理思路一样:控制连接生命周期,加业务幂等。

5. 进阶:把审计日志做成一个基类,全系统复用

CRM 上线三个月后,一定会有人来问:"这个客户是谁改成 VIP 的?什么时候改的?" 如果没有审计日志,你就只能在数据库日志里翻,运气好能找到,运气不好就是一笔糊涂账。与其事后挖数据,不如把审计做成统一的基类,用 C# 反射把实体变化自动抓出来。

5.1 用反射做字段级审计

先定义一个忽略标记和审计项:

[AttributeUsage(AttributeTargets.Property)] public class AuditIgnoreAttribute : Attribute { } public class AuditItem { public string FieldName { get; set; } public string OldValue { get; set; } public string NewValue { get; set; } }

再写比较工具:

using System; using System.Collections.Generic; using System.Reflection; public static class AuditHelper { public static List<AuditItem> Compare<T>(T oldObj, T newObj) { var items = new List<AuditItem>(); PropertyInfo[] props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (PropertyInfo p in props) { if (p.GetCustomAttributes(typeof(AuditIgnoreAttribute), true).Length > 0) continue; string oldText = p.GetValue(oldObj)?.ToString(); string newText = p.GetValue(newObj)?.ToString(); if (oldText != newText) items.Add(new AuditItem { FieldName = p.Name, OldValue = oldText, NewValue = newText }); } return items; } }

在实体属性上标记[AuditIgnore]跳过不关心的字段(比如UpdateTime),然后在基类里统一调用:

protected void SaveWithAudit<T>(T oldEntity, T newEntity, string bizType) { var changes = AuditHelper.Compare(oldEntity, newEntity); if (changes.Count == 0) return; _auditRepository.Insert(CurrentUserId, bizType, changes); _repository.Update(newEntity); }

5.2 落地时要注意的两个细节

第一个细节是性能。GetProperties每次调用都走反射,CRM 列表页批量操作时会拖慢响应。解决方法是把PropertyInfo[]缓存到静态字典里,按类型做 key,只在第一次访问时反射。

第二个细节是审计表不能只记 JSON。虽然把变化序列化成一段文本最省事,但"按字段查修改历史"的需求迟早会来。建议审计表拆成主表和明细表,明细表一行存一个字段的旧值和新值,查询某一列的历史用一条带WHERE FieldName = 'LevelType'的 SQL 就能解决。

这套审计机制是我的血泪经验换来的。第一版 CRM 上线时我没做审计,结果销售和主管因为一个客户归属问题吵到老板那里,我翻了三天日志才勉强拼出修改链路。后来在第二版里我给自己定了规矩:CRM 里凡是 Update 操作,都从同一个 Save 方法走,审计在 Save 方法里统一调,不依赖程序员自觉。组件可以换,表结构可以加,审计这条线一旦断了,后面想补比登天还难。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询