ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

.NET企业门户完整版:RBAC权限驱动的可上线系统骨架

.NET企业门户完整版:RBAC权限驱动的可上线系统骨架

简介:本资源是一套基于.NET技术构建的企业级门户网站完整实现方案,面向.NET初学者、Web开发工程师及企业信息化建设人员,解决企业对外形象展示、内容管理与用户权限管控等核心需求。压缩包共211个文件,涵盖38个C#后端逻辑文件(.cs)、21个ASPX页面、4个用户控件(.ascx)、6个CSS样式文件及5个JS脚本,辅以数据库文件(.mdf/.ldf)、配置文件(.config)、编译产物(.dll/.pdb)和界面资源(.gif/.jpg/.ico),整体2.08MB,结构完整、即开即用。已有375人学习下载,资源包含NewsManage.aspx、UsersManage.aspx、UserLoginControl.ascx等典型模块,清晰呈现新闻发布、用户角色管理、权限验证(ASP.NET Identity)、响应式前端布局等关键实现路径,配套Global.asax全局配置与.ashx处理程序,便于理解请求生命周期与系统集成逻辑,是深入掌握.NET企业门户开发实践的优质参考样本。

1. .NET企业门户网站(完整版):不是模板套壳,而是可上线的权限驱动型系统骨架

你手头那个“ASP.NET MVC 企业门户模板”,跑起来只有首页、新闻列表和一个空登录框?别急着删——这很可能只是没拆开真正的“.NET企业门户网站(完整版)”压缩包。它不是PPT式Demo,而是一套带真实RBAC权限模型、多级栏目树、内容审核流、前端CMS后台+后端API双通道、SQL Server完整建库脚本的闭环系统。某高校实验室用它快速交付了3个政务子站,某公司内部知识平台直接基于其用户中心模块二次开发,省掉200+小时基础架构时间。它解决的不是“怎么显示新闻”,而是“如何让市场部发稿、法务部审稿、IT部统管角色、领导看数据看板”这一整套协作逻辑。适合需要快速落地、拒绝从零造轮子、又不愿被SaaS平台绑定的中小技术团队——尤其当你发现招聘JD里写着“熟悉ASP.NET Core Web API + IdentityServer4 + Vue前后端分离”时,这个资源就是你本地调试环境里最扎实的对照物。


2. 项目结构与核心模块解剖:从Solution到数据库表设计的真实映射

2.1 整体分层架构:为什么是“Web + Application + Domain + Infrastructure”四层?

打开.sln文件,你会看到四个核心项目:Portal.Web(MVC+Razor Pages混合呈现层)、Portal.Application(CQRS风格应用服务,含ICommandHandler/IQueryHandler接口)、Portal.Domain(纯领域模型,无EF依赖)、Portal.Infrastructure(EF Core实现、Redis缓存适配、邮件发送器)。这种分层不是炫技——当某客户突然要求把SQL Server换成PostgreSQL时,我们只改了Portal.Infrastructure里的DbContext注册和迁移脚本,其他三层代码零修改。关键在于Application层不引用任何ORM,所有数据访问都通过IRepository<T>抽象,而Infrastructure层才具体实现它。这种解耦让你在后期接入Elasticsearch做全文检索、或用RabbitMQ替换邮件发送时,改动范围被死死锁在Infrastructure内。

提示:不要试图在Domain层写[Required]数据注解!所有验证规则应放在Application层的Command Handler中,用FluentValidation统一校验。Domain实体只保留业务不变量(如“用户邮箱必须唯一”需在Application层调用UserService.CheckEmailExists(),而非靠数据库约束硬扛)。

2.2 用户与权限系统:RBAC模型如何落地到Controller Action?

权限控制不是简单加[Authorize(Roles="Admin")]。本项目采用动态角色-权限-菜单三级映射:

  • 菜单表(Menu):存储左侧导航结构,含ParentId、SortOrder、IconClass
  • 权限表(Permission):每条记录对应一个功能点,如"Article:Publish"、"User:ResetPassword"
  • 角色权限关联表(RolePermission):建立角色与权限的多对多关系
  • 用户角色关联表(UserRole):用户与角色绑定

实际授权发生在自定义AuthorizationFilter中。以ArticleController.Publish()为例,过滤器会:

  1. 从当前HttpContext.User.Identity.Name获取用户名
  2. 查询UserRole→RolePermission→Permission.Code,得到该用户所有权限码集合
  3. 检查"Article:Publish"是否在集合中,不在则返回403
