跳至主要內容

用户中心2

项目实战用户中心项目实战用户中心大约 8 分钟约 2489 字全民制作人ikun

用户中心

登录逻辑

接受参数:用户账户,密码

接受类型:POST

请求体:JSON格式的数据

请求参数很长时不建议用get

返回值:用户信息(脱敏)

具体逻辑:

  1. 校验用户的账户,密码,是否符合要求
    1. 账户不少于四位
    2. 密码不少于四位
    3. 账户不能重复
    4. 账户不包含特殊字符
    5. 其他的校验
  2. 校验密码是否输入正确 ,要和数据库中的密码进行对比
  3. 记录用户的登录态(Session),存到服务器上,(用后端SpringBoot 框架封装的服务器tomcat去记录)
  4. 返回用户信息(脱敏)

如何知道是哪个用户?

  1. 连接服务器之后,得到一个session态,返回给前端
  2. 登录成功,得到登录成功的session,返回给前端一个设置cookie的命令 session => cookie
  3. 前端接收到后端命令后,设置cookie,保存到浏览器中
  4. 前端再次去请求后端的时候,在请求头中带着cookie去请求
  5. 后端拿到前端传来的cookie,找到对应的session
  6. 后端从session中可以取出基于该session存储的变量等。

登录service

/**
 * 用户登录
 *
 * @param userLoginDto 用户登录信息
 * @param request
 * @return 脱敏后的用户信息
 */
User userLogin(UserLoginDto userLoginDto, HttpServletRequest request);

重点逻辑,用户脱敏:

//用户脱敏
User safetyUser = new User();
safetyUser.setId(user.getId());
safetyUser.setUsername(user.getUsername());
safetyUser.setUserAccount(userAccount);
safetyUser.setAvatarUrl(user.getAvatarUrl());
safetyUser.setGender(user.getGender());
safetyUser.setPhone(user.getPhone());
safetyUser.setEmail(user.getEmail());
safetyUser.setUserStatus(user.getUserStatus());

记录登录态:

request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE,user);

完整代码:

public User userLogin(UserLoginDto userLoginDto, HttpServletRequest request) {
    String userAccount = userLoginDto.getUserAccount();
    String userPassword = userLoginDto.getUserPassword();
    if (StringUtils.isAnyBlank(userAccount, userPassword)) {
        //todo 修改为自定义异常
        return null;
    }
    if (userAccount.length() < 4) {
        return null;
    }
    if (userPassword.length() < 4) {
        return null;
    }

    //账户不能包含字符
    //账户不能包含特殊字符
    String validPattern = "^[a-zA-Z0-9_]+$";
    Matcher matcher = Pattern.compile(validPattern).matcher(userAccount);
    if (!matcher.find()) {
        return null;
    }

    String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());

    //查询用户是否存在
    QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
    userQueryWrapper.eq("userAccount", userAccount);
    userQueryWrapper.eq("userPassword", encryptPassword);
    User user = userMapper.selectOne(userQueryWrapper);
    if (user == null) {
        log.info("user login failed, userAccount cannot match userPassword");
        return null;
    }
    //用户脱敏
    User safetyUser = new User();
    safetyUser.setId(user.getId());
    safetyUser.setUsername(user.getUsername());
    safetyUser.setUserAccount(userAccount);
    safetyUser.setAvatarUrl(user.getAvatarUrl());
    safetyUser.setGender(user.getGender());
    safetyUser.setPhone(user.getPhone());
    safetyUser.setEmail(user.getEmail());
    safetyUser.setUserStatus(user.getUserStatus());
    //记录用户登录态
    request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE,user);
    return safetyUser;
}

这里注意,系统设置的删除并不是真正的删除,而是逻辑删除(0-未删除 1-删除)可以在mybatis-plus中设置

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: false
  global-config:
    db-config:
      logic-delete-field: flag # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

控制器UserController

提高效率的插件

下面这个插件可以自动填充函数 的参数

image-20231016195611322
image-20231016195611322

使用如下:

image-20231016195719640
image-20231016195719640

此时就可以自动填充了

image-20231016195845930
image-20231016195845930

完整逻辑如下:

@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private UserService userService;

    @PostMapping("/register")
    public Long userRegister(@RequestBody UserRegisterDto userRegisterDto) {
        if (userRegisterDto == null) {
            throw new RuntimeException("参数不能为空");
        }
        long id = userService.userRegister(userRegisterDto);
        return id;
    }

    @PostMapping("/login")
    public User userLogin(@RequestBody UserLoginDto userLoginDto, HttpServletRequest request) {
        if (userLoginDto == null) {
            throw new RuntimeException("参数不能为空");
        }
        String userAccount = userLoginDto.getUserAccount();
        String userPassword = userLoginDto.getUserPassword();
        if (StringUtils.isAnyBlank(userAccount, userPassword)) {
            throw new RuntimeException("用户名或密码不能为空");
        }
        return userService.userLogin(userLoginDto, request);
    }
}

登录注册测试

使用RestfulTool 进行接口测试:

image-20231016200612792
image-20231016200612792

测试成功:

image-20231016200709652
image-20231016200709652

查询用户:

@GetMapping("/search")
public List<User> searchUsers(String username, HttpServletRequest request) {
    if (!isAdmin(request)) {
        return new ArrayList<>();
    }
    QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
    if (StringUtils.isNotBlank(username)) {
        userQueryWrapper.like("username", username);
    }
    List<User> userList = userService.list(userQueryWrapper);
    return userList.stream().map(user -> {
        return userService.getSafetyUser(user);
    }).collect(Collectors.toList());
}

这里封装了一个用户脱敏的函数:

@Override
public User getSafetyUser(User user) {
    User safetyUser = new User();
    safetyUser.setId(user.getId());
    safetyUser.setUsername(user.getUsername());
    safetyUser.setUserAccount(user.getUserAccount());
    safetyUser.setAvatarUrl(user.getAvatarUrl());
    safetyUser.setGender(user.getGender());
    safetyUser.setPhone(user.getPhone());
    safetyUser.setEmail(user.getEmail());
    safetyUser.setUserStatus(user.getUserStatus());
    safetyUser.setUserRole(user.getUserRole());
    return safetyUser;
}

删除用户:

@PostMapping("/delete/{id}")
public boolean deleteUser(@PathVariable Long id, HttpServletRequest request) {
    if (!isAdmin(request)) {
        return false;
    }
    if (id < 0) {
        return false;
    }
    return userService.removeById(id);
}

判断是不是管理员:

private boolean isAdmin(HttpServletRequest request) {
    //仅管理员可以查询
    User user = (User) request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
    if (user == null || user.getUserRole() != UserConstant.ADMIN_USER_ROLE) {
        return false;
    }
    return true;
}

前端代码编写

修改页脚代码为自己的信息:

{
  key: 'github',
  title: <GithubOutlined />,
  href: 'https://github.com/yunfeidog',
  blankTarget: true,
},
{
  key: 'Blog',
  title: 'Blog',
  href: 'https://yunfeidog.github.io/blogv2/',
  blankTarget: true,
},

页脚如下:

image-20231017154227098
image-20231017154227098

定义一个LOGO的常量:

export const SYSTEM_LOGO = "https://s2.loli.net/2023/10/16/QRiUYmDLB2vZuE6.webp"

使用:

          logo={<img alt="logo" src={SYSTEM_LOGO} />}

删掉一些代码,保留登录页面:

image-20231017155255273
image-20231017155255273

修改前端登录参数:

  type LoginParams = {
    userAccount?: string;
    userPassword?: string;
    autoLogin?: boolean;
    type?: string;
  };

前后端交互

前端 需要向后端发送请求 ajax

axios封装了ajax

request 是ant design 项目又封装了aixos

追踪request源码,用到了umi插件,requesConfig是一个配置

代理

正向代理:替客户端向服务器发送请求

反向代理:替服务器接受请求。

Nginx服务器,nodejs服务器

原本请求:http://localhost:8000/api/user/loginopen in new window

代理之后:http://localhost:8080/user/loginopen in new window

proxy.ts文件:

dev: {
  // localhost:8000/api/** -> http://localhost:8080/api/**
  '/api/': {
    // 要代理的地址
    target: 'http://localhost:8080',
    // 配置了这个可以从 http 代理到 https
    // 依赖 origin 的功能可能需要这个,比如 cookie
    changeOrigin: true,
    //去掉 api 前缀
    pathRewrite: { '^/api': '' },
  },

成功测试登录:

image-20231017174107430
image-20231017174107430

注册页面

直接复制login页面进行修改即可:

添加一个路由:

  {
    path: '/user',
    layout: false,
    routes: [
      { name: '登录', path: '/user/login', component: './user/Login' },
      { name: '注册', path: '/user/register', component: './user/Register' },
      { component: './404' },
    ],
  },

此时访问http://localhost:8000/user/register发现会重定向到loginopen in new window

在app.tsx中,需要修改onPageChange和fetchUserInfo函数

    onPageChange: () => {
      const {location} = history;
      const whiteList = ['/user/register', loginPath]
      // 如果在白名单中,不做任何处理
      if (whiteList.includes(location.pathname)) {
        return;
      }

      // 如果没有登录,重定向到 login
      if (!initialState?.currentUser && location.pathname !== loginPath) {
        history.push(loginPath);
      }
    },

此时发现 注册按钮的字是登录 无法进行修改,因为这是procomponents里面写死的组件这里我们查看源码进行修改

image-20231017181304607
image-20231017181304607

找到登录按钮,这时就可以修改属性来 修改注册 按钮的值

image-20231017181527032
image-20231017181527032

修改如下:

image-20231017181647734
image-20231017181647734

成功:

image-20231017181710615
image-20231017181710615

注册逻辑:

  const handleSubmit = async (values: API.RegisterParams) => {
    const {userPassword, checkPassword} = values;
    if (userPassword != checkPassword) {
      message.error('两次输入的密码不一致!');
      return;
    }
    try {
      // 注册
      const id = await register(values);
      if (id > 0) {
        const defaultLoginSuccessMessage = '注册成功!';
        message.success(defaultLoginSuccessMessage);

        /** 此方法会跳转到 redirect 参数所在的位置 */
        if (!history) return;
        const {query} = history.location;
        history.push({
          pathname: '/user/login',
          query
        })
        return;
      } else {
        throw new Error(`register error id =${id}`)
      }
      // 如果失败去设置用户错误信息
    } catch (error) {
      const defaultLoginFailureMessage = '注册失败,请重试!';
      message.error(defaultLoginFailureMessage);
    }
  };

注册 成功页面:

image-20231017183406608
image-20231017183406608

获取当前用户

获取当前用户后端接口:

    @GetMapping("/current")
    public User gerCurrentUser(HttpServletRequest request) {
        User user = (User) request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
        if (user == null) {
            return null;
        }
        Long userId = user.getId();
        User user1 = userService.getById(userId);
        return userService.getSafetyUser(user1);
    }

修改前端每次刷新自动获取当前用户的逻辑:

export async function getInitialState(): Promise<{
  settings?: Partial<LayoutSettings>;
  currentUser?: API.CurrentUser;
  loading?: boolean;
  fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
  // 页面刚进入时,获取用户信息
  const fetchUserInfo = async () => {
    try {
      return await queryCurrentUser();
    } catch (error) {
      // history.push(loginPath);
    }
    return undefined;
  };
  // 如果是无需登录的页面,不执行
  if (WHITE_LIST.includes(history.location.pathname)) {
    return {
      fetchUserInfo,
      settings: defaultSettings,
    };
  }
  const currentUser = await fetchUserInfo();
  return {
    fetchUserInfo,
    currentUser,
    settings: defaultSettings,
  };
}

查询用户表格

复制一份组件,修改为UserManage,并且在Admin目录下面:

image-20231017192334416
image-20231017192334416

Ant Design Pro(Umi 框架)

app.tsx 项目全局入口文件,定义了整个项目中使用的公共数据(比如用户信息)

access.ts 控制用户的访问权限

首次访问页面(刷新页面),进入 app.tsx,执行 getInitialState 方法,该方法的返回值就是全局可用的状态值。

access.ts代码:用户判断当前用户是不是管理员

export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) {
  const { currentUser } = initialState ?? {};
  return {
    canAdmin: currentUser && currentUser.userRole === 1,
  };
}

添加一个路由,用户管理:

  {
    path: '/admin',
    name: '管理页',
    icon: 'crown',
    access: 'canAdmin',
    component: './Admin', // 该组件下的路由都会被添加到路由配置中
    routes: [
      { path: '/admin/user-manage', name: '用户管理', icon: 'smile', component: './Admin/UserManage' },
      { component: './404' },
    ],
  },

修改Admin.tsx页面,使用children来控制子页面: 里面的children就是子页面

const Admin: React.FC = (props) => {
  const {children} = props;
  return (
    <PageHeaderWrapper>
      {children}
    </PageHeaderWrapper>
  );
};
export default Admin;

