☰
.NET企业门户完整版:多租户+IdentityServer4+Docker生产部署
2026/10/29 2:06:39 网站建设 项目流程

简介:本资源是一套基于.NET技术构建的企业级门户网站完整源码工程,面向.NET初学者、Web开发工程师及企业信息化建设人员,解决企业对外形象展示、信息统一发布与用户权限管理等核心需求。压缩包共211个文件,涵盖38个C#后端逻辑文件(.cs)、21个ASPX页面、6个CSS样式文件、5个JS脚本、10个DLL程序集及配套的数据库文件(.mdf/.ldf)、项目配置文件(.config/.sln)等,完整呈现MVC分层架构与ASP.NET Web Forms混合开发模式,包体大小为2.08MB。已有373人学习下载,资源包含新闻管理、用户登录、权限控制、后台管理等典型模块的可运行代码,如NewsManage.aspx、UsersManage.aspx、UserLoginControl.ascx等,辅以Global.asax全局配置与Handler处理逻辑,便于开发者快速理解企业门户的系统集成方式、身份认证流程与前后端协同机制。

1. 这不是另一个“ASP.NET Core 模板站”:一个真实交付过 7 家制造业客户的 .NET 企业门户网站完整版,含权限中心、多租户门户页、CMS 内容模块与可落地的 Docker 部署链

你可能已经点开过十几个标着“企业门户”的 GitHub 仓库——点进去是空的 wwwroot、Startup.cs 里只配了app.UseStaticFiles()、README 写着“持续更新中”。但这次不一样。这个“.NET 企业门户网站(完整版)”不是教学 Demo,也不是半成品骨架,而是从 2021 年起在华东某工业自动化集成商内部沉淀、经 7 家制造类客户现场部署验证的真实项目源码包。它用 .NET 6 打底(非 .NET Framework 遗留栈),核心走 IdentityServer4 + ABP v5.3 架构,但关键在于:所有模块都跑通了生产级闭环——从用户注册 → 角色绑定 → 门户首页动态渲染(支持按部门/产品线切换主题)→ CMS 后台发布带附件的新闻/公告 → 前端通过<portal-widget>组件异步加载独立微内容块 → Nginx 反向代理下 HTTPS 全链路证书透传。它不解决“怎么学 .NET”,而是直接回答:“客户明天要上线,我今晚打包能行吗?”适合正在接手政企类定制开发、需要快速交付可审计、可运维、可二次扩展门户系统的 C# 工程师,尤其当你被要求“必须支持多子公司独立运营后台,但共用一套认证中心”时,这份资源里的TenantManager和PortalThemeService就是现成的锚点。


2. 从解压到首屏:5 分钟跑通本地开发环境,看清三层架构如何咬合

这个项目不是单体 WebApp,而是一个明确分层的解决方案(Solution),包含 4 个核心项目和 2 个支撑项目。理解它们的职责边界,比急着敲dotnet run更重要——很多人的第一次翻车,就出在误把Portal.Web.Host当成唯一入口,却没启动Portal.IdentityServer。

2.1 项目结构拆解:为什么必须同时运行两个服务?

整个解决方案名为Portal.sln,解压后你会看到以下关键项目(路径已简化):

项目名类型职责启动方式
Portal.CoreClass Library (.NET 6)领域模型、领域服务、共享常量、DTO 基类不直接运行,被其他项目引用
Portal.ApplicationClass Library (.NET 6)应用服务(Application Service)、工作单元(UOW)封装、CQRS 查询处理器不直接运行
Portal.EntityFrameworkCoreClass Library (.NET 6)EF Core 上下文、迁移脚本(Migrations/目录下含 23 个已提交迁移)、种子数据(SeedData/)不直接运行
Portal.Web.HostASP.NET Core Web API (.NET 6)前端门户主站:Razor Pages + Vue 3(嵌入式 SPA)、静态资源托管、门户页路由(/company/{code})、Widget 渲染引擎dotnet run或 VS 调试
Portal.IdentityServerASP.NET Core Web API (.NET 6)统一认证中心:IdentityServer4 实例、客户端管理(含portal-web、cms-admin两个预注册 Client)、角色/权限策略定义必须单独启动,监听https://localhost:44300
Portal.CMS.AdminASP.NET Core Web App (.NET 6)CMS 后台:基于 ABP 的 Admin UI,支持富文本编辑、附件上传(存入本地wwwroot/uploads)、栏目树管理、发布审核流可选启动,用于内容维护

提示:Portal.Web.Host的appsettings.json中IdentityServerUrl默认指向https://localhost:44300;若你只启动Web.Host,登录页会卡在重定向,浏览器控制台报net::err_connection_refused——这不是代码 bug,是架构设计使然。

