外卖项目笔记总结上 (后端板块)

一、分层架构思想(最核心)

复制代码
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
相关推荐
前端不太难2 小时前
当 AI 接管 Workspace:鸿蒙 PC Agent 架构设计实践
人工智能·状态模式·harmonyos
Maimai108081 天前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·javascript·react.js·前端框架·web3·状态模式
不吃青椒!2 天前
LangGraph 流式事件处理:从实战到体系
ai·langchain·状态模式
前端不太难2 天前
鸿蒙游戏世界模型:实现原理 + Demo实现
游戏·状态模式·harmonyos
星恒随风2 天前
C++ 类和对象入门(六):友元、内部类、匿名对象和编译器优化
开发语言·c++·笔记·学习·状态模式
Curvatureflight2 天前
大数据量 Excel 导出怎么优化?一套可落地的异步化方案
java·后端·excel·状态模式
星恒随风3 天前
C++ 类和对象入门(五):初始化列表、explicit 和 static 成员详解
开发语言·c++·笔记·学习·状态模式
码农飞哥3 天前
Spring Boot 多角色权限隔离实战:接口层+路由层+UI层三层防御,杜绝生产数据泄露
spring boot·状态模式·架构设计·系统设计·权限控制
147API3 天前
Claude Fable 5 接入拆解:从 Messages API 到 fallback 要改哪些地方
状态模式·claude·fable5