用戶管理系統
一、需求分析
對于用戶模塊,通常要具有下列功能:
二、方案設計
(一)庫表設計
實現用戶模塊的難度不大,在方案設計階段,我們需要確認以下內容:
- 庫表設計
- 用戶登錄流程
- 如何對用戶權限進行控制
1. 核心設計
用戶表的核心是用戶登錄憑證(賬號密碼)和個人信息,SQL如下:
-- 創建數據庫
create database if not exists yu_picture;-- 切換庫
use yu_picture;-- 用戶表
-- 用戶表
create table if not exists user
(id bigint auto_increment comment 'id' primary key,userAccount varchar(256) not null comment '賬號',userPassword varchar(512) not null comment '密碼',userName varchar(256) null comment '用戶昵稱',userAvatar varchar(1024) null comment '用戶頭像',userProfile varchar(512) null comment '用戶簡介',userRole varchar(256) default 'user' not null comment '用戶角色:user/admin',editTime datetime default current_timestamp not null comment '編輯時間',createTime datetime default current_timestamp not null comment '創建時間',updateTime datetime default current_timestamp not null on update current_timestamp comment '更新時間',isDelete tinyint default 0 not null comment '是否刪除',unique key uk_userAccount (userAccount),index idx_userName (userName)
) comment '用戶' collate = utf8mb4_unicode_ci;
注意事項:
editTime
和updateTime
的區別:editTime
表示用戶編輯個人信息的時間(需要業務代碼來更新),而updateTime
表示這條用戶記錄任何字段發生修改的時間(由數據庫自動更新)。- 給唯一值添加唯一鍵(唯一索引),比如賬號
userAccount
,利用數據庫天然防重復,同時可以增加查詢效率。 - 給經常用于查詢的字段添加索引,比如用戶昵稱
userName
,可以增加查詢效率。 - 建議:養成好習慣,將庫表設計 SQL 保存到項目的目錄中,比如新建
sql/create_table.sql
文件,這樣其他開發者就能更快地了解項目。
在當前情況下,我們創建的 SQL 文件無法通過 Ctrl+Alt+L
快捷鍵進行格式化,因為 IDEA 在未連接數據庫之前,無法識別應使用 MySQL 或其他數據庫的特定格式來格式化 SQL 語句。
按照下面的操作,配置數據源,即可格式化代碼:
執行 sql 語句:
2. 擴展設計
(1)會員功能擴展
如果要實現會員功能,可以對表進行如下擴展:
- 給
userRole
字段新增枚舉值vip
,表示會員用戶,可根據該值判斷用戶權限。 - 新增會員過期時間字段,可用于記錄會員有效期。
- 新增會員兌換碼字段,可用于記錄會員的開通方式。
- 新增會員編號字段,可便于定位用戶并提供額外服務,并增加會員歸屬感。
對應的 SQL 如下:
vipExpireTime datetime null comment '會員過期時間',
vipCode varchar(128) null comment '會員兌換碼',
vipNumber bigint null comment '會員編號'
(2)用戶邀請功能擴展
如果要實現用戶邀請功能,可以對表進行如下擴展:
- 新增
shareCode
分享碼字段,用于記錄每個用戶的唯一邀請標識,可拼接到邀請網址后面,比如 面試鴨 - 程序員求職面試刷題神器,高頻編程題目免費刷。 - 新增
inviteUser
字段,用于記錄該用戶被哪個用戶邀請了,可通過這個字段查詢某用戶邀請的用戶列表。
對應的 SQL 如下:
shareCode varchar(20) DEFAULT NULL COMMENT '分享碼',
inviteUser bigint DEFAULT NULL COMMENT '邀請用戶 id'
(二)用戶登錄流程
-
建立初始會話
:前端與服務器建立連接后,服務器會為該客戶端創建一個初始的匿名 Session,并將其狀態保存下來。這個 Session 的 ID 會作為唯一標識,返回給前端。 -
登錄成功,更新會話信息
:當用戶在前端輸入正確的賬號密碼并提交到后端驗證成功后,后端會更新該用戶的 Session,將用戶的登錄信息(如用戶 ID、用戶名等)保存到與該 Session 關聯的存儲中。同時,服務器會生成一個Set-Cookie
的響應頭,指示前端保存該用戶的 Session ID。 -
前端保存 Cookie
:前端接收到后端的響應后,瀏覽器會自動根據Set-Cookie
指令,將 Session ID 存儲到瀏覽器的 Cookie 中,與該域名綁定。 -
帶 Cookie 的后續請求
:當前端再次向相同域名的服務器發送請求時,瀏覽器會自動在請求頭中附帶之前保存的 Cookie,其中包含 Session ID。 -
后端驗證會話
:服務器接收到請求后,從請求頭中提取 Session ID,找到對應的 Session 數據。 -
獲取會話中存儲的信息
:后端通過該 Session 獲取之前存儲的用戶信息(如登錄名、權限等),從而識別用戶身份并執行相應的業務邏輯。
(三)用戶權限控制
可以將接口分為以下 4 種權限:
- 未登錄也可以使用
- 登錄用戶才能使用
- 未登錄也可以使用,但登錄用戶能進行更多操作(比如登錄后查看全文)
- 僅管理員才能使用
1. 傳統的權限控制方法
傳統的權限控制方法,是在每個接口內單獨編寫邏輯:先獲取到當前登錄用戶信息,然后判斷用戶的權限是否符合要求。
這種方法最靈活,但會寫很多重復的代碼,且其他開發者無法一眼得知接口所需要的權限。
2. 推薦的權限控制方法
權限校驗其實是一個比較通用的業務需求,一般會通過 Spring AOP 切面 + 自定義權限校驗注解
實現統一的接口攔截和權限校驗。如果有特殊的權限校驗邏輯,再單獨在接口中編碼。
如果需要更復雜、更靈活的權限控制,可以引入以下專門的權限管理框架:
- Shiro
- Spring Security
- Sa-Token
💡 建議:選擇合適的權限管理框架,可以大大簡化權限控制的實現。
三、后端開發
(一)數據訪問層代碼生成
連接數據庫:首先利用 IDEA 連接 MySQL 數據庫。
執行 SQL 腳本:創建數據庫表。
生成代碼:數據訪問層的代碼一般包括實體類、MyBatis 的 Mapper 類和 XML 等。
相比手動編寫,建議使用 MyBatisX 代碼生成插件(可在 idea 中裝這個插件),可以快速得到這些文件。
選中數據庫的表,右鍵選擇 MybatisX 生成器。
按照下圖進行配置。
查看生成的代碼:生成的代碼包括實體類、Mapper、Service 等。
Git 托管:
從當前情況可以看出,生成的文件字體顯示為紅色,這通常意味著該文件尚未提交到 Git 版本控制系統
中。接下來,我們需要先將代碼提交到Git中。
Git 回滾:
通過使用 Git,如果我們在后續開發過程中發現某些代碼不再需要,或者需要撤銷某些更改,可以利用 Git 的回滾機制,輕松恢復到之前的代碼版本:
展開所有子目錄:
移動代碼:將生成的代碼移動到項目對應的位置,例如:
- 將
User
移動到model.entity
包。 - 將
Mapper
移動到mapper
包。 - 將
Service
移動到service
包。
為了增加數據爬取的難度,我們可以調整數據庫中字段的主鍵生成策略:
-
將字段的主鍵類型從自動遞增(
IdType.AUTO
)更改為手動指定(IdType.ASSIGN_ID
): -
這樣的修改,可以使得主鍵的生成,不再遵循簡單的遞增規則,從而提高數據抓取的復雜性。
@TableLogic
是 MyBatis 框架中用于標識邏輯刪除的注解:
通過使用 @TableLogic
注解:
- 在指示 MyBatis 在執行插入和更新操作時,會忽略該字段;
在查詢操作時,MyBatis 會根據配置自動添加條件
,來排除那些被標記為已刪除的記錄。
這樣,開發者就無需在每次查詢時手動添加過濾條件,從而簡化了代碼并提高了開發效率。
如果我們不理解一些 mybatis-plus 的注解,可以仔細閱讀官方文檔:
修改包名:移動之后,注意修改 UserMapper.xml
等文件的包名,確保路徑正確。
(二)數據模型開發
1. 實體類
生成的代碼可能無法完全滿足需求,例如數據庫實體類,我們可以手動更改其字段配置,指定主鍵策略和邏輯刪除:
- 主鍵策略:默認是連續生成的,容易被爬蟲抓取,因此更換策略為
ASSIGN_ID
(雪花算法生成)。 - 邏輯刪除:數據刪除時默認為徹底刪除記錄,如果出現誤刪,將難以恢復。因此采用邏輯刪除,通過修改
isDelete
字段為1
表示已失效的數據。
@TableName(value = "user")
@Data
public class User implements Serializable {/*** id(要指定主鍵策略)*/@TableId(type = IdType.ASSIGN_ID)private Long id;// ...(其他字段省略)/*** 是否刪除(邏輯刪除)*/@TableLogicprivate Integer isDelete;
}
使用框架時的建議:在使用框架的過程中,如果有任何疑問,都可以在官方文檔中查閱。例如,了解 MyBatis Plus 的主鍵生成注解,可以參考以下鏈接:
MyBatis Plus 注解配置
2. 枚舉類
對于用戶角色
這樣值的數量有限的、可枚舉的字段
,最好定義一個枚舉類,便于在項目中獲取值、減少枚舉值輸入錯誤的情況。
在 model.enums
包下新建 UserRoleEnum
:
@Getter
public enum UserRoleEnum {USER("用戶", "user"),ADMIN("管理員", "admin");private final String text;private final String value;UserRoleEnum(String text, String value) {this.text = text;this.value = value;}/*** 根據 value 獲取對應的枚舉實例** @param value 要查找的枚舉實例的 value* @return 匹配的枚舉實例,如果未找到則返回 null*/public static UserRoleEnum getEnumByValue(String value) {if (ObjUtil.isEmpty(value)) {// 使用 Hutool 工具類 ObjUtil 檢查 value 是否為空return null;}for (UserRoleEnum anEnum : UserRoleEnum.values()) {// values() 方法是枚舉類型提供的,用于獲取枚舉類中定義的所有枚舉常量if (anEnum.value.equals(value)) {return anEnum;}}return null;}
}
優化建議:如果枚舉值特別多,可以使用 Map
緩存所有枚舉值來加速查找,而不是遍歷列表。
@Getter
public enum UserRoleEnum {USER("用戶", "user"),ADMIN("管理員", "admin");private final String text;private final String value;// 定義一個靜態的Map來存儲枚舉值和枚舉實例的對應關系private static final Map<String, UserRoleEnum> CACHE = new HashMap<>();// 構造函數UserRoleEnum(String text, String value) {this.text = text;this.value = value;// 在構造函數中將當前枚舉實例添加到CACHE中CACHE.put(this.value, this);// way1}// 將枚舉元素放入哈希表,有兩個方法: way1, way2 , 二選一即可// way2// 靜態初始化塊,在類加載時執行static {// 遍歷UserRoleEnum的所有實例for (UserRoleEnum role : UserRoleEnum.values()) {// 將每個實例的value作為鍵,實例本身作為值,存入CACHE Map中CACHE.put(role.value, role);}}/*** 根據 value 獲取對應的枚舉實例** @param value 要查找的枚舉實例的 value* @return 匹配的枚舉實例,如果未找到則返回 null*/public static UserRoleEnum getEnumByValue(String value) {if (ObjUtil.isEmpty(value)) {// 使用 Hutool 工具類 ObjUtil 檢查 value 是否為空return null;}// 從CACHE Map中獲取枚舉實例return CACHE.get(value);}
}
我們暫時先繼續使用列表獲取枚舉類;
各功能接口的開發
用戶注冊
1. 數據模型
在 model.dto.user
包下新建用于接收請求參數的類 UserRegisterRequest
:
/*** 用于用戶注冊的實體類*/@Data
public class UserRegisterRequest implements Serializable {private static final long serialVersionUID = 6055996074332767695L;/*** 賬號*/private String userAccount;/*** 密碼*/private String userPassword;/*** 確認密碼*/private String checkPassword;
}6055996074332767695L
-
序列化的目的:
- 在Java中,實現
Serializable
接口允許對象轉換為字節序列。 - 這種轉換使得對象可以
被寫入文件
或通過網絡
發送到另一臺機器上,從而實現對象的持久化和網絡傳輸
。
- 在Java中,實現
-
實現
Serializable
接口:- 實現
Serializable
接口,是啟用對象序列化
的簡單方法。 - 一旦
對象
實現了Serializable
接口,就可以使用 Java 的序列化機制來轉換對象狀態
。
- 實現
-
序列化ID(serialVersionUID):
-
實現
Serializable
接口后,通常需要為類提供一個唯一的序列化 ID
。 -
安裝插件
GenerateSerialversionUID
,使用該插件為類生成唯一序列化 ID -
這個ID用于
驗證序列化對象的版本兼容性
,確保在反序列化
過程中,發送方和接收方的類版本是兼容的
。
-
-
序列化ID的更新:
- 如果類的實現發生了變化,如添加或刪除字段,應該
更新序列化ID
。 - 更新序列化 ID ,可以防止
因類結構變化
而導致的反序列化錯誤
。
- 如果類的實現發生了變化,如添加或刪除字段,應該
開發建議:在 Java 接口開發中,為每個接口定義一個專門的類,來接收請求參數
,可以提高代碼的可讀性和維護性,便于對參數進行統一驗證和擴展,同時減少接口方法參數過多導致的復雜性,有助于在復雜場景下更清晰地管理和傳遞數據。
2. 服務開發
在 service
包的 UserService
中增加方法聲明:
public interface UserService extends IService<User> {/*** 用戶注冊** @param userAccount 用戶賬戶* @param userPassword 用戶密碼* @param checkPassword 校驗密碼* @return 新用戶 id*/long userRegister(String userAccount, String userPassword, String checkPassword);
}
在 UserServiceImpl
中增加實現代碼,注意補充一些校驗條件:
@Service// (8) 在 MyBatis-Plus 框架中,ServiceImpl 類為服務層提供了基礎實現,它包含了對數據庫進行 CRUD(創建、讀取、更新、刪除)操作的方法。
public class UserServiceImpl extends ServiceImpl<UserMapper, User>implements UserService {@Overridepublic long userRegister(String userAccount, String userPassword, String checkPassword) {// (5) 先寫步驟 1、2、3、4, 后續可以利用 AI 根據注釋生成相應代碼// (1) 校驗參數if(StrUtil.hasBlank(userAccount, userPassword, checkPassword)){// (6) StrUtil.hasBlank 校驗傳入的參數是否為空, 拋出自定義異常throw new BusinessException(ErrorCode.PARAMS_ERROR, "參數為空");}if (userAccount.length() < 4) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "用戶賬號過短");}if (userPassword.length() < 8 || checkPassword.length() < 8) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "用戶密碼過短");}if (!userPassword.equals(checkPassword)) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "兩次輸入的密碼不一致");}// (2) 檢查用戶賬號是否存于數據庫中// (7) 構造查詢條件QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("userAccount",userAccount);// (9) 校驗當前賬戶是否是未注冊賬戶Long count = this.baseMapper.selectCount(queryWrapper);// this 指的是 UserServiceImpl 的一個對象// baseMapper 是 ServiceImpl 類中的一個屬性,它是一個泛型參數,通常對應于與實體類相關的 Mapper 接口// 調用 selectCount 方法時,它的作用是執行一個數據庫查詢,計算與提供的查詢條件相匹配的記錄數量// (10) 當前是注冊賬戶, 所以 count > 0 表示該用戶已存在, 注冊失敗if(count > 0){throw new BusinessException(ErrorCode.PARAMS_ERROR, "賬號重復");}// (3) 密碼使用加鹽加密String encryptPassword = getEncryptPassword(userPassword);// (4) 插入數據到數據庫中User user = new User();user.setUserAccount(userAccount);user.setUserPassword(encryptPassword);user.setUserName("無名");user.setUserRole(UserRoleEnum.USER.getValue());// (14) 保存用戶信息到數據庫中boolean saveResult = this.save(user);// save() 的作用是將 user 對象保存到數據庫中, 是 ServiceImpl 接口內置的方法// save() 返回一個 boolean 類型的結果,表示保存操作是否成功// (15) 保存失敗, 說明是系統內部的問題if(!saveResult){throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注冊失敗, 數據庫錯誤");}// (16)// this.save(user) , mybatis 框架利用主鍵回填// 在調用 save() 時, 自動生成 user 的主鍵 id, 因此在這里可以直接獲取 idreturn user.getId();}/*** (11) 獲取加密后的密碼* @param userPassword 傳入用戶輸入的密碼* @return 經過加密算法加密后的密碼*/@Overridepublic String getEncryptPassword(String userPassword){// (12) 加鹽, 混淆密碼final String SALT = "yupi";// (13) 返回加密后的結果return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());// (鹽值 + 密碼) 的字符串拼接結果, 轉為字節數組// 再利用 spring 內置的加密工具類, 對字節數組使用 md5 算法進行單向加密}
}
3. 接口開發
在 controller
包中新建 UserController
,新增用戶注冊接口:
@RestController
// @RestController 會把所有接口的返回結果轉為 JSON 格式@RequestMapping("/user")
public class UserController {// (4) 注入 service 對象@Resourceprivate UserService userService;@PostMapping("/register")public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest){// (1) 使用 @RequestBody 注解,后端能夠接收前端以 JSON 格式, 發送的對象參數// (2) 傳入對象為空, 使用自定義工具類拋異常ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);// (3) 安裝插件 Generate All Getter And Setter, 下面三行代碼只需要輸入 userRegisterRequest.allgetString userAccount = userRegisterRequest.getUserAccount();String userPassword = userRegisterRequest.getUserPassword();String checkPassword = userRegisterRequest.getCheckPassword();// (5) 封裝返回結果long result = userService.userRegister(userAccount, userPassword, checkPassword);return ResultUtils.success(result);}
}
4. 測試接口
每開發完一個接口,都可以使用 Swagger 接口文檔
來測試,測試可以采取 Debug 模式,同樣可以滿足測試接口功能:
💡 測試注意事項:測試時要尤其注意邊界值和特殊值,不能只測試正常的情況
,如:
用戶登錄
1. 數據模型
在 model.dto
包下新建用于接收請求參數的類 UserLoginRequest
:
@Data
public class UserLoginRequest implements Serializable {private static final long serialVersionUID = 4110612674161298294L;/*** 賬號*/private String userAccount;/*** 密碼*/private String userPassword;
}
在 model.vo
包下新建用于返回響應參數的類 LoginUserVO
,表示脫敏后的數據:
- 復制
model.entity
包下的User
類,粘貼到model.vo
- 重構
User
為LoginUserVO
- 刪除不必要的字段(如 Password, isdelete)
- 刪除注解(返回給前端的數據不需要這些注解)
/*** 已登錄用戶視圖脫敏后的數據*/@Data
public class LoginUserVO implements Serializable {/*** id*/private Long id;/*** 賬號*/private String userAccount;/*** 用戶昵稱*/private String userName;/*** 用戶頭像*/private String userAvatar;/*** 用戶簡介*/private String userProfile;/*** 用戶角色:user/admin*/private String userRole;/*** 編輯時間*/private Date editTime;/*** 創建時間*/private Date createTime;/*** 更新時間*/private Date updateTime;private static final long serialVersionUID = 1L;
}
2. 服務開發
在 service
包的 UserService
中增加方法聲明:
/*** 用戶登錄** @param userAccount 用戶賬戶* @param userPassword 用戶密碼* @param request HTTP 請求對象* @return 脫敏后的用戶信息*/
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);
在 UserServiceImpl
中增加實現代碼:
注意補充一些校驗條件。在用戶登錄成功后,將用戶信息存儲在當前的 Session 中。代碼如下:
/*** 用戶登錄* @param userAccount 用戶賬戶* @param userPassword 用戶密碼* @param request HTTP 請求對象* @return 脫敏后的用戶信息*/
@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {// 1. 校驗if (StrUtil.hasBlank(userAccount, userPassword)) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "參數為空");}if (userAccount.length() < 4) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "賬號錯誤");}if (userPassword.length() < 8) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "密碼錯誤");}// 2. 對用戶傳遞的秘密進行加密String encryptPassword = getEncryptPassword(userPassword);// 3. 查詢用戶是否存在于數據庫QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("userAccount", userAccount);queryWrapper.eq("userPassword",encryptPassword); // 用加密密碼比較// 5. selectOne() 保證查出來的結果只有一條數據User user = this.baseMapper.selectOne(queryWrapper);// 注意, 我們的數據庫表對 userAccount 字段設置為唯一索引,// 根據上面的構造條件, 只會查詢出一條結果, 所以不需要使用事務// 6. 查詢結果為空, 表示賬戶不存在if(user == null){log.info("user login failed, userAccount cannot match userPassword");throw new BusinessException(ErrorCode.PARAMS_ERROR, "用戶不存在或者密碼錯誤");}// 4. 保留用戶的登錄態request.getSession().setAttribute("USER_LOGIN_STATE", user);// "USER_LOGIN_STATE" 是寫死的, 會造成硬編碼, 后續需要拓展常量 constant// 7. 將查詢的 user 轉為 LoginUserVOreturn this.getLoginUserVO(user);
}/*** 8. 獲取脫敏后的登錄用戶信息* @param user 查詢數據庫得到的結果* @return 對查詢結果脫敏后, 得到的結果*/
@Override
public LoginUserVO getLoginUserVO(User user) {if(user == null){return null;}LoginUserVO loginUserVO = new LoginUserVO();BeanUtil.copyProperties(user,loginUserVO);// hutool 工具類, 拷貝源對象的屬性, 對給目標對象已有且對應的屬性進行賦值return loginUserVO;
}
注意事項:
- 由于注冊用戶時存入數據庫的密碼是加密后的,查詢用戶信息時,也要對用戶輸入的密碼進行同樣算法的加密,才能與數據庫中的信息對應上。
- 可以將上述的
Session
理解為一個Map
,可以給Map
設置key
和value
。每個不同的SessionID
對應的Session
存儲都是不同的,不用擔心會污染。 - 所以上述代碼中,給
Session
設置了固定的key
(USER_LOGIN_STATE
),可以將這個key
值提取為常量,便于后續獲取。
在 constant
包下新建 UserConstant
接口類,統一聲明用戶相關的常量:
將
UserConstant
設計為接口類是合適的,因為接口中的字段默認是public static final
的,這意味著它們是常量,且只能被賦值一次。這種特性使得接口成為定義常量的理想選擇。
/*** 用戶常量*/
public interface UserConstant {/*** 用戶登錄態鍵*/String USER_LOGIN_STATE = "user_login";// region 權限/*** 默認角色*/String DEFAULT_ROLE = "user";/*** 管理員角色*/String ADMIN_ROLE = "admin";// endregion
}
修改登錄態寫死的部分:
3. 接口開發
在 UserController
中新增用戶登錄接口:
@PostMapping("/login")
public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {// 1 登錄接口的參數, 比校驗接口多了一個 HttpServletRequest requestThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);String userAccount = userLoginRequest.getUserAccount();String userPassword = userLoginRequest.getUserPassword();LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, request);return ResultUtils.success(loginUserVO);
}
4. 測試接口
重新運行程序,測試接口:
獲取當前登錄用戶
可以從 request
請求對象對應的 Session
中直接獲取到之前保存的登錄用戶信息,無需其他請求參數。
1. 服務開發
在 service
包的 UserService
中增加方法聲明:
/*** 獲取當前登錄用戶** @param request HTTP 請求對象* @return 用戶信息*/
User getLoginUser(HttpServletRequest request);
在 UserServiceImpl
中增加實現代碼:
此處為了保證獲取到的數據始終是最新的,先從 Session
中獲取登錄用戶的 id
,然后從數據庫中查詢最新的結果。代碼如下:
@Override
public User getLoginUser(HttpServletRequest request) {// 1. 先判斷是否已經登錄Object userObj = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);// 2. 根據 Sesssion 獲取對象后, 先把 Object 轉為 UserUser currentUser = (User) userObj;// 3. 根據對象判斷當前是否登錄if(currentUser == null || currentUser.getId() == null){// 4. 多判斷一個 id 是否為空, 后續前端需要根據 id 來判斷用戶是否登錄throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);}// 5. 從數據庫中查詢信息, 如果追求性能, 直接返回 currentUser 對象(利用緩存, 不推薦)Long userId = currentUser.getId();// 6. 根據 id 從數據庫中查詢, 查詢的結果重新賦值給 currentUsercurrentUser = this.getById(userId);// 7. 再次校驗, 當前用戶信息在數據庫中查不到, 可能該用戶被管理員刪除if(currentUser == null){throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);}// 8. 返回數據庫查詢結果return currentUser;
}
2. 接口開發
在 UserController
中新增獲取當前登錄用戶接口:
// 獲取當前登錄用戶信息@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {// 1. 調用 service , 獲取用戶信息User loginUser = userService.getLoginUser(request);// 2. Controller 接口可以被前端直接調用, 因此要對 Service 拿到的信息進行脫敏LoginUserVO loginUserVO = userService.getLoginUserVO(loginUser);// 3. 統一返回結果, 封裝脫敏后的數據, 返回給前端return ResultUtils.success(loginUserVO);
}
注意事項:上述代碼直接將數據庫查到的所有信息都返回給了前端(包括密碼),可能存在信息泄露的安全風險。因此,需要對返回結果進行脫敏處理。
用戶注銷
用戶注銷可以從 request
請求對象對應的 Session
中直接獲取到之前保存的登錄用戶信息,來完成注銷,無需其他請求參數。
1. 服務開發
在 service
包的 UserService
中增加方法聲明:
/*** 用戶注銷** @param request HTTP 請求對象* @return 是否注銷成功*/
boolean userLogout(HttpServletRequest request);
在 UserServiceImpl
中增加實現代碼,從 Session
中移除掉當前用戶的登錄態即可:
@Override
public boolean userLogout(HttpServletRequest request) {// 先判斷是否已登錄Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);if (userObj == null) {throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登錄");}// 移除登錄態request.getSession().removeAttribute(USER_LOGIN_STATE);return true;
}
2. 接口開發
在 UserController
中新增用戶注銷接口:
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR);boolean result = userService.userLogout(request);return ResultUtils.success(result);
}
用戶權限控制
在本節教程的方案設計中提到:權限校驗是一個比較通用的業務需求,通常通過 Spring AOP 切面 + 自定義權限校驗注解
實現統一的接口攔截和權限校驗。
如果有特殊的權限校驗邏輯,再單獨在接口中編碼。
1. 權限校驗注解
首先編寫權限校驗注解
,將其放在 annotation
包下:
下面的代碼定義了一個名為 AuthCheck
的自定義注解,用于在運行時進行權限檢查:
@Target(ElementType.METHOD) // 注解的生效范圍: 定義為方法注解
@Retention(RetentionPolicy.RUNTIME) // 注解的生效時機: 運行時生效
public @interface AuthCheck {/*** 必須有某個角色, 也就是說, */String mustRole() default "";
}
注解
AuthCheck
包含一個元素mustRole()
:
mustRole() 一個字符串類型的元素
,用于指定必須擁有的角色
。- 如果該元素
不指定值(即默認值),則默認為空字符串 ""
。
使用這個注解的方法,需要確保調用者具有指定的角色
,否則可能會拒絕訪問
或執行相應的權限檢查邏輯
。這個注解可以用于實現基于角色的訪問控制(RBAC)
。
2. 權限校驗切面
編寫權限校驗 AOP,采用環繞通知,在 打上該注解的方法
執行前后進行一些額外的操作,比如校驗權限:
代碼如下,放在 aop
包下:
@Aspect // 表示該類是一個切面類,用于定義橫切關注點
@Component // 將該類聲明為 Spring 組件,使其成為 Spring 應用上下文中的一個 Bean
public class AuthInterceptor {@Resourceprivate UserService userService;/*** 執行權限攔截* @param joinPoint 切點參數,用于獲取被攔截方法的信息* @param authCheck 自定義的權限注解,用于指定權限要求* @return 攔截方法的執行結果* @throws Throwable 可能拋出的異常*/@Around("@annotation(authCheck)") // 定義切點,指定只有被 @AuthCheck 注解標記的方法才會被攔截public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {// 1. 獲取必須具備的角色權限String mustRole = authCheck.mustRole();// 2. 從全局請求上下文中獲取當前請求RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();// 根據獲取全局請求上下文 RequestContextHolder, 獲取當前請求的各個屬性 currentRequestAttributes()// 3. 從請求上下文中獲取 HttpServletRequest 對象HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();// 需要把 RequestAttributes 類型的對象, 轉為子類 ServletRequestAttributes 類型的對象, 才可以調用 getRequest()// 4. 根據請求對象獲取當前登錄用戶的詳細信息User loginUser = userService.getLoginUser(request);// 5. 將角色權限字符串轉換為枚舉類型UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);// 6. 如果mustRoleEnum 為空,表示沒有指定必須具備的角色權限,直接放行if(mustRoleEnum == null){return joinPoint.proceed();}// 7. 獲取當前登錄用戶的角色權限(會員權限、管理員權限等特殊權限)UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUserRole());// 8. 如果用戶角色權限為空,表示用戶沒有權限,拋出異常if (userRoleEnum == null) {throw new BusinessException(ErrorCode.NO_AUTH_ERROR);}// 9. 如果必須具備管理員權限,但當前登錄用戶不是管理員,則拋出異常if(UserRoleEnum.ADMIN.equals(mustRoleEnum) && !UserRoleEnum.ADMIN.equals(userRoleEnum)){throw new BusinessException(ErrorCode.NO_AUTH_ERROR);}// 10. 如果當前用戶是管理員,放行執行原方法return joinPoint.proceed();}
}
3. 使用注解
只要給方法添加了 @AuthCheck
注解,就必須要登錄,否則會拋出異常。可以設置 mustRole
為管理員,這樣僅管理員才能使用該接口:
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
對于不需要登錄就能使用的接口,不需要使用該注解。
4. 注解測試
我們測試以下權限校驗切面
的功能,在注冊接口設置管理員權限的注解:
重新啟動程序,當前用戶不是管理員,如果調用注冊接口,會產生如下效果:
我們調用登錄接口登錄,再調用注冊接口:
修改后端用戶權限:
重新運行程序,再次調用注冊接口,繼續測試權限校驗切面
的功能:
賬戶重復,表示程序已經進入到切點內了,也就表示權限校驗注解已經開發完成了;
注冊接口在當前用戶的 userRole = admin 的情況下,可以正常訪問:
用戶管理
用戶管理功能
具體可以拆分為以下幾部分:
-
管理員操作
-
創建用戶
-
根據 id 刪除用戶
-
更新用戶
-
分頁獲取用戶列表(需要脫敏)
-
根據 id 獲取用戶(未脫敏)
-
-
普通用戶操作
- 根據 id 獲取用戶(脫敏)
1. 數據模型
每個操作都需要提供一個請求類
,都放在 dto.user
包下;用戶管理功能,本質上就是對用戶信息進行增刪改查:
用戶刪除請求
/*** 通用的刪除請求類(刪除操作都是根據用戶 id 進行刪除, 所以放在 common 進行復用)*/
@Data
public class DeleteRequest implements Serializable {/*** 數據 ID*/private Long id;private static final long serialVersionUID = 1L;
}
用戶創建請求
@Data
public class UserAddRequest implements Serializable {/*** 用戶昵稱*/private String userName;/*** 賬號*/private String userAccount;/*** 用戶頭像*/private String userAvatar;/*** 用戶簡介*/private String userProfile;/*** 用戶角色: user, admin*/private String userRole;private static final long serialVersionUID = 1L;
}
用戶更新請求
@Data
public class UserUpdateRequest implements Serializable {/*** id*/private Long id;/*** 用戶昵稱*/private String userName;/*** 用戶頭像*/private String userAvatar;/*** 簡介*/private String userProfile;/*** 用戶角色:user/admin*/private String userRole;private static final long serialVersionUID = 1L;
}
用戶查詢請求
需要繼承公共包中的 PageRequest
來支持分頁查詢:
@EqualsAndHashCode(callSuper = true)
@Data
public class UserQueryRequest extends PageRequest implements Serializable {/*** id*/private Long id;/*** 用戶昵稱*/private String userName;/*** 賬號*/private String userAccount;/*** 簡介*/private String userProfile;/*** 用戶角色:user/admin/ban*/private String userRole;private static final long serialVersionUID = 1L;
}@Data
public class PageRequest {/*** 當前頁號,默認值為 1*/private int current = 1;/*** 頁面大小,默認值為 10*/private int pageSize = 10;/*** 排序字段*/private String sortField;/*** 排序順序(默認降序)*/private String sortOrder = "descend";
}
用戶信息脫敏
由于要提供獲取用戶信息
的接口,需要和獲取當前登錄用戶
的接口一樣,對用戶信息進行脫敏
。
在 model.vo
包下新建 UserVO
,表示脫敏后的用戶信息:
@Data
public class UserVO implements Serializable {/*** id*/private Long id;/*** 賬號*/private String userAccount;/*** 用戶昵稱*/private String userName;/*** 用戶頭像*/private String userAvatar;/*** 用戶簡介*/private String userProfile;/*** 用戶角色:user/admin*/private String userRole;/*** 創建時間*/private Date createTime;private static final long serialVersionUID = 1L;
}
2. 服務開發
(1)獲取脫敏后的單個用戶信息
在 UserService
中編寫獲取脫敏后的單個用戶信息方法:
/*** 獲取脫敏后的登錄用戶信息(普通用戶、管理員)** @param user 查詢數據庫得到的結果* @return 對查詢結果脫敏后, 得到的結果*/
UserVO getUserVO(User user);
@Override
public UserVO getUserVO(User user) {if (user == null) {return null;}UserVO userVO = new UserVO();BeanUtils.copyProperties(user, userVO);return userVO;
}
(2)獲取脫敏后的用戶列表
在 UserService
中編寫獲取脫敏后的用戶列表方法:
/*** 獲取脫敏后的登錄用戶信息列表(管理員)** @param userList 查詢數據庫, 得到的結果列表* @return 對查詢結果脫敏后, 得到的結果列表*/
List<User> getUserVOList(List<User> userList);
@Override
public List<UserVO> getUserVOList(List<User> userList) {if(CollUtil.isEmpty(userList)){// CollUtil 是 Hutool 關于集合操作的工具類return new ArrayList<>();// 傳入的 userList 為空, 脫敏結果也是一個空數組}return userList.stream().map(this::getUserVO).collect(Collectors.toList());
}
(3)獲取查詢條件
除了上述方法外,對于分頁查詢接口,需要根據用戶傳入的參數來構造 SQL 查詢。
由于使用了 MyBatis Plus
框架,無需手動拼接 SQL,而是通過構造 QueryWrapper
對象來生成 SQL 查詢。
可以在 UserService
中編寫一個方法,專門用于將查詢請求轉換為 QueryWrapper
對象:
/*** 獲取查詢條件* @param userQueryRequest* @return*/
QueryWrapper<User> getQueryWrapper(UserQueryRequest userQueryRequest);
實現獲取查詢條件
的接口方法:
@Override
public QueryWrapper<User> getQueryWrapper(UserQueryRequest userQueryRequest) {if (userQueryRequest == null) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "請求參數為空");}// 1. userQueryRequest.allgetLong id = userQueryRequest.getId();String userName = userQueryRequest.getUserName();String userAccount = userQueryRequest.getUserAccount();String userProfile = userQueryRequest.getUserProfile();String userRole = userQueryRequest.getUserRole();// 2. 下面兩個字段展示用不到// int current = userQueryRequest.getCurrent();// int pageSize = userQueryRequest.getPageSize();String sortField = userQueryRequest.getSortField();String sortOrder = userQueryRequest.getSortOrder();// 3. 將獲取到的值作為為請求參數來構造條件QueryWrapper<User> queryWrapper = new QueryWrapper<>();// 4. 傳入參數中, 獲取到的 id 不為空, 根據數據庫 id 字段, 查找傳入的 id 值queryWrapper.eq(ObjUtil.isNotEmpty(id), "id", id);queryWrapper.eq(StrUtil.isNotBlank(userRole), "userRole", userRole);// 5. 用到模糊查詢 like, 只要賬號, 用戶名....等字段內容部分相同, 就可以匹配出符合條件的結果queryWrapper.like(StrUtil.isNotBlank(userAccount), "userAccount", userAccount);queryWrapper.like(StrUtil.isNotBlank(userName), "userName", userName);queryWrapper.like(StrUtil.isNotBlank(userProfile), "userProfile", userProfile);// 6. 利用排序, 如果傳入參數中, 獲取到的排序規則參數不為空, 就按照升序排序 ascend 排序查找到的數據, 降序規則: descendqueryWrapper.orderBy(StrUtil.isNotEmpty(sortField), sortOrder.equals("ascend"), sortField);return queryWrapper;
}
補充說明,光標放在方法上,按ctrl + p
可以顯示方法的參數:
orderBy()
的第二個參數,和我們自己傳入的參數sortOrder.equals("ascend")
是呼應的;
3. 接口開發
上述功能主要是常見的 CRUD(增刪改查) 操作,代碼實現相對簡單。需要注意以下幾點:
- 添加對應的權限注解。
- 做好參數校驗。
(1)創建用戶
/*** 創建用戶* @param userAddRequest 創建用戶請求* @return 創建好用戶后, 在數據庫中存的該用戶的 id*/@PostMapping("/add")// 6. 該接口需要管理員權限
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {// 1. 傳入參數為空, 調用自定義工具類, 拋出參數錯誤異常ThrowUtils.throwIf(userAddRequest == null, ErrorCode.PARAMS_ERROR);// 2. 把傳入的參數賦值給 User 對象User user = new User();BeanUtil.copyProperties(userAddRequest,user);// 3. 給 User 對象設置密碼默認值final String DEFAULT_PASSWORD = "12345678";String encryptPassword = userService.getEncryptPassword(DEFAULT_PASSWORD); // 對默認密碼加密user.setUserPassword(encryptPassword);// 4. 插入數據庫boolean result = userService.save(user);ThrowUtils.throwIf(result == false, ErrorCode.OPERATION_ERROR); // 操作異常// 5. 統一返回結果 BaseResponse<Long>: user.getId() 對應 Long 類型 return ResultUtils.success(user.getId());
}
(2)根據 id 獲取用戶(僅管理員)
@GetMapping("/get")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<User> getUserById(long id) {ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);User user = userService.getById(id);ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);return ResultUtils.success(user);
}
(3)根據 id 獲取包裝類
@GetMapping("/get/vo")
public BaseResponse<UserVO> getUserVOById(long id) {BaseResponse<User> response = getUserById(id);User user = response.getData();return ResultUtils.success(userService.getUserVO(user));
}
(4)刪除用戶
/*** 根據 id 刪除用戶, 僅管理員* @param deleteRequest* @return*/
@PostMapping("/delete")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> deleteUser(@RequestBody DeleteRequest deleteRequest) {if (deleteRequest == null || deleteRequest.getId() <= 0) {throw new BusinessException(ErrorCode.PARAMS_ERROR);}boolean b = userService.removeById(deleteRequest.getId());return ResultUtils.success(b);
}
(5)更新用戶
類比創建用戶接口:
參數校驗
:
- 首先,對輸入的請求參數進行校驗。
創建用戶對象
:
- 根據校驗通過的請求參數,創建一個新的
User
對象,并復制相關的屬性。
更新數據庫
:
- 使用創建的
User
對象更新數據庫中的相應記錄。
處理更新結果
:
- 根據數據庫更新操作的結果,采取相應的措施:
- 如果更新失敗(例如,由于違反約束或技術問題),則拋出自定義異常
- 如果更新成功,則封裝結果并返回給調用者。
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updateUser(@RequestBody UserUpdateRequest userUpdateRequest) {if (userUpdateRequest == null || userUpdateRequest.getId() == null) {throw new BusinessException(ErrorCode.PARAMS_ERROR);}User user = new User();BeanUtils.copyProperties(userUpdateRequest, user);boolean result = userService.updateById(user);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);return ResultUtils.success(true);
}
(6)分頁獲取用戶封裝列表(僅管理員)
注意事項
- 因為分頁查詢涉及到復雜的查詢,參數會比較復雜,
POST
比GET
能發送的數據量更大BaseResponse<Page<UserVO>>
中,要注意引入Page
的包:
@PostMapping("/list/page/vo")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<UserVO>> listUserVOByPage(@RequestBody UserQueryRequest userQueryRequest){// 1. 校驗參數ThrowUtils.throwIf(userQueryRequest == null, ErrorCode.PARAMS_ERROR);// 2. 從參數中獲取當前頁碼、每頁顯示的記錄樹int current = userQueryRequest.getCurrent(); // 當前頁碼int pageSize = userQueryRequest.getPageSize(); // 每頁顯示的記錄數// 3. 構建分頁查詢條件并執行查詢,獲取分頁結果Page<User> userPage = userService.page(new Page<>(current, pageSize), // 創建分頁對象,包含當前頁碼和頁面大小userService.getQueryWrapper(userQueryRequest) // 根據請求參數構造查詢條件);// page(Page<current, pageSize> 對象, 查詢條件) 是 MyBatis-Plus 提供的一個方法, 用于執行分頁查詢// 4. 根據當前頁碼、頁面大小以及從數據庫查詢得到的總記錄數, 創建一個新的 Page<UserVO> 分頁對象, 用于后續存儲轉換后的 UserVO 列表Page<UserVO> userVOPage = new Page<>(current, pageSize, userPage.getTotal());// userPage.getTotal() 表示從數據庫查詢得到的總記錄數;// 這里從 userPage(一個 Page<User> 對象)獲取總記錄數, 因為 userPage 已經包含了這次查詢的分頁信息// 5. 對分頁結果進行脫敏List<UserVO> userVOList = userService.getUserVOList(userPage.getRecords());// getRecords() 方法的作用是從一個分頁對象 userPage 中提取當前頁的記錄列表// 6. 將 userVOList 列表, 設置為 userVOPage 分頁對象中的當前頁記錄userVOPage.setRecords(userVOList);// setRecords() 用于設置分頁對象中, 當前頁的數據記錄列表return ResultUtils.success(userVOPage);
}
4.分頁功能修復
使用 Swagger 接口文檔
依次對上述接口進行測試,發現 listUserVOByPage
接口有一些問題!
發送請求:
分頁好像沒有生效,還是查出了全部數據。
由于我們用的是 MyBatis Plus
來操作數據庫,所以需要通過 官方文檔
來查詢解決方案。
查閱后發現,原來必須要配置一個分頁插件。必須要注意,本項目使用的 v3.5.9 版本引入分頁插件的方式和之前不同!v3.5.9 版本后需要獨立安裝分頁插件依賴!!!
在 pom.xml
中引入分頁插件依賴:
<!-- MyBatis Plus 分頁插件 -->
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
</dependency>
注意,這個插件適用于 JDK8,如果是其他版本的 JDK ,需要去官方文檔中引入其他版本的分頁插件;
光引入這一條,大概率是無法成功下載依賴的;
在 pom.xml
的依賴管理配置中補充 mybatis-plus-bom
:
<dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>${spring-boot.version}</version><type>pom</type><scope>import</scope></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-bom</artifactId><version>3.5.9</version><type>pom</type><scope>import</scope></dependency></dependencies>
</dependencyManagement>
依賴下載成功后,根據官方文檔的提示,在 config
包下新建 MyBatis Plus 攔截器配置,添加分頁插件:
@Configuration
@MapperScan("com.yupi.yupiturebackend.mapper")
// 注意: 掃描路徑需要復制自己項目中 mapper 對應的包
public class MyBatisPlusConfig {/*** 攔截器配置** @return {@link MybatisPlusInterceptor}*/@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();// 分頁插件interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;}
}
重啟項目,重新調用登錄接口(項目使用本地 session,重啟需要重新登錄),再次調用分頁接口,這次就能正常完成分頁了:
5. 數據精度修復
但是,在測試中,如果你打開 F12 控制臺
,利用預覽來查看響應數據,就會發現另一個問題:
id
的最后兩位好像都變成 0
了!
但是在響應中、以及 Swagger 中查看,卻是正常的。
這是由于前端 JS 的精度范圍有限,我們后端返回的 id
范圍過大,導致前端精度丟失,會影響前端頁面獲取到的數據結果。
為了解決這個問題,可以在后端 config
包下新建一個 全局 JSON 配置
,將整個后端 Spring MVC 接口返回值的長整型數字轉換為字符串進行返回
,從而集中解決問題。
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;/*** Spring MVC Json 配置*/
@JsonComponent
public class JsonConfig {/*** 添加 Long 轉 json 精度丟失的配置*/@Beanpublic ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {ObjectMapper objectMapper = builder.createXmlMapper(false).build();SimpleModule module = new SimpleModule();module.addSerializer(Long.class, ToStringSerializer.instance);module.addSerializer(Long.TYPE, ToStringSerializer.instance);objectMapper.registerModule(module);return objectMapper;}
}
重啟項目進行測試,這次看到的 id
值就正常了。
至此,用戶相關的后端接口開發完畢,大家可以按需完善上述代碼~~~