文章目录
- 功能扩充
- 管理员修改用户信息
- 管理员删除用户
- 管理员添加用户
- 添加个人主页,可以完善个人信息(上传头像没有实现)
- 添加默认头像
- 打造一个所有用户可发帖的页面
- 前端页面,√
- 后端建表,接口,√
- 前后端联调√
- 后端添加全局请求拦截器(统一去判断用户权限、统计记录请求日志)
- BUG
- 前端页面不能同步操作需要刷新
- 上传头像的BUG
- 功能扩充
- 管理员修改用户信息、删除用户、添加用户√
- 添加个人主页,可以完善个人信息上传头像√ ×
- 添加默认头像√
- 更改星球编号,换成验证码形式×
- 借鉴星球思路,打造一个所有用户可发帖的页面(不然普通用户太单调了)
- 前端页面,√
- 后端建表,接口,√
- 前后端联调√
- 按照更多的条件去查询用户×
- 更新密码加密×
- 更改权限×
- 修改 bug×
- 项目登录改为分布式 session(单点登录 - Redis)×
- 通用性 ×
- set-cookie domain 域名更通用,比如改为 *.xxx.com
- 把用户管理系统 => 用户中心(之后所有的服务器都请求这个后端)
- 后端添加全局请求拦截器(统一去判断用户权限、统计记录请求日志)√
- 优化统一返回体处理×
功能扩充
前端代码比较多就不附带了(本人也不是很懂,好多bug)
管理员修改用户信息
在 model/domain/request 包下创建 UserUpdateRequest 类,表示更新用户请求体
@Data
public class UserUpdateRequest {private Long id;private String userAccount;private String userName;private String userPassword;// 你可以决定是否允许通过此API修改密码private Integer gender;private String avatarUrl;private String phone;private String email;private Integer userStatus;private String planetCode;
}
在 service 包下的 UserService 接口创建新的方法
* 管理员更新用户* @param userUpdateRequest* @return*/
boolean updateUser(UserUpdateRequest userUpdateRequest);
这里为了减少代码, 因为校验账户是否存在、星球编号是否存在的操作,在上面的用户注册方法里面有过,于是我就提取出来了
/*** 判断账户是否已经存在* @param userAccount* @return*/private boolean checkUserAccountExists(String userAccount) {QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("userAccount", userAccount);return count(queryWrapper) > 0;}/*** 判断星球编号是否已经存在* @param planetCode* @return*/private boolean checkPlanetCodeExists(String planetCode) {QueryWrapper<User> queryWrapper = new QueryWrapper<>();queryWrapper.eq("planetCode", planetCode);return count(queryWrapper) > 0;}
然后在 service/impl 包下的 UserServiceImpl 类
/*** 根据提供的用户信息更新用户。* 此方法会根据 userUpdateRequest 中提供的信息更新数据库中相应的用户记录。* 具体校验逻辑如下:* - 用户ID不能为空,确保能正确定位需要更新的用户记录。* - 用户名如果提供,其长度不能少于4位。* - 账号如果提供,不能包含特殊字符。* - 密码如果提供,其长度不能少于8位,并对密码进行MD5加密处理。* - 星球编号如果提供,其长度不能超过5位。* - 账号和星球编号如果提供,需要检查它们的唯一性,以避免与其他用户的账号或星球编号冲突。** @param userUpdateRequest 包含要更新用户信息的请求体。* @return 更新成功返回true,否则返回false。*/@Overridepublic boolean updateUser(UserUpdateRequest userUpdateRequest) {// 检查更新请求中的用户ID是否为空if (userUpdateRequest.getId() == null) {throw new BusinessException(ErrorCode.NULL_ERROR, "用户ID不能为空");}// 若更新请求中包含用户名,则进行长度校验if (StringUtils.isNotBlank(userUpdateRequest.getUsername()) && userUpdateRequest.getUsername().length() < 4) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户名长度过短,至少需要4位。");}// 账号特殊字符校验if (StringUtils.isNotBlank(userUpdateRequest.getUserAccount())) {String validPattern = "[`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]";Matcher matcher = Pattern.compile(validPattern).matcher(userUpdateRequest.getUserAccount());if (matcher.find()) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号包含非法字符。");}}// 若更新请求中包含用户密码,则进行长度校验if (StringUtils.isNotBlank(userUpdateRequest.getUserPassword()) && userUpdateRequest.getUserPassword().length() < 8) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码长度过短,至少需要8位。");}// 若更新请求中包含星球编号,则进行长度校验if (StringUtils.isNotBlank(userUpdateRequest.getPlanetCode()) && userUpdateRequest.getPlanetCode().length() > 5) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "星球编号过长,不得超过5位。");}// 账户是否已存在if(checkUserAccountExists(userUpdateRequest.getUserAccount())) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号已存在");}// 星球编号是否已存在if(checkPlanetCodeExists(userUpdateRequest.getPlanetCode())) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "星球编号已存在");}// 构造更新条件UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();updateWrapper.eq("id", userUpdateRequest.getId());// 准备更新的用户实体User user = new User();BeanUtils.copyProperties(userUpdateRequest, user);// 如果提供了密码,则进行加密处理if (StringUtils.isNotBlank(user.getUserPassword())) {String encryptedPassword = DigestUtils.md5DigestAsHex((SALT + user.getUserPassword()).getBytes());user.setUserPassword(encryptedPassword);}// 执行更新操作int updateCount = userMapper.update(user, updateWrapper);return updateCount > 0;}
然后是 controller 包下的 UserController 类
@PostMapping("/update")
public BaseResponse<Boolean> updateUser(@RequestBody UserUpdateRequest userUpdateRequest,HttpServletRequest request){// 权限校验逻辑,确保是管理员操作if(!isAdmin(request)){throw new BusinessException(ErrorCode.NO_AUTH);}boolean result=userService.updateUser(userUpdateRequest);return ResultUtils.success(result);
}
管理员删除用户
逻辑删除
在 service 包下的 UserService 接口创建新的方法
/*** 删除指定ID的用户* @param id 用户ID* @return 操作是否成功*/
boolean deleteUserById(Long id);
然后在 service/impl 包下的 UserServiceImpl 类
/*** 根据用户ID删除用户。* 调用MyBatis Plus的deleteById方法,根据提供的用户ID执行删除操作。* 如果删除操作影响的行数大于0,则返回true表示删除成功;否则返回false表示删除失败。* @param id 用户的ID。* @return 删除成功返回true,失败返回false。*/
@Override
public boolean deleteUserById(Long id) {// 这里简单地调用了MyBatis Plus的内置方法进行删除,实际情况可能需要更复杂的逻辑int rows = userMapper.deleteById(id);return rows > 0;
}
然后是 controller 包下的 UserController 类
/*** 管理员通过用户ID删除用户。此操作要求执行者具有管理员权限。* 通过HTTP请求中的路径变量接收要删除的用户ID,并进行权限验证。* 如果当前操作用户是管理员,调用Service层执行删除操作。* @param id 用户的唯一标识ID,通过URL路径传递。* @param request 用于获取当前HTTP请求的对象,主要用于执行权限校验。* @return 返回一个包含操作结果的响应体,操作成功返回true,失败返回false。*/
@DeleteMapping("/delete/{id}")
public BaseResponse<Boolean> deleteUser(@PathVariable Long id, HttpServletRequest request) {// 权限校验,确保是管理员操作if (!isAdmin(request)) {throw new BusinessException(ErrorCode.NO_AUTH);}boolean result = userService.deleteUserById(id);return ResultUtils.success(result);
}
管理员添加用户
在 model/domain/request 包下创建 UserCreateRequest 类,表示创建用户请求体
private String userPassword;private String checkPassword;private String userName;private String avatarUrl; // 可选private Integer gender; // 可选private String phone; // 可选private String email; // 可选private Integer userRole; // 可选,管理员在创建用户时指定角色private String planetCode;
}
在 service 包下的 UserService 接口创建新的方法
* 管理员创建用户* @param createRequest* @return*/
boolean createUser(UserCreateRequest createRequest);
这里我考虑到创建用户可能要传的参数可能有很多,就不把这个请求里的参数从 UserController 里提取出来再传入了,那样太多太杂了
然后在 service/impl 包下的 UserServiceImpl 类
/*** 创建用户操作* @param createRequest* @return*/@Overridepublic boolean createUser(UserCreateRequest createRequest) {// 校验账户、密码、用户名、星球编号不能为空if(StringUtils.isAnyBlank(createRequest.getUserAccount(),createRequest.getUsername(),createRequest.getPlanetCode())){throw new BusinessException(ErrorCode.NULL_ERROR, "账户、用户名、星球编号不能为空");}// 账户是否已存在if(checkUserAccountExists(createRequest.getUserAccount())) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号已存在");}// 星球编号是否已存在if(checkPlanetCodeExists(createRequest.getPlanetCode())) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "星球编号已存在");}// 创建用户对象并设置字段User user = new User();user.setUserAccount(createRequest.getUserAccount());user.setUsername(createRequest.getUsername());user.setPlanetCode(createRequest.getPlanetCode());user.setAvatarUrl(createRequest.getAvatarUrl());user.setGender(createRequest.getGender());user.setPhone(createRequest.getPhone());user.setEmail(createRequest.getEmail());user.setUserRole(createRequest.getUserRole() != null ? createRequest.getUserRole() : UserConstant.DEFAULT_ROLE); // 默认为普通用户,如果未指定角色// 密码加密String encryptedPassword = DigestUtils.md5DigestAsHex((SALT + createRequest.getUserPassword()).getBytes());user.setUserPassword(encryptedPassword);return save(user);}
然后是 controller 包下的 UserController 类
/*** 处理管理员创建用户的请求。* 此接口仅允许管理员调用,用于创建新用户。* @param createRequest 包含用户信息的请求体,需要符合用户创建的各项要求。* @param request HTTP请求对象,用于权限校验,确保当前操作者具有管理员权限。* @return 返回操作的结果,创建成功时返回true,否则返回false。* @throws BusinessException 如果当前操作者不具备管理员权限,则抛出无权限的业务异常。*/
@PostMapping("/create")
public BaseResponse<Boolean> createUser(@RequestBody UserCreateRequest createRequest, HttpServletRequest request){// 权限校验逻辑,确保是管理员操作if(!isAdmin(request)){throw new BusinessException(ErrorCode.NO_AUTH);}boolean result=userService.createUser(createRequest);return ResultUtils.success(result);
}
添加个人主页,可以完善个人信息(上传头像没有实现)
上传头像没有实现,主要我不知道怎么把本地上传的图片远程保存,前端代码这里不太会写(网上有,但不知道怎么改自己的代码)
import React, { useState, useRef, useLayoutEffect } from 'react';
import { GridContent } from '@ant-design/pro-layout';
import { Menu } from 'antd';
import BaseView from './components/base';
import styles from './style.less';
import SecurityView from './components/security';const { Item } = Menu;type AccountSettingsStateKeys = 'base' | 'security' | 'binding' | 'notification';
type AccountSettingsState = {mode: 'inline' | 'horizontal';selectKey: AccountSettingsStateKeys;
};const AccountSettings: React.FC = () => {const menuMap: Record<string, React.ReactNode> = {base: '基本设置',security: '安全设置',// binding: '账号绑定',// notification: '新消息通知',};const [initConfig, setInitConfig] = useState<AccountSettingsState>({mode: 'inline',selectKey: 'base',});const dom = useRef<HTMLDivElement>();const resize = () => {requestAnimationFrame(() => {if (!dom.current) {return;}let mode: 'inline' | 'horizontal' = 'inline';const { offsetWidth } = dom.current;if (dom.current.offsetWidth < 641 && offsetWidth > 400) {mode = 'horizontal';}if (window.innerWidth < 768 && offsetWidth > 400) {mode = 'horizontal';}setInitConfig({ ...initConfig, mode: mode as AccountSettingsState['mode'] });});};useLayoutEffect(() => {if (dom.current) {window.addEventListener('resize', resize);resize();}return () => {window.removeEventListener('resize', resize);};}, [dom.current]);const getMenu = () => {return Object.keys(menuMap).map((item) => <Item key={item}>{menuMap[item]}</Item>);};const renderChildren = () => {const { selectKey } = initConfig;switch (selectKey) {case 'base':return <BaseView />;case 'security':return <SecurityView />;default:return null;}};return (<GridContent><divclassName={styles.main}ref={(ref) => {if (ref) {dom.current = ref;}}}><div className={styles.leftMenu}><Menumode={initConfig.mode}selectedKeys={[initConfig.selectKey]}onClick={({ key }) => {setInitConfig({...initConfig,selectKey: key as AccountSettingsStateKeys,});}}>{getMenu()}</Menu></div><div className={styles.right}><div className={styles.title}>{menuMap[initConfig.selectKey]}</div>{renderChildren()}</div></div></GridContent>
);
};
export default AccountSettings;
import { CheckOutlined, UploadOutlined } from '@ant-design/icons';
import { ProForm, ProFormInstance, ProFormSelect, ProFormText } from '@ant-design/pro-components';// @ts-ignore
import { Button, GetProp, message, Upload, UploadProps } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import styles from './BaseView.less';
import { currentUser as queryCurrent, updateLoginUser } from '@/services/ant-design-pro/api';const isDev = process.env.NODE_ENV === 'development';const BaseView: React.FC = () => {//const { styles } = useStyles();// @ts-ignoreconst [currentUser, setCurrentUser] = useState<API.CurrentUser>(null);const [loading, setLoading] = useState(true);const [avatar, setAvatar] = useState<string>('');const [modifyPhone, setModifyPhone] = useState<boolean>(true);const [modifyEmail, setmodifyEmail] = useState<boolean>(true);type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];const props: UploadProps = {name: 'avatar',action: isDev ? '/user-center/upload' : 'http://47.109.85.240/user-center/upload',listType: 'picture',defaultFileList: [],onChange(info) {if (info.file.status !== 'uploading') {console.log(info.file, info.fileList);}if (info.file.status === 'done') {message.success(`${info.file.name} file uploaded successfully`);setAvatar(info.file.response.data);} else if (info.file.status === 'error') {message.error(`${info.file.name} file upload failed.`);}},beforeUpload(file: FileType) {// @ts-ignoreconst isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';if (!isJpgOrPng) {message.error('只能上传 JPG/PNG 文件!');}// @ts-ignoreconst isLt2M = file.size / 1024 / 1024 < 2;if (!isLt2M) {message.error('文件大小必须小于 2MB!');}return isJpgOrPng && isLt2M;},};// 头像组件 方便以后独立,增加裁剪之类的功能// eslint-disable-next-line @typescript-eslint/no-shadowconst AvatarView = ({ avatar }: { avatar: string }) => (<><div className={styles.avatar_title}>头像</div><div className={styles.avatar}><img src={avatar} alt="avatar" /></div><div className={styles.button_view}>/<Upload {...props}><Button icon={<UploadOutlined />}>上传头像</Button></Upload></div></>);const fetchData = async () => {try {const userData = await queryCurrent();// @ts-ignoresetCurrentUser(userData);setLoading(false);} catch (error) {// 处理错误}};useEffect(() => {fetchData();// 清除副作用return () => {// 如果有需要,在组件卸载时进行清理工作};}, []); // 传入空的依赖数组,确保 useEffect 只在组件挂载时执行一次const getAvatarURL = () => {console.log('currentser', currentUser);if (currentUser) {if (currentUser.avatarUrl) {return currentUser.avatarUrl;}const url = 'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png';return url;}return '';};const handleFinish = async (values: API.CurrentUser) => {try {// 打印 values 和 avatar 的值console.log('values:', values);console.log('avatar:', avatar);// 更新// @ts-ignoreconst res = await updateLoginUser({...values,avatar: avatar === '' ? currentUser.avatar : avatar,id: currentUser.id,});// @ts-ignoreif (res === 0) {message.success('更新基本信息成功');fetchData();return;} else {message.error('更新基本信息失败');}// 如果失败去设置用户错误信息} catch (error) {console.log(error);message.error('更新基本信息失败');}};const formRef = useRef<ProFormInstance>();return (<div className={styles.baseView}>{loading ? null : (<><div className={styles.left}><ProFormformRef={formRef}layout="vertical"onFinish={async (values) => {await handleFinish(values as API.CurrentUser);}}submitter={{searchConfig: {submitText: '更新基本信息',},render: (_, dom) => dom[1],}}initialValues={{...currentUser,phone: currentUser?.phone,}}><ProFormTextwidth="md"name="username"label="用户名"rules={[{required: true,message: '请输入您的用户名!',},]}/><ProFormSelectwidth="md"options={[{value: 0,label: '男',},{value: 1,label: '女',},]}name="gender"label="性别"/><div style={{ display: 'flex', alignItems: 'center' }}><ProFormText width="md" name="phone" label="电话" disabled={modifyPhone} />{modifyPhone ? (<span style={{ marginLeft: '15px' }}><akey="Modify"style={{ fontSize: 15, lineHeight: '32px' }}onClick={() => {setModifyPhone(false);formRef?.current?.setFieldsValue({ phone: '' });}}>修改</a></span>) : (<CheckOutlinedtwoToneColor="#52c41a"style={{ marginLeft: '15px' }}onClick={() => {setModifyPhone(true);}}/>)}</div><div style={{ display: 'flex', alignItems: 'center' }}><ProFormText width="md" name="email" label="邮箱" disabled={modifyEmail} />{modifyEmail ? (<span style={{ marginLeft: '15px' }}><akey="Modify"style={{ fontSize: 15, lineHeight: '32px' }}onClick={() => {setmodifyEmail(false);formRef?.current?.setFieldsValue({ email: '' });}}>修改</a></span>) : (<CheckOutlinedtwoToneColor="#52c41a"style={{ marginLeft: '15px' }}onClick={() => {setmodifyEmail(true);}}/>)}</div></ProForm></div><div className={styles.right}><AvatarView avatar={getAvatarURL()} /></div></>)}</div>);
};
export default BaseView;
添加默认头像
注册时就把默认头像添加进去
因为右上角头像位置还有用户名所以在注册时添加用户名强制这样登录进去右上角就不会转圈了
打造一个所有用户可发帖的页面
前端页面,√
后端建表,接口,√
后端跟用户类似
建表语句
create table post(id bigint auto_increment comment 'id' primary key,content text null comment '内容',photo varchar(1024) null comment '照片地址',reviewStatus int default 0 not null comment '状态(0-待审核, 1-通过, 2-拒绝)',reviewMessage varchar(512) null comment '审核信息',viewNum int not null default 0 comment '浏览数',thumbNum int not null default 0 comment '点赞数',userId bigint not null comment '创建用户 id',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 '是否删除'
);alter table post add FOREIGN KEY (userId) REFERENCES user(id);create table post_thumb
(id bigint auto_increment comment 'id'primary key,postId bigint not null comment '帖子 id',userId bigint not null comment '创建用户 id',createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)comment '帖子点赞记录';
MVC架构这里就省略(controller, service, mapper)
前端也省略
前后端联调√
后端添加全局请求拦截器(统一去判断用户权限、统计记录请求日志)
我们通过 AOP 实现,先引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
我们重新创建个包 aspect,和 controller 这些同级
在包里创建 LogAndAuthAspect 类
package com.ivy.usercenter.aspect;import com.ivy.usercenter.common.ErrorCode;
import com.ivy.usercenter.contant.UserConstant;
import com.ivy.usercenter.exception.BusinessException;
import com.ivy.usercenter.model.domain.User;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;/*** 全局请求拦截器** @author ivy* @date 2024/4/22 14:19*/
@Aspect
@Slf4j
@Component
public class LogAndAuthAspect {// 定义切入点,这里指的是UserController下的所有方法,排除登录和注册方法@Pointcut("execution(* com.ivy.usercenter.controller.UserController.*(..)) " +"&& !execution(* com.ivy.usercenter.controller.UserController.userLogin(..)) " +"&& !execution(* com.ivy.usercenter.controller.UserController.userRegister(..))" +"&& !execution(* com.ivy.usercenter.controller.UserController.updateCurrentUser(..))"// + "execution(* com.ivy.usercenter.controller.PostController.*(..)) " +// "&& !execution(* com.ivy.usercenter.controller.PostController.listPostWithUser(..)) " +// "&& !execution(* com.ivy.usercenter.controller.PostController.addPost(..))" +// "&& !execution(* com.ivy.usercenter.controller.PostController.postDoThumb(..))")public void controllerLogAndAuth() {}// 定义前置通知,记录请求信息和进行权限验证@Before("controllerLogAndAuth()")public void beforeAdvice(JoinPoint joinPoint) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();// 记录请求内容,使用log对象进行日志记录log.info("Request URL: {}", request.getRequestURL().toString());log.info("HTTP Method: {}", request.getMethod());log.info("IP: {}", request.getRemoteAddr());log.info("Class Method: {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());// 对于特定的方法,允许所有登录用户访问if ("getCurrentUser".equals(joinPoint.getSignature().getName())) {// 只要用户登录即可if (request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE) == null) {throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录");}// 直接返回,不进行后续的管理员权限验证return;}// 权限验证逻辑User user = (User) request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);if (user == null) {throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "用户未登录");} else if (user.getUserRole() != UserConstant.ADMIN_ROLE) {// 需要管理员权限throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无管理员权限");}}// 环绕通知用于记录方法执行时间@Around("controllerLogAndAuth()")public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {long startTime = System.currentTimeMillis();Object result = joinPoint.proceed(); // 执行目标方法long endTime = System.currentTimeMillis();log.info("执行时间:{} ms", endTime - startTime);return result;}
}
BUG
前端页面不能同步操作需要刷新
比如删除,添加用户或帖子时,还有发帖时都会有错误弹窗(不知道哪里的)
刷新后页面才好(不刷新,操作后仍是原样)
上传头像的BUG
解决上传头像,更新头像的问题,还有信息的更新