ASP.NET C#自研CRM系统:架构选型、权限设计与性能优化
简介面向需要学习ASP.NETC#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 的跟踪机制在单表操作时很省事但延迟加载在列表页容易搞出 N1 查询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 (ListCustomer 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.ExecuteScalarint(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.QueryCustomer(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 ListCustomer GetCustomersWithOwner() { return _ctx.Customers .Where(c c.IsDeleted false) .Include(c c.Owner) // 一次性 LEFT JOIN 用户表避免 N1 .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 HashSetstring; 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.ExecuteScalarint(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 EventHandlerFollowLogSavedEventArgs 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;filenameCustomerReport.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 按单元格写 trtd比直接 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 modeInProc timeout120 / /system.web这只是一种缓解。真正靠谱的做法是把 Session 存到 SQL Server 或 RedismodeStateServer或modeSQLServer这样 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 延迟加载导致的 N1 查询列表页一次打开几十条 SQL现象客户列表页只有 50 条数据打开却花了三秒SQL Server Profile 一看执行了几十条甚至上百条 SQL。原因EF 的延迟加载Lazy Loading在访问导航属性时才去查数据库。列表页循环渲染每个客户的负责人姓名循环 50 次就触发 50 次查询。解决查询时用Include预先加载导航属性上面 2.2 节已经给过写法。如果用的是 EF Core多级导航要用ThenInclude。这个问题的隐蔽之处在于本地测试数据量小看不出性能问题生产环境几百个客户就开始雪崩。还有一个判断技巧打开 SQL Server Profile 看NumberOf Statements如果远大于页面上的记录数基本就是 N1。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 ListAuditItem CompareT(T oldObj, T newObj) { var items new ListAuditItem(); 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 SaveWithAuditT(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 方法里统一调不依赖程序员自觉。组件可以换表结构可以加审计这条线一旦断了后面想补比登天还难。希望帮到你。本文还有配套的精品资源点击获取