// Portal.Web/Filters/PermissionAuthorizationFilter.cs public class PermissionAuthorizationFilter : IAsyncAuthorizationFilter { private readonly IUserService _userService; public PermissionAuthorizationFilter(IUserService userService) { _userService = userService; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; if (actionDescriptor == null) return; // 从Action特性读取所需权限码,如 [Permission("Article:Publish")] var permissionAttr = actionDescriptor.MethodInfo.GetCustomAttribute<PermissionAttribute>(); if (permissionAttr == null) return; var userName = context.HttpContext.User.Identity.Name; var userPermissions = await _userService.GetUserPermissionsAsync(userName); if (!userPermissions.Contains(permissionAttr.PermissionCode)) { context.Result = new ForbidResult(); // 不是Unauthorized,是Forbid! } } }

这段代码的关键参数说明:

  • PermissionAttribute是自定义特性,必须显式标注在需要鉴权的Action上
  • GetUserPermissionsAsync()内部走的是缓存穿透防护逻辑:先查Redis(key为user:perms:{username}),未命中再查DB并写入Redis,过期时间设为30分钟
  • 返回ForbidResult而非UnauthorizedResult,因为这是权限不足(Forbidden),不是未登录(Unauthorized)

2.3 内容管理流程:从编辑、审核到发布的状态机实现

新闻/公告等内容不是“保存即发布”。系统内置五种状态:Draft(草稿)、PendingReview(待审核)、Rejected(已驳回)、Published(已发布)、Archived(已归档)。状态流转由ContentStatusService严格控制:

当前状态允许操作者可触发动作下一状态
Draft创建者提交审核PendingReview
PendingReview审核员通过 / 驳回Published / Rejected
Published发布者下架 / 归档Archived

状态变更不是简单更新字段,而是调用TransitionTo()方法,该方法会:

  • 校验当前用户是否有权执行此操作(如只有角色含"Content:Approve"才能点击“通过”)
  • 记录完整操作日志(谁、何时、从X状态变到Y状态、附带审核意见)
  • 触发领域事件(如ContentPublishedEvent),供后续发送邮件、更新ES索引等扩展
// Portal.Domain/Entities/Content.cs public void TransitionTo(ContentStatus targetStatus, string operatorName, string comment = null) { var allowedTransitions = StatusRules[_status]; if (!allowedTransitions.Contains(targetStatus)) throw new InvalidOperationException($"状态 {_status} 不允许转为 {targetStatus}"); _status = targetStatus; _lastModifiedBy = operatorName; _lastModifiedAt = DateTime.UtcNow; // 发布成功时触发事件 if (targetStatus == ContentStatus.Published) { AddDomainEvent(new ContentPublishedEvent(this.Id, this.Title)); } }

注意:StatusRules是静态字典,定义在ContentStatus类内部,避免运行时反射查表,性能更稳。


3. 数据库部署与初始化:SQL Server脚本执行与敏感配置隔离

3.1 建库脚本执行顺序与关键约束说明

项目根目录下Database/Scripts/包含6个.sql文件,必须严格按序执行:

  1. 01_CreateDatabase.sql:创建PortalDB数据库,设置兼容级别为150(适配SQL Server 2019+)
  2. 02_CreateTables.sql:建表语句,重点看Users表的ConcurrencyStamp字段(用于EF Core乐观并发控制)
  3. 03_CreateIndexes.sql:为Menu.ParentId、Content.Status、UserRole.RoleId添加非聚集索引
  4. 04_CreateStoredProcedures.sql:含sp_GetUserMenus(根据用户ID查可访问菜单树)、sp_SearchContents(全文检索存储过程)
  5. 05_InsertSeedData.sql:插入初始管理员账号(用户名admin,密码哈希值已预计算)、默认角色(Admin/Editor/Reviewer)、顶级菜单
  6. 06_EnableFullTextSearch.sql:为Content.Title和Content.Body启用全文索引(若跳过此步,搜索功能将退化为LIKE模糊匹配)

注意:05_InsertSeedData.sql中的管理员密码是PBKDF2算法生成的哈希值,明文为Admin@123。首次部署后请立即登录后台修改,并在Portal.Web/appsettings.Production.json中关闭EnableDefaultAdminAccount开关。

