简介:本资源是一套基于.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.Core | Class Library (.NET 6) | 领域模型、领域服务、共享常量、DTO 基类 | 不直接运行,被其他项目引用 |
Portal.Application | Class Library (.NET 6) | 应用服务(Application Service)、工作单元(UOW)封装、CQRS 查询处理器 | 不直接运行 |
Portal.EntityFrameworkCore | Class Library (.NET 6) | EF Core 上下文、迁移脚本(Migrations/目录下含 23 个已提交迁移)、种子数据(SeedData/) | 不直接运行 |
Portal.Web.Host | ASP.NET Core Web API (.NET 6) | 前端门户主站:Razor Pages + Vue 3(嵌入式 SPA)、静态资源托管、门户页路由(/company/{code})、Widget 渲染引擎 | dotnet run或 VS 调试 |
Portal.IdentityServer | ASP.NET Core Web API (.NET 6) | 统一认证中心:IdentityServer4 实例、客户端管理(含portal-web、cms-admin两个预注册 Client)、角色/权限策略定义 | 必须单独启动,监听https://localhost:44300 |
Portal.CMS.Admin | ASP.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 个服务:
| 服务名 | 镜像来源 | 端口映射 | 关键配置 |
|---|---|---|---|
identityserver | mcr.microsoft.com/dotnet/aspnet:6.0 | 44300:443 | 挂载./certs/idsrv.pfx:/app/certs/idsrv.pfx,环境变量ASPNETCORE_Kestrel__Certificates__Default__Password=yourpass |
webhost | mcr.microsoft.com/dotnet/aspnet:6.0 | 44301:443 | 依赖identityserver,环境变量IdentityServer__Url=https://identityserver:44300(容器内通信) |
cmsadmin | mcr.microsoft.com/dotnet/aspnet:6.0 | 44302:443 | 同样依赖identityserver,独立后台入口 |
nginx | nginx:alpine | 80: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)。
希望帮到你。
本文还有配套的精品资源,点击获取