ProComponents 高级表单

  1. 通过 columns 定义表格有哪些列
  2. columns 属性
    • dataIndex 对应返回数据对象的属性
    • title 表格列名
    • copyable 是否允许复制
    • ellipsis 是否允许缩略
    • valueType:用于声明这一列的类型(dateTime、select)
image-20231017194039521
image-20231017194039521

直接复制源代码进行使用

前端定义搜索用户接口:

/** 搜索用户 GET /api/search */
export async function searchUsers(options?: { [key: string]: any }) {
  return request<API.CurrentUser[]>('/api/user/search', {
    method: 'GET',
    ...(options || {}),
  });
}

查询表格完整代码如下:

import type {ActionType, ProColumns} from '@ant-design/pro-components';
import {ProTable, TableDropdown} from '@ant-design/pro-components';
import {useRef} from 'react';
import {searchUsers} from "@/services/ant-design-pro/api";
import {SYSTEM_LOGO} from "@/constant";


const columns: ProColumns<API.CurrentUser>[] = [
  {
    dataIndex: 'id',
    title: 'ID',
    width: 48,
  },
  {
    title: '用户名',
    dataIndex: 'username',
    copyable: true,
  },
  {
    title: '用户账户',
    dataIndex: 'userAccount',
    copyable: true,
  },
  {
    title: '用户头像',
    dataIndex: 'avatarUrl',
    render: (_, record) => (
      <div>
        <img src={record.avatarUrl ?? SYSTEM_LOGO} alt={"cxk"} width={100}/>
        {/*<Image src={record.avatarUrl} width={100}/>*/}
      </div>
    ),
    copyable: true,
  },
  {
    title: '性别',
    dataIndex: 'gender',
  },
  {
    title: '电话',
    dataIndex: 'phone',
    copyable: true,
  },
  {
    title: '邮件',
    dataIndex: 'email',
    copyable: true,
  },
  {
    title: '状态',
    dataIndex: 'userStatus',
  },
  {
    title: '角色',
    dataIndex: 'userRole',
    valueType: 'select',
    valueEnum: {
      0: {text: '普通用户', status: 'Default'},
      1: {text: '管理员', status: 'Success'},
    }
  },
  {
    title: '创建时间',
    dataIndex: 'createTime',
    valueType: 'date',
  },
  {
    title: '操作',
    valueType: 'option',
    key: 'option',
    render: (text, record: any, _, action) => [

      <a
        key="editable"
        onClick={() => {
          action?.startEditable?.(record.id);
        }}
      >
        编辑
      </a>,


      <a href={record.url} target="_blank" rel="noopener noreferrer" key="view">
        查看
      </a>,


      <TableDropdown
        key="actionGroup"
        onSelect={() => action?.reload()}
        menus={[
          {key: 'copy', name: '复制'},
          {key: 'delete', name: '删除'},
        ]}
      />,


    ],
  },
];

export default () => {
  const actionRef = useRef<ActionType>();
  // @ts-ignore
  return (
    <ProTable<API.CurrentUser>
      columns={columns}
      actionRef={actionRef}
      cardBordered
      request={async (params = {}, sort, filter) => {
        console.log(sort, filter);
        const userList = await searchUsers();
        return {
          data: userList
        }
      }}
      editable={{
        type: 'multiple',
      }}
      columnsState={{
        persistenceKey: 'pro-table-singe-demos',
        persistenceType: 'localStorage',
        onChange(value) {
          console.log('value: ', value);
        },
      }}
      rowKey="id"
      search={{
        labelWidth: 'auto',
      }}
      // options={{
      //   setting: {
      //     listsHeight: 400,
      //   },
      // }}
      form={{
        // 由于配置了 transform,提交的参与与定义的不同这里需要转化一下
        syncToUrl: (values, type) => {
          if (type === 'get') {
            return {
              ...values,
              created_at: [values.startTime, values.endTime],
            };
          }
          return values;
        },
      }}
      pagination={{
        pageSize: 5,
        onChange: (page) => console.log(page),
      }}
      dateFormatter="string"
      headerTitle="高级表格"
    />
  );
};

页面 效果如下:

image-20231017203631534
image-20231017203631534

Todo: bug 使用react自带的Image标签无法请求后端,但是自带的 img可以。

上次编辑于:
贡献者: yunfeidog