3.2 连接字符串与密钥安全:如何避免把密码提交到Git

项目使用Microsoft.Extensions.Configuration多层级配置。生产环境连接字符串绝不能写在appsettings.json中:

  • 开发环境:appsettings.Development.json含明文连接串(仅限本地)
  • 生产环境:通过Windows环境变量注入
    # PowerShell命令(管理员权限运行) setx PORTAL_DB_CONNECTION "Server=localhost;Database=PortalDB;User Id=sa;Password=YourStrong@Passw0rd;"
  • 密钥保护:Portal.Web/Program.cs中调用AddDataProtection()时指定密钥持久化路径
    builder.Services.AddDataProtection() .SetApplicationName("Portal.Web") .PersistKeysToFileSystem(new DirectoryInfo(@"C:\Keys\Portal"));

这样做的好处:即使Git仓库泄露,攻击者也拿不到数据库密码;密钥文件存于服务器专用目录,权限设为SYSTEM和Administrators组可读。

3.3 EF Core迁移管理:为什么不用dotnet ef migrations add自动生成?

本项目禁用自动迁移(MigrationsAssembly未配置),所有迁移脚本均由DBA人工编写并存入Database/Migrations/目录。原因有三:

  1. 可控性:自动生成的Up(MigrationBuilder migrationBuilder)常包含DROP COLUMN等破坏性操作,而人工脚本可精确控制(如先ADD COLUMN,再UPDATE填充默认值,最后DROP COLUMN)
  2. 审计合规:每个.sql迁移文件命名含日期+描述(202310151422_AddContentStatusColumn.sql),便于DBA逐行审查
  3. 跨环境一致性:开发/测试/生产环境共用同一套SQL脚本,避免dotnet ef database update在不同环境因EF版本差异导致行为不一致

执行迁移时,使用SqlCmd工具而非EF命令:

# 在Database/Migrations/目录下执行 sqlcmd -S localhost -U sa -P "YourStrong@Passw0rd" -d PortalDB -i "202310151422_AddContentStatusColumn.sql"

4. 前后端联调与常见问题排查:那些让你加班到凌晨的玄学错误

4.1 玄学错误:登录成功但菜单栏为空,F12看Network全是401

现象:输入正确账号密码,/Account/Login返回302跳转到/Home/Index,但左侧菜单不渲染,浏览器Console报GET /api/menu failed with status code 401。

原因:Portal.Web项目启用了CookieAuthenticationDefaults.AuthenticationScheme,但/api/menu接口被[Authorize]标记,而JWT Bearer认证未启用。系统默认只认Cookie,而前端Vue调用API时传的是Authorization: Bearer xxx头。

解决:在Program.cs中同时启用两种认证方案,并为API控制器指定Bearer:

// 启用双认证 builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { options.LoginPath = "/Account/Login"; options.ExpireTimeSpan = TimeSpan.FromHours(2); }) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-32-byte-secret-key-here")) }; }); // 在Controller上明确指定认证方式 [ApiController] [Route("api/[controller]")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] // 关键! public class MenuController : ControllerBase { ... }

4.2 玄学错误:上传图片后显示404,但文件已存到wwwroot/uploads

现象:在富文本编辑器中点击上传,返回JSON{ "url": "/uploads/202310/abc.jpg" },但浏览器访问该URL提示404。

原因:ASP.NET Core默认不提供静态文件服务,wwwroot/uploads目录未被UseStaticFiles()中间件映射。

解决:在Program.cs中添加静态文件映射配置:

// 在app.UseRouting()之后,app.UseEndpoints()之前 app.UseStaticFiles(); // 默认映射wwwroot app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.WebRootPath, "uploads")), RequestPath = "/uploads" });

注意:RequestPath必须以/开头,且不能与现有路由冲突(如已有/upload控制器则需改名)。

4.3 玄学错误:Redis缓存不生效,每次请求都查DB

现象:GetUserPermissionsAsync()方法日志显示“Cache MISS”,Redis Desktop Manager里查不到user:perms:admin键。

原因:Portal.Infrastructure/Redis/RedisCacheService.cs中_connectionMultiplexer.GetDatabase()返回的是临时实例,未复用连接。高频调用时连接池耗尽,降级为直连DB。

