后台系统登录功能与通用返回结果类设计
原型页面
| 外卖平台 LOGO | 交互说明 1、密码登录(账号、密码不能为空) 1. 若为空提示:不能为空 1.2 正确:登录成功,进入系统 错误:提示语3秒,输入有误请重试,还剩4次! 1.3 限制输入1-20字符 |
| 外卖管理后台 | 【 门店管理 】 |
用户名:[ 请输入账号 ] |
|
密 码:[ 请输入密码 ] |
|
| 【 登 录 】 |
纲要
本篇实现外卖平台项目的第一个业务功能 ------ 管理后台登录,并统一全项目的服务端响应格式。
- 需求分析 :从
login.html的Vue代码反推接口契约 - 接口契约 :
POST /employee/login,入参{username, password},出参R<Employee> - 三层搭建 :
EmployeeMapper→EmployeeService/EmployeeServiceImpl→EmployeeController - 通用返回结果类
R<T>:code/msg/data/map四个属性 +success/error/add三个静态方法 - 登录六步逻辑 :
MD5加密 → 按用户名查库 → 判空 → 比对密码 → 判状态 → 写Session - 技术要点 :
@RequestBody接收JSON、LambdaQueryWrapper条件构造、DigestUtils.md5DigestAsHex加密、getOne依赖唯一索引
需求分析:从前端代码反推接口契约
后端的接口不是凭空设计的,而是被前端页面「逼」出来的。所以第一步应当是打开登录页,用 F12 调试工具看清楚它到底发了什么请求、期望什么响应。
观察请求
在 backend/page/login.html 输入用户名 admin、密码 123456 并点击登录,控制台显示请求:
text
POST http://localhost:8080/employee/login
Request Payload: {"username":"admin","password":"123456"}
Response: 404
此时后端还没写任何处理器,所以是 404。关键信息已经拿到三条:
| 维度 | 值 |
|---|---|
| 请求方法 | POST |
| 请求路径 | /employee/login |
| 请求体 | JSON 格式的 username 与 password |
阅读前端源码确认细节
登录页的核心 Vue 代码:
javascript
methods: {
async handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (valid) {
this.loading = true
// 调用 loginApi 发送请求
let res = await loginApi(this.loginForm)
if (String(res.code) === '1') {
// 登录成功:将用户信息转为 JSON 存入浏览器 localStorage
localStorage.setItem('userInfo', JSON.stringify(res.data))
// 跳转到后台首页
window.location.href = '/backend/page/index.html'
} else {
// 登录失败:提示错误信息
this.$message.error(res.msg)
this.loading = false
}
}
})
}
}
loginApi 定义在 backend/api/login.js:
javascript
function loginApi(data) {
return $axios({
'url': '/employee/login',
'method': 'post',
data
})
}
由此可以确定响应体必须包含三个字段:
| 字段 | 前端用法 | 含义 |
|---|---|---|
code |
res.code === '1' 判断成功 |
1 成功,0 及其他为失败 |
data |
JSON.stringify(res.data) 存入浏览器 |
主体数据,登录场景下是员工对象 |
msg |
res.msg 作为错误提示 |
失败时的错误信息 |
这三条约定直接决定了后端 R 类的属性设计 ------ R 类不是抽象设计的产物,而是前后端契约的直译。
登录流程
MySQL employee EmployeeMapper EmployeeService EmployeeController 浏览器 login.html MySQL employee EmployeeMapper EmployeeService EmployeeController 浏览器 login.html #mermaid-svg-Slwebt4uzUU6Mc1w{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Slwebt4uzUU6Mc1w .error-icon{fill:#552222;}#mermaid-svg-Slwebt4uzUU6Mc1w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Slwebt4uzUU6Mc1w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Slwebt4uzUU6Mc1w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Slwebt4uzUU6Mc1w .marker.cross{stroke:#333333;}#mermaid-svg-Slwebt4uzUU6Mc1w svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Slwebt4uzUU6Mc1w p{margin:0;}#mermaid-svg-Slwebt4uzUU6Mc1w .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Slwebt4uzUU6Mc1w text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Slwebt4uzUU6Mc1w .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Slwebt4uzUU6Mc1w .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Slwebt4uzUU6Mc1w #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Slwebt4uzUU6Mc1w .sequenceNumber{fill:white;}#mermaid-svg-Slwebt4uzUU6Mc1w #sequencenumber{fill:#333;}#mermaid-svg-Slwebt4uzUU6Mc1w #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Slwebt4uzUU6Mc1w .messageText{fill:#333;stroke:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Slwebt4uzUU6Mc1w .labelText,#mermaid-svg-Slwebt4uzUU6Mc1w .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .loopText,#mermaid-svg-Slwebt4uzUU6Mc1w .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Slwebt4uzUU6Mc1w .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Slwebt4uzUU6Mc1w .noteText,#mermaid-svg-Slwebt4uzUU6Mc1w .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Slwebt4uzUU6Mc1w .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Slwebt4uzUU6Mc1w .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Slwebt4uzUU6Mc1w .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Slwebt4uzUU6Mc1w .actorPopupMenu{position:absolute;}#mermaid-svg-Slwebt4uzUU6Mc1w .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Slwebt4uzUU6Mc1w .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Slwebt4uzUU6Mc1w .actor-man circle,#mermaid-svg-Slwebt4uzUU6Mc1w line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Slwebt4uzUU6Mc1w :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 未查到 密码不一致 账号已禁用 全部通过 POST /employee/login {username, password} 1. MD5 加密明文密码 2. getOne(按 username 查询) selectOne SELECT * FROM employee WHERE username = ? 员工记录 Employee Employee R.error("登录失败") R.error("登录失败") R.error("账号已禁用") 6. session.setAttribute("employee", id) R.success(employee)
通用返回结果类 R<T>
为什么需要它
项目后续会有几十个 Controller 方法,如果各自返回 Map、String、实体对象,前端将无法用统一的方式判断成功与失败。统一封装成 R<T> 之后,前端永远只看 code,再取 data,最后才看 msg。
实现
src/main/java/com/itheima/reggie/common/R.java:
java
package com.itheima.reggie.common;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
* 通用返回结果,服务端响应的数据最终都会封装成此对象
* @param <T>
*/
@Data
public class R<T> {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
设计要点
| 要点 | 说明 |
|---|---|
泛型 <T> |
data 的类型由调用方决定:登录返回 Employee,分页返回 Page,删除返回 String |
| 静态工厂 | R.success(x) / R.error(msg) 替代 new R() + 手工 setter,避免忘记设置 code |
code 语义 |
1 成功,0 失败。注意不是 HTTP 状态码 ,业务失败时 HTTP 仍是 200 |
msg 仅失败时赋值 |
成功响应中 msg 为 null,前端不需要展示 |
map 动态数据 |
用于少数需要额外返回键值对的场景,如分页之外的补充统计 |
add 方法返回 this,支持链式调用:
java
return R.success(pageInfo).add("total", 100).add("extra", extraData);
三层结构搭建
Employee 实体
src/main/java/com/itheima/reggie/entity/Employee.java:
java
package com.itheima.reggie.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 员工实体,对应 employee 表
*/
@Data
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String name;
private String password;
private String phone;
private String sex;
/** 身份证号码,对应表字段 id_number */
private String idNumber;
/** 状态:1 正常,0 禁用 */
private Integer status;
/** 插入时自动填充 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 插入和更新时均自动填充 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
注意 idNumber 与表字段 id_number 的差异正是靠 map-underscore-to-camel-case: true 自动桥接的,实体里不需要写 @TableField("id_number")。
Serializable 与 serialVersionUID:R<Employee> 作为 JSON 返回时不需要序列化到字节流,但实体实现 Serializable 是持久层框架的通用约定,MyBatis-Plus 的二级缓存等场景会用到。
Mapper 层
src/main/java/com/itheima/reggie/mapper/EmployeeMapper.java:
java
package com.itheima.reggie.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.Employee;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
继承 BaseMapper<Employee> 后直接获得全部单表 CRUD 方法:
| 方法 | 作用 |
|---|---|
insert(T) |
插入一条记录 |
deleteById(Serializable) |
按主键删除 |
updateById(T) |
按主键更新(null 字段不更新) |
selectById(Serializable) |
按主键查询 |
selectList(Wrapper) |
条件查询列表 |
selectOne(Wrapper) |
条件查询单条 |
selectPage(Page, Wrapper) |
分页查询 |
这就是为什么 Mapper 接口里一个方法都不用写。
Service 层
src/main/java/com/itheima/reggie/service/EmployeeService.java:
java
package com.itheima.reggie.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.entity.Employee;
public interface EmployeeService extends IService<Employee> {
}
src/main/java/com/itheima/reggie/service/impl/EmployeeServiceImpl.java:
java
package com.itheima.reggie.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.mapper.EmployeeMapper;
import com.itheima.reggie.service.EmployeeService;
import org.springframework.stereotype.Service;
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}
ServiceImpl<M, T> 需要两个泛型:M 是 Mapper 类型,T 是实体类型。继承后 IService 的方法(getOne、page、save、updateById 等)全部有了默认实现。
Controller 层
java
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
}
@RestController = @Controller + @ResponseBody,表示所有方法返回值直接写 HTTP 响应体(经 Jackson 序列化为 JSON),不做视图跳转。
@RequestMapping("/employee") 对应前端请求路径的第一段,与 login.js 中的 /employee/login 一致。
登录方法实现
六步处理逻辑
#mermaid-svg-dCm3pTsXHn5Pltdp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dCm3pTsXHn5Pltdp .error-icon{fill:#552222;}#mermaid-svg-dCm3pTsXHn5Pltdp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dCm3pTsXHn5Pltdp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dCm3pTsXHn5Pltdp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dCm3pTsXHn5Pltdp .marker.cross{stroke:#333333;}#mermaid-svg-dCm3pTsXHn5Pltdp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dCm3pTsXHn5Pltdp p{margin:0;}#mermaid-svg-dCm3pTsXHn5Pltdp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp .cluster-label text{fill:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp .cluster-label span{color:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp .cluster-label span p{background-color:transparent;}#mermaid-svg-dCm3pTsXHn5Pltdp .label text,#mermaid-svg-dCm3pTsXHn5Pltdp span{fill:#333;color:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp .node rect,#mermaid-svg-dCm3pTsXHn5Pltdp .node circle,#mermaid-svg-dCm3pTsXHn5Pltdp .node ellipse,#mermaid-svg-dCm3pTsXHn5Pltdp .node polygon,#mermaid-svg-dCm3pTsXHn5Pltdp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dCm3pTsXHn5Pltdp .rough-node .label text,#mermaid-svg-dCm3pTsXHn5Pltdp .node .label text,#mermaid-svg-dCm3pTsXHn5Pltdp .image-shape .label,#mermaid-svg-dCm3pTsXHn5Pltdp .icon-shape .label{text-anchor:middle;}#mermaid-svg-dCm3pTsXHn5Pltdp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dCm3pTsXHn5Pltdp .rough-node .label,#mermaid-svg-dCm3pTsXHn5Pltdp .node .label,#mermaid-svg-dCm3pTsXHn5Pltdp .image-shape .label,#mermaid-svg-dCm3pTsXHn5Pltdp .icon-shape .label{text-align:center;}#mermaid-svg-dCm3pTsXHn5Pltdp .node.clickable{cursor:pointer;}#mermaid-svg-dCm3pTsXHn5Pltdp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dCm3pTsXHn5Pltdp .arrowheadPath{fill:#333333;}#mermaid-svg-dCm3pTsXHn5Pltdp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dCm3pTsXHn5Pltdp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dCm3pTsXHn5Pltdp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dCm3pTsXHn5Pltdp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dCm3pTsXHn5Pltdp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dCm3pTsXHn5Pltdp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dCm3pTsXHn5Pltdp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dCm3pTsXHn5Pltdp .cluster text{fill:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp .cluster span{color:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dCm3pTsXHn5Pltdp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dCm3pTsXHn5Pltdp rect.text{fill:none;stroke-width:0;}#mermaid-svg-dCm3pTsXHn5Pltdp .icon-shape,#mermaid-svg-dCm3pTsXHn5Pltdp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dCm3pTsXHn5Pltdp .icon-shape p,#mermaid-svg-dCm3pTsXHn5Pltdp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dCm3pTsXHn5Pltdp .icon-shape .label rect,#mermaid-svg-dCm3pTsXHn5Pltdp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dCm3pTsXHn5Pltdp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dCm3pTsXHn5Pltdp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dCm3pTsXHn5Pltdp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
是
否
开始:接收 Employee 对象
- 对 password 做 MD5 加密
- 按 username 查数据库 getOne
- 查询结果为空?
return R.error('登录失败') - 数据库密码与加密后密码一致?
return R.error('登录失败') - status == 0 ?
return R.error('账号已禁用') - session 存入员工 id,return R.success(emp)
为什么是六步而不是「查到即成功」
朴素登录逻辑只有「查用户名密码能否匹配」一步,但真实业务必须覆盖:
- 密码不能明文传输与存储:前端传明文,后端加密后比对;库里存的也是密文
- 用户名不存在与密码错误要区分对待(本项目统一返回「登录失败」以避免账号枚举,实际生产可按需区分)
- 账号状态检查 :
status = 0的员工即使密码正确也不允许登录,这是「禁用员工账号」功能的前提
完整代码
src/main/java/com/itheima/reggie/controller/EmployeeController.java:
java
package com.itheima.reggie.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.service.EmployeeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
/**
* 员工登录
* @param request 用于获取 Session
* @param employee 接收页面提交的用户名与密码
* @return 通用返回结果,成功时携带员工信息
*/
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
// 1、将页面提交的密码 password 进行 md5 加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2、根据页面提交的用户名 username 查询数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername, employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);
// 3、如果没有查询到则返回登录失败结果
if (emp == null) {
return R.error("登录失败");
}
// 4、密码比对,如果不一致则返回登录失败结果
if (!emp.getPassword().equals(password)) {
return R.error("登录失败");
}
// 5、查看员工状态,如果为已禁用状态,则返回员工已禁用结果
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
// 6、登录成功,将员工 id 存入 Session 并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
}
逐段解析
@RequestBody :前端 Content-Type 为 application/json,请求体是 {"username":"admin","password":"123456"}。@RequestBody 告诉 Spring MVC 用消息转换器把 JSON 反序列化为 Employee 对象。
反序列化成功的前提是**JSON 的 key 与实体属性名一致**。前端传 username,实体属性是 username,匹配;若前端传 userName,则 username 会为 null。
DigestUtils.md5DigestAsHex :Spring 提供的工具类,把字节数组做 MD5 摘要并转为十六进制字符串。数据库初始化数据中 admin 的密码字段是 e10adc3949ba59abbe56e057f20f883e,正是 123456 的 MD5。
LambdaQueryWrapper :MyBatis-Plus 的条件构造器。Lambda 写法用方法引用 Employee::getUsername 代替字符串 "username",好处是编译期可检查、重构安全:
java
// 推荐:Lambda 写法,属性重命名时编译报错
queryWrapper.eq(Employee::getUsername, employee.getUsername());
// 不推荐:字符串写法,属性重命名时静默失效
queryWrapper.eq("username", employee.getUsername());
getOne 为什么安全 :employee 表的 username 上有唯一索引 idx_username,因此最多只有一条匹配记录。getOne 在查到多条时会抛 TooManyResultsException ------ 而唯一索引从数据库层面杜绝了这种情况。若字段没有唯一约束,应改用 list() 取第一条。
HttpServletRequest 的作用 :登录成功后要把员工 ID 写入 Session,以便后续请求识别当前登录人。Session 只能从 request 获取。
emp.getStatus() == 0 :status 是 Integer,与 int 字面量 0 比较时会自动拆箱,所以 == 比较的是数值而非引用。这是安全的写法;但如果写成 emp.getStatus().equals(0),则 status 为 null 时会 NPE。
完整可运行代码
目录结构
text
src/main/java/com/itheima/reggie/
├── ReggieApplication.java
├── common/
│ └── R.java # 通用返回结果类
├── config/
│ └── WebMvcConfig.java # 静态资源映射 + 消息转换器
├── controller/
│ └── EmployeeController.java # 员工控制器(登录、退出、增删改查)
├── entity/
│ └── Employee.java # 员工实体
├── mapper/
│ └── EmployeeMapper.java # 员工 Mapper
└── service/
├── EmployeeService.java
└── impl/
└── EmployeeServiceImpl.java
R.java
java
package com.itheima.reggie.common;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
* 通用返回结果,服务端响应的数据最终都会封装成此对象
* @param <T>
*/
@Data
public class R<T> {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
Employee.java
java
package com.itheima.reggie.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 员工实体
*/
@Data
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String name;
private String password;
private String phone;
private String sex;
private String idNumber;//身份证号码
private Integer status;
@TableField(fill = FieldFill.INSERT) //插入时填充字段
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) //插入和更新时填充字段
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT) //插入时填充字段
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE) //插入和更新时填充字段
private Long updateUser;
}
EmployeeMapper.java
java
package com.itheima.reggie.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.Employee;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
EmployeeService.java
java
package com.itheima.reggie.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.entity.Employee;
public interface EmployeeService extends IService<Employee> {
}
EmployeeServiceImpl.java
java
package com.itheima.reggie.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.mapper.EmployeeMapper;
import com.itheima.reggie.service.EmployeeService;
import org.springframework.stereotype.Service;
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}
EmployeeController.java(登录部分)
java
package com.itheima.reggie.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Employee;
import com.itheima.reggie.service.EmployeeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
/**
* 员工登录
* @param request
* @param employee
* @return
*/
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
//1、将页面提交的密码password进行md5加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
//2、根据页面提交的用户名username查询数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername, employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);
//3、如果没有查询到则返回登录失败结果
if (emp == null) {
return R.error("登录失败");
}
//4、密码比对,如果不一致则返回登录失败结果
if (!emp.getPassword().equals(password)) {
return R.error("登录失败");
}
//5、查看员工状态,如果为已禁用状态,则返回员工已禁用结果
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
//6、登录成功,将员工id存入Session并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
}
功能测试
测试数据
employee 表初始化数据中已有一条管理员记录:
username |
明文密码 | 库中密码(MD5) |
status |
|---|---|---|---|
admin |
123456 |
e10adc3949ba59abbe56e057f20f883e |
1 |
测试用例
| 场景 | 输入 | 预期响应 |
|---|---|---|
| 正常登录 | admin / 123456 |
{"code":1,"data":{...},"msg":null} |
| 用户名不存在 | notexist / 123456 |
{"code":0,"msg":"登录失败","data":null} |
| 密码错误 | admin / wrongpwd |
{"code":0,"msg":"登录失败","data":null} |
| 账号禁用 | 将 status 改为 0 后登录 |
{"code":0,"msg":"账号已禁用","data":null} |
验证 Session 写入
登录成功后,浏览器 Application → Local Storage 中应能看到 userInfo 键,值为员工对象的 JSON 字符串。注意其中 id 是字符串 而非数字 ------ 这正是第 20 篇配置的 JacksonObjectMapper 生效的证明。
API 速览
API / 注解 |
所属 | 作用 |
|---|---|---|
@RestController |
Spring MVC |
组合 @Controller 与 @ResponseBody |
@RequestMapping |
Spring MVC |
类级别的 URL 前缀映射 |
@PostMapping |
Spring MVC |
限定 POST 方法的方法级映射 |
@RequestBody |
Spring MVC |
将 JSON 请求体反序列化为 Java 对象 |
@Autowired |
Spring |
按类型注入依赖 |
HttpServletRequest.getSession() |
Servlet |
获取当前请求的会话 |
HttpSession.setAttribute |
Servlet |
向会话写入属性 |
DigestUtils.md5DigestAsHex(byte[]) |
Spring Core |
MD5 摘要并转十六进制字符串 |
LambdaQueryWrapper<T> |
MyBatis-Plus |
Lambda 风格条件构造器 |
Wrapper.eq(SFunction, Object) |
MyBatis-Plus |
等值条件 |
IService.getOne(Wrapper) |
MyBatis-Plus |
按条件查询单条 |
BaseMapper<T> |
MyBatis-Plus |
单表 CRUD 基础接口 |
IService<T> |
MyBatis-Plus |
业务层基础接口 |
ServiceImpl<M, T> |
MyBatis-Plus |
业务层基础实现 |
@Data |
Lombok |
生成 getter/setter/equals/hashCode/toString |
官方文档
Spring MVC@RequestBody:Spring Framework 5.3.x 官方文档 - RequestBodySpringDigestUtils:Spring Framework 5.3.x Javadoc - DigestUtilsMyBatis-Plus条件构造器:MyBatis-Plus 官方文档 - 条件构造器MyBatis-PlusCRUD接口:MyBatis-Plus 官方文档 - CRUD 接口ServletHttpSession:Jakarta Servlet 4.0 API - HttpSession
总结
本篇完整实现了外卖平台管理后台的登录功能 ,并同步搭建了贯穿全项目的通用返回结果类 R<T>,核心收获如下:
- 接口契约由前端反推 :通过
F12观察login.html的请求与响应,确定POST /employee/login的入参{username, password}与出参{code, msg, data},后端设计完全对齐前端约定。 - 三层架构落地 :
EmployeeMapper(BaseMapper)→EmployeeService/EmployeeServiceImpl(IService/ServiceImpl)→EmployeeController(@RestController),职责清晰、开箱即用。 - 登录六步逻辑 :
MD5加密 → 按用户名查库 → 判空 → 比对密码 → 判状态 → 写Session,覆盖了明文加密、账号枚举防护、禁用账号拦截等真实业务诉求。 - 关键技术点 :
@RequestBody接收JSON、LambdaQueryWrapper编译期安全的条件构造、DigestUtils.md5DigestAsHex加密、getOne依赖唯一索引保证安全。 - 统一响应格式 :
R<T>的code/msg/data/map四属性 +success/error/add静态工厂,让前端永远只看code即可判断成败,为后续几十个接口打下统一基础。
至此,登录链路已跑通,下一步可基于 Session 中的员工 id 继续实现员工管理(增删改查、分页、状态禁用)等核心业务。