限模塊設(shè)計(jì):RBAC模型與Dapper實(shí)踐)
簡(jiǎn)介面向C# Winform開(kāi)發(fā)者的用戶管理功能完整示例以MySQL為數(shù)據(jù)底座覆蓋用戶創(chuàng)建、角色創(chuàng)建、用戶日志記錄、操作權(quán)限設(shè)置四大模塊適合課程設(shè)計(jì)或企業(yè)后臺(tái)賬號(hào)體系開(kāi)發(fā)時(shí)初學(xué)與參考。壓縮包共49個(gè)文件、約957KB以.cs源碼、.resx窗體資源、.sln/.csproj項(xiàng)目配置為主體另附可運(yùn)行exe、依賴dll以及.sql數(shù)據(jù)庫(kù)腳本打開(kāi)解決方案即可運(yùn)行調(diào)試適合通過(guò)實(shí)例理解項(xiàng)目結(jié)構(gòu)。工程內(nèi)包含DalMySQL、DbExtension、Bll等分層數(shù)據(jù)訪問(wèn)與業(yè)務(wù)處理代碼以及用戶注冊(cè)、登錄、角色分配、權(quán)限校驗(yàn)、日志記錄等窗體邏輯數(shù)據(jù)庫(kù)腳本提供用戶表、角色表、權(quán)限表及關(guān)聯(lián)表的建表語(yǔ)句可直觀學(xué)習(xí)ADO.NET連接MySQL、密碼加密存儲(chǔ)、RBAC權(quán)限檢查等落地寫法。已有374人學(xué)習(xí)下載對(duì)想快速掌握WinformMySQL用戶權(quán)限管理寫法的開(kāi)發(fā)者具有較高參考價(jià)值也可為后續(xù)擴(kuò)展操作審計(jì)、菜單權(quán)限等功能提供基礎(chǔ)。1. 用戶模塊看著簡(jiǎn)單為什么Winform項(xiàng)目里最容易返工做過(guò)內(nèi)部管理系統(tǒng)、MES上位機(jī)的Winform開(kāi)發(fā)者應(yīng)該都有同感用戶創(chuàng)建、角色分配、操作權(quán)限設(shè)置這些功能單看每個(gè)都不難但湊到一起就成了返工重災(zāi)區(qū)。原因在于需求總在變——上線第一版只要一個(gè)登錄框第二版客戶就要分管理員和操作員第三版又要求“操作員不能導(dǎo)出數(shù)據(jù)”“這個(gè)按鈕只有主管能點(diǎn)”這時(shí)候才發(fā)現(xiàn)權(quán)限判斷早散落在每個(gè)按鈕的點(diǎn)擊事件里改一處漏三處。這篇筆記就是圍繞C# Winform里的用戶、角色、日志、操作權(quán)限這套完整模塊講怎么用數(shù)據(jù)庫(kù)把模型一次搭對(duì)再用Dapper把用戶創(chuàng)建、角色綁定、權(quán)限加載、日志記錄串成一條可靠鏈路。適合正在做Winform項(xiàng)目案例、準(zhǔn)備給系統(tǒng)加賬號(hào)體系的開(kāi)發(fā)者也適合給學(xué)校數(shù)據(jù)庫(kù)課程設(shè)計(jì)做參考。2. 先定數(shù)據(jù)模型用戶、角色、權(quán)限、日志五張表怎么設(shè)計(jì)2.1 五張表的分工為什么用戶不直接掛權(quán)限常見(jiàn)做法是采用RBAC模型用戶不直接和權(quán)限掛鉤而是通過(guò)“用戶-角色-權(quán)限”三層結(jié)構(gòu)來(lái)管理。這樣設(shè)計(jì)的直接好處是給三個(gè)新用戶分配權(quán)限時(shí)不用分別勾選十幾個(gè)權(quán)限項(xiàng)只要把他們都掛到“操作員”角色上即可。角色是權(quán)限的集合用戶是角色的成員權(quán)限變更只需要?jiǎng)咏巧?。整個(gè)模塊需要五張核心表SysUser用戶表存賬號(hào)、密碼哈希、鹽、狀態(tài)、創(chuàng)建時(shí)間、最后登錄時(shí)間。SysRole角色表存角色名、角色編碼、備注。SysUserRole用戶和角色的關(guān)聯(lián)表一個(gè)用戶掛多個(gè)角色一個(gè)角色被多個(gè)用戶使用。SysPermission權(quán)限表存權(quán)限編碼、權(quán)限名稱、所屬分組。SysRolePermission角色和權(quán)限的關(guān)聯(lián)表決定“這個(gè)角色能做什么”。再加上一張SysLog日志表記錄誰(shuí)在什么時(shí)間做了什么操作。六張表構(gòu)成了整個(gè)權(quán)限體系的地基。我見(jiàn)過(guò)有人圖省事在用戶表里加一個(gè)“權(quán)限字符串”字段把權(quán)限寫成“1,3,8,12”也有人用一張用戶表外加一張日志表就開(kāi)工。短期跑得通一旦出現(xiàn)“所有主管都要增加導(dǎo)出數(shù)據(jù)權(quán)限”這種需求用角色表只需要改一處角色權(quán)限關(guān)聯(lián)用權(quán)限字符串就得逐個(gè)用戶改過(guò)去改完還得擔(dān)心誰(shuí)漏了。所以在這個(gè)標(biāo)題的場(chǎng)景下五張表再加一張日志表是性價(jià)比最高的結(jié)構(gòu)。2.2 建庫(kù)腳本和初始化數(shù)據(jù)角色、權(quán)限碼一次配齊以SQL Server為例建表腳本如下。如果項(xiàng)目用MySQL把IDENTITY換成AUTO_INCREMENT、GETDATE換成NOW、NVARCHAR換成VARCHAR即可邏輯不變。CREATE TABLE SysUser ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, PasswordHash NVARCHAR(128) NOT NULL, Salt NVARCHAR(32) NOT NULL, DisplayName NVARCHAR(50) NULL, IsEnabled BIT NOT NULL DEFAULT 1, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), LastLoginTime DATETIME NULL ); CREATE TABLE SysRole ( RoleId INT IDENTITY(1,1) PRIMARY KEY, RoleName NVARCHAR(50) NOT NULL, RoleCode NVARCHAR(50) NOT NULL ); CREATE TABLE SysUserRole ( UserId INT NOT NULL, RoleId INT NOT NULL ); CREATE TABLE SysPermission ( PermissionId INT IDENTITY(1,1) PRIMARY KEY, PermissionCode NVARCHAR(50) NOT NULL, PermissionName NVARCHAR(50) NOT NULL, Category NVARCHAR(50) NULL ); CREATE TABLE SysRolePermission ( RoleId INT NOT NULL, PermissionId INT NOT NULL ); CREATE TABLE SysLog ( LogId BIGINT IDENTITY(1,1) PRIMARY KEY, UserId INT NULL, UserName NVARCHAR(50) NULL, ActionType NVARCHAR(50) NULL, Detail NVARCHAR(500) NULL, IpAddress NVARCHAR(50) NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() );權(quán)限表里用PermissionCode作為業(yè)務(wù)判斷依據(jù)是個(gè)關(guān)鍵設(shè)計(jì)。比如“UserManage”“RoleManage”“DataImport”“DataExport”這些編碼在C#代碼里以字符串常量存在權(quán)限判斷時(shí)比較的是可讀的編碼而不是數(shù)字ID。這樣看代碼能看到含義排查問(wèn)題時(shí)也能直接對(duì)上號(hào)。初始化數(shù)據(jù)時(shí)把管理員角色和常用權(quán)限一次性寫入INSERT INTO SysRole (RoleName, RoleCode) VALUES (N管理員, NAdmin); INSERT INTO SysRole (RoleName, RoleCode) VALUES (N操作員, NOperator); INSERT INTO SysPermission (PermissionCode, PermissionName, Category) VALUES (NUserManage, N用戶管理, N系統(tǒng)管理), (NRoleManage, N角色管理, N系統(tǒng)管理), (NDataImport, N數(shù)據(jù)導(dǎo)入, N業(yè)務(wù)操作), (NDataExport, N數(shù)據(jù)導(dǎo)出, N業(yè)務(wù)操作), (NLogView, N日志查看, N系統(tǒng)管理); -- 管理員擁有全部權(quán)限 INSERT INTO SysRolePermission (RoleId, PermissionId) SELECT 1, PermissionId FROM SysPermission; -- 操作員只擁有數(shù)據(jù)導(dǎo)入和導(dǎo)出 INSERT INTO SysRolePermission (RoleId, PermissionId) SELECT 2, PermissionId FROM SysPermission WHERE PermissionCode IN (NDataImport, NDataExport);這段腳本有兩個(gè)取舍要說(shuō)明。第一沒(méi)有給關(guān)聯(lián)表設(shè)置物理外鍵只靠程序保證數(shù)據(jù)完整性。Winform內(nèi)部系統(tǒng)一般并發(fā)不高去掉物理外鍵可以避免刪除角色時(shí)被外鍵攔住、操作順序受限。如果團(tuán)隊(duì)規(guī)范允許可以加上外鍵求穩(wěn)。第二權(quán)限碼用NVARCHAR而非整數(shù)ID是我做權(quán)限系統(tǒng)的習(xí)慣具體原因在4.3節(jié)展開(kāi)。2.3 索引與連接串SQL Server、MySQL、SQLite怎么快速切換表建好后索引是登錄查詢快不快的前提。實(shí)際工作中最需要保證的是三條SysUser.UserName唯一索引登錄時(shí)按用戶名查用戶SysUserRole.RoleId索引加載用戶角色時(shí)按用戶ID反查SysRolePermission.RoleId索引加載角色權(quán)限時(shí)聯(lián)動(dòng)查詢。CREATE UNIQUE INDEX UX_SysUser_UserName ON SysUser(UserName); CREATE INDEX IX_SysUserRole_RoleId ON SysUserRole(RoleId); CREATE INDEX IX_SysRolePermission_RoleId ON SysRolePermission(RoleId);數(shù)據(jù)庫(kù)的選型會(huì)影響連接串和分頁(yè)寫法但表結(jié)構(gòu)可以保持一致。開(kāi)發(fā)階段我用SQLite或LocalDB提速部署時(shí)再切到SQL Server連接串可以用配置項(xiàng)區(qū)分。SqlConnection本身就帶連接池程序里每次new出來(lái)的連接關(guān)閉后回到池里復(fù)用不要在循環(huán)里頻繁打開(kāi)關(guān)閉同一個(gè)連接否則連接池耗盡后會(huì)出現(xiàn)“連接超時(shí)”的玄學(xué)問(wèn)題。這是Winform連數(shù)據(jù)庫(kù)最常見(jiàn)的隱性坑后面避坑章再細(xì)說(shuō)。3. 用戶創(chuàng)建與角色分配用Dapper把新增和授權(quán)寫進(jìn)一個(gè)事務(wù)3.1 用戶創(chuàng)建窗口從界面到數(shù)據(jù)庫(kù)的完整鏈路用戶創(chuàng)建的需求拆開(kāi)看只有兩步往SysUser插一條用戶記錄往SysUserRole插若干條關(guān)聯(lián)記錄。但“若干條”和“一條”必須同時(shí)成功或同時(shí)失敗。比如創(chuàng)建用戶成功、分配角色失敗程序里會(huì)出現(xiàn)一個(gè)沒(méi)有角色的賬號(hào)登錄后什么都做不了這種數(shù)據(jù)在排錯(cuò)時(shí)極難發(fā)現(xiàn)。常見(jiàn)做法是在新增用戶窗體里上方放用戶名、密碼、確認(rèn)密碼、顯示名下方放一個(gè)CheckedListBox展示所有角色點(diǎn)擊保存后一次提交。界面布局不是重點(diǎn)重點(diǎn)是數(shù)據(jù)鏈路。我一般用Dapper替代原來(lái)的DataSet和SqlCommand手寫參數(shù)原因很實(shí)際Dapper擴(kuò)展方法簡(jiǎn)單SQL可控性能開(kāi)銷極小團(tuán)隊(duì)里任何人接手都能看懂。密碼不能明文入庫(kù)入庫(kù)的是“鹽”和“哈希值”。鹽是每次生成的一串隨機(jī)字節(jié)轉(zhuǎn)Hex哈希用PBKDF2或SHA256計(jì)算。注意一件事同一個(gè)用戶登錄時(shí)必須用同一個(gè)鹽去重新計(jì)算哈希所以鹽必須單獨(dú)存一列不能只存一個(gè)哈希值。3.2 用Dapper把用戶和角色寫進(jìn)同一個(gè)事務(wù)下面是核心代碼。為了聚焦業(yè)務(wù)邏輯省略了控件取值部分但從方法簽名能看出入?yún)⒑统鰠ⅰublic bool CreateUserWithRoles(string userName, string password, string displayName, Listint roleIds) { // 1. 生成鹽和密碼哈希 string salt PasswordHelper.GenerateSalt(); string passwordHash PasswordHelper.HashPassword(password, salt); // 2. 插入用戶拿到自增主鍵 const string sqlUser INSERT INTO SysUser (UserName, PasswordHash, Salt, DisplayName, IsEnabled, CreateTime) VALUES (UserName, PasswordHash, Salt, DisplayName, 1, GETDATE()); SELECT CAST(SCOPE_IDENTITY() AS INT);; // 3. 插入用戶角色關(guān)聯(lián) const string sqlRole INSERT INTO SysUserRole (UserId, RoleId) VALUES (UserId, RoleId);; using (var conn new SqlConnection(_connectionString)) { conn.Open(); using (var tx conn.BeginTransaction()) { try { int newUserId conn.ExecuteScalarint(sqlUser, new { UserName userName, PasswordHash passwordHash, Salt salt, DisplayName displayName }, tx); foreach (int roleId in roleIds) { conn.Execute(sqlRole, new { UserId newUserId, RoleId roleId }, tx); } tx.Commit(); return true; } catch (Exception ex) { tx.Rollback(); Logger.Error(CreateUserWithRoles failed, ex); return false; } } } }參數(shù)說(shuō)明Dapper的匿名對(duì)象參數(shù)名和SQL里的參數(shù)名必須一一對(duì)應(yīng)ExecuteScalar 用來(lái)拿自增主鍵SQL Server里是SCOPE_IDENTITY而不是IDENTITY。事務(wù)是這里的主角。把Insert用戶和Insert角色放進(jìn)同一個(gè)BeginTransaction要么全提交要么全回滾。我在實(shí)際項(xiàng)目里見(jiàn)過(guò)有人分開(kāi)調(diào)用兩個(gè)方法執(zhí)行兩條SQL結(jié)果用戶創(chuàng)建成功、角色分配失敗最后靠手工寫腳本補(bǔ)數(shù)據(jù)才恢復(fù)。有了事務(wù)這類事故從根上斷了。PasswordHelper的生成邏輯也值得展開(kāi)GenerateSalt用RNGCryptoServiceProvider生成16字節(jié)隨機(jī)數(shù)轉(zhuǎn)Hex長(zhǎng)度32位HashPassword用Rfc2898DeriveBytes迭代10000次輸出128位十六進(jìn)制字符串。不要用MD5也不要只用一次SHA256理由在5.3節(jié)會(huì)講。3.3 參數(shù)校驗(yàn)清單空密碼、重復(fù)用戶名、停用角色怎么攔數(shù)據(jù)庫(kù)增刪改查只是基礎(chǔ)用戶模塊真正的麻煩在校驗(yàn)。以下三條校驗(yàn)我每次都會(huì)做漏掉任何一條都會(huì)在測(cè)試階段暴露。用戶名重復(fù)插入前先查一下SysUser里有沒(méi)有同名用戶。數(shù)據(jù)庫(kù)雖然有唯一索引兜底但Winform里重復(fù)點(diǎn)擊保存按鈕時(shí)兩個(gè)請(qǐng)求可能同時(shí)越過(guò)查詢直接插入唯一索引拋出的異常用戶體驗(yàn)極差。正確寫法是捕獲唯一索引沖突的SQLException轉(zhuǎn)成中文提示“用戶名已存在”而不是把異常堆棧直接彈給操作員??彰艽a和弱密碼密碼在客戶端不能為空這個(gè)校驗(yàn)要在按鈕點(diǎn)擊事件里先做而不是等數(shù)據(jù)庫(kù)報(bào)錯(cuò)。長(zhǎng)度校驗(yàn)至少6位如果這是給客戶內(nèi)部系統(tǒng)用可以在后臺(tái)留一個(gè)“是否強(qiáng)制強(qiáng)密碼”的配置項(xiàng)不寫死。停用角色角色表里可以加一個(gè)IsEnabled字段來(lái)區(qū)分啟用和停用。分配角色時(shí)CheckedListBox只勾選啟用角色保存前再校驗(yàn)一遍roleIds里沒(méi)有停用角色I(xiàn)D。不然用戶綁定了一個(gè)被停用的角色登錄時(shí)權(quán)限加載為空排查起來(lái)以為是權(quán)限bug其實(shí)是數(shù)據(jù)問(wèn)題。4. 操作權(quán)限設(shè)置登錄后按權(quán)限碼控制菜單和按鈕而不是到處if判斷4.1 登錄成功之后權(quán)限碼一次性加載進(jìn)內(nèi)存用戶登錄成功后第一件事不是跳轉(zhuǎn)主窗體而是把該用戶的所有權(quán)限碼查出來(lái)加載到內(nèi)存。這個(gè)查詢跨越四張表SysUser、SysUserRole、SysRolePermission、SysPermission。常見(jiàn)做法是寫一個(gè)靜態(tài)權(quán)限工具類加載后全程序共享。public static class PermissionHelper { private static readonly HashSetstring _permissions new HashSetstring(); public static void LoadPermissions(int userId) { _permissions.Clear(); const string sql SELECT DISTINCT p.PermissionCode FROM SysUser u INNER JOIN SysUserRole ur ON u.UserId ur.UserId INNER JOIN SysRolePermission rp ON ur.RoleId rp.RoleId INNER JOIN SysPermission p ON rp.PermissionId p.PermissionId WHERE u.UserId UserId AND u.IsEnabled 1;; using (var conn new SqlConnection(_connectionString)) { var codes conn.Querystring(sql, new { UserId userId }); foreach (var code in codes) { _permissions.Add(code); } } } public static bool HasPermission(string permissionCode) { return _permissions.Contains(permissionCode); } }參數(shù)說(shuō)明HashSet的Contains是O(1)判斷比每次訪問(wèn)數(shù)據(jù)庫(kù)判斷快得多權(quán)限碼統(tǒng)一用小寫或大寫風(fēng)格我建議全部用PascalCase和C#常量風(fēng)格保持一致。這個(gè)加載動(dòng)作必須在主窗體展示之前完成。如果用戶沒(méi)有登錄成功主窗體壓根不該出現(xiàn)。權(quán)限加載失敗時(shí)不能靜默吞掉異常否則界面上所有按鈕都會(huì)以“無(wú)權(quán)限”狀態(tài)呈現(xiàn)看起來(lái)像系統(tǒng)壞了實(shí)際是權(quán)限沒(méi)查出來(lái)。4.2 渲染時(shí)攔截菜單隱藏、按鈕禁用而不是點(diǎn)擊后才彈窗操作權(quán)限設(shè)置最容易寫崩的地方是權(quán)限判斷散落在按鈕點(diǎn)擊事件里。比如每給“刪除用戶”按鈕加一段if (PermissionHelper.HasPermission(UserManage)) else MessageBox.Show(無(wú)權(quán)限)。代碼能跑但一個(gè)窗體十幾個(gè)按鈕就要寫十幾段重復(fù)判斷而且按鈕只是點(diǎn)擊時(shí)提示用戶能看到一個(gè)點(diǎn)了沒(méi)反應(yīng)的按鈕體驗(yàn)很差。更穩(wěn)的做法是渲染時(shí)攔截窗體加載完成后遍歷所有需要控制的控件按權(quán)限碼統(tǒng)一設(shè)置Visible或Enabled。核心是寫一個(gè)遞歸掃描控件的方法private void ApplyPermission(Control root) { foreach (Control c in root.Controls) { if (c is Button btn btn.Tag ! null) { string code btn.Tag.ToString(); btn.Visible PermissionHelper.HasPermission(code); } else if (c is ToolStripMenuItem menu menu.Tag ! null) { string code menu.Tag.ToString(); menu.Visible PermissionHelper.HasPermission(code); } if (c.HasChildren) { ApplyPermission(c); } } }調(diào)用時(shí)機(jī)放在主窗體的Load事件里。關(guān)鍵點(diǎn)是控件的Tag屬性。給每個(gè)需要權(quán)限控制的按鈕和菜單設(shè)置Tag為權(quán)限碼代碼里不在業(yè)務(wù)邏輯里判斷權(quán)限只在渲染階段掃描一次。這樣權(quán)限變更時(shí)只需要改窗體的Tag不需要改按鈕的Click邏輯。做法反過(guò)來(lái)也成立登錄用戶擁有超管角色時(shí)直接跳過(guò)權(quán)限掃描所有控件可見(jiàn)普通角色走掃描邏輯。這是給管理員留后門的標(biāo)準(zhǔn)做法用角色編碼判斷。4.3 權(quán)限碼粒度按功能塊控制別把按鈕點(diǎn)成雪花粒度是操作權(quán)限設(shè)置最需要拿捏的地方。權(quán)限碼太少比如只有“管理員”和“普通用戶”兩種角色控制不細(xì)致權(quán)限碼太多比如“修改用戶姓名”“修改用戶密碼”“修改用戶郵箱”各一個(gè)權(quán)限碼權(quán)限配置界面會(huì)讓客戶看得頭皮發(fā)麻。我一般按功能塊劃分一個(gè)模塊一個(gè)權(quán)限碼。比如“UserManage”管用戶模塊的新增、修改、刪除、重置密碼“DataImport”管數(shù)據(jù)導(dǎo)入“DataExport”管數(shù)據(jù)導(dǎo)出。按鈕級(jí)別不再細(xì)分。原因是Winform內(nèi)部系統(tǒng)的使用群體有限操作員和管理員的職能差異遠(yuǎn)沒(méi)有大到需要給每個(gè)按鈕授權(quán)。權(quán)限碼數(shù)量控制在20個(gè)以內(nèi)配置界面用CheckBox分組勾選客戶能看明白程序也好維護(hù)。5. 用戶模塊開(kāi)發(fā)避坑權(quán)限散落、日志卡UI、MD5密碼等6條血淚經(jīng)驗(yàn)5.1 權(quán)限判斷散落在按鈕點(diǎn)擊事件里需求一變就要翻遍整個(gè)窗體現(xiàn)象客戶提出“操作員不能刪除用戶”開(kāi)發(fā)人員需要在五六個(gè)窗體的刪除按鈕點(diǎn)擊事件里分別加判斷。改完后測(cè)試發(fā)現(xiàn)列表右鍵菜單里的刪除沒(méi)攔、工具欄刪除沒(méi)攔、快捷鍵刪除沒(méi)攔。原因權(quán)限判斷分散在各處每個(gè)入口單獨(dú)寫一份if邏輯遺漏是必然的。改需求時(shí)找不到“所有需要判斷權(quán)限的地方”。解決用4.2節(jié)的控件掃描方案權(quán)限判斷集中到一處。所有按鈕、菜單的權(quán)限碼都通過(guò)Tag配置渲染時(shí)統(tǒng)一處理刪除用戶的方法內(nèi)部再加一道服務(wù)端校驗(yàn)UI層和業(yè)務(wù)層雙重保險(xiǎn)。這樣需求變更時(shí)只需要調(diào)整Tag或權(quán)限碼不用翻窗體代碼。5.2 日志直接寫在UI線程點(diǎn)幾下鼠標(biāo)界面就卡住現(xiàn)象用戶操作幾次后Winform界面越來(lái)越卡點(diǎn)擊按鈕要等一兩秒才有反應(yīng)。任務(wù)管理器看到進(jìn)程CPU不高但界面無(wú)響應(yīng)。原因日志表寫入操作直接放在按鈕點(diǎn)擊事件里同步執(zhí)行每次寫日志都占用UI線程操作一多UI線程全耗在等待數(shù)據(jù)庫(kù)響應(yīng)上。更隱蔽的是日志寫入失敗拋異常直接打斷正常業(yè)務(wù)操作。解決日志寫入統(tǒng)一走后臺(tái)線程不阻塞UI。常見(jiàn)做法是定義一個(gè)日志方法內(nèi)部用ThreadPool.QueueUserWorkItem或Task.Run把插入操作丟到后臺(tái)如果希望順序?qū)懭胗靡粋€(gè)日志隊(duì)列加單線程消費(fèi)。日志寫入失敗不能影響主流程寫日志的代碼整體包一層try-catch失敗就丟掉本次日志。5.3 密碼用MD5裸存被脫庫(kù)等于全網(wǎng)撞庫(kù)現(xiàn)象數(shù)據(jù)庫(kù)被拖走后攻擊者用彩虹表直接反查出大部分用戶的密碼包括管理員。因?yàn)镸D5相同的明文所有系統(tǒng)都一樣撞庫(kù)成本極低。原因開(kāi)發(fā)階段為了省事直接用MD5(密碼)存庫(kù)沒(méi)有加鹽也沒(méi)有迭代計(jì)算。MD5本身不是為密碼存儲(chǔ)設(shè)計(jì)的計(jì)算太快暴力破解成本低。解決用PBKDF2或bcrypt。代碼里已經(jīng)提到PasswordHelper核心是每人生成獨(dú)立鹽鹽和哈希一起存登錄時(shí)取出鹽重新計(jì)算哈希再比對(duì)。迭代次數(shù)不要低于10000這個(gè)開(kāi)銷在登錄時(shí)完全可以接受。老系統(tǒng)遷移時(shí)可以用“舊密碼哈希算法標(biāo)記”區(qū)分存量用戶用戶下次登錄自動(dòng)升級(jí)為新算法。5.4 刪除角色沒(méi)處理用戶角色關(guān)聯(lián)要么外鍵報(bào)錯(cuò)要么留孤兒數(shù)據(jù)現(xiàn)象刪除一個(gè)正在被用戶使用的角色系統(tǒng)報(bào)錯(cuò)“外鍵約束沖突”刪除不了或者去掉物理外鍵后直接刪除用戶登錄時(shí)加載不到權(quán)限成了“有賬號(hào)沒(méi)權(quán)限”的孤兒數(shù)據(jù)。原因刪除角色前沒(méi)有檢查SysUserRole和SysRolePermission表里有沒(méi)有關(guān)聯(lián)記錄。開(kāi)發(fā)階段圖省事不加物理外鍵刪除時(shí)不報(bào)錯(cuò)反而掩蓋了數(shù)據(jù)問(wèn)題。解決寫一個(gè)刪除角色的服務(wù)方法按順序檢查關(guān)聯(lián)表、刪除關(guān)聯(lián)數(shù)據(jù)、再刪角色表。刪除前彈窗提示“該角色下有3個(gè)用戶刪除后這些用戶將失去權(quán)限”。數(shù)據(jù)完整性靠代碼保證但提示和確認(rèn)環(huán)節(jié)不能省。這個(gè)邏輯放在一個(gè)事務(wù)里刪除失敗全部回滾。5.5 權(quán)限加載失敗時(shí)靜默吞掉異常整個(gè)窗體像沒(méi)裝權(quán)限一樣全開(kāi)現(xiàn)象某客戶端登錄后所有菜單按鈕全部可見(jiàn)點(diǎn)任何功能都能操作甚至包括“重置密碼”——實(shí)際上這個(gè)用戶只應(yīng)該看日志。原因加載權(quán)限的代碼里寫了空的catch塊或者加載SQL里表名拼錯(cuò)被捕獲后沒(méi)處理。權(quán)限集為空HasPermission永遠(yuǎn)返回false掃描邏輯把所有控件隱藏或顯示的邏輯寫反時(shí)會(huì)導(dǎo)致權(quán)限全部放開(kāi)。我見(jiàn)過(guò)最危險(xiǎn)的寫法是catch后直接return然后登錄流程繼續(xù)往下走。解決權(quán)限加載失敗必須中斷登錄流程拋出明確錯(cuò)誤不讓用戶進(jìn)入主界面。調(diào)試階段可以把加載結(jié)果打印出來(lái)核對(duì)上線后保留至少一條錯(cuò)誤日志。加一條驗(yàn)證邏輯加載完成后如果角色數(shù)大于0但權(quán)限數(shù)為0視為異常狀態(tài)記錄日志并提示管理員檢查角色權(quán)限配置。5.6 時(shí)間字段用字符串存日志排序和查詢?nèi)强蝇F(xiàn)象日志列表按CreateTime排序時(shí)出現(xiàn)“2024/1/10”排在“2024/1/9 23:59:59”前面的情況按日期范圍查詢?nèi)罩究傆幸粌商斓臄?shù)據(jù)查不出來(lái)。原因時(shí)間字段在數(shù)據(jù)庫(kù)里建成了VARCHAR插入時(shí)用了當(dāng)前時(shí)間的ToString()格式受系統(tǒng)區(qū)域設(shè)置影響排序規(guī)則是字符串字典序和自然時(shí)間序不一致。解決數(shù)據(jù)庫(kù)里時(shí)間字段一律用DATETIME或DATETIME2代碼里用DateTime.NowDapper自動(dòng)映射不要手動(dòng)ToString()。界面展示格式化放到顯示層做。如果有的項(xiàng)目已經(jīng)用字符串存了老數(shù)據(jù)遷移時(shí)用一個(gè)標(biāo)準(zhǔn)格式y(tǒng)yyy-MM-dd HH:mm:ss重新整理排序才能可靠。6. 權(quán)限驗(yàn)證SQL、熱刷新與日志歸檔上線后維護(hù)的三件小事6.1 一條SQL直接核對(duì)某個(gè)用戶有哪些權(quán)限程序界面上權(quán)限判斷是否符合預(yù)期開(kāi)發(fā)時(shí)無(wú)法全部靠點(diǎn)擊測(cè)試覆蓋。我的習(xí)慣是寫完權(quán)限模塊后直接在數(shù)據(jù)庫(kù)客戶端執(zhí)行一條SQL把某個(gè)用戶最終能執(zhí)行的功能碼列出來(lái)核對(duì)。SELECT DISTINCT p.PermissionCode, p.PermissionName FROM SysUser u INNER JOIN SysUserRole ur ON u.UserId ur.UserId INNER JOIN SysRole r ON ur.RoleId r.RoleId INNER JOIN SysRolePermission rp ON r.RoleId rp.RoleId INNER JOIN SysPermission p ON rp.PermissionId p.PermissionId WHERE u.UserName Nzhangsan AND u.IsEnabled 1 ORDER BY p.PermissionCode;這條SQL就是把登錄后的權(quán)限查詢單獨(dú)拿出來(lái)驗(yàn)證跑完能直接看到用戶掛了哪些角色、每個(gè)角色給了哪些權(quán)限。上線后客戶反饋“某人權(quán)限不對(duì)”先用這條SQL查數(shù)據(jù)再去看代碼邏輯能篩掉一半的誤報(bào)。6.2 權(quán)限熱刷新管理員改完權(quán)限不重啟程序Winform程序連續(xù)運(yùn)行幾天后管理員改了某角色的權(quán)限配置客戶端還保留著登錄時(shí)的內(nèi)存權(quán)限必須重啟才能生效。內(nèi)部系統(tǒng)這樣做影響不大但如果客戶端很多逐個(gè)重啟也是麻煩。我在權(quán)限模塊里加了一個(gè)重置按鈕管理員在主界面點(diǎn)擊“刷新權(quán)限”調(diào)用PermissionHelper.ClearPermissions()后重新按當(dāng)前登錄用戶ID走一遍加載流程界面控件再掃描一次。這樣權(quán)限熱更新不用重啟程序。實(shí)現(xiàn)時(shí)有個(gè)細(xì)節(jié)刷新前要記錄當(dāng)前窗體的權(quán)限狀態(tài)刷新后重新調(diào)用ApplyPermission即可否則已經(jīng)隱藏的按鈕不會(huì)自動(dòng)恢復(fù)。另外一個(gè)更省事的方案是提供一個(gè)隱藏快捷鍵管理員按F12調(diào)出權(quán)限刷新入口不影響普通用戶使用。6.3 日志歸檔按時(shí)間分區(qū)還是定時(shí)清理日志表只增不改時(shí)間久了SysLog會(huì)膨脹到幾千萬(wàn)行。Winform內(nèi)部系統(tǒng)一般不會(huì)高頻產(chǎn)生日志但“導(dǎo)出數(shù)據(jù)”“登錄失敗”這類操作每天都可能有幾百條一年積累下來(lái)也有幾十萬(wàn)行。日志查詢界面如果直接掃全表分頁(yè)會(huì)越翻越慢。我的做法是日志表保留最近半年的數(shù)據(jù)歸檔腳本每月月底把半年前的記錄插入到SysLog_History表再?gòu)闹鞅韯h除SysLog_History按月份分區(qū)或按年份分表。這個(gè)歸檔邏輯用數(shù)據(jù)庫(kù)計(jì)劃任務(wù)調(diào)度不需要Winform程序參與。日志表的數(shù)據(jù)量大是因?yàn)闆](méi)有歸檔策略不是數(shù)據(jù)庫(kù)不行這個(gè)問(wèn)題越早處理越輕松。最后說(shuō)一句我的習(xí)慣用戶模塊做完之后我會(huì)刻意用“新建用戶-分配角色-改權(quán)限-查日志”四個(gè)動(dòng)作走一遍全流程再故意制造幾類數(shù)據(jù)異常比如刪一個(gè)有用戶的角色、往日志表里寫超長(zhǎng)Detail字段。這套動(dòng)作堅(jiān)持下來(lái)線下踩掉的坑比上線后客戶發(fā)現(xiàn)的少得多。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取