☰
ASP.NET CRM源码解析:三层架构、权限控制与部署二次开发
2026/11/2 13:28:07 网站建设 项目流程

简介:面向ASP.NET WebForms与C#初学者、课程设计及毕业设计开发者的一套客户关系管理(CRM)系统源码,可用于理解客户信息维护、服务派单、投诉反馈、流失客户跟进与后台管理等典型业务模块,适合作为中小型管理系统的学习范本和二次开发基础。压缩包共297个文件,约1.15MB,包含100个cs后台代码、38个aspx页面、32个dll程序集,以及数据库、配置和样式资源,覆盖页面展示、业务逻辑与数据存储等主要层次。目前已有441人学习下载。读者可据此梳理WebForms项目结构、登录入口、客户资料编辑、服务工单流转和反馈列表等实现思路,并结合数据库文件还原运行环境,快速搭建可调试的CRM演示系统,为课程作业、毕业设计或企业内部管理系统原型提供参考。

1. 从一张反复另存的 Excel 说起:这套 ASP.NET CRM 到底替你管了哪些事

销售在群里丢过来一个「客户跟进表-最终版-最终确认版(2).xlsx」,两个人同时改,商机阶段对不上,上周谁打过电话也查不到——这是大多数小团队做客户关系管理的真实起点。这套用 ASP.NET(C#) 写的 CRM 系统,要解决的就是把这张表拆成客户档案、联系人、跟进记录、商机、合同回款几张互相关联的表,再补上登录、角色权限、数据隔离和统计报表。标题里的「客户关系 管理 CRM asp.net 源码」几个词,落到代码上就是三件事:一套能跑的三层架构、一套能改的页面、一份能还原的数据库脚本。适合两类人:一类是想给自己团队搭一套内部客户管理系统的开发者,另一类是拿它当交付底座做二次开发的人。它不追求 SaaS 那种多租户和复杂流程引擎,胜在代码直白,改一个字段半小时能改完。

2. 技术栈与目录结构:先用十分钟看清这套代码的骨架

拿到压缩包别急着找 .sln 按 F5。一个完整的 ASP.NET 项目,能不能在半小时内跑起来,取决于三件事:解决方案里到底有几个项目、web.config 里的连接串长什么样、数据库脚本放在哪个目录。这三样看明白了,剩下的都是体力活;看不明白,你就会在「未能加载文件或程序集」和「登录后跳回登录页」之间来回折腾一下午。

2.1 技术栈判断:WebForms 还是 MVC,看三个文件就够

这类 CRM 源码包市面上主要有两种形态,判断起来很快。看根目录有没有Global.asax里注册路由的那几行RouteConfig.RegisterRoutes、有没有Controllers和Views目录、有没有.edmx或继承DbContext的类——三个都有,是 MVC + Entity Framework 版;只有.aspx和.aspx.cs,数据访问层里手写SqlConnection,那就是 WebForms + ADO.NET 版。

两者没有优劣,只有维护手感差别。WebForms 版的好处是页面和逻辑贴得近,一个CustomerEdit.aspx配一个CustomerEdit.aspx.cs,业务方提需求说「客户列表加一列」,你打开文件加个<asp:BoundField>就完事;坏处是ViewState和UpdatePanel会让人怀疑人生,页面体积大、调试时事件顺序绕。MVC 版结构清爽,但对不熟悉路由和模型绑定的开发者反而更绕。

典型的 WebForms 版目录长这样:

CrmSolution/ ├── CrmSolution.sln ├── Crm.Model/ # 实体类,和数据库表一一对应 │ ├── CustomerInfo.cs │ └── FollowUp.cs ├── Crm.DAL/ # 数据访问,纯 ADO.NET │ ├── SqlHelper.cs │ ├── CustomerDAL.cs │ └── FollowUpDAL.cs ├── Crm.BLL/ # 业务逻辑,校验 + 拼装 + 事务 │ ├── CustomerBLL.cs │ └── OpportunityBLL.cs ├── Crm.Web/ # 站点 │ ├── Web.config │ ├── Global.asax │ ├── MasterPage/Main.Master │ ├── Customer/List.aspx、Edit.aspx │ ├── Opportunity/List.aspx │ ├── System/RoleEdit.aspx │ ├── Scripts/ # jquery + layui/bootstrap-table │ └── Upload/ # 附件目录,需要写权限 └── DB/ ├── CrmDB_Create.sql # 建库建表 └── CrmDB_InitData.sql # 初始账号、菜单、角色

前端通常不是单页应用,而是母版页 + jQuery + layui 或 bootstrap-table 的组合。表格用bootstrap-table走服务端分页,弹层用layer.open,表单校验用 jQuery Validate。这套组合在 2015 到 2020 年之间的企业内系统里非常普遍,好处是你不需要 Node 环境,改完 HTML 直接刷新就能看到效果。

技术点常见形态接手时要注意
页面框架WebForms + MasterPage事件顺序、ViewState 关掉能提速
数据访问ADO.NET + SqlHelper 封装是否有参数化,有没有字符串拼 SQL
前端表格bootstrap-table / layui table服务端分页参数名要对齐
认证Session + 自定义 BasePage超时时间、IIS 回收会导致掉登录
数据库SQL Server 2008/2012 及以上排序规则影响中文和唯一索引

2.2 分层结构:Model、DAL、BLL、Web 的边界在哪

四层的职责,写得清楚的源码包一眼能看出来。Model 是纯属性类,只承载数据,不带逻辑;DAL 只做数据库读写,一个方法对应一条或一组 SQL;BLL 做参数校验、业务规则判断、跨表事务;Web 只负责接收请求、调 BLL、把结果绑到控件上。

真正容易写乱的是 BLL 和 DAL 的边界。见过不少项目在 DAL 里写「如果客户状态是已成交就不允许删除」这种判断,结果换个入口调用就漏了。合格的写法是:DAL 只回答「数据库里有什么」,BLL 回答「能不能这么干」。看一段典型的 BLL:

// Crm.BLL/CustomerBLL.cs public class CustomerBLL { private readonly CustomerDAL dal = new CustomerDAL(); /// <summary> /// 分页取客户列表 /// </summary> /// <param name="keyword">客户名称/电话模糊匹配,传 null 或空表示不限</param> /// <param name="ownerId">数据权限过滤的销售员ID,0 表示不限(管理员)</param> /// <param name="pageIndex">页码,从 1 开始</param> /// <param name="pageSize">每页条数,默认 20</param> /// <param name="total">输出参数:符合条件的总记录数</param> public DataTable GetPagedList(string keyword, int ownerId, int pageIndex, int pageSize, out int total) { // 参数兜底放在 BLL:前端传 pageSize=99999 会把内存打满 if (pageIndex < 1) pageIndex = 1; if (pageSize < 1 || pageSize > 100) pageSize = 20; // 模糊查询关键字统一在这里 trim 并转义 %,避免用户搜 "%" 全表返回 keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim().Replace("%", "[%]"); return dal.GetPagedList(keyword, ownerId, pageIndex, pageSize, out total); } /// <summary> /// 删除客户:有商机或合同的客户不允许物理删除,只能标记失效 /// </summary> public string Delete(int customerId) { if (dal.HasRelatedBusiness(customerId)) { dal.SetStatus(customerId, 0); // 0 = 已失效 return "该客户存在关联商机,已标记为失效"; } dal.Delete(customerId); return "删除成功"; } }

这里有两处值得注意。第一处是pageSize的上限限制,看起来多余,但我在真实项目里见过前端分页组件被手动改成pageSize=100000直接把应用池拖崩的情况。第二处是模糊查询里对%的转义,SQL Server 的LIKE通配符如果不过滤,用户输入一个%就等于全表扫描加全量返回。

DAL 那边对应的方法通常长这样,注意参数是通过SqlParameter走的,不是拼字符串:

// Crm.DAL/CustomerDAL.cs public DataTable GetPagedList(string keyword, int ownerId, int pageIndex, int pageSize, out int total) { var where = new StringBuilder(" WHERE c.IsDeleted = 0 "); var ps = new List<SqlParameter>(); if (!string.IsNullOrEmpty(keyword)) { where.Append(" AND (c.CustomerName LIKE @kw OR c.Phone LIKE @kw) "); ps.Add(new SqlParameter("@kw", "%" + keyword + "%")); } if (ownerId > 0) { where.Append(" AND c.OwnerId = @ownerId "); ps.Add(new SqlParameter("@ownerId", ownerId)); } // 先查总数,分页控件要用 total = Convert.ToInt32(SqlHelper.ExecuteScalar( "SELECT COUNT(1) FROM Customer c" + where, ps.ToArray())); // ROW_NUMBER 分页,兼容 SQL Server 2008,不能用 OFFSET FETCH var sql = string.Format(@" SELECT * FROM ( SELECT c.*, ROW_NUMBER() OVER (ORDER BY c.CreateTime DESC) AS rn FROM Customer c {0} ) t WHERE t.rn BETWEEN @start AND @end", where); ps.Add(new SqlParameter("@start", (pageIndex - 1) * pageSize + 1)); ps.Add(new SqlParameter("@end", pageIndex * pageSize)); return SqlHelper.ExecuteDataTable(sql, ps.ToArray()); }

ROW_NUMBER() OVER这种写法是为了兼容 SQL Server 2008,如果你的环境是 2012 及以上,OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY更简洁也更好读。改成后者时记得把ORDER BY补全,否则分页顺序不稳定,翻页会出现重复行。

2.3 配置与数据库:web.config 里那几行决定成败

连接串和运行时配置全在 Web.config。这类系统一般还要配附件上传大小、Session 模式、错误页跳转。

<configuration> <connectionStrings> <!-- 改这里:Data Source 写实例名,127.0.0.1 或 .\SQLEXPRESS --> <add name="CrmConn" connectionString="Data Source=.;Initial Catalog=CrmDB;User Id=sa;Password=你的密码;" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <add key="PageSize" value="20" /> <!-- 附件物理路径,IIS 应用程序池账号必须对这个目录有写权限 --> <add key="UploadPath" value="D:\CrmUpload\" /> </appSettings> <system.web> <!-- 上线前务必改成 false,否则异常堆栈会直接显示给用户 --> <compilation debug="false" targetFramework="4.5" /> <!-- 单次请求最大 100MB,配合下面 system.webServer 的请求筛选一起改 --> <httpRuntime maxRequestLength="102400" targetFramework="4.5" /> <!-- 默认 30 分钟,内部系统一般调到 120,减少掉登录投诉 --> <sessionState mode="InProc" timeout="120" /> <customErrors mode="RemoteOnly" defaultRedirect="/Error.aspx" /> </system.web> <system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="104857600" /> </requestFiltering> </security> </system.webServer> </configuration>

几个参数的实际含义:sessionState mode="InProc"表示 Session 存在当前进程内存里,应用程序池一回收就全丢,用户表现为「莫名其妙退出登录」,所以内部系统要么把 timeout 调大,要么改成StateServer并启动对应服务。maxRequestLength和maxAllowedContentLength是两套限制,前者是 ASP.NET 层(单位 KB),后者是 IIS 层(单位字节),只改一个的话大附件依然会被拦下来。

数据库恢复一般是一个脚本或者一个.bak文件。用脚本的话,SSMS 里打开后先改CREATE DATABASE的文件路径,再整段执行;用备份文件就是右键「还原数据库」。命令行也行:

# 用备份文件还原,注意 MOVE 到实际数据目录,否则会还原到 SQL Server 默认路径 sqlcmd -S . -U sa -P 你的密码 -Q "RESTORE DATABASE CrmDB FROM DISK='D:\db\CrmDB.bak' WITH REPLACE, MOVE 'CrmDB' TO 'D:\db\CrmDB.mdf', MOVE 'CrmDB_log' TO 'D:\db\CrmDB_log.ldf'"

还原完先跑一句SELECT COUNT(*) FROM SysUser,能查出来说明表结构和初始数据都在,再去改连接串。顺序反了会很痛苦:连接串指向一个空库,程序启动不报错,但登录永远失败,你会在密码加密逻辑里找半天。

提示:初始化脚本里通常自带一个默认管理员账号,登录后第一件事是改密码并新建一个自己的账号,别直接在线上用初始账号跑业务。

3. 核心模块拆解:客户、跟进、商机、合同的数据怎么流动

后台管理系统看起来页面很多,本质上是几组主从表在 CRUD。把数据关系理清楚,页面就只是这组 SQL 的不同展示形态。这个 CRM 的核心是四张表:客户、联系人、跟进记录、商机,再往下挂合同和回款。

3.1 客户档案:主表加子表的典型增删改查

客户表是主表,联系人、跟进记录、商机都用CustomerId挂在它下面。页面上新增客户时,通常会一次性提交客户基本信息 + 一到多个联系人,后端要用事务保证要么全成功要么全回滚。

-- 客户主表(简化版,实际还会多几个自定义字段) CREATE TABLE Customer ( Id INT IDENTITY(1,1) PRIMARY KEY, CustomerName NVARCHAR(100) NOT NULL, Industry NVARCHAR(50) NULL, Level TINYINT NOT NULL DEFAULT 3, -- 1=A 2=B 3=C OwnerId INT NOT NULL, -- 归属销售,数据权限靠它 DeptId INT NOT NULL, Status TINYINT NOT NULL DEFAULT 1, -- 1 跟进中 0 失效 IsDeleted BIT NOT NULL DEFAULT 0, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CreateBy INT NOT NULL ); CREATE INDEX IX_Customer_Owner ON Customer(OwnerId, IsDeleted); -- 联系人子表 CREATE TABLE Contact ( Id INT IDENTITY(1,1) PRIMARY KEY, CustomerId INT NOT NULL, Name NVARCHAR(50) NOT NULL, Title NVARCHAR(50) NULL, -- 职务,决策人标记很重要 Phone NVARCHAR(30) NULL, IsPrimary BIT NOT NULL DEFAULT 0 ); CREATE INDEX IX_Contact_Customer ON Contact(CustomerId);

后端保存时,客户主表插入拿到自增 Id,再循环插入联系人。这段代码是新手最容易写出「插了一半」的地方:

// Crm.BLL/CustomerBLL.cs public int Save(CustomerInfo model, List<ContactInfo> contacts) { if (string.IsNullOrWhiteSpace(model.CustomerName)) throw new ArgumentException("客户名称不能为空"); // 同一销售名下不允许重名客户,这是业务方明确提过的要求 if (dal.ExistsSameName(model.CustomerName, model.OwnerId, model.Id)) throw new ArgumentException("该销售名下已存在同名客户"); using (var conn = new SqlConnection(SqlHelper.ConnString)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { int customerId = model.Id > 0 ? dal.Update(conn, tran, model) // 返回原 Id : dal.Insert(conn, tran, model); // 返回新自增 Id // 联系人采用「先删后插」策略,避免前端做复杂的差量对比 dal.DeleteContacts(conn, tran, customerId); foreach (var c in contacts) { c.CustomerId = customerId; dal.InsertContact(conn, tran, c); } tran.Commit(); return customerId; } catch { tran.Rollback(); throw; // 别吞异常,页面要拿 message 提示用户 } } } }

参数说明一下:model.Id > 0判断是新增还是编辑,这是这类系统里最常见的约定;conn和tran一路往下传,是让 DAL 方法复用同一个连接和事务,不能在里面再new SqlConnection,那样事务根本不生效——这个坑我见过太多次,表现是「主表插入成功、子表失败,数据脏了但没报错」。

3.2 跟进记录与商机漏斗:一次阶段推进要写两张表

跟进记录是一张时间轴表,商机是一张有阶段字段的表。销售在页面上点「推进到下一阶段」时,业务上要求两个动作必须同时发生:商机阶段变了,同时留一条跟进记录说明为什么变。

// Crm.BLL/OpportunityBLL.cs public void ChangeStage(int opportunityId, int newStage, string remark, int userId) { // 阶段只能往前进,不能倒退,除非是管理员(这里传 isAdmin 简化处理) var opp = dal.GetById(opportunityId); if (opp == null) throw new Exception("商机不存在"); if (newStage <= opp.Stage) throw new Exception("阶段只能向前推进"); using (var conn = new SqlConnection(SqlHelper.ConnString)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 更新商机阶段与赢单概率(概率按阶段映射,避免销售随便填) int rate = StageRateMap.Get(newStage); // 10/30/50/70/100 dal.UpdateStage(conn, tran, opportunityId, newStage, rate); // 2. 写一条跟进记录,Type=2 表示阶段变更 followUpDal.Insert(conn, tran, new FollowUpInfo { CustomerId = opp.CustomerId, OpportunityId = opportunityId, Type = 2, Content = remark, CreateBy = userId, CreateTime = DateTime.Now }); tran.Commit(); } catch { tran.Rollback(); throw; } } } }

StageRateMap是个静态字典,把阶段映射成赢单概率,报表里的「加权商机金额」就是金额 × 概率求和。这个映射表建议做成配置项而不是写死,因为业务方每隔几个月就想改一次阶段定义,写死的话每次都要发版。

3.3 数据访问层:SqlHelper 是这套系统的地基

几乎每个 ADO.NET 项目都有一个SqlHelper,写得好的和写得烂的差距很大。核心要求只有一条:所有参数必须走SqlParameter,绝不拼字符串。下面是精简过的实现,够用且能防住常见注入:

// Crm.DAL/SqlHelper.cs public static class SqlHelper { public static string ConnString => ConfigurationManager.ConnectionStrings["CrmConn"].ConnectionString; public static DataTable ExecuteDataTable(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnString)) using (var cmd = new SqlCommand(sql, conn)) using (var da = new SqlDataAdapter(cmd)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); var dt = new DataTable(); da.Fill(dt); return dt; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnString)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } public static object ExecuteScalar(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnString)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteScalar(); } } }

三个方法分别对应查询列表、执行增删改、取单个值(条数、求和)。using嵌套保证连接一定释放,这点比try/finally简洁。要注意的是这个版本没有支持事务,前面 BLL 里用的那些(conn, tran, ...)重载需要另外写一组接收SqlConnection和SqlTransaction的方法,实现几乎一样,只是不自己 new 连接。

3.4 统计报表:几条聚合 SQL 撑起整个首页

首页通常放四个数字:客户总数、本月新增、本月跟进次数、商机金额。这些不需要缓存,几条聚合查询就够:

-- 本月各销售的新增客户数与跟进次数,用于首页排行 SELECT u.RealName, COUNT(DISTINCT c.Id) AS NewCustomers, COUNT(f.Id) AS FollowCount, ISNULL(SUM(o.Amount * o.Rate / 100.0), 0) AS WeightedAmount FROM SysUser u LEFT JOIN Customer c ON c.OwnerId = u.Id AND c.IsDeleted = 0 AND c.CreateTime >= @monthStart AND c.CreateTime < @monthEnd LEFT JOIN FollowUp f ON f.CreateBy = u.Id AND f.CreateTime >= @monthStart AND f.CreateTime < @monthEnd LEFT JOIN Opportunity o ON o.OwnerId = u.Id AND o.Stage < 5 -- 排除已关闭 WHERE u.IsActive = 1 GROUP BY u.RealName ORDER BY NewCustomers DESC;

@monthStart和@monthEnd用月初和次月初,而不是BETWEEN ... AND ...加23:59:59——后者在毫秒精度上会漏数据,这是很经典的一处陷阱。COUNT(DISTINCT c.Id)是因为三个 LEFT JOIN 会互相放大行数,不去重数字会翻好几倍。

4. 登录、角色、数据权限:一套能撑住内部使用的权限模型

内部系统的权限要求通常比想象中高。老板要看全部数据,销售总监看本部门,销售只看自己名下的客户。这个范围不是靠页面藏按钮实现的,得在查询层过滤。

4.1 登录流程与密码存储

登录这块最容易出问题的是密码存储。老项目里用 MD5 明文的不少,如果你拿到的是这种版本,第一步就是换成加盐哈希,哪怕只是SHA256(password + salt)也比裸 MD5 强。

// Crm.Web/Login.aspx.cs protected void btnLogin_Click(object sender, EventArgs e) { string userName = txtUserName.Text.Trim(); string pwd = txtPassword.Text; if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(pwd)) { ShowMsg("用户名和密码不能为空"); return; } var user = new UserBLL().GetByUserName(userName); // 统一提示语,避免暴露「这个账号不存在」给撞库的人 if (user == null || !PasswordHelper.Verify(pwd, user.Password, user.Salt)) { ShowMsg("用户名或密码错误"); return; } if (!user.IsActive) { ShowMsg("账号已停用,请联系管理员"); return; } Session["UserId"] = user.Id; Session["UserName"] = user.RealName; Session["RoleId"] = user.RoleId; Session["DeptId"] = user.DeptId; Session["IsAdmin"] = user.RoleId == 1; Response.Redirect("/Default.aspx"); } // PasswordHelper:注册时生成 Salt,登录时用同一套算法校验 public static bool Verify(string input, string storedHash, string salt) { using (var sha = SHA256.Create()) { var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input + salt)); var sb = new StringBuilder(); foreach (var b in bytes) sb.Append(b.ToString("x2")); return string.Equals(sb.ToString(), storedHash, StringComparison.OrdinalIgnoreCase); } }

Session里只放必要字段,不要塞整个用户实体对象——不仅占内存,而且在InProc模式下对象一改就到处串数据。IsAdmin单独存一份是为了页面判断方便,但真正的权限校验不能只信 Session,登录后角色被改的边上情况要能兜住,所以下面还有一层。

4.2 菜单权限与操作权限:BasePage 统一拦截

WebForms 里最省事的做法是写一个BasePage,所有需要登录的页面继承它,在OnInit里做校验。菜单权限决定能不能进这个页面,操作权限决定能不能点删除。

// Crm.Web/App_Code/BasePage.cs public class BasePage : System.Web.UI.Page { protected int UserId => Convert.ToInt32(Session["UserId"] ?? 0); protected bool IsAdmin => Convert.ToBoolean(Session["IsAdmin"] ?? false); protected override void OnInit(EventArgs e) { if (UserId == 0) { Response.Redirect("/Login.aspx?returnUrl=" + Server.UrlEncode(Request.RawUrl)); return; // 重定向后必须 return,否则下面还会继续跑 } if (!IsAdmin && !HasMenuPermission(Request.Path)) { Response.StatusCode = 403; Response.Write("无权限访问该页面"); Response.End(); return; } base.OnInit(e); } private bool HasMenuPermission(string path) { // 菜单权限缓存在 Application 里,登录后第一次读库,后续直接命中 var cacheKey = "Menu_" + Convert.ToInt32(Session["RoleId"]); var menus = HttpContext.Current.Application[cacheKey] as List<MenuInfo>; if (menus == null) { menus = new RoleBLL().GetMenusByRole(Convert.ToInt32(Session["RoleId"])); HttpContext.Current.Application[cacheKey] = menus; } return menus.Any(m => path.EndsWith(m.Url, StringComparison.OrdinalIgnoreCase)); } /// <summary>按钮级权限判断,页面上直接写成 if (HasAction("customer:delete"))</summary> protected bool HasAction(string actionCode) { if (IsAdmin) return true; var actions = new RoleBLL().GetActionsByRole(Convert.ToInt32(Session["RoleId"])); return actions.Contains(actionCode); } }

Web.config里不要再多写一套authorization规则,两处维护必然打架。权限数据的三张表是SysRole、SysMenu、SysRoleMenu,操作权限挂在SysMenu的ActionCode字段上,用冒号命名空间(customer:delete、contract:export)比用数字 Id 可读得多。

注意:Application缓存菜单后,改了角色权限必须手动清一次缓存,否则要等应用池回收才生效。改权限的 BLL 方法里加一行Application.Remove("Menu_" + roleId)是最省事的补丁。

4.3 数据权限:把过滤条件塞进 SQL 而不是页面

数据权限最忌讳的做法是查出来再在内存里Where。数据量一大就卡,而且容易漏。正确做法是在 DAL 拼查询条件时注入一段OwnerId IN (...)或DeptId IN (...)。

// Crm.BLL/DataScopeHelper.cs /// <summary>返回当前用户可见的数据范围 ID 列表,用于拼 IN 条件</summary> public static List<int> GetVisibleUserIds() { int userId = Convert.ToInt32(HttpContext.Current.Session["UserId"]); int roleId = Convert.ToInt32(HttpContext.Current.Session["RoleId"]); int deptId = Convert.ToInt32(HttpContext.Current.Session["DeptId"]); // 1 = 管理员看全部,返回空列表表示不加过滤 if (roleId == 1) return new List<int>(); // 2 = 总监看本部门 if (roleId == 2) return new UserDAL().GetIdsByDept(deptId); // 其他角色只看自己 return new List<int> { userId }; }

DAL 调用时这样用,参数化拼IN有个小技巧:不要拼字符串,而是用SqlParameter逐个加,或者用表值参数。数据量小的话逐个加最省事:

var ids = DataScopeHelper.GetVisibleUserIds(); var where = new StringBuilder(" WHERE c.IsDeleted = 0 "); var ps = new List<SqlParameter>(); for (int i = 0; i < ids.Count; i++) { where.Append(i == 0 ? " AND c.OwnerId IN (" : ","); where.Append("@u" + i); ps.Add(new SqlParameter("@u" + i, ids[i])); } if (ids.Count > 0) where.Append(")");

返回空列表时代表「不过滤」,这是个隐含约定,写注释标清楚,否则下一个接手的人会以为空列表代表没权限,改一下就出事故。

5. 部署与排查:把源码跑起来这一步最容易翻车

本地 VS 里按 F5 跑通和部署到 IIS 上跑通是两件事。前者用的是 IIS Express,权限宽松、路径简单;后者有应用程序池、有身份、有请求筛选,出问题的点一下子多出好几倍。

5.1 IIS 部署:应用程序池、.NET 版本、目录权限

标准流程:装好 IIS 与 .NET Framework 对应版本 → 把编译好的站点目录拷到服务器 → 新建应用程序池(托管管道选「集成」,.NET 版本选 v4.0)→ 新建网站或应用程序,指向目录 → 给应用程序池账号(默认IIS AppPool\你的池名)授予目录读写权限。

# 用 appcmd 建站点和池,比图形界面快,也方便写成部署脚本 %windir%\system32\inetsrv\appcmd.exe add apppool /name:CrmPool /managedRuntimeVersion:v4.0 /managedPipelineMode:Integrated %windir%\system32\inetsrv\appcmd.exe add site /name:CrmSite /physicalPath:"D:\www\CrmWeb" /bindings:http/*:8080: %windir%\system32\inetsrv\appcmd.exe set app "CrmSite/" /applicationPool:CrmPool

目录权限一定要给Upload目录单独的写权限,而且不能给执行权限。这是安全基线,也是很多人在部署后发现「上传成功但文件为 0 字节」的原因之一。

5.2 数据库还原与连接串调整

还原顺序建议是:先还原数据库 → 用 SSMS 验证表和初始数据 → 再改 web.config 连接串 → 最后访问首页。这样出问题时能立刻定位是数据库问题还是应用问题。连接串里的User Id=sa在正式环境不太合适,建议建一个只对CrmDB有db_owner的独立账号,密码写长一点。

验证连通性用一句最短的 SQL:

# 只验证能不能连上并读到用户表,不启动站点 sqlcmd -S . -d CrmDB -U crmuser -P 你的密码 -Q "SELECT TOP 1 Id, UserName FROM SysUser"

5.3 五条踩坑记录

其一,页面报「未能加载文件或程序集」或CS0433类型冲突。现象是本地能跑,服务器上一访问就 500。原因是bin目录里同时存在两份同名 DLL,或者App_Code里编译出的临时程序集和旧版本打架。解决方式:清空站点bin目录重新拷贝,再删掉C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files下对应的缓存目录,重启应用程序池。血泪经验是拷贝发布包时不要用「增量覆盖」,直接删干净再放。

其二,登录成功但反复跳回登录页。现象是输入正确的账号密码,页面闪一下又回到登录页,日志里Session["UserId"]一直是 null。原因通常是三个之一:应用池空闲超时被回收导致InProcSession 丢失、web.config里sessionState改成了StateServer但服务没启动、或者站点挂在虚拟目录下而 Cookie 的路径不对。排查顺序是先看应用池的「空闲超时」和「回收」时间,再确认sessionState配置与实际服务一致,最后检查是否有多台服务器但没有做 Session 共享。

其三,中文显示成问号或乱码。现象是列表里客户名称显示为???,或者只有部分字段乱码。原因是数据库的排序规则不是Chinese_PRC_CI_AS,或者 ASPX 页面缺少<%@ Page ... %>里没有指定ResponseEncoding,或者连接串没带编码。先查数据库属性里的排序规则,再查表字段是不是VARCHAR存的中文——VARCHAR在非中文排序规则下必然乱码,得改成NVARCHAR。

其四,上传附件报 404 或 500。现象是小文件能传,超过 4MB 就失败,或者路径正确也报错。原因是双重限制没配对:httpRuntime的maxRequestLength单位是 KB,requestFiltering的maxAllowedContentLength单位是字节,两个都要改;另外 IIS 如果装了请求筛选之外的安全模块,也可能拦掉。另外别忘了给Upload目录加IIS_IUSRS的写权限。

其五,报表页或导出功能超时。现象是首页四个数字慢慢出来,导出全部客户时直接转圈到 30 秒超时。原因是LIKE '%关键字%'前置通配导致索引失效,加上SELECT *拖了宽表。解决办法是先看执行计划有没有走IX_Customer_Owner,把不带前置通配的查询条件拆开,导出改成异步任务加进度条,同时SELECT只取需要的列。数据量超过几十万行的话,导出直接走SqlDataReader流式写文件,比一次性DataTable稳得多。

6. 二次开发:加一个字段、加一个模块、换一套皮肤

接手这套 CRM 之后,需求基本都是三种:加字段、加模块、换界面。加字段是最常见的,也是最能看出代码整洁度的操作。我给客户表加「来源渠道」这个字段时,要动的地方一共六处:数据库表加列、CustomerInfo实体加属性、CustomerDAL的Insert/Update加参数、CustomerEdit.aspx加一个下拉框、List.aspx加一列、导出 Excel 的模板加一列。如果这个项目有代码生成器(很多这类源码包会带一个简版生成器),前四处能自动生成,后两处手动补。

加模块的路径更清晰,参照「跟进记录」抄一遍就行:建表 → Model → DAL → BLL → 列表页 + 编辑页 → 菜单表插一条记录 → 角色菜单表授权。我一般先只做列表和新增,跑通数据再补编辑和删除,避免一次性写五个文件后编译报错找不着北。

换 UI 这件事要克制。这类系统的页面和ViewState、UpdatePanel、服务端控件绑定得很深,直接换一套前端框架等于重写。折中方案是保留.aspx结构,把bootstrap-table换成同类的表格组件,CSS 统一覆盖,母版页换一次全部页面跟着变。真正要迁到 MVC 或 ASP.NET Core 的话,我的做法是先迁数据访问层到 Dapper 或 EF Core,页面按模块逐个改,权限和登录单独抽成一个中间件,别一次性动全部。

从那以后我每次拿到这类管理系统源码,第一件事都是先把数据库还原一份到本地、跑通登录、把「加一个字段」完整走一遍再动手改业务——这一步十分钟,能提前发现代码生成器是否可用、权限缓存是否好清、页面绑定是否写死。希望帮到你。

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

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

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

立即咨询