ASP.NET Core + EF Core 从零搭建CRM系统:核心设计与部署实践
发布时间:2026/9/30 17:36:19来源:尧图网络
1. 从零开始落地一套CRM需求边界与核心设计思路刚接到这个项目需求的时候客户方的描述其实很模糊“我们要一个客户关系管理系统能管理客户资料能记录跟进情况。”这句话看起来简单但真要动手涉及的边界问题一堆要不要做销售漏斗要不要对接邮件要不要做任务提醒客户希望我直接给出一套能跑起来的方案而不是又抛出一堆问卷让他们填。我的做法是先判断这套系统的核心闭环是什么。客户关系管理说白了就是围绕“客户资料—跟进过程—成交结果”这一条主线的数据流转。售前的所有动作最终都是为了回答三个问题客户是谁聊到哪一步了下一步该干什么所以系统的最小可用闭环就是三个模块客户台账、跟进记录、商机状态。再加上用户登录权限和简单的统计报表一套能真正用的CRM就立住了。技术栈方面标题里写的是ASP.NET但我自己在实际项目里很少用传统的ASP.NET Web Forms搞新系统了除非是维护老项目。新开的CRM项目我选的是ASP.NET Core MVC搭配EF Core和SQL Server。原因后面会细讲先记住一个结论ASP.NET Core在跨平台部署、性能、依赖注入、内置身份认证这些方面比传统ASP.NET省心太多。尤其是后面部署到Linux服务器、用Nginx做反向代理、跑Docker容器的时候ASP.NET Core的体验几乎是碾压级的。客户自然语言里的“NET”值得多说一句。在.NET生态里NET这个词在不同语境下指的东西完全不一样。有人说的.NET是Framework的老框架有人说的是.NET 6/8这种跨平台运行时还有人说的.NET是具体的类库能力。做CRM这种业务系统我推荐的组合是.NET 8LTS版本 ASP.NET Core MVC EF Core SQL Server 2019。这套组合不仅稳定社区资料也齐全团队招人也好招。还有一个容易被忽视的点这套系统到底要给谁用就我接手的这个客户而言使用角色分为三类——销售、销售主管、管理员。销售只管自己的客户和跟进记录主管能看团队数据管理员管用户和基础配置。这个权限模型听起来并不复杂但如果不在一开始就设计好后期加功能的时候会非常痛苦。数据权限这块我后面有一整节专门讲这里先埋个伏笔。2. 数据模型设计客户、跟进与商机的状态机2.1 表结构设计CRM的根子是客户数据模型CRM系统的表结构设计我从来不用那种大而全的通用模型一上来就搞十几个表、几十个字段、一堆ER图。那看着专业实际上对内部管理系统来说反而增加了业务人员的录入负担也增加了开发维护成本。我比较务实的做法是围绕业务动作建表表服务于真实使用场景。核心表我设计了三张Customer客户表存公司名称、联系人、电话、邮箱、地区、行业、来源、当前负责人。FollowUpRecord跟进记录表每次销售和客户沟通后填写关联客户ID记录沟通方式、沟通内容、下次跟进时间。Opportunity商机表记录潜在项目或成交机会关联客户ID带金额预估、阶段状态、预计成交日期。这三张表已经能覆盖80%以上的CRM核心功能。剩下的用户表User和角色表Role属于权限体系的根基花的时间不多但地位很重要。客户表里最容易被忽略的字段是“数据来源”和“客户归属”。数据来源决定后期能不能做渠道分析客户归属决定谁能看到这条数据。这两个字段在表设计阶段就要想清楚后期补字段虽然SQL一跑就能加上但要改EF Core实体映射、改界面表单、改查询逻辑那工作量是翻倍的。public class Customer { public int Id { get; set; } public string CompanyName { get; set; } public string ContactName { get; set; } public string Phone { get; set; } public string Email { get; set; } public string Region { get; set; } public string Industry { get; set; } public string Source { get; set; } // 来源展会/网络推广/老客户介绍/其他 public int OwnerUserId { get; set; } // 客户归属人 public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } public bool IsDeleted { get; set; } // 软删除标记 }2.2 EF Core的Fluent API不写SQL也把关系敲死实体类定义完以后我习惯用Fluent API在DbConext里配置表映射而不是在实体类里堆一堆DataAnnotation特性。原因是Fluent API把关系配置集中在一个地方改起来清晰不会在实体类里东一个特性西一个特性。这里有一个基础但关键的配置客户与跟进记录是一对多的关系。一个客户可以有很多条跟进记录但一条跟进记录只属于一个客户。EF Core里配置这种关系只需要在FollowUpRecord实体上加一个CustomerId外键属性然后在DbContext里用HasMany和WithOne把关系明确下来就可以了。protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityCustomer(entity { entity.ToTable(Customer); entity.HasKey(e e.Id); entity.Property(e e.CompanyName).IsRequired().HasMaxLength(120); entity.Property(e e.Phone).HasMaxLength(30); entity.Property(e e.Email).HasMaxLength(100); // 软删除的全局过滤 entity.HasQueryFilter(e !e.IsDeleted); }); modelBuilder.EntityFollowUpRecord(entity { entity.ToTable(FollowUpRecord); entity.HasKey(e e.Id); entity.HasOne(e e.Customer) .WithMany(e e.FollowUpRecords) .HasForeignKey(e e.CustomerId) .OnDelete(DeleteBehavior.Cascade); }); }这段配置里有一个人人都容易忽略的细节HasQueryFilter(e !e.IsDeleted)。这个全局查询过滤器相当有用一旦配好所有查询都不需要手动加Where(e !e.IsDeleted)EF Core会自动把条件拼接上。但要注意软删除字段配合外键关系时删除行为要谨慎。我第一版把客户删除设置成了Cascade级联删除后来发现客户一旦被删跟进记录全没了根本没机会恢复。第二次调整之后就改成软删除——用户执行删除时系统只把IsDeleted置为true数据不会真从库里消失。2.3 商机的状态流转四五个状态就够别做太复杂商机阶段是销售管理里的经典话题。很多CRM会把商机阶段做成十几步的销售漏斗什么初步接触、需求挖掘、方案汇报、商务谈判、合同评审……做得很全但实际使用者销售根本不会认真维护。我这里的商机阶段只设计了五个状态发现商机、需求确认、方案报价、谈判中、赢单/输单。整个逻辑就是一条线推进是线性的销售只需要在商机发生变化的时候更新一下状态即可。状态字段在数据库里用int存状态值代码里用一个枚举类管理。这样既避免了字符串混乱又能配合ASP.NET Core MVC的模型绑定做下拉框。界面里商机列表页需要允许销售人员快速切换状态这里有几个实现细节状态变更要写进跟进记录否则状态改了追溯的时候完全不知道为什么改的赢单之后要把客户的某个字段更新为“已成交”同时把商机的实际成交金额记录下来。逻辑很简单但如果不写后期做销售业绩报表的时候数据就是空的。public enum OpportunityStatus { Discovered 1, // 发现商机 RequirementConfirmed 2, Proposal 3, // 方案报价 Negotiating 4, Won 5, // 赢单 Lost 6 // 输单 }3. 代码分层与核心业务实现3.1 分层不是炫技是为了以后改得动这套系统的代码结构我采用了经典的分层方式Controller表现层→ Service业务逻辑层→ Repository数据访问层。这个分层在资深开发眼里可能觉得朴素但正是这个朴素保证了项目小、逻辑清晰、新人接手也能快速上手。核心逻辑放在Service层Controller只负责接收HTTP请求、调用Service、返回视图或JSON。Repository层我基于EF Core的DbContext做了泛型封装提供基础的增删改查方法。不需要过度设计UoWUnit of Work那一套EF Core的DbContext本身就是一个工作单元SaveChanges就是事务提交点再包一层反而画蛇添足。public class CustomerService { private readonly ApplicationDbContext _db; private readonly ILoggerCustomerService _logger; public CustomerService(ApplicationDbContext db, ILoggerCustomerService logger) { _db db; _logger logger; } public async TaskPagedResultCustomer GetPageAsync(int pageIndex, int pageSize) { var query _db.Customers.AsNoTracking().OrderByDescending(c c.CreatedAt); var total await query.CountAsync(); var items await query.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync(); return new PagedResultCustomer { Items items, Total total }; } }所有Service都通过构造函数注入DbContext、ILogger等依赖然后由ASP.NET Core内置的依赖注入容器管理生命周期。这里有个经验DbContext是按请求注册的默认ScopedService也应该是ScopedRepository同理。千万不要用错生命周期否则会出现跨请求使用已释放的DbContext这种诡异异常。3.2 你有ASP.NET Core MVC的两个写法传统视图和Razor PagesASP.NET Core MVC在渲染页面时有两种主流选择ControllerView的传统MVC模式和Razor Pages模式。很多初学者搞不清这两者的区别。我个人的理解是如果系统是页面多、表单多、每个页面相对独立的内部管理系统Razor Pages的开发效率更高如果系统需要丰富的路由控制、API接口较多或者视图要重用组件那传统MVC模式更适合。我选择的是传统MVC模式。原因是这套CRM后续可能要给移动端或第三方系统提供APIMVC模式下Controller天然可以同时返回View和Json一套代码能兼顾页面和接口。Razor Pages虽然也能加API但路由模型比MVC的Controller-Route要绕一点。客户管理模块的页面结构是这样的客户列表页分页展示客户基本信息支持按公司名搜索、按负责人过滤。客户详情页显示客户资料四个Tab分别放跟进记录、商机列表、联系人信息、操作日志。新增/编辑客户页一个表单页共用同一个ViewModel。[Authorize] public class CustomerController : Controller { private readonly CustomerService _customerService; private readonly FollowUpService _followUpService; public CustomerController(CustomerService customerService, FollowUpService followUpService) { _customerService customerService; _followUpService followUpService; } [HttpGet] public async TaskIActionResult Index(int page 1) { var model await _customerService.GetPageAsync(page, 10); ViewBag.CurrentPage page; return View(model); } [HttpPost] [ValidateAntiForgeryToken] public async TaskIActionResult Create(CustomerFormViewModel form) { if (!ModelState.IsValid) { return View(form); } form.OwnerUserId User.FindFirst(UserId)?.Value?.ToString(); await _customerService.CreateAsync(form); return RedirectToAction(nameof(Index)); } }3.3 跟进记录与下次跟进提醒这是CRM最容易被人夸的功能跟进记录这个模块是整个系统里业务人员日均使用频率最高的功能。一次电话、一次微信沟通、一次线下见面都要在这里登记。字段设计得实用就好沟通方式电话/微信/邮件/面谈、沟通内容摘要、下次跟进时间。重点提一下“下次跟进时间”这个字段——它不只是用来提醒还支撑了销售主管对团队执行力的管理。在编码实现上跟进记录的列表要按客户聚合。也就是说进入客户详情页默认展示这个客户最近10条跟进记录并按时间倒序排列。除此之外首页我就不放传统CRM那种复杂的“待办事项中心”了而是做了一个“今日待跟进”的列表从FollowUpRecord表里取NextFollowUpTime小于当天结束时间、且属于当前登录用户的记录。这个功能特别实用销售每天打开系统第一眼就知道自己今天该联系谁客户给他们带来的直接反馈就是“这东西帮我记住了好多事”。写SQL的思路是先按客户ID取MAXNextFollowUpTime再过滤状态最后和当前时间比较。EF Core实现这个查询核心是用GroupBy加Max聚合。public async TaskListCustomerWithNextFollowUp GetTodayFollowUpListAsync(int userId) { var todayEnd DateTime.Today.AddDays(1); var query from f in _db.FollowUpRecords where f.NextFollowUpTime todayEnd group f by f.CustomerId into g select new CustomerWithNextFollowUp { CustomerId g.Key, LatestFollowUp g.Max(f f.NextFollowUpTime) }; var list await query.ToListAsync(); // 再关联客户表补充名称等信息 }这里有坑要提一下EF Core的GroupBy查询在对接SQL Server时投影只有键和聚合字段可以用不要尝试在同一个查询里把关联实体的复杂字段都投影出来。报错信息提示“cannot be translated”还是很常见的。我的处理方式是先用聚合查询拿到CustomerId和LatestFollowUpTime再二次查询客户信息最后在内存里合并。别嫌多一次查询代码清晰度提升SQL翻译的坑也少很多。4. 权限体系身份认证、角色授权与数据归属4.1 Cookie认证加角色授权内部系统最合适的方案ASP.NET Core的认证方案选择和系统形态强相关。CRM是典型的内部业务系统不涉及移动端扫码登录、不涉及第三方OAuth所以直接用Cookie认证就是最务实的做法。配置起来极其简单Startup里AddAuthentication().AddCookie()然后在需要登录的Controller上加[Authorize]特性即可。登录逻辑这里特别注意发布时要开着HTTPS否则Cookie里携带的认证票据会被明文传输。理论上开发环境用HTTP无所谓但正式部署时一定要在Cookie中间件里设置SecurePolicy CookieSecurePolicy.Always否则安全测试第一关就过不了。角色的处理我用了最简单的三个角色Admin管理员、Manager销售主管、Sales销售。用户登录后从数据库读取角色并映射为Claim写入认证票据。Controller层用[Authorize(Roles Admin)]这种特性按角色控制页面访问权限。var claims new ListClaim { new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.Role, user.Role), new Claim(UserId, user.Id.ToString()), new Claim(DisplayName, user.RealName) }; var identity new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal new ClaimsPrincipal(identity); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);4.2 数据权限别再让销售看到全公司的客户了角色权限解决的是“谁能进哪个页面”但CRM还有一个更核心的权限问题数据归属。销售角色登录系统后客户列表只应该显示自己名下的客户不能看到别人的客户。主管角色能看到全团队的数据。管理员角色能看全部。这个规则如果写在业务代码的每一个查询里代码会非常啰嗦而且容易漏。我的方案是做一个CurrentUser服务把当前用户信息封装在Session或Claim里然后在所有查询入口通过一个统一的方法拼接数据权限过滤条件。实际编码中我建议把所有查询都收口到Service层不直接在外面搞IQueryable。Service层的每个查询方法都先判断当前用户角色再决定是否添加数据过滤条件private IQueryableCustomer ApplyDataPermission(IQueryableCustomer query) { var role _currentUser.Role; if (role Sales) { query query.Where(c c.OwnerUserId _currentUser.UserId); } else if (role Manager) { // 主管可以看到自己以及自己团队成员的数据这里简化为查询所有归属人为本团队 var teamUserIds _db.Users.Where(u u.ManagerId _currentUser.UserId) .Select(u u.Id).ToList(); teamUserIds.Add(_currentUser.UserId); query query.Where(c teamUserIds.Contains(c.OwnerUserId)); } // Admin不做过滤 return query; }4.3 登录日志与操作审计出事的时候留条后路之前帮客户做系统的过程中遇到过销售离职后把客户资料批量导走的情况。从那以后凡是CRM系统我都会加操作审计。操作审计不是要把每一步鼠标点击都记录而是记录关键操作登录、导出客户、删除客户、修改商机金额、修改用户权限。实现方式比较轻量写一个ActionFilter在Controller Action执行前记录当前用户、操作名称、请求参数、操作时间异步写入一个OperationLog表。public class OperationLogFilter : IAsyncActionFilter { private readonly OperationLogService _operationLogService; public OperationLogFilter(OperationLogService operationLogService) { _operationLogService operationLogService; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { if (context.HttpContext.User.Identity.IsAuthenticated) { var userId context.HttpContext.User.FindFirst(UserId)?.Value; var path context.HttpContext.Request.Path; await _operationLogService.LogAsync(userId, path, context.ActionArguments); } await next(); } }这个小Filter大约是30行代码但价值很高。客户问“上周到底谁改了这个客户的负责人”管理人员一查操作日志就有答案。运营一段时间后我发现有了日志员工对数据的敬畏心也会明显提高这是制度手段达不到的效果。5. 报表与仪表盘让管理层一眼看懂系统价值5.1 一个简单但说到做到的数据看板CRM系统跑了一两周数据有积累之后管理层最想看的往往是这个月的客户新增了多少商机总额是多少销售团队谁跟进最勤快谁手头的商机金额最大这些报表要做得好看我采用了两个层面的实现方式。一个层面是首页仪表盘Dashboard放几个卡片和关键统计今日新增客户数、本月商机总额、待跟进事项数、成交客户数。这些数据是用来“唤醒记忆”的让销售一早打开系统就知道当前状态而不是让经理盯数据。另一个层面是商机漏斗分析页按Opportunity的Status字段做聚合统计展示每个阶段的商机数量和总金额。这部分我直接用EF Core的GroupBy查询拿回数据后用Chart.js画一个横向条形图或漏斗图。选择Chart.js而不是其他重量级前端可视化框架的原因很简单它是纯前端库不需要单独引入庞大的渲染依赖支持各种常见图表类型文档清爽适合后端人员快速上手。public async TaskChartDataModel GetOpportunityPipelineAsync() { var data await _db.Opportunities .Where(o o.Status ! (int)OpportunityStatus.Lost) .GroupBy(o o.Status) .Select(g new { Status g.Key, Count g.Count(), Amount g.Sum(o o.EstimatedAmount) }) .ToListAsync(); // 转换成图表数据结构 }5.2 导出Excel管理报表的最后一公里光有在线图表还不够管理层几乎一定会让IT部门“把数据导成Excel”。这个需求逃不掉索性一开始就把导出功能做了。早期版本我用过NPOI功能强大但上手成本稍高API偏底层。后来换成了MiniExcel轻量、API简单特别适合ASP.NET Core场景下导出表格数据性能也很能打。导出功能的实现思路查询出要导出的数据转换为ListDictionarystring, object或定义好的DTO然后调用MiniExcel的SaveAs方法输出二进制字节流最后通过File()方法返回Excel文件。public async TaskIActionResult ExportCustomers() { var customers await _customerService.GetAllForExportAsync(); var columns new Dictionarystring, string { [公司名称] CompanyName, [联系人] ContactName, [电话] Phone, [地区] Region, [行业] Industry, [归属人] OwnerName, [创建时间] CreatedAt }; // MiniExcel支持按映射导出代码比NPOI简单很多 }这套导出实现大概30分钟就能写完但数据类型转换有个槛日期时间格式默认导出后会带毫秒非常难看一定要格式化成年月日金额字段导出前要用ToString(N2)保留两位小数。这些细节虽然不复杂但直接影响报表交付时客户对系统的印象。6. 部署上线从开发机到服务器的完整经历6.1 环境准备Windows服务器还是Linux容器CRM系统部署这块很多.NET开发者的默认动作是买一台Windows云服务器装上SQL Server再装IIS发布的时候用Web Deploy推送。这套流程很成熟但对多数中小型公司的服务器预算来说不够友好尤其是数据库许可费用和Windows Server授权费用加在一起成本不低。我这次部署选择了另一条路Docker Compose在Linux服务器上跑两个容器一个是ASP.NET Core应用容器一个是SQL Server 2019容器。这样不管是新环境初始化还是备份恢复都更省心。SQL Server官方镜像在Linux容器里运行得很稳定性能对中小型CRM系统完全够用。有人会问为什么不用MySQL或PostgreSQL答案是EF Core对接SQL Server最顺求职市场上会组合拳ASP.NET CoreSQL Server的人也最多后续维护风险最低。发布配置里appsettings.Production.json单独维护数据库连接字符串Dockerfile一个就够FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 8080 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet restore RUN dotnet publish -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --frombuild /app/publish . ENTRYPOINT [dotnet, CrmSystem.dll]6.2 Swagger在生产环境必须关掉开发环境里Swagger是不可或缺的调试工具但发布到生产环境Swagger默认是会暴露所有Controller和Action的。让外部人员通过Swagger页面看到系统的API结构这是远远超过可接受范围的安全事故。ASP.NET Core把Swagger关掉的做法很直接在Program.cs里用app.Environment.IsDevelopment()判断只允许开发环境启用Swagger中间件。如果生产环境确实需要调试也要在Swagger配置里添加登录认证或者限定从内网访问。热词里有“net core swagger页面api添加统一前缀”这个需求在部署了网关或前后端分离的项目里很常见。如果只在Swagger里给API加统一前缀需要在UseSwagger和UseSwaggerUI之间配置RoutePrefix同时API的Route特性里加上api前缀。但对内部管理系统来说我的建议是保持简单Controller直接用[Route([controller])]不加额外前缀除非有API网关层。6.3 上线后踩过的几个坑写给你们避雷系统上线后的前两周是踩坑高发期。我挑几个典型的讲每个都有对应解决办法坑一Linux容器里的时区不对。默认容器是UTC时间页面显示的时间和数据库时间差了8小时报表统计全部错位。解决方式是在Dockerfile里设置ENV TZAsia/Shanghai同时在代码层统一用DateTime.Now而不是DateTime.UtcNow做业务时间。这个坑不踩一遍绝对想不到。坑二外网访问时出现连接超时或重置。部署在新服务器上后销售在办公室访问系统总是偶发性打不开登录一次要刷新好几次。排查下来是Nginx反向代理没有配置长连接相关参数proxy_read_timeout默认60秒页面如果有个报表查询超过60秒连接就被掐断了。解决方式很简单把proxy_read_timeout调到120秒并把Nginx的keepalive参数配好同时在程序层面把报表查询时间压缩到5秒以内。坑三.NET Framework 3.5安装失败。这个坑虽然不在CRM项目本身但我帮客户部署到一台较老的Windows服务器时遇到了。有些老环境的IIS或辅助组件要求.NET Framework 3.5但它没启用的Windows功能模块。服务器上没网络时离线安装需要从系统镜像的sxs目录安装否则会报0x800D03805之类的错误。如果你也遇到整个服务器环境是新装、内网无外网的情况提前准备好对应操作系统的镜像文件用dism命令离线启用功能比在图形界面里手动勾选可靠得多。坑四首次运行迁移数据库权限不够。EF Core的自动迁移在第一次启动时可能需要CREATE DATABASE权限但生产数据库账号通常只给了最小权限。我的做法是发布前先在本地或服务器上用sa账号执行一次dotnet ef database update生成好数据库结构再切换应用账号连接。如果是Docker部署那就在docker-entrypoint脚本里先执行一次迁移再从应用启动。可以避免把高权限账号硬编码在连接字符串里的低级错误。坑五HTTPS证书过期导致前端资源加载失败。这个坑更隐蔽。页面能打开但部分静态资源、Cookie失效控制台报net::ERR_CERT_DATE_INVALID绝大多数情况是证书到期。个人建议用一个开源项目如Caddy或Nginx的certbot插件做自动化续期设置定时任务每月自动检查更新避免手工续期遗忘。这些坑单看都是小问题但合在一起会严重影响系统的信任度。上线后第一个月我几乎每天都在处理这一类问题后面把服务器配置、部署脚本都标准化之后情况就好了很多。这也是我建议大家在项目初期就把部署文档写清楚的原因——不是给客户看是给三个月后的自己看。
网站建设高端定制企业官网