简介:这套ASP.NET(C#)客户关系管理系统CRM源码面向课程设计、毕业设计及.NET入门实战者,提供结构较完整的客户关系管理示例工程。项目覆盖客户档案维护、客户编辑、服务派发、服务受理与反馈、流失客户管理及后台管理等业务,可用于理解Web Forms页面交互、分层组织与数据库访问方式。压缩包共297个文件,约1.15MB,以100个cs源文件、38个aspx页面、32个dll程序集及pdb调试文件为主,另含gif/jpg界面素材、txt说明、csproj项目、sln解决方案、config配置和db、mdf/ldf数据库文件,兼顾源码、页面、资源与工程配置。已有441人学习,适合作为CRM业务建模与ASP.NET开发的参考案例,也可在源码基础上改造为课程项目;包体较轻,便于整理和二次开发,运行时需按实际环境核对数据库连接与依赖配置。
1. 从一份 ASP.NET CRM 源码说起:别急着改页面,先看客户主链路
客户关系管理 CRM 的 asp.net(c#) 源码包,真正的价值不在页面多漂亮,而在客户、联系人、跟进记录、权限和统计这条主链路是否闭合。
很多人下载后先改登录页配色,结果数据库脚本没跑、连接串没换,连客户列表都出不来。
它适合想用现成 WebForms 架构做中小团队客户台账、销售跟进和权限分级的开发者,也适合拿来做课程设计级别的二次开发底稿。先跑通登录和一条客户跟进记录,再谈改业务。
2. 拆解解决方案与数据库:从 .sln 到客户主表的落地顺序
ASP.NET CRM 源码最常见的翻车,不是业务逻辑多复杂,而是打开解决方案后不知道先动哪里:页面、类库、数据库脚本、配置文件混在一起,改了三处登录页,最后发现客户表字段根本没对上。稳妥顺序是先认结构,再还原数据库,最后跑通客户主链路。下面按“看文件、连数据库、串业务”三步走,每一步都留可验证的结果。
2.1 先看解决方案结构:WebForms、类库与配置的分层
拿到源码包,先别双击默认页。把根目录展开,找.sln、.csproj、web.config、*.sql和所有.aspx文件。常见的中小 CRM 源码会有一个 Web 站点项目,再加BLL、DAL、Model、Common这类类库;也有不少项目把 SQL 直接写在.aspx.cs里,分层只是文件夹名字。先确认启动项目是不是 Web 项目,再确认引用有没有黄色感叹号,缺 DLL 的项目改到最后一定卡在编译。
| 位置 | 用途 | 先确认什么 |
|---|---|---|
.sln/.csproj | 解决方案与项目入口 | 启动项目是不是 Web 站点,引用是否缺失 |
web.config | 连接串、认证、上传限制 | connectionStrings是否存在,是否有硬编码 |
*.sql/App_Data | 建库脚本或数据库文件 | 脚本顺序、字符集、初始账号 |
*.aspx+.aspx.cs | 页面与后台事件 | 列表、编辑、跟进记录入口是否齐全 |
BLL/DAL/Model | 分层与数据访问 | 是否真分层,SQL 是否散落在页面里 |
先用一条命令把关键文件列出来,避免在几十个文件夹里翻:
# 在源码根目录执行:先定位解决方案、项目文件、配置和数据库脚本 find . -maxdepth 3 -type f \( -name "*.sln" -o -name "*.csproj" -o -name "*.sql" -o -name "web.config" \) | sort逻辑说明:find只扫三层目录,先把入口文件找齐,不急着读业务代码。参数说明:-maxdepth 3防止在bin、obj、上传目录里打转;-name后面的模式按实际扩展名增减,如果项目用.config拆分配置,也要把*.config加进去。跑完这条命令,至少要知道数据库脚本在哪、连接串在哪、页面入口在哪,否则后面排错只能靠猜。
2.2 还原数据库与连接串:先跑通登录再谈改业务
数据库还原是第一个硬门槛。源码包里的.sql脚本可能包含建库、建表、初始账号和演示数据,也可能只给表结构。先连一个空测试库,按脚本文件名顺序执行;遇到GO分批、外键依赖、字符集报错,先解决脚本顺序,不要直接改表名。还原完成后,别急着打开客户列表,先用系统表把业务表认一遍:
-- 先列当前库里的业务表,避免照着文章猜表名 SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME;逻辑说明:这条查询只读元数据,不改任何数据,适合第一次接触陌生 CRM 库。参数说明:INFORMATION_SCHEMA.TABLES是标准视图;TABLE_TYPE = 'BASE TABLE'过滤掉视图。跑完重点找用户表、角色表、客户表、联系人表、跟进记录表、操作日志表。表名可能是Customer、Client、CrmCustomer,不要硬套,以实际结果为准。
连接串一般在web.config的connectionStrings节点里,也可能是某个DbHelper类里硬编码。先改成测试库,再启动登录页:
<!-- web.config 中的连接串示例:库名、账号、密码全部换成自己的测试环境 --> <connectionStrings> <add name="CrmDb" connectionString="Data Source=.;Initial Catalog=CrmDb;User ID=crm_app;Password=YourTestPwd;MultipleActiveResultSets=True;" providerName="System.Data.SqlClient" /> </connectionStrings>逻辑说明:连接串的name要和代码里ConfigurationManager.ConnectionStrings["CrmDb"]对得上,否则运行时直接报“未将对象引用设置到对象的实例”。参数说明:Data Source是本机实例时用.或localhost;Initial Catalog是数据库名;User ID/Password用测试账号,不要拿高权限账号跑业务;MultipleActiveResultSets=True只在同一个连接里同时开多个 Reader 时需要,老项目如果不熟可以先去掉。
连接串改完,用一段最小 C# 代码验证表名和字段,不要一上来就跑完整登录:
// 只取前 50 条,先验证连接串和客户表字段是否匹配 using (var conn = new SqlConnection(ConfigurationManager.ConnectionStrings["CrmDb"].ConnectionString)) using (var cmd = new SqlCommand( "SELECT TOP 50 CustomerId, CustomerName, Status FROM Customer ORDER BY CustomerId DESC", conn)) { conn.Open(); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { var id = reader.GetInt32(0); var name = reader.GetString(1); var status = reader.IsDBNull(2) ? "未设置" : reader.GetString(2); } } }逻辑说明:这段代码只做“连接 + 读字段”,不参与业务,适合放在临时页面或控制台项目里验证。参数说明:TOP 50防止演示库数据量大时拖慢首次加载;GetInt32(0)、GetString(1)的序号要和 SELECT 列顺序一致;IsDBNull(2)处理状态为空的情况。表名或字段名不一致时,先改 SQL,不要改实体类硬扛。
2.3 客户主表与跟进记录:把 CRM 最小闭环串起来
CRM 的最小闭环不是“有客户列表”,而是“客户有负责人、有状态、有跟进记录、有下次跟进时间”。客户主表通常放CustomerId、CustomerName、OwnerId、Status、CreatedAt、NextFollowAt;联系人表放电话、邮箱、职务;跟进记录表放CustomerId、FollowType、Content、FollowAt、NextFollowAt、CreatedBy。三张表靠CustomerId串起来,缺一张,销售跟进就只能靠备注字段硬塞。
| 字段 | 作用 | 注意 |
|---|---|---|
CustomerId | 客户主键 | 自增或 GUID 要全项目统一 |
CustomerName | 客户名称 | 搜索用前缀匹配,别默认%关键字% |
OwnerId | 负责人 | 数据隔离的核心字段 |
Status | 客户状态 | 状态值不要带前后空格 |
NextFollowAt | 下次跟进时间 | 为空表示不提醒,不要用1900-01-01代替 |
查客户列表时,如果直接LEFT JOIN跟进记录,一个客户有多条跟进就会把列表行数放大,分页和导出都会翻车。常见做法是用相关子查询只取最近一条跟进:
-- 客户列表带最近一条跟进:避免一对多 JOIN 把客户行数放大 SELECT c.CustomerId, c.CustomerName, c.Status, c.OwnerId, (SELECT TOP 1 f.Content FROM FollowRecord f WHERE f.CustomerId = c.CustomerId ORDER BY f.FollowAt DESC) AS LastFollow FROM Customer c WHERE c.Status = @Status ORDER BY c.CreatedAt DESC;逻辑说明:外层只查客户,内层按CustomerId取最近一条跟进,列表行数不会被跟进记录撑大。参数说明:@Status用参数传入,不要拼字符串;TOP 1依赖FollowAt排序,跟进记录表建议加组合索引CustomerId, FollowAt DESC。如果源码里字段叫FollowTime,按实际字段替换,不要直接复制。
写跟进记录时,插入跟进表和更新客户主表要放在同一个事务里,否则会出现“跟进写进去了,列表上的下次跟进时间没变”:
BEGIN TRAN; INSERT INTO FollowRecord(CustomerId, FollowType, Content, FollowAt, NextFollowAt, CreatedBy) VALUES (@CustomerId, @FollowType, @Content, GETDATE(), @NextFollowAt, @UserId); UPDATE Customer SET LastFollowAt = GETDATE(), NextFollowAt = @NextFollowAt WHERE CustomerId = @CustomerId; COMMIT;逻辑说明:先插跟进记录,再更新客户主表,两个动作要么都成功,要么都回滚。参数说明:@CustomerId、@FollowType、@Content、@NextFollowAt、@UserId全部用参数化;GETDATE()用数据库时间,避免 Web 服务器和数据库时间不一致;如果@NextFollowAt允许为空,插入前要判断业务是否允许“不提醒”。
3. 登录、权限与客户归属:CRM 源码里最容易改崩的三处
ASP.NET CRM 源码能打开登录页,不代表权限体系能用。很多包只做了“登录后显示菜单”,菜单一隐藏就以为权限做完了;客户详情页只按CustomerId查,换个 URL 就能看到别人的客户;密码字段可能还是明文或裸 MD5。下面三处是二次开发前必须确认的底线,改错任何一处,后面补权限都会返工。
3.1 登录态与密码存储:先确认是明文、MD5 还是加盐哈希
先查用户表,不要直接拿演示账号登录就完事。重点看密码字段是明文、32 位 MD5,还是带盐哈希;看是否有IsActive、RoleId、DeptId这些字段。查询时只取少量测试数据,别把整张用户表导出来:
-- 先确认用户表字段和密码形态,不要在生产库打印明文 SELECT TOP 20 UserId, UserName, PasswordHash, PasswordSalt, IsActive FROM SysUser ORDER BY UserId;逻辑说明:这条查询用于识别密码存储方式,不参与登录逻辑。参数说明:TOP 20限制结果量;如果表里没有PasswordSalt,说明可能是裸 MD5 或明文;如果字段名是Pwd、Password,按实际替换。看到明文密码,先改成哈希再谈上线;看到裸 MD5,不要直接改字段长度,先做“登录成功时升级哈希”的兼容逻辑。
较新的运行时可以用 PBKDF2 校验,老项目至少要做到“加盐 + 多次迭代”。下面这段只做校验,不负责生成:
// PBKDF2 校验示例:存量 MD5 不要直接改字段,先做登录时升级 public static bool VerifyPbkdf2(string password, string saltBase64, string hashBase64) { var salt = Convert.FromBase64String(saltBase64); var expected = Convert.FromBase64String(hashBase64); using (var kdf = new Rfc2898DeriveBytes(password, salt, 100000)) { var actual = kdf.GetBytes(expected.Length); return SlowEquals(actual, expected); } } private static bool SlowEquals(byte[] a, byte[] b) { var diff = a.Length ^ b.Length; for (var i = 0; i < a.Length && i < b.Length; i++) diff |= a[i] ^ b[i]; return diff == 0; }逻辑说明:Rfc2898DeriveBytes用盐和迭代次数派生哈希,SlowEquals避免普通字符串比较的时序差异。参数说明:saltBase64和hashBase64从数据库读取;100000是迭代次数,老服务器要看登录耗时,太低不安全,太高会拖慢登录;expected.Length决定派生字节数。老项目如果还在用 MD5,正确做法是登录成功后把新哈希写回,而不是一次性全表改密。
3.2 角色权限:菜单隐藏不等于权限控制
菜单隐藏只能骗过普通用户,骗不过直接改 URL 或构造 POST 请求的人。CRM 的权限点至少拆成查看、新增、编辑、删除、导出、分配负责人。每个权限点都要在服务端入口校验,不能只在Page_Load里判断一次就完事;按钮的Visible=false只是界面效果,事件处理器里仍然要再查一次权限。
// 页面入口校验:没有 Customer.Edit 权限直接拒绝,不要只隐藏按钮 protected void Page_Load(object sender, EventArgs e) { if (Session["UserId"] == null) { Response.Redirect("Login.aspx"); return; } var userId = Convert.ToInt32(Session["UserId"]); if (!PermissionService.HasPermission(userId, "Customer.Edit")) { Response.StatusCode = 403; Response.End(); } }逻辑说明:先判断登录态,再判断权限码,未登录跳登录页,无权限返回 403。参数说明:Session["UserId"]必须是登录成功后才写入的值;Customer.Edit是权限码,不要用中文菜单名当权限码;Response.End()会中断当前请求,导出等特殊页面要评估是否改用CompleteRequest()。
| 权限码 | 控制点 | 最小要求 |
|---|---|---|
Customer.View | 列表、详情 | 登录且角色允许 |
Customer.Edit | 新增、修改 | 服务端校验 |
Customer.Export | 导出 | 单独授权,避免批量带走客户 |
Customer.Assign | 转移负责人 | 主管或管理员 |
Customer.Delete | 删除 | 建议只做软删除 |
权限表不要只存“角色-菜单”,要存“角色-操作码”。菜单可以动态渲染,但服务端判断只认操作码;否则后面加一个导出按钮,又要改一遍角色表。
3.3 客户归属与数据隔离:OwnerId 要落到每一次查询
客户归属是 CRM 最容易被忽略的坑。列表页加了OwnerId = @UserId,详情页却只按CustomerId查;编辑页做了权限判断,删除接口忘了加条件。正确做法是把数据范围收口到查询层:销售只看自己名下,主管看本部门,管理员看全部。每次查客户、改客户、导出客户,都带同一套范围条件。
-- 销售只能看自己名下客户;主管看本部门;管理员看全部 SELECT CustomerId, CustomerName, OwnerId FROM Customer WHERE (@RoleCode = 'Admin') OR (@RoleCode = 'Manager' AND DeptId = @DeptId) OR (@RoleCode = 'Sales' AND OwnerId = @UserId);逻辑说明:用角色码控制数据范围,避免在页面里写三套 SQL。参数说明:@RoleCode从服务端会话或权限服务取,不能从前端表单取;@DeptId是当前用户部门;@UserId是当前用户。这个写法在数据量大时可能走不到最优索引,常见优化是按角色拼接不同 WHERE,或者拆成三条固定查询。
更新和删除也要带范围,不能只靠页面传CustomerId:
-- 更新客户状态时同样带数据范围,返回受影响行数用于判断是否越权 UPDATE Customer SET Status = @Status WHERE CustomerId = @CustomerId AND (@RoleCode = 'Admin' OR OwnerId = @UserId);逻辑说明:执行后检查ExecuteNonQuery()返回值,返回 0 就说明客户不存在或当前用户无权修改。参数说明:@Status用参数传入;@CustomerId和@UserId都来自服务端校验后的值;不要用SELECT先查再改,中间会有并发窗口。
4. 客户跟进与统计报表:从一张列表到能交差的看板
CRM 源码里客户列表通常能跑,跟进提醒和统计报表却经常是半成品:下次跟进时间不更新、逾期客户查不出来、成交率把跟进记录当客户数算。二次开发时,先把跟进状态机和统计口径定清楚,再改页面。否则页面越改越花,销售还是不愿意用。
4.1 跟进提醒:NextFollowAt 与状态机的边界
客户状态不要做成自由文本。常见状态是“新建、跟进中、已成交、已流失”,每次写跟进记录时根据业务动作推进状态,而不是让用户随便选。NextFollowAt为空表示不提醒;有值且小于当前时间表示逾期;有值且在今天结束前表示今日待跟进。状态机和提醒时间要一起维护,否则会出现“已成交客户还在提醒跟进”的脏数据。
| 当前状态 | 允许动作 | 下一状态 | 是否写跟进 |
|---|---|---|---|
| 新建 | 首次联系 | 跟进中 | 是 |
| 跟进中 | 继续跟进 | 跟进中 | 是 |
| 跟进中 | 确认成交 | 已成交 | 是 |
| 跟进中 | 确认流失 | 已流失 | 是 |
| 已成交 | 复购跟进 | 跟进中 | 是 |
今日待跟进查询不要用字符串比较日期,也不要把当天已经过期的客户漏掉:
-- 今日待跟进:只取未关闭且已到提醒时间的客户 SELECT c.CustomerId, c.CustomerName, c.NextFollowAt, c.OwnerId FROM Customer c WHERE c.Status IN (N'新建', N'跟进中') AND c.NextFollowAt IS NOT NULL AND c.NextFollowAt < DATEADD(DAY, 1, CAST(GETDATE() AS date)) ORDER BY c.NextFollowAt;逻辑说明:Status IN排除已成交和已流失,NextFollowAt非空才提醒,时间边界取“明天零点之前”。参数说明:状态值要和数据库实际值一致,不要用带空格的N'跟进中 ';CAST(GETDATE() AS date)去掉时分秒;如果业务要求“只提醒今天到期的”,把条件改成>= 今天零点 AND < 明天零点。跟进表建议加(OwnerId, NextFollowAt)或(Status, NextFollowAt)索引,否则数据一多列表就慢。
4.2 分页、搜索与导出:别把内存和数据库一起拖死
客户列表的默认查询不要SELECT *,也不要把整表读进内存再分页。老项目常见写法是GridView绑定全量DataTable,前几页看不出问题,数据过万后每次打开都卡。分页尽量在数据库完成;如果数据库版本较老,用ROW_NUMBER()兼容,不要依赖新语法。
-- 兼容较老数据库的分页查询,@PageIndex 从 1 开始 WITH Paged AS ( SELECT c.CustomerId, c.CustomerName, c.Status, c.OwnerId, ROW_NUMBER() OVER (ORDER BY c.CreatedAt DESC) AS RowNo FROM Customer c WHERE (@Keyword = N'' OR c.CustomerName LIKE @Keyword + N'%') AND (@Status = N'' OR c.Status = @Status) ) SELECT * FROM Paged WHERE RowNo BETWEEN (@PageIndex - 1) * @PageSize + 1 AND @PageIndex * @PageSize;逻辑说明:先给结果集编行号,再按页取区间,避免每次取全量。参数说明:@PageIndex从 1 开始,前端如果从 0 开始要转换;@PageSize建议 20 到 50;@Keyword + N'%'是前缀匹配,能走索引的情况比%关键字%好,但用户输入%或_时要转义。搜索条件不要直接拼进 SQL,否则一个单引号就能把查询打崩。
导出客户时,不要复用列表页的DataSet直接写 Excel。常见做法是单独查导出数据、限制最大行数、用流式响应写 CSV。CSV 要处理逗号、双引号和换行,否则销售打开后列会错位。导出权限也要单独控制,不能因为能看列表就能批量导出。
4.3 统计口径:成交率、复购率与负责人维度
统计报表最容易“数字看起来对,业务不认”。成交率的分母是客户数还是跟进数,复购率按客户算还是按订单算,必须写死在报表说明里。客户表里一个客户一行,成交状态在Status字段,那么按负责人统计客户数和成交数时,要用COUNT(DISTINCT CustomerId),不要被跟进记录 JOIN 放大。
-- 按负责人统计客户数与成交数,成交率用 NULLIF 避免除零 SELECT OwnerId, COUNT(DISTINCT CustomerId) AS CustomerCount, COUNT(DISTINCT CASE WHEN Status = N'已成交' THEN CustomerId END) AS DealCount, CAST(COUNT(DISTINCT CASE WHEN Status = N'已成交' THEN CustomerId END) AS decimal(10,4)) / NULLIF(COUNT(DISTINCT CustomerId), 0) AS DealRate FROM Customer GROUP BY OwnerId;逻辑说明:COUNT(DISTINCT ...)保证一个客户只算一次,NULLIF防止客户数为 0 时除零。参数说明:decimal(10,4)保留四位小数,前端再格式化成百分比;Status = N'已成交'的状态值要和业务字典一致。如果要按月份看新增客户,用CONVERT(char(7), CreatedAt, 120)分月,不要把日期转成字符串后再比较大小。
5. 部署与排查:ASP.NET CRM 上线前最常见的 5 个翻车现场
源码在本地能跑,部署到 IIS 后问题才集中出现:登录页 500、登录后又跳回登录页、按钮没反应、导出乱码、销售能看到别人的客户。下面 5 个现象按“现象 → 原因 → 解决”拆开,基本都是 ASP.NET WebForms 项目的老毛病。先按顺序排查,不要一上来重装环境。
5.1 登录与连接:两个最常见的 500
现象:打开登录页直接 500,堆栈指向SqlConnection或ConfigurationManager。
原因:web.config里还是模板连接串,或者应用池身份没有数据库登录权限;也有项目把连接串写死在DbHelper里,改配置根本不生效。
解决:先全局搜索SqlConnection、ConnectionStrings、Data Source=,确认实际读取的是哪一段;再用测试账号连库,给应用池身份或独立 SQL 登录最小权限,至少要有业务表的读写权限和存储过程执行权限,不要用高权限账号跑站点。
现象:登录成功后又跳回登录页,或者刷新一次就掉登录。
原因:Session模式、超时时间、Cookie 路径和表单认证配置不一致;IIS 应用池回收后 Session 丢失;多台服务器部署时机器密钥不统一。
解决:先看web.config的authentication、authorization、sessionState三段是否一致;单机环境先确认登录成功后Session["UserId"]确实有值;再看应用池的空闲超时和回收时间。多服务器场景不要只靠内存 Session,改成数据库或统一缓存,并统一机器密钥。
5.2 页面与导出:按钮无响应和乱码
现象:
本文还有配套的精品资源,点击获取