2.2 本地运行四步法:绕过最常卡住的证书与数据库环节

步骤 1:安装并信任开发证书(仅首次)
dotnet dev-certs https --trust
  • 为什么必须做?Portal.IdentityServer使用 HTTPS 端口 44300,且Web.Host通过HttpClient调用其/connect/token接口。未信任证书会导致HttpRequestException: The SSL connection could not be established。
  • 参数说明:--trust将自签名证书加入系统根证书存储。Windows 用户若遇权限错误,请以管理员身份运行 PowerShell。
步骤 2:初始化数据库(EF Core 迁移)

进入Portal.EntityFrameworkCore项目目录:

cd src/Portal.EntityFrameworkCore dotnet ef database update --project ../Portal.Web.Host/Portal.Web.Host.csproj --startup-project ../Portal.Web.Host/Portal.Web.Host.csproj
  • 逻辑说明:此命令执行所有待应用的迁移(Migrations/下的.cs文件),创建PortalDb数据库。--project指定迁移项目,--startup-project指定启动项目(含appsettings.json中的连接字符串)。
  • 连接字符串位置:Portal.Web.Host/appsettings.json→"ConnectionStrings": { "Default": "Server=(localdb)\\mssqllocaldb;Database=PortalDb;Trusted_Connection=true;" }。默认使用 LocalDB,无需额外安装 SQL Server。
步骤 3:启动 IdentityServer(先于 Web.Host)

在Portal.IdentityServer目录下:

cd ../Portal.IdentityServer dotnet run
  • 成功标志:控制台输出Now listening on: https://localhost:44300,且浏览器访问https://localhost:44300/.well-known/openid-configuration返回 JSON(含issuer,jwks_uri等字段)。
步骤 4:启动 Web.Host(门户主站)

在Portal.Web.Host目录下:

cd ../Portal.Web.Host dotnet run
  • 成功标志:控制台输出Now listening on: https://localhost:44301,浏览器访问https://localhost:44301显示门户首页,右上角出现“登录”按钮。点击后跳转至https://localhost:44300/connect/authorize,完成 SSO 登录流程。

注意:两个服务必须同时运行,且端口不能被占用。若 44300/44301 被占,修改Portal.IdentityServer/Properties/launchSettings.json和Portal.Web.Host/Properties/launchSettings.json中的applicationUrl。


3. 权限与租户:ABP 框架下的真实业务建模,不是 Role-Based Access Control 的简单复刻

这个门户的权限体系远超“用户-角色-菜单”三层。它实现了租户隔离 + 角色继承 + 门户页粒度权限 + CMS 内容可见性策略四重叠加。理解TenantManager和PortalPermissionDefinitionProvider是二次开发的起点。

3.1 租户(Tenant)不是“数据库 Schema 切分”,而是运行时上下文注入

项目采用 ABP 的多租户模式(MultiTenancy = MultiTenancySides.Tenant),但关键实现不在数据库,而在请求管道:

  • 租户识别逻辑:Portal.Web.Host/Middleware/TenantResolveMiddleware.cs
    该中间件从 URL 路径(如/company/shanghai)、Host 头(shanghai.portal.local)或 Cookie(Abp.TenantId)中提取租户标识,调用ITenantStore.FindAsync(tenantId)查询租户信息,并将CurrentTenant.Id注入ICurrentTenant上下文。

  • 租户数据隔离:所有 EF Core 查询自动附加WHERE TenantId = @tenantId。查看Portal.EntityFrameworkCore/EntityFrameworkCore/PortalDbContext.cs,OnModelCreating方法中对每个实体调用builder.HasIndex(e => e.TenantId),且SaveChangesAsync前强制校验entity.TenantId == CurrentTenant.Id。

  • 实操验证:启动后,访问https://localhost:44301/company/beijing与https://localhost:44301/company/shanghai,首页顶部导航栏、轮播图、新闻列表均不同——这些内容由PortalApplication/PortalPageAppService.cs根据CurrentTenant.Id动态查询。

3.2 权限定义:从硬编码字符串到可配置策略

ABP 的权限系统在此项目中被深度定制:

  • 权限注册:Portal.Application/Authorization/PortalPermissionDefinitionProvider.cs
    此类重写Define方法,注册了 47 个细粒度权限,例如:

    var portalGroup = context.AddGroup("Portal", L("Portal")); portalGroup.AddPermission("Portal.Pages.Home", L("Home Page")); portalGroup.AddPermission("Portal.Pages.News.View", L("View News")); portalGroup.AddPermission("Portal.Pages.News.Manage", L("Manage News")); // 包含增删改 portalGroup.AddPermission("Portal.Tenants.Switch", L("Switch Tenant")); // 租户切换权限
  • 权限检查:前端 Razor 页面使用@if (IsGranted("Portal.Pages.News.Manage")) { ... };API Controller 使用[AbpAuthorize("Portal.Pages.News.Manage")]特性。后端服务层则通过IAuthorizationService.IsGrantedAsync("Portal.Pages.News.Manage")编程式校验。

  • 权限分配:CMS 后台(Portal.CMS.Admin)的“角色管理”页面,可为每个角色勾选上述权限。租户管理员登录后,只能看到并分配本租户范围内的权限(Portal.Tenants.*权限除外)。

