的自研CRM系统实战:从数据库设计到部署上线)
简介这是一份基于ASP.NET(C#)技术栈的客户关系管理系统CRM完整源码面向正在学习Web应用开发的学生、课程设计者以及需要快速搭建中小型企业客户管理后台的初级工程师能够帮助理解从客户信息录入、服务派发到反馈处理的一整套业务闭环同时也有助于梳理权限设计与数据关联思路。资源包共297个文件以aspx动态页面、cs业务逻辑代码、dll程序集为核心另外包含gif/jpg示意图片、CSS样式、JS交互脚本、以及mdf/ldf数据库附加文件压缩后体积仅约1.15MB目录结构清晰便于在Visual Studio中直接打开运行。源码覆盖登录验证、客户管理、管理员配置、用户流失提醒、调度服务、服务受理与反馈处理等核心模块按角色和业务功能划分代码组织适合课程设计、毕业设计以及轻量级企业管理后台的二次开发。目前已有439人学习下载对于想理解ASP.NET三层结构、Session权限控制、基础增删改查及文件上传等常见功能的开发者来说是一份不错的参考项目。 做开发这些年我一直觉得“客户关系管理系统”这种题材特别有意思——它不像上层建筑那么玄也不像底层协议那么硬但它能把asp.net(c#)一整条技术栈串起来数据库设计、权限控制、业务流转、报表导出、部署上线。这篇文章我不讲那种网上随处可见的“企业级模板项目”怎么下载跑通而是复盘我从零开始设计、开发、交付一套CRM系统的完整思路和关键代码把这些年在c#业务系统里踩过的坑一起写出来。这套系统最终跑在ASP.NET Core MVC上配合EF Core、SQL Server前端用Bootstrap加ECharts做看板目前支撑着公司三个销售团队大约四十号人的日常客户管理。文章适合手里有c#基础、想尝试完整业务系统开发的读者也适合正在为团队选型CRM、纠结是买现成的还是自己写的人。我会把技术决策背后的理由讲清楚而不是只丢一堆代码。1. 为什么自己用asp.net(c#)写CRM而不是买一套现成的1.1 现成CRM与自研的真实取舍在动手之前我先把市面上的免费CRM、付费CRM、甚至那类“永久在线的crm网站”都摸底了一遍。坦白说很多SaaS型CRM在客户管理、跟进记录这些核心功能上做得确实成熟但切换到我这边的场景就尴尬了销售团队要跟工业设备订单挂钩一个客户名下可能挂多个商机、多个合同、还有售后工单这些字段和状态流转每个行业都不一样SaaS产品往往只给一个宽泛的“自定义字段”让你自己折腾。另一个致命问题是数据归属。免费CRM与私人网站最大的区别也在这儿——免费SaaS把数据存在别人的服务器上导出接口还限流我这边销售合同金额、成本价、报价审批都涉及商业敏感信息放人家平台上睡不踏实。自己用asp.net(c#)写一套数据库在我自己手里备份策略、权限粒度、审计日志全都能自定义。1.2 为什么选ASP.NET Core MVC而不是WebForms或者前后端分离说实话如果早五年做这个项目我大概率会用ASP.NET WebForms毕竟拖控件确实快。但这次是全新项目再抱着WebForms那套ViewState和事件模型就没有必要了。我用的是ASP.NET Core MVC。理由有几个一是MVC的模型绑定和验证在处理表单提交时干净利落配合jQuery做局部刷新很顺手二是EF Core的LINQ查询在做客户列表筛选、销售漏斗聚合时比SqlConnection拼SQL舒服太多三是这套系统后续要面对的可能不光是浏览器还有内部的小程序端、钉钉集成MVC的Web API部分可以直接复用业务逻辑层。我也考虑过前后端完全分离Vue加Web API那种。但最后放弃了——CRM这种项目大量的操作是表单、列表、弹窗、状态更新服务端渲染的MVC加上少量AJAX请求开发效率最高部署也最简单不用架Nginx也不用处理跨域。// Program.cs 核心配置 var builder WebApplication.CreateBuilder(args); builder.Services.AddDbContextCrmDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection))); builder.Services.AddScopedICustomerService, CustomerService(); builder.Services.AddScopedIFollowUpService, FollowUpService(); builder.Services.AddScopedIOpportunityService, OpportunityService(); builder.Services.AddSession(options { options.IdleTimeout TimeSpan.FromMinutes(30); options.Cookie.HttpOnly true; options.Cookie.IsEssential true; }); var app builder.Build(); app.UseSession(); app.UseAuthentication(); app.UseAuthorization();这套配置做完之后后面所有模块都围绕这几个核心服务展开。顺便说一句用Scoped生命周期很关键每个HTTP请求内共享同一个DbContext实例不会出现DbContext在线程池里乱飞的问题。2. 数据模型设计这套CRM的表结构是怎么一步步磨出来的2.1 核心表与关系设计客户关系管理系统最忌讳一上来就设计二十张表我的经验是先画业务闭环再倒推表结构。CRM的核心闭环其实就三件事客户、跟进、商机。我设计的核心表大概是这样的客户表Crm_Customer客户基本信息包括公司名称、行业、规模、来源渠道、所属销售等。联系人表Crm_Contact一个客户下多个联系人记录姓名、职位、电话、微信、喜好。跟进记录表Crm_FollowUp每次销售跟客户的沟通内容回访时间下次跟进时间沟通方式。商机表Crm_Opportunity商机名称、预计金额、成交概率、当前阶段、预计成交日期。合同表Crm_Contract商机转合同后生成存合同金额、签订日期、回款计划。用户与角色表Sys_User、Sys_Role、Sys_UserRole、Sys_Menu、Sys_RoleMenu支撑权限系统。这里特别说明一下客户和商机的关系。很多CRM新手会把商机做成客户表上的一个状态字段这是不对的。一个客户可能会同时存在多个商机比如同时跟这家企业谈设备采购和维保服务这是两个商机金额、阶段、负责人都不一样。把商机独立成表后面做好销售漏斗分析时才有数据支撑。2.2 数据库设计中容易忽略的三个细节第一个细节是逻辑删除。客户数据误删了是大事所以每张业务表我都有IsDeleted字段EF Core查询时自动过滤掉已删除的数据。做法是在DbContext里重写SaveChangesAsync或者用全局查询过滤器但要注意复杂关联查询时过滤器对性能的影响。第二个细节是时间字段统一为UTC存储。销售团队跨时区吗严格说没有但为了以后集团层报表统一我还是把所有CreateTime、UpdateTime存成UTC展示的时候在前端做一次转换。这样报表系统接数据时不会因为时区差异对不上销售额。第三个细节是数据库中一定要有“操作日志表”。这个表不参与业务逻辑就是记谁在什么时间改了什么字段。我用了一个很土的方案在EF Core的SaveChanges里对比EntityState.Modified的实体把旧值和新值写成JSON存到Sys_Log表。public override async Taskint SaveChangesAsync(CancellationToken cancellationToken default) { var entries ChangeTracker.Entries() .Where(e e.State EntityState.Modified || e.State EntityState.Added || e.State EntityState.Deleted); foreach (var entry in entries) { _logger.LogInformation(实体类型{EntityType}状态{State}主键{Id}, entry.Entity.GetType().Name, entry.State, entry.Property(Id)?.CurrentValue); } return await base.SaveChangesAsync(cancellationToken); }有了这张表后面出现“谁把客户共享给了别的部门”“谁改了大客户的成交状态”这种争议直接一查日志就知道销售总监心里也有底。3. 权限系统与登录认证CRM的安全底线不能只靠按钮隐藏3.1 RBAC权限模型的落地细节CRM系统的权限跟普通网站不一样。普通内容网站可能只分用户和管理员两种角色但CRM至少要有销售只能看自己的客户、销售主管看本组成员的客户、销售总监看所有客户和商机、管理员系统配置。如果只用IsAdmin这种布尔字段后面扩角色的时候能改到你怀疑人生。我采用的是标准RBAC模型用户表、角色表、菜单表、用户角色关系表、角色菜单关系表。菜单表里每条记录对应一个Controller的Action或一个页面上的操作按钮比如“查看客户列表”“新增客户”“删除客户”“导出客户数据”。每个Action通过一个自定义特性[PermissionFilter(customer:delete)]来标记用户登录后把其所有角色对应的权限代码集合放进Session或内存缓存中每次请求在全局过滤器中校验。[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] public class PermissionFilterAttribute : Attribute { public string PermissionCode { get; } public PermissionFilterAttribute(string permissionCode) { PermissionCode permissionCode; } } // 在Controller中使用 [PermissionFilter(customer:export)] public IActionResult ExportCustomers(SearchDto search) { // 导出逻辑 }这里有个容易被忽视的坑菜单权限过滤只是第一层MVC的Action过滤才是真正兜底的。如果只在前端根据角色隐藏“导出”按钮那懂技术的人直接拼URL访问/Customer/ExportCustomers就绕过去了。所以服务端所有敏感操作必须加同样的过滤特性前端隐藏只是优化体验不是安全措施。3.2 登录认证与Session踩过的坑登录这块我用的方案是Cookie认证加Session存用户信息。ASP.NET Core里用AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)配合HttpContext.SignInAsync实现这部分网上教程很多我不多写。我想说的是两个实际踩过的坑。第一个坑是Session的序列化类型问题。默认的Session用二进制序列化我一开始直接往Session里塞了一个自定义的UserInfo类结果这个类后来加了字段旧Session反序列化直接崩。解决办法是Session里只存UserId需要用户信息时每次从数据库查或者存ClaimsPrincipal而不是自定义对象。第二个坑是超时时间。CRM系统跟普通的博客不一样销售人员早上登录系统挂一整天期间可能长时间停留在某个客户详情页不动。如果按默认20分钟Session过期下午回来一提交表单直接跳登录页刚写的一大段跟进记录全丢了这个体验能让人骂街。我是把Session超时设成8小时但把Cookie的滑动过期时间设成30分钟无操作清理。注意这两个时间要配合好否则会出现Session还有效但Cookie已经过期的情况。builder.Services.ConfigureApplicationCookie(options { options.ExpireTimeSpan TimeSpan.FromMinutes(30); options.SlidingExpiration true; options.LoginPath /Account/Login; options.AccessDeniedPath /Account/Forbidden; });另外厂商自带的那种asp.net登录注册控件我在MVC项目里没用那是WebForms时代的产物。MVC的注册登录建议直接用框架自带的Identity或自己写一套简单的反而更好控制。4. 客户、跟进、商机CRM三驾马车的核心实现拆解4.1 客户360°视图所有信息的汇聚点客户管理的核心不是“增删改查”那个CRUD而是客户详情页的360°视图。一个销售点开客户详情页他要在这一屏之内看到这个客户的基本资料、所有联系人、所有跟进记录、当前在跟进哪些商机、历史合同金额、回款情况。如果这些信息分布在五个页面里销售根本不会去点慢慢就沦为纯记录工具了。我的实现方式是客户详情页用一个聚合查询把客户、联系人和最近几条跟进记录一次性查出来。这个看起来简单但EF Core用不对就会产生N1问题而且性能会随着数据量增大迅速恶化。我最终的查询写法是这样的var query _context.Customers .Where(c c.Id id !c.IsDeleted) .Include(c c.Contacts.Where(x !x.IsDeleted)) .Include(c c.FollowUps.Where(x !x.IsDeleted) .OrderByDescending(f f.FollowUpTime) .Take(10)) .Include(c c.Opportunities.Where(x !x.IsDeleted)) .FirstOrDefault();注意Include和Where的配合EF Core的过滤包含可以做到只加载指定条件的数据而不是把整个客户的所有联系人和跟进记录全load出来。如果客户有几百条跟进记录全查出来传给前端页面直接就卡死了。4.2 跟进记录最轻但最容易写错字的模块跟进记录是CRM系统里插入频率最高的模块一个活跃销售一天可能要给十几个客户新增跟进。我最初把每条跟进记录直接插入数据库同时更新客户表的LastFollowUpTime字段。数据量小的时候没问题但客户数量过万之后列表页每次查询都要关联更新时间而且分页排序越来越慢。后来我做了个调整跟进记录插入走异步队列。具体来说Crm_FollowUp表插入用普通的SaveChangesAsync这是用户能感知的操作必须快而客户表的LastFollowUpTime和NextFollowUpTime更新放到一个后台服务里通过ChannelT做生产者消费者队列每过几秒批量刷一次。这样用户提交跟进的响应时间从原来的120毫秒左右降到了30毫秒左右体感明显。另一个细节是跟进记录的内容清洗。销售经常从Excel或者聊天记录里直接复制粘贴文本进来里面可能带大量HTML标签和脚本片段。我用了一个简单的HtmlEncoder做编码处理保证回显时不会执行任何脚本同时在保存前把字符串长度截断到2000字以内防止有人通过表单提交超大文本拖垮数据库。4.3 商机阶段管理与销售漏斗商机管理的核心是阶段变化。我参考了经典的销售方法论把商机阶段设置为初步接触、需求确认、方案报价、商务谈判、赢单、输单。每次阶段变化都记录时间和操作人这样销售总监在看漏斗报表时能知道哪个阶段的转化率最低可能是话术问题或报价策略问题。商机的操作逻辑其实就两个要处理好阶段变更保存时同时更新商机的Stage字段和LastStageChangeTime如果变成“赢单”自动弹窗引导用户创建合同。金额与概率每个阶段预设一个成交概率预计金额 × 概率就是加权销售额报表里用这个数字做销售预测。这里有个实用的经验商机表要做金额变更审计。销售谈着谈着客户砍价金额从30万变成25万这个过程一定要有记录。不然月底复盘的时候销售说“一直都是25万啊”你这边拿不出证据就尴尬了。我是用一个Crm_OpportunityLog表专门记录阶段和金额的每次变更改成什么、谁改的、为什么改备注字段。5. 数据导入导出与可视化报表让系统真正被用起来的临门一脚5.1 用NPOI处理Excel批量导入客户系统上线最大的门槛不是开发是数据迁移。销售手里几十个客户都躺在个人Excel表格里如果让他们一个一个手动往新系统里录没人会配合。所以客户批量导入功能必须做而且要好用。我用的NuGet包是NPOI在c#生态里处理Excel最成熟的库。导入流程是前端用input[typefile]选文件AJAX传到后端后端用NPOI读Excel每一行做数据验证公司名称不能为空、电话格式是否正确、重复客户检测最终给用户一个导入结果清单成功多少条失败多少条每一条失败原因是什么。public async TaskImportResult ImportCustomersAsync(IFormFile file, int currentUserId) { var result new ImportResult(); using var stream file.OpenReadStream(); var workbook new XSSFWorkbook(stream); var sheet workbook.GetSheetAt(0); for (int rowIndex 1; rowIndex sheet.LastRowNum; rowIndex) { var row sheet.GetRow(rowIndex); if (row null) continue; var name row.GetCell(0)?.ToString()?.Trim(); var phone row.GetCell(2)?.ToString()?.Trim(); if (string.IsNullOrEmpty(name)) { result.AddError(rowIndex 1, 公司名称不能为空); continue; } if (!Regex.IsMatch(phone ?? , ^1[3-9]\d{9}$)) { result.AddError(rowIndex 1, 手机号格式不正确); continue; } // 重复检测 if (await _context.Customers.AnyAsync(c c.Name name !c.IsDeleted)) { result.AddError(rowIndex 1, 客户已存在); continue; } // 插入数据 } return result; }这里有个教训导入文件大小限制要提前想清楚。默认ASP.NET Core的文件上传上限是30MB左右但真有人会丢一个几十MB的Excel过来里面还带样式和图片。我在FormOptions里把MultipartBodyLengthLimit调到了100MB并且前端用accept.xlsx限制格式后端也校验了文件扩展名防止有人传exe上来。Excel导入功能上线第一周就导入了三千多个客户很多是销售攒了好几年的老客户名单。这一步对于系统推广的价值比任何培训都管用。5.2 搜索条件与ECharts报表的联动设计CRM列表页最核心的交互是“搜索条件”。热搜词里反复出现“搜索条件”不是没道理的销售经常要筛出“下个月要过生日的重要客户”或者“超过两周没跟进的客户”这直接关系到他们能不能及时做回访。我的客户列表页搜索条件有关键字模糊匹配公司名/联系人电话、所属行业、客户来源、创建时间范围、最近跟进时间范围、所属销售。每个条件都映射到EF Core的查询表达式上用IQueryable逐步Where最终分页返回。搜索条件设计里有一个容易被人忽略的体验细节——搜索条件要能保存。我加了一个“我的常用筛选”功能让销售可以把常用组合存起来比如“重点客户最近一周有跟进金额大于10万”下次一键加载。这个功能实现不复杂就是数据库存一张Crm_FilterPreference表但实际使用率非常高销售主管特别爱用。报表这一块我用的是ECharts。EF Core从数据库聚合好数据转成JSON传给前端ECharts渲染。最常用的三个报表是新增客户趋势图按周聚合商机阶段漏斗图统计每个阶段的数量和金额销售个人业绩排行按合同金额排行// 前端ECharts配置示例 $.ajax({ url: /Report/OpportunityFunnel, data: { startDate: 2025-01-01, endDate: 2025-03-31 }, success: function (data) { var chart echarts.init(document.getElementById(funnel)); chart.setOption({ series: [{ type: funnel, data: data.map(function (item) { return { name: item.stageName, value: item.amount }; }) }] }); } });报表功能最有成就感的时刻是销售总监开会的时候直接投屏打开系统看漏斗说“这个月方案报价阶段堆积了800万金额推动率只有20%大家抓紧推进”。那一刻你会觉得这套c#系统真的在影响业务决策而不是一个摆设。6. 部署、性能优化与交付后的那些坑6.1 IIS部署和.NET Core版本迁移的注意事项这套系统最初部署在Windows Server 2016的IIS上用ASP.NET Core模块反向代理Kestrel。如果你也是用IIS部署.NET Core项目有两点要特别注意。第一点是应用池的“加载用户配置文件”必须设为True。不设的话证书相关的功能会报错比如某些需要访问用户存储区证书的API会失败。第二点是应用程序池的回收时间。默认的定时回收在生产环境会出现凌晨某个时段系统第一次访问特别慢的情况因为工作进程被回收了所有缓存和编译结果都没了。我直接把应用池回收时间设成了凌晨4点并且开启了“特定时间回收”尽量避开工作时间。另外这套系统最初用的.NET 6后来平滑升到了.NET 8。升级过程中没遇到特别严重的坑只要注意EF Core版本要配套升级以及System.Text.Json的循环引用处理。EF Core 8相比6在ExecuteUpdate、ExecuteDelete这些批量操作上性能好很多比如批量更新客户的LastFollowUpTime字段用ExecuteUpdate直接生成一条UPDATE SQL不需要先把实体Load进内存这个优化在批量任务里收益很大。6.2 实测过程中遇到的性能瓶颈系统上线三个月后客户量到了一万五左右跟进记录二十五万条开始出现一些性能问题。我做了一次完整的排查把最关键的三个瓶颈列出来第一个瓶颈是客户列表页的模糊搜索。需求里支持关键字匹配公司名称、联系人电话、联系人姓名而客户和联系人是一对多关系。直接Where(c c.Name.Contains(keyword) || c.Contacts.Any(x x.Phone.Contains(keyword)))在数据量上万时全表扫描非常慢。我的解决办法是给这几个字段加了全文索引同时把关键字最小长度限制为2个字符。CRM系统的客户名称一般都有明确关键字用不着必须支持单字搜索这个上线的限制换来的是查询速度从3秒降到300毫秒。第二个瓶颈是跟进记录查询的分页。用户看客户详情时默认展示最近十条但点“查看全部”时要把这个客户的所有跟进记录展示出来。有些大客户的跟进记录上千条直接在页面上渲染会很卡。我把“查看全部”改成了分页模式每页50条前端用简单的load more方式追加体验反而更好。第三个瓶颈是报表导出的内存占用。合同明细导出用的Excel包数据量大时在内存里构建XSSFWorkbook会导致内存暴涨甚至内存溢出让应用池回收。我把导出拆成了两个方案小数据量五千行以内直接用NPOI内存构建大数据量用Excel 2003格式的HSSFWorkbook分Sheet写入或者用SXSSFWorkbook流式写入后者是POI的Streaming版本内存占用小。这个改动之后导出两万条合同数据再也没崩过。6.3 免费CRM与自研系统相比我的最终结论回到最开始那个问题到底是买现成的免费CRM还是用asp.net(c#)自己写一套我的结论是这样如果团队小于十个人业务需求也不复杂优先用成熟SaaS别折腾自研。但如果你像我一样面临多个部门、复杂权限、数据敏感、统计口径频繁调整那自研CRM的价值就出来了。因为你可以在一个晚上把“新增了客户来源字段”上线而SaaS要等排期你可以把客户数据直接导出到第三方BI工具而SaaS可能对你说“高级导出需要付费”。而这套基于asp.net(c#)的CRM系统最大的好处还不是技术上的而是它的业务完全长在你手里。销售团队说要增加“客户分级打分”我花两天时间做完上线老板要一个“本月回款预测”我写个聚合SQL加ECharts就出图了。这套系统的价值不在于代码写了多少行而在于它能跟你的业务一起长大这是任何免费CRM都做不到的。最后分享一个小技巧如果你的系统也用了分页列表加搜索条件建议把搜索条件放在URL的QueryString上这样销售把筛选出来的客户列表链接发给同事对方打开就能看到同样筛选结果的页面省去一步步点筛选的麻烦。这个细节我们上线后两周才补上一补上销售群里就炸了都说这个功能太香了。本文还有配套的精品资源点击获取