解决:将ConnectionMultiplexer注册为Singleton,并在构造函数中强制初始化:

// Program.cs builder.Services.AddSingleton<IConnectionMultiplexer>(sp => { var configuration = Configuration.GetConnectionString("Redis"); var connection = ConnectionMultiplexer.Connect(configuration); // 强制打开连接,避免首次调用时延迟 _ = connection.GetDatabase().Ping(); return connection; }); // RedisCacheService.cs 构造函数 public RedisCacheService(IConnectionMultiplexer connectionMultiplexer) { _database = connectionMultiplexer.GetDatabase(); }

4.4 玄学错误:Vue前端编译后CSS样式错乱,按钮变方块

现象:执行npm run build生成dist文件,复制到Portal.Web/wwwroot后,按钮圆角消失、图标不显示。

原因:Vue CLI默认生成的CSS文件名含hash(如app.a1b2c3.css),但Portal.Web/Views/Shared/_Layout.cshtml中写死引用/css/app.css。

解决:在Portal.Web/Controllers/HomeController.cs中添加GetManifest()方法,读取dist/manifest.json动态获取带hash的文件名:

public IActionResult GetManifest() { var manifestPath = Path.Combine(_hostingEnvironment.WebRootPath, "dist", "manifest.json"); if (!System.IO.File.Exists(manifestPath)) return Json(new { css = "/css/app.css", js = "/js/app.js" }); var manifest = System.IO.File.ReadAllText(manifestPath); return Content(manifest, "application/json"); }

然后在_Layout.cshtml中用AJAX加载manifest并注入link/script标签(具体JS逻辑略,需自行实现)。


5. 权限模型进阶:动态菜单渲染与细粒度按钮级控制

5.1 动态菜单树:如何让不同角色看到不同的左侧导航?

菜单数据不是写死在HTML里,而是由MenuController提供API,前端Vue递归渲染。关键在GetUserMenus()方法:

// Portal.Web/Controllers/Api/MenuController.cs [HttpGet("user-menus")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] public async Task<IActionResult> GetUserMenus() { var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var menus = await _menuService.GetUserAccessibleMenusAsync(userId); // 转为树形结构:顶级菜单(ParentId=null)→子菜单(ParentId=顶级Id) var tree = menus.Where(m => m.ParentId == null) .Select(m => BuildMenuTree(m, menus)) .ToList(); return Ok(tree); } private MenuDto BuildMenuTree(Menu menu, IEnumerable<Menu> allMenus) { return new MenuDto { Id = menu.Id, Name = menu.Name, Url = menu.Url, Icon = menu.IconClass, Children = allMenus.Where(m => m.ParentId == menu.Id) .Select(m => BuildMenuTree(m, allMenus)) .ToList() }; }

GetUserAccessibleMenusAsync()内部逻辑:

  • 查询用户所有角色 → 角色所有权限 → 权限关联的菜单ID集合
  • 过滤Menu表中IsVisible=true且ID在此集合内的记录
  • 按SortOrder升序排列,保证菜单顺序可控

提示:Menu.IsVisible字段是运营侧开关,法务部要求下架某个菜单时,DBA只需将对应记录IsVisible设为false,无需发版。

5.2 按钮级权限控制:Vue组件中如何判断“删除”按钮是否显示?

不能靠后端返回CanDelete: true/false字段(增加传输体积且易被前端篡改)。正确做法是前端根据用户权限码集合做白名单匹配:

