☰
ASP.NET Core + EF Core 从零搭建CRM系统:核心设计与部署实践
2026/9/30 17:36:05 网站建设 项目流程

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 8(LTS版本)+ 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.Entity<Customer>(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.Entity<FollowUpRecord>(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做了泛型封装,提供基础的增删改查方法。不需要过度设计UoW(Unit of Work)那一套,EF Core的DbContext本身就是一个工作单元,SaveChanges就是事务提交点,再包一层反而画蛇添足。

public class CustomerService { private readonly ApplicationDbContext _db; private readonly ILogger<CustomerService> _logger; public CustomerService(ApplicationDbContext db, ILogger<CustomerService> logger) { _db = db; _logger = logger; } public async Task<PagedResult<Customer>> 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 PagedResult<Customer> { Items = items, Total = total }; } }

所有Service都通过构造函数注入DbContext、ILogger等依赖,然后由ASP.NET Core内置的依赖注入容器管理生命周期。这里有个经验:DbContext是按请求注册的(默认Scoped),Service也应该是Scoped,Repository同理。千万不要用错生命周期,否则会出现跨请求使用已释放的DbContext这种诡异异常。

3.2 你有ASP.NET Core MVC的两个写法:传统视图和Razor Pages

ASP.NET Core MVC在渲染页面时有两种主流选择:Controller+View的传统MVC模式,和Razor Pages模式。很多初学者搞不清这两者的区别。我个人的理解是:如果系统是页面多、表单多、每个页面相对独立的内部管理系统,Razor Pages的开发效率更高;如果系统需要丰富的路由控制、API接口较多,或者视图要重用组件,那传统MVC模式更适合。

我选择的是传统MVC模式。原因是这套CRM后续可能要给移动端或第三方系统提供API,MVC模式下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 Task<IActionResult> Index(int page = 1) { var model = await _customerService.GetPageAsync(page, 10); ViewBag.CurrentPage = page; return View(model); } [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> 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取MAX(NextFollowUpTime),再过滤状态,最后和当前时间比较。EF Core实现这个查询,核心是用GroupBy加Max聚合。

public async Task<List<CustomerWithNextFollowUp>> 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 List<Claim> { 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 IQueryable<Customer> ApplyDataPermission(IQueryable<Customer> 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 Task<ChartDataModel> 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场景下导出表格数据,性能也很能打。

导出功能的实现思路:查询出要导出的数据,转换为List<Dictionary<string, object>>或定义好的DTO,然后调用MiniExcel的SaveAs方法输出二进制字节流,最后通过File()方法返回Excel文件。

public async Task<IActionResult> ExportCustomers() { var customers = await _customerService.GetAllForExportAsync(); var columns = new Dictionary<string, 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 Core+SQL 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 --from=build /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 TZ=Asia/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插件做自动化续期,设置定时任务每月自动检查更新,避免手工续期遗忘。

这些坑单看都是小问题,但合在一起会严重影响系统的信任度。上线后第一个月,我几乎每天都在处理这一类问题,后面把服务器配置、部署脚本都标准化之后,情况就好了很多。这也是我建议大家在项目初期就把部署文档写清楚的原因——不是给客户看,是给三个月后的自己看。

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

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

立即咨询