一、分层架构思想(最核心)
Controller(接收请求/响应)
↓ 调用 Service 接口
Service(业务逻辑)
↓ 调用 Mapper 接口
Mapper(数据库操作)
↓
MySQL
每一层只做自己的事,不越界:
| 层 | 职责 | 不做什么 |
|---|---|---|
| Controller | 接收参数、校验基础格式、调 Service、封装 Result 返回 | 不写 SQL、不处理业务逻辑 |
| Service | 业务逻辑编排、事务控制、调多个 Mapper | 不操作 HttpServletRequest/Response |
| Mapper | 单表/关联 CRUD,一句 SQL 一个方法 | 不处理业务判断 |
面试价值: "我按标准的 Controller-Service-Mapper 三层架构开发,每层职责清晰,便于团队协作和单元测试。"
二、DTO / VO / Entity 三层数据模型
各层用途
| 模型 | 全称 | 用在 | 说明 |
|---|---|---|---|
| Entity | 实体类 | 贯穿全层 | 和数据库表字段一一对应 |
| DTO | Data Transfer Object | Controller → Service | 接收前端参数,可能比 Entity 多字段(如:页码)或少字段(不暴露密码) |
| VO | View Object | Service → Controller | 返回给前端的数据,可能多表聚合、或隐藏敏感字段 |
为什么不用 Entity 走天下?
前端参数带了 page、pageSize → Entity 没有这两个字段
后端返回的数据里要隐藏 password → Entity 里有 password
DTO:只包含前端传来的参数(page, pageSize, name)
VO:只包含前端要看的字段(id, name, status),不含 password
Entity:包含完整的数据库字段
通用模式:
// ===== Entity(和数据库字段完全一致)=====
@Data
public class Employee {
private Long id;
private String username;
private String password; // 敏感字段,不返回给前端
private String name;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
// ===== DTO(接收前端参数)=====
@Data
public class EmployeeDTO {
private Long id;
private String username;
private String name;
// 没有 password、createTime 等前端不需要传的字段
}
// ===== VO(返回给前端的数据)=====
@Data
@Builder
public class EmployeeVO {
private Long id;
private String username;
private String name;
private Integer status;
// 没有 password
}
面试价值: "我用 DTO 收参、VO 返参、Entity 映射数据库,三层隔离。前端接口变化只改 DTO/VO,不改 Entity,避免牵一发动全身。"
三、统一返回结果封装
每个接口返回相同结构,前端统一解析:
@Data
public class Result<T> {
private Integer code; // 1=成功,0=失败
private String msg; // 提示信息
private T data; // 泛型数据
public static <T> Result<T> success(T data) {
return new Result<>(1, "success", data);
}
public static <T> Result<T> error(String msg) {
return new Result<>(0, msg, null);
}
}
前端统一处理:
// 响应拦截器里只需要判断 res.data.code
if (response.data.code === 1) return response.data // 成功,取 data
else ElMessage.error(response.data.msg) // 业务失败,提示
四、ThreadLocal 线程上下文(BaseContext)
解决什么问题
用户登录后 → JWT 解析出用户 id → 后续每个方法都要这个 id(谁创建的?谁修改的?)
传统做法
// 每个方法手动传参
public void save(EmployeeDTO dto, Long currentUserId) { ... }
public void update(EmployeeDTO dto, Long currentUserId) { ... }
ThreadLocal 做法
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id); // 存到当前线程
}
public static Long getCurrentId() {
return threadLocal.get(); // 当前线程取
}
public static void removeCurrentId() {
threadLocal.remove(); // 清空(防止内存泄漏)
}
}
使用流程
请求进来 → JWT 过滤器解析 token → BaseContext.setCurrentId(userId)
↓
Service 中任何时候都能 BaseContext.getCurrentId()
↓
请求结束 → 拦截器/过滤器 → BaseContext.removeCurrentId()
为什么是 ThreadLocal?
Tomcat 每处理一个请求,用一个线程。
同一个请求的 Filter → Controller → Service → Mapper 都在同一个线程里执行。
ThreadLocal 是线程隔离的 → 不同用户的数据不会串。
面试高频考点:
"ThreadLocal 的底层是 ThreadLocalMap,key 是当前 ThreadLocal 对象,value 是存的值。使用完必须 remove(),否则线程池复用线程时会发生内存泄漏。"
五、AOP + 自定义注解实现公共字段自动填充
解决什么问题
每张表都有 create_time / create_user / update_time / update_user
每个 insert/update 方法都要写这几行重复代码:
employee.setCreateTime(LocalDateTime.now());
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateTime(LocalDateTime.now());
employee.setUpdateUser(BaseContext.getCurrentId());
解决方案(AOP + 反射)
① 定义枚举(操作类型)
public enum OperationType {
INSERT, // 新增
UPDATE // 修改
}
② 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
OperationType value();
}
③ 切面实现
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {}
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
// 获取当前方法上的注解(判断是 INSERT 还是 UPDATE)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType type = autoFill.value();
// 获取方法参数(约定第一个参数是实体对象)
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) return;
Object entity = args[0];
// 准备要赋的值
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();
// 反射赋值
if (type == OperationType.INSERT) {
entity.getClass().getDeclaredMethod("setCreateTime", LocalDateTime.class).invoke(entity, now);
entity.getClass().getDeclaredMethod("setCreateUser", Long.class).invoke(entity, currentId);
entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class).invoke(entity, now);
entity.getClass().getDeclaredMethod("setUpdateUser", Long.class).invoke(entity, currentId);
} else {
entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class).invoke(entity, now);
entity.getClass().getDeclaredMethod("setUpdateUser", Long.class).invoke(entity, currentId);
}
}
}
④ Mapper 方法上使用
@AutoFill(OperationType.INSERT)
void insert(Employee employee);
@AutoFill(OperationType.UPDATE)
void update(Employee employee);
这套设计的亮点(面试可聊)
| 技术 | 作用 |
|---|---|
| 自定义注解 | 声明式标记,比配置更直观 |
| AOP 切面 | 统一拦截,不侵入业务代码 |
| 反射 | 动态赋值,不依赖具体实体类型 |
| 枚举 | 区分 INSERT / UPDATE,减少重复 |
| 约定 | 约定 Mapper 方法第一个参数是实体对象 |
面试话术:
"我通过 AOP + 自定义注解实现了公共字段自动填充。Mapper 方法上标注 @AutoFill(INSERT) 或 @AutoFill(UPDATE),切面在方法执行前通过反射给实体里的 createTime/createUser/updateTime/updateUser 自动赋值。这样新增业务模块时,只要实体类包含这些字段,不需要写一行重复代码。"
六、JWT + 登录校验拦截器
登录流程
前端 POST /admin/login { username, password }
↓
后端校验用户名密码
↓
校验通过 → 生成 JWT Token(含用户 id + 过期时间)→ 返回给前端
↓
前端存到 localStorage → 后续请求 Header 带上 Authorization: Bearer xxx
拦截器校验
@Component
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
/**
* 请求到达 Controller 之前执行
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 从请求头获取 token
String token = request.getHeader(jwtProperties.getAdminTokenName());
try {
// 解析 token
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long userId = claims.get("id", Long.class);
// 存入 ThreadLocal,后续方法可以直接获取
BaseContext.setCurrentId(userId);
return true; // 放行
} catch (Exception e) {
// token 解析失败 → 返回 401
response.setStatus(401);
return false; // 拦截
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 请求结束后清除 ThreadLocal(防止内存泄漏)
BaseContext.removeCurrentId();
}
}
注册拦截器
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**") // 拦截 /admin/ 开头的所有请求
.excludePathPatterns("/admin/employee/login"); // 登录接口不拦截
}
}
七、全局异常处理器
统一捕获 Controller 层抛出的异常,返回统一格式的错误响应:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/** 业务异常 */
@ExceptionHandler(AccountNotFoundException.class)
public Result<String> handleAccountNotFound(AccountNotFoundException ex) {
return Result.error(ex.getMessage());
}
/** SQL 唯一键冲突(如:用户名重复) */
@ExceptionHandler(DuplicateKeyException.class)
public Result<String> handleDuplicateKey() {
return Result.error("数据已存在,请勿重复添加");
}
/** 参数校验失败 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> handleValidation(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldError().getDefaultMessage();
return Result.error(msg);
}
/** 兜底:未知异常 */
@ExceptionHandler(Exception.class)
public Result<String> handleException(Exception ex) {
log.error("未知异常", ex);
return Result.error("服务器内部错误");
}
}
核心思想: Controller 里不写 try-catch,所有异常抛到全局处理器统一处理。代码更干净。
八、Redis 缓存模式(Cache-Aside)
苍穹外卖中用的是最经典的 Cache-Aside(旁路缓存) 模式:
读流程
请求过来
↓
查 Redis → 有数据 → 直接返回(缓存命中)
↓ 没有
查数据库 → 存入 Redis → 返回
// 查 Redis
String key = "dish_" + categoryId;
List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
if (list != null && list.size() > 0) {
return Result.success(list); // 缓存命中
}
// 查数据库
list = dishService.listWithFlavor(dish);
// 写入 Redis
redisTemplate.opsForValue().set(key, list);
return Result.success(list);
写流程(写数据时清缓存)
新增/修改/删除数据
↓
操作数据库
↓
清理缓存(下次读时重新加载)
// 新增菜品后,清除该分类的缓存
cleanCache("dish_" + dishDTO.getCategoryId());
// 批量删除后,清除所有菜品缓存
cleanCache("dish_*");
为什么不直接更新缓存而是清缓存?
更新缓存 → 可能需要复杂计算(比如多表关联),成本高
清缓存 → 下次读时自然重建,简单可靠
这叫 lazy loading(懒加载),是 Cache-Aside 的标准做法。
九、Spring Task 定时任务
使用步骤
// ① 启动类开启定时任务
@SpringBootApplication
@EnableScheduling // ← 开启
public class SkyApplication {
public static void main(String[] args) {
SpringApplication.run(SkyApplication.class, args);
}
}
// ② 写任务类
@Component
@Slf4j
public class OrderTask {
/**
* 每分钟执行一次:检查超时未支付的订单,自动取消
*/
@Scheduled(cron = "0 * * * * ?") // 每分钟的 0 秒触发
public void processTimeoutOrder() {
// 查询创建时间超过 15 分钟且未支付的订单
// 将其状态改为"已取消"
}
/**
* 每天凌晨 1 点:检查处于"派送中"超过一定时间的订单
*/
@Scheduled(cron = "0 0 1 * * ?") // 每天 01:00:00
public void processDeliveryOrder() {
// ...
}
}
Cron 表达式速查
格式:秒 分 时 日 月 周 [年]
常用示例:
0/5 * * * * ? 每隔 5 秒
0 */1 * * * ? 每隔 1 分钟
0 0/5 * * * ? 每隔 5 分钟
0 0 1 * * ? 每天凌晨 1 点
0 0 2 * * ? 每天凌晨 2 点
0 0 0/2 * * ? 每隔 2 小时
0 0 9-18 * * ? 每天 9 点到 18 点拨每整点
通配符:
* → 所有值
? → 不指定(日和周的冲突用 ? 解决)
- → 范围(9-18)
, → 枚举(9,12,15)
/ → 递增(0/5 表示从 0 开始每 5 单位)
十、WebSocket 实时推送
适用场景
来单提醒、客户催单 — 服务端主动推给前端,不是前端轮询。
服务端(Spring Boot)
@Component
@ServerEndpoint("/ws/{sid}") // WebSocket 端点
@Slf4j
public class WebSocketServer {
// 在线客户端集合(sid → session)
private static Map<String, Session> sessionMap = new ConcurrentHashMap<>();
/**
* 连接建立成功
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
sessionMap.put(sid, session); // 存入在线列表
}
/**
* 连接关闭
*/
@OnClose
public void onClose(@PathParam("sid") String sid) {
sessionMap.remove(sid); // 移出在线列表
}
/**
* 向指定客户端推送消息
*/
public static void sendMessage(String sid, String message) {
Session session = sessionMap.get(sid);
if (session != null && session.isOpen()) {
session.getBasicRemote().sendText(message);
}
}
/**
* 向所有在线客户端广播(如:来单提醒)
*/
public static void broadcast(String message) {
sessionMap.values().forEach(session -> {
if (session.isOpen()) {
session.getBasicRemote().sendText(message);
}
});
}
}
前端(JS)
// 建立连接
const socket = new WebSocket('ws://localhost:8080/ws/1');
// 收到消息
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'new_order') {
// 播放提示音、弹出提醒
}
};
十一、通用开发模式总结
苍穹外卖的核心就是一套 CRUD 脚手架,所有管理模块都按这个模式开发:
新增一个业务模块的步骤
① 建表(SQL)
② 生成 Entity(和表字段对应)
③ 设计 DTO(前端传什么参数)
④ 设计 VO(后端返什么数据)
⑤ 写 Mapper 接口(SQL)
⑥ 写 Service 实现(业务逻辑)
⑦ 写 Controller(接收请求 → 调用 Service → 返回 Result)
⑧ 注册拦截器路径(如果需要登录)
⑨ 写 API 文档注解(Swagger)
每个步骤可复用的东西
| 步骤 | 可复用组件 | 来自 |
|---|---|---|
| Entity 公共字段 | create_time/create_user/update_time/update_user | 每个表都有 |
| DTO/VO 分层 | DTO → Controller收参, VO → Controller返参 | 所有接口 |
| Result 封装 | Result.success(data) / Result.error(msg) | 所有接口 |
| 公共字段赋值 | @AutoFill 注解 + AOP | 所有 Mapper |
| 分页查询 | PageHelper 插件的 PageResult | 所有列表接口 |
| 登录校验 | JWT 拦截器 | 所有 /admin/ 接口 |
| 全局异常 | GlobalExceptionHandler | 所有 Controller |
| 当前用户 | BaseContext.getCurrentId() | 所有 Service |
十二、项目结构骨架
sky-take-out/
├── sky-common/ ← 公共模块(所有模块都能用)
│ ├── constant/ 常数
│ ├── context/ BaseContext
│ ├── enumeration/ 枚举
│ ├── exception/ 自定义异常
│ ├── json/ JSON 处理
│ ├── properties/ 配置属性类(读取 application.yml)
│ ├── result/ Result 统一返回
│ └── utils/ JWT 工具、阿里云 OSS 工具
│
├── sky-pojo/ ← 实体类模块
│ ├── entity/ Entity(和数据库对应)
│ ├── dto/ DTO(接收请求参数)
│ └── vo/ VO(返回响应数据)
│
├── sky-server/ ← 服务端模块(主要写代码的地方)
│ ├── config/ 配置类(拦截器注册、WebSocket 配置、Redis 配置)
│ ├── controller/ Controller 层
│ │ ├── admin/ 管理端接口
│ │ └── user/ 用户端接口
│ ├── service/ Service 层
│ │ ├── impl/ 实现类
│ ├── mapper/ Mapper 层(MyBatis)
│ ├── annotation/ 自定义注解(如 @AutoFill)
│ ├── aspect/ AOP 切面类
│ ├── interceptor/ 拦截器(JWT 校验)
│ └── task/ 定时任务类
每个模块只做一件事,依赖方向:
sky-server → sky-pojo → sky-common
↓
主要依赖 common 里的 Result、BaseContext、utils