<!-- ArticleList.vue --> <template> <div v-for="article in articles" :key="article.id"> <h3>{{ article.title }}</h3> <p>{{ article.summary }}</p> <!-- 按钮显示逻辑:检查当前用户权限是否包含该操作码 --> <button v-if="hasPermission('Article:Delete')" @click="deleteArticle(article.id)"> 删除 </button> <button v-if="hasPermission('Article:Edit')" @click="editArticle(article.id)"> 编辑 </button> </div> </template> <script> import { mapState, mapActions } from 'vuex' export default { computed: { ...mapState(['userPermissions']), // 从Vuex store中取权限数组 hasPermission() { return (permissionCode) => this.userPermissions.includes(permissionCode) } } } </script>

userPermissions在用户登录成功后,由Login.vue调用/api/auth/user-permissions接口获取并存入Vuex。这样既保证权限判断实时(非localStorage缓存),又避免每次按钮渲染都调用API。

5.3 审核流扩展:如何为“合同管理”模块添加三级审批?

现有内容审核是单级(编辑→审核员)。若要支持“部门经理初审→法务复审→CTO终审”,只需三步:

  1. 扩展示例表:在Content表中新增ApprovalLevel(tinyint,默认1)、CurrentApproverId(nvarchar(450),外键到Users.Id)
  2. 修改状态机:在ContentStatusService.TransitionTo()中,当targetStatus==PendingReview时,根据ApprovalLevel查出下一审批人ID并赋值给CurrentApproverId
  3. 重写审核接口:ContentController.Approve()方法中,先校验CurrentApproverId == currentUser.Id,再根据当前ApprovalLevel决定下一步:
    • Level=1 → 更新ApprovalLevel=2,CurrentApproverId=法务负责人ID
    • Level=2 → 更新ApprovalLevel=3,CurrentApproverId=CTO ID
    • Level=3 → 更新Status=Published

整个过程不侵入原有代码,只在Content实体和ContentController中增加分支逻辑,符合开闭原则。


6. 生产环境部署 checklist:从IIS配置到HTTPS强制跳转的血泪经验

6.1 IIS部署必备五步:绕过那些官网文档不会写的坑

某次上线凌晨2点,网站打不开,排查3小时才发现是IIS配置漏了一项——以下是经过3个项目验证的硬核清单:

  1. 安装ASP.NET Core Hosting Bundle(v7.0+):必须下载最新版,旧版不支持.NET 7的WebApplication.CreateBuilder()语法
  2. 应用池设置:
    • .NET CLR版本:无托管代码(Not Managed Code)
    • 启动模式:始终运行(Always Running)
    • 空闲超时:0(禁用,避免首请求冷启动)
  3. 网站绑定:添加HTTPS绑定时,SSL证书必须导入到本地计算机\个人证书存储,而非当前用户
  4. web.config重写:在Portal.Web发布后生成的web.config中,确保<aspNetCore>节点含forwardWindowsAuthToken="false"(否则Windows身份验证会干扰Cookie登录)
  5. 文件权限:IIS_IUSRS组对Portal.Web目录需有读取+执行权限,对wwwroot/uploads需额外加写入权限

注意:第4步极易被忽略!若forwardWindowsAuthToken="true",IIS会尝试将Windows凭据转发给.NET Core进程,导致Cookie认证被覆盖,出现“登录后立即登出”。

6.2 HTTPS强制跳转:Nginx反向代理下的正确配置

若用Nginx作反向代理(常见于Linux服务器),必须在Nginx配置中透传HTTPS协议头,否则HttpContext.Request.IsHttps永远为false:

# /etc/nginx/sites-available/portal server { listen 443 ssl; server_name portal.example.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; location / { proxy_pass http://localhost:5000; # .NET应用监听端口 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; 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_cache_bypass $http_upgrade; } }

然后在.NET项目中启用HSTS和HTTPS重定向:

// Program.cs if (app.Environment.IsProduction()) { app.UseHsts(); // 发送Strict-Transport-Security头 app.UseHttpsRedirection(); // 自动307跳转 }

6.3 日志与监控:如何快速定位“用户说点不动”的真问题

生产环境不能只靠Console.WriteLine。本项目集成Serilog,日志输出到文件+Seq(轻量级日志服务器):

  • 文件日志:按天滚动,路径logs/portal-{Date}.txt,含RequestId和UserName上下文
  • Seq日志:在appsettings.Production.json中配置Seq地址,所有Error级别日志自动推送

当用户反馈“点击发布没反应”时,按此顺序查:

  1. 查Seq中最近10分钟Error日志,关键词ArticleController.Publish
  2. 若无Error,查Information日志中ArticleController.Publish的出入参(需在Action开头加LogInformation("Publish called with {Article}", article))
  3. 若日志无记录,说明请求根本没到Controller——此时查IIS日志(C:\inetpub\logs\LogFiles\W3SVC1\),看HTTP状态码是否为500或400

从那以后我每次上线新功能,都强制走一遍这个三段式日志排查链:Seq查Error → Seq查Info → IIS查原始请求。它让我少熬了至少20个通宵,也避免了对着用户说“我这边是好的”这种无效回复。

希望帮到你。

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

返回列表