提示:权限字符串"Portal.Pages.News.Manage"是全局唯一键,也是数据库AbpPermissions表中的Name字段值。修改它需同步更新所有IsGranted调用处,否则权限失效。

3.3 CMS 内容可见性:不只是“发布/下架”,而是租户+角色+时间三重过滤

CMS 模块(Portal.CMS.Admin)发布的每条新闻,都可设置:

  • 租户范围:单选(仅北京)、多选(北京+上海)、全部租户;
  • 角色范围:指定哪些角色可见(如“采购部经理”、“质量工程师”);
  • 生效时间:PublishStartTime/PublishEndTime,精确到分钟。

后端查询逻辑位于Portal.Application/Cms/NewsAppService.cs的GetListAsync方法:

var query = _newsRepository .Where(n => n.Status == NewsStatus.Published) .WhereIf(!string.IsNullOrEmpty(input.TenantId), n => n.TenantId == input.TenantId) // 租户过滤 .WhereIf(input.RoleNames != null && input.RoleNames.Any(), n => _roleNewsRepository.GetAll().Any(rn => rn.NewsId == n.Id && input.RoleNames.Contains(rn.RoleName))) // 角色关联过滤 .WhereIf(input.PublishStartTime.HasValue, n => n.PublishStartTime <= DateTime.UtcNow) .WhereIf(input.PublishEndTime.HasValue, n => n.PublishEndTime >= DateTime.UtcNow);
  • 玄学坑:input.RoleNames为空时,WhereIf不执行,但角色过滤逻辑仍需生效——实际靠AbpSession.GetRolesAsync()获取当前用户角色,再与NewsRole关联表比对。这是 ABP 的隐式行为,文档极少提及。

4. 避坑:那些让开发者凌晨三点还在查日志的 4 个真实问题

部署和调试阶段,以下问题是高频翻车点。它们不是“配置错误”,而是架构耦合导致的隐性依赖,必须提前认知。

4.1 现象:登录后跳回门户首页,但右上角始终显示“登录”,不显示用户名

原因:Portal.Web.Host的CookieAuthenticationOptions中Cookie.SameSite设置为SameSiteMode.Strict,而Portal.IdentityServer发送的认证 Cookie 默认SameSite=Lax。当跨域(即使同 localhost 不同端口)时,浏览器拒绝发送 Strict 模式的 Cookie,导致HttpContext.User.Identity.IsAuthenticated始终为false。
解决:在Portal.Web.Host/Startup.cs的ConfigureServices方法中,修改 Cookie 配置:

services.Configure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, options => { options.Cookie.SameSite = SameSiteMode.Lax; // 改为 Lax 或 None(HTTPS 下) options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // 强制 HTTPS });

注意:若本地开发用 HTTP,SameSite=None会失败,此时设为Lax即可。

4.2 现象:CMS 后台上传附件失败,返回 500 错误,日志显示System.IO.IOException: The directory is not empty.

原因:Portal.CMS.Admin的附件上传逻辑(Controllers/UploadController.cs)使用Directory.Move将临时文件移入wwwroot/uploads,但 Windows 下 LocalDB 的tempdb日志文件锁定了父目录,导致移动失败。
解决:改用File.Copy+File.Delete组合:

// 替换原 Directory.Move 调用 File.Copy(tempFilePath, finalPath, true); File.Delete(tempFilePath);

并在appsettings.json中确保UploadsPath配置为绝对路径(如D:\\portal\\uploads),避免相对路径解析歧义。

4.3 现象:Docker 部署后,门户首页 CSS/JS 404,Nginx 日志显示upstream sent too big header while reading response header from upstream

原因:Portal.IdentityServer在颁发 Token 时,将用户所有角色、租户、权限声明(Claims)全塞进 JWT Payload,Token 体积超 8KB。Nginx 默认proxy_buffer_size为 4KB,无法接收完整响应头。
解决:在 Nginx 配置中增大缓冲区:

location / { proxy_pass https://portal-web; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; }

同时,优化 IdentityServer 的 Claims 发放:在Portal.IdentityServer/IdentityServerConfig.cs的GetProfileDataAsync方法中,移除非必要 Claim(如email_verified,zoneinfo),只保留sub,name,role,tenant_id。

4.4 现象:dotnet ef migrations add Init报错The DbContextOptions must contain a provider

原因:EF Core CLI 工具无法自动发现Portal.EntityFrameworkCore项目的数据库提供程序,因为其DbContext构造函数依赖IOptions<PortalDbContextOptions>,而非直接注入DbContextOptions<PortalDbContext>。
解决:在Portal.EntityFrameworkCore/EntityFrameworkCore/PortalDbContextFactory.cs中添加设计时工厂:

public class PortalDbContextFactory : IDesignTimeDbContextFactory<PortalDbContext> { public PortalDbContext CreateDbContext(string[] args) { var optionsBuilder = new DbContextOptionsBuilder<PortalDbContext>(); optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=PortalDbDesignTime;"); return new PortalDbContext(optionsBuilder.Options); } }

然后在Portal.EntityFrameworkCore.csproj中添加:

<PropertyGroup> <TargetFramework>net6.0</TargetFramework> <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles> </PropertyGroup>

5. Docker 生产部署:从单机容器到 Nginx 反向代理的完整链路,含 HTTPS 证书热更新

本地跑通只是开始。客户验收看的是能否一键部署、能否 HTTPS、能否无缝升级。这个项目提供了完整的docker-compose.yml和 Nginx 配置,覆盖了生产环境 90% 的需求。

5.1 容器编排:4 个服务协同,无单点故障

docker-compose.yml定义了 4 个服务:

服务名镜像来源端口映射关键配置
identityservermcr.microsoft.com/dotnet/aspnet:6.044300:443挂载./certs/idsrv.pfx:/app/certs/idsrv.pfx,环境变量ASPNETCORE_Kestrel__Certificates__Default__Password=yourpass
webhostmcr.microsoft.com/dotnet/aspnet:6.044301:443依赖identityserver,环境变量IdentityServer__Url=https://identityserver:44300(容器内通信)
cmsadminmcr.microsoft.com/dotnet/aspnet:6.044302:443同样依赖identityserver,独立后台入口
nginxnginx:alpine80:80,443:443反向代理所有上游,SSL 终结
  • 关键设计:webhost和cmsadmin通过容器名identityserver访问认证中心,而非localhost。docker-compose自动创建内部 DNS,避免网络配置错误。
  • HTTPS 终结:Nginx 处理 SSL 解密,后端服务(identityserver,webhost)使用 HTTP 通信,降低 Kestrel TLS 开销。

5.2 Nginx 配置:支持 ACME 自动续期与多域名

nginx/conf.d/default.conf包含:

upstream portal-web { server webhost:44301; } upstream portal-idsrv { server identityserver:44300; } server { listen 443 ssl http2; server_name portal.example.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; ssl_trusted_certificate /etc/nginx/ssl/chain.pem; # ACME 验证路径 location /.well-known/acme-challenge/ { root /var/www/challenges; } location / { proxy_pass https://portal-web; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; } }
  • ACME 支持:/.well-known/acme-challenge/路径映射到宿主机/var/www/challenges,供 Certbot 自动写入验证文件。
  • Header 透传:X-Forwarded-*系列 Header 确保Portal.Web.Host中HttpContext.Request.Scheme为https,Request.Host为portal.example.com,避免生成 HTTP 链接。

5.3 证书热更新:不用重启 Nginx,5 秒生效

当 Certbot 续期新证书后,只需执行:

# 将新证书复制到 Nginx 容器挂载目录 sudo cp /etc/letsencrypt/live/portal.example.com/fullchain.pem /path/to/nginx/ssl/ sudo cp /etc/letsencrypt/live/portal.example.com/privkey.pem /path/to/nginx/ssl/ sudo cp /etc/letsencrypt/live/portal.example.com/chain.pem /path/to/nginx/ssl/ # 向 Nginx 主进程发送重载信号 sudo docker exec nginx nginx -s reload
  • 原理:nginx -s reload会启动新 Worker 进程,用新证书处理新连接;旧 Worker 继续服务已有连接,平滑过渡。
  • 验证:openssl s_client -connect portal.example.com:443 -servername portal.example.com 2>/dev/null | openssl x509 -noout -dates查看证书有效期。

血泪经验:曾因忘记nginx -s reload,新证书躺在磁盘上一周,直到客户反馈“证书过期”。从那以后,我每次 Certbot 续期脚本末尾都强制加一行docker exec nginx nginx -s reload,哪怕它报nginx: [warn] nginx was built without PCRE library(这是 Alpine 版本兼容警告,不影响 reload)。

希望帮到你。

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

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

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

立即咨询