【苍穹外卖】万字复盘笔记

苍穹外卖项目复盘


一、项目概述

苍穹外卖是一个典型的前后端分离项目,整体分为两端:

  • 管理端:餐厅后台,负责员工管理、菜品管理、套餐管理、订单处理、数据统计等

  • 用户端:微信小程序,负责浏览商品、下单、支付、查看订单、催单等

项目整体采用分层架构:


Controller -> Service -> Mapper -> MySQL / Redis

项目模块结构如下:

  • sky-common:公共工具类、常量、上下文、统一返回结果

  • sky-pojo:实体类 Entity、参数类 DTO、返回类 VO

  • sky-server:核心业务模块,包含 Controller、Service、Mapper、配置类、切面、拦截器等


二、技术栈全景图

| 技术 | 在项目中的作用 |

|------|----------------|

| Spring Boot | 快速搭建项目骨架,简化配置 |

| Spring MVC | 处理 HTTP 请求,构建 RESTful 接口 |

| MyBatis | 持久层框架,负责数据库访问 |

| PageHelper | 分页插件,简化分页查询 |

| MySQL | 存储用户、员工、订单、菜品、套餐等业务数据 |

| Redis | 缓存店铺状态、套餐列表等热点数据 |

| Spring Cache | 用注解方式操作缓存 |

| JWT | 无状态登录认证 |

| WebSocket | 来单提醒、催单消息推送 |

| Spring Task | 定时处理超时订单、归档订单 |

| Apache POI | 导出运营报表 Excel |

| 阿里云 OSS | 存储图片等静态资源 |

| Swagger / Knife4j | 自动生成接口文档 |


三、启动类与核心开关

在这个项目里,Spring Boot 启动类不仅仅负责启动,还打开了事务、缓存、定时任务等关键能力。

核心源码


package com.sky;

  

import lombok.extern.slf4j.Slf4j;

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.scheduling.annotation.EnableScheduling;

import org.springframework.transaction.annotation.EnableTransactionManagement;

  

@SpringBootApplication

@EnableTransactionManagement

@EnableCaching

@EnableScheduling

@Slf4j

public class SkyApplication {

    public static void main(String[] args) {

        SpringApplication.run(SkyApplication.class, args);

        log.info("server started");

    }

}

最值得记住的点

  • @SpringBootApplication:项目主启动入口,包含组件扫描和自动配置

  • @EnableTransactionManagement:开启注解事务

  • @EnableCaching:开启 Spring Cache

  • @EnableScheduling:开启定时任务

这类注解为什么加了才生效,本质上就是开启了 Spring 对这些功能的自动代理和基础设施注册。


四、技术栈与源码映射

1. IOC / DI:Bean 由 Spring 容器管理

项目里最常见的写法就是 @Autowired 注入依赖,例如:


@Service

@Slf4j

public class ReportServiceImpl implements ReportService {

  

    @Autowired

    private OrderMapper orderMapper;

  

    @Autowired

    private UserMapper userMapper;

  

    @Autowired

    private WorkspaceService workspaceService;

}

核心理解
  • 对象不再由我们手动 new

  • 而是交给 Spring 容器统一创建和维护

  • Service 依赖 Mapper,Mapper 依赖数据源,Spring 负责注入这些依赖

面试回答模板

IOC 是控制反转,指对象的创建和生命周期管理交给 Spring 容器;DI 是依赖注入,是 IOC 的实现方式。项目里通过 @Component@Service@Mapper 等注解把对象注册为 Bean,再通过 @Autowired 把依赖注入进来,从而降低耦合。


2. AOP:公共字段自动填充

场景

很多表都有这几个字段:

  • createTime

  • updateTime

  • createUser

  • updateUser

如果每次都手动 set,会非常繁琐,也容易漏写。

自定义注解

package com.sky.annotation;

  

import com.sky.enumeration.OperationType;

import java.lang.annotation.ElementType;

import java.lang.annotation.Retention;

import java.lang.annotation.RetentionPolicy;

import java.lang.annotation.Target;

  

@Target(ElementType.METHOD)

@Retention(RetentionPolicy.RUNTIME)

public @interface AutoFill {

    OperationType value();

}

切面实现

package com.sky.aspect;

  

import com.sky.annotation.AutoFill;

import com.sky.constant.AutoFillConstant;

import com.sky.context.BaseContext;

import com.sky.enumeration.OperationType;

import lombok.extern.slf4j.Slf4j;

import org.aspectj.lang.JoinPoint;

import org.aspectj.lang.annotation.Aspect;

import org.aspectj.lang.annotation.Before;

import org.aspectj.lang.annotation.Pointcut;

import org.aspectj.lang.reflect.MethodSignature;

import org.springframework.stereotype.Component;

  

import java.lang.reflect.Method;

import java.time.LocalDateTime;

  

@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){

        MethodSignature signature = (MethodSignature) joinPoint.getSignature();

        AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);

        OperationType operationType = 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();

  

        try {

            if(operationType == OperationType.INSERT){

                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);

                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);

                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);

                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

  

                setCreateTime.invoke(entity, now);

                setUpdateTime.invoke(entity, now);

                setCreateUser.invoke(entity, currentId);

                setUpdateUser.invoke(entity, currentId);

            } else if(operationType == OperationType.UPDATE){

                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);

                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

  

                setUpdateTime.invoke(entity, now);

                setUpdateUser.invoke(entity, currentId);

            }

        } catch (Exception e) {

            e.printStackTrace();

        }

    }

}

这里最值得记住的点
  • AOP 解决的是横切逻辑复用问题

  • @AutoFill 标注“哪些方法要自动填充”

  • 切面负责在方法执行前统一处理

  • 底层用到了反射,动态调用 setter 方法

面试可展开
  • AOP 的底层实现是动态代理

  • Spring AOP 常见是 JDK 动态代理和 CGLIB

  • 这里的反射不是代理本身,而是切面里为了动态调用方法而使用


3. MyBatis:Mapper + XML + 动态 SQL

苍穹外卖里大量数据库操作都是通过 MyBatis 完成的,最典型的就是 Mapper 接口配合 Mapper.xml

订单 Mapper 示例

<mapper namespace="com.sky.mapper.OrderMapper">

  

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">

        insert into orders (

            number, status, user_id, address_book_id, order_time, checkout_time,

            pay_method, pay_status, amount, remark, user_name, phone, address,

            consignee, estimated_delivery_time, delivery_time, delivery_status,

            tableware_number, tableware_status, pack_amount

        )

        values (

            #{number}, #{status}, #{userId}, #{addressBookId}, #{orderTime}, #{checkoutTime},

            #{payMethod}, #{payStatus}, #{amount}, #{remark}, #{userName}, #{phone}, #{address},

            #{consignee}, #{estimatedDeliveryTime}, #{deliveryTime}, #{deliveryStatus},

            #{tablewareNumber}, #{tablewareStatus}, #{packAmount}

        )

    </insert>

  

    <update id="update" parameterType="com.sky.entity.Orders">

        update orders

        <set>

            <if test="cancelReason != null and cancelReason!=''">cancel_reason=#{cancelReason},</if>

            <if test="rejectionReason != null and rejectionReason!=''">rejection_reason=#{rejectionReason},</if>

            <if test="cancelTime != null">cancel_time=#{cancelTime},</if>

            <if test="payStatus != null">pay_status=#{payStatus},</if>

            <if test="payMethod != null">pay_method=#{payMethod},</if>

            <if test="checkoutTime != null">checkout_time=#{checkoutTime},</if>

            <if test="status != null">status = #{status},</if>

            <if test="deliveryTime != null">delivery_time = #{deliveryTime}</if>

        </set>

        where id = #{id}

    </update>

  

</mapper>

核心理解
  • Mapper 接口负责定义方法

  • XML 负责写 SQL

  • <if><where><set><foreach> 用于动态拼装 SQL

  • #{} 是预编译参数,占位符安全

面试高频点
  • #{}${} 的区别

  • MyBatis 为什么 Mapper 接口不需要自己写实现类

  • MyBatis 执行流程:Mapper 代理 -> MappedStatement -> Executor -> ResultSet 映射


4. JWT + 拦截器 + ThreadLocal:登录态透传

这个项目里 JWT 登录态校验是通过拦截器实现的。

JWT 管理端拦截器

package com.sky.interceptor;

  

import com.sky.constant.JwtClaimsConstant;

import com.sky.context.BaseContext;

import com.sky.properties.JwtProperties;

import com.sky.utils.JwtUtil;

import io.jsonwebtoken.Claims;

import lombok.extern.slf4j.Slf4j;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Component;

import org.springframework.web.method.HandlerMethod;

import org.springframework.web.servlet.HandlerInterceptor;

  

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

  

@Component

@Slf4j

public class JwtTokenAdminInterceptor implements HandlerInterceptor {

  

    @Autowired

    private JwtProperties jwtProperties;

  

    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod)) {

            return true;

        }

  

        String token = request.getHeader(jwtProperties.getAdminTokenName());

  

        try {

            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);

            Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());

            BaseContext.setCurrentId(empId);

            return true;

        } catch (Exception ex) {

            response.setStatus(401);

            return false;

        }

    }

}

ThreadLocal 上下文

package com.sky.context;

  

public class BaseContext {

  

    public 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();

    }

}

MVC 配置注册拦截器

@Configuration

@Slf4j

public class WebMvcConfiguration extends WebMvcConfigurationSupport {

  

    @Autowired

    private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;

  

    @Autowired

    private JwtTokenUserInterceptor jwtTokenUserInterceptor;

  

    protected void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(jwtTokenAdminInterceptor)

                .addPathPatterns("/admin/**")

                .excludePathPatterns("/admin/employee/login");

  

        registry.addInterceptor(jwtTokenUserInterceptor)

                .addPathPatterns("/user/**")

                .excludePathPatterns("/user/user/login")

                .excludePathPatterns("/user/shop/status");

    }

}

这里值得记住的点
  • JWT 用于无状态认证

  • 登录成功后,前端携带 token 访问接口

  • 拦截器统一解析 token

  • 解析出的用户 id / 员工 id 放入 BaseContext

  • 后续 Service 层可以随时通过 BaseContext.getCurrentId() 获取当前登录人


5. 事务:订单提交为什么必须加 @Transactional

订单提交核心代码

@Transactional

public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {

    AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());

    if (addressBook == null) {

        throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);

    }

  

    Long userId = BaseContext.getCurrentId();

    ShoppingCart shoppingCart = ShoppingCart.builder()

            .userId(userId)

            .build();

    List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);

    if (shoppingCartList == null || shoppingCartList.size() == 0) {

        throw new AddressBookBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);

    }

  

    Orders orders = new Orders();

    BeanUtils.copyProperties(ordersSubmitDTO, orders);

    orders.setUserId(userId);

    orders.setNumber(String.valueOf(System.currentTimeMillis()));

    orders.setOrderTime(LocalDateTime.now());

    orders.setStatus(Orders.PENDING_PAYMENT);

    orders.setConsignee(addressBook.getConsignee());

    orders.setPhone(addressBook.getPhone());

    orders.setPayStatus(Orders.PENDING_PAYMENT);

    orderMapper.insert(orders);

  

    List<OrderDetail> orderDetailList = new ArrayList<>();

    for (ShoppingCart cart : shoppingCartList) {

        OrderDetail orderDetail = new OrderDetail();

        BeanUtils.copyProperties(cart, orderDetail);

        orderDetail.setOrderId(orders.getId());

        orderDetailList.add(orderDetail);

    }

    orderDetailMapper.insertBatch(orderDetailList);

  

    shoppingCartMapper.deleteByUserId(userId);

  

    return OrderSubmitVO.builder()

            .id(orders.getId())

            .orderNumber(orders.getNumber())

            .orderAmount(orders.getAmount())

            .orderTime(orders.getOrderTime())

            .build();

}

为什么必须加事务

因为这里至少包含 3 步:

  1. 插入订单主表

  2. 插入订单明细表

  3. 清空购物车

如果中间某一步失败,而没有事务,就会导致数据不一致。

面试高频点
  • 为什么事务建议加在 Service 层

  • 为什么同类内部调用事务可能失效

  • Spring 事务底层是 AOP 代理,不是数据库自动帮你做的


6. Redis + Spring Cache:缓存套餐列表

苍穹外卖里套餐列表查询是典型的高频读场景,所以用缓存做优化。

Redis 配置

@Configuration

@Slf4j

public class RedisConfiguration {

  

    @Bean

    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {

        RedisTemplate redisTemplate = new RedisTemplate();

        redisTemplate.setConnectionFactory(redisConnectionFactory);

        redisTemplate.setKeySerializer(new StringRedisSerializer());

        return redisTemplate;

    }

}

用户端套餐缓存

@RestController("userSetmealController")

@RequestMapping("/user/setmeal")

@Api(tags = "C端-套餐浏览接口")

public class SetmealController {

  

    @Autowired

    private SetmealService setmealService;

  

    @GetMapping("/list")

    @ApiOperation("根据分类id查询套餐")

    @Cacheable(cacheNames = "setmealCache", key="#categoryId")

    public Result<List<Setmeal>> list(Long categoryId) {

        Setmeal setmeal = new Setmeal();

        setmeal.setCategoryId(categoryId);

        setmeal.setStatus(StatusConstant.ENABLE);

  

        List<Setmeal> list = setmealService.list(setmeal);

        return Result.success(list);

    }

}

这里值得记住的点
  • Spring Cache 是注解式缓存

  • @Cacheable:先查缓存,缓存没有才执行方法

  • 套餐缓存的 key 是 categoryId

  • 管理端新增 / 修改 / 删除 / 起售停售套餐时,需要配合 @CacheEvict 清缓存

面试可展开
  • 缓存穿透、击穿、雪崩

  • 双写一致性怎么做

  • Spring Cache 底层原理是 CacheInterceptor + CacheManager


7. WebSocket:来单提醒与催单消息

WebSocket 配置类

@Configuration

public class WebSocketConfiguration {

  

    @Bean

    public ServerEndpointExporter serverEndpointExporter() {

        return new ServerEndpointExporter();

    }

}

WebSocket 服务端

@Component

@ServerEndpoint("/ws/{sid}")

public class WebSocketServer {

  

    private static Map<String, Session> sessionMap = new HashMap();

  

    @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 void sendToAllClient(String message) {

        Collection<Session> sessions = sessionMap.values();

        for (Session session : sessions) {

            try {

                session.getBasicRemote().sendText(message);

            } catch (Exception e) {

                e.printStackTrace();

            }

        }

    }

}

支付成功后推送来单消息

public void paySuccess(String outTradeNo) {

    Orders ordersDB = orderMapper.getByNumber(outTradeNo);

  

    Orders orders = Orders.builder()

            .id(ordersDB.getId())

            .status(Orders.TO_BE_CONFIRMED)

            .payStatus(Orders.PAID)

            .checkoutTime(LocalDateTime.now())

            .build();

  

    orderMapper.update(orders);

  

    Map<String, Object> map = new HashMap<>();

    map.put("type", 1);

    map.put("orderId", ordersDB.getId());

    map.put("content", "订单号:" + outTradeNo);

  

    String msg = JSON.toJSONString(map);

    webSocketServer.sendToAllClient(msg);

}

这里值得记住的点
  • WebSocket 适合服务端主动推送消息

  • 本项目里有两个典型场景:来单提醒、催单提醒

  • 相比前端轮询,WebSocket 实时性更高、带宽开销更小


8. 定时任务:订单超时取消与归档

核心代码

@Component

@Slf4j

public class OrderTask {

  

    @Autowired

    private OrderMapper orderMapper;

  

    @Scheduled(cron = "0 * * * * ? ")

    public void processTimeoutOrder(){

        List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(

                Orders.PENDING_PAYMENT,

                LocalDateTime.now().minusMinutes(15)

        );

  

        if (ordersList != null && ordersList.size() > 0) {

            for (Orders orders : ordersList) {

                orders.setStatus(Orders.CANCELLED);

                orders.setCancelTime(LocalDateTime.now());

                orders.setCancelReason("订单超时未支付,系统自动取消");

                orderMapper.update(orders);

            }

        }

    }

  

    @Scheduled(cron = "0 0 1 * * ? ")

    public void processDeliveryOrder() {

        List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(

                Orders.DELIVERY_IN_PROGRESS,

                LocalDateTime.now().minusMinutes(60)

        );

        if (ordersList != null && ordersList.size() > 0) {

            for (Orders orders : ordersList) {

                orders.setStatus(Orders.COMPLETED);

                orderMapper.update(orders);

            }

        }

    }

}

这里值得记住的点
  • @Scheduled 通过 cron 表达式定时执行

  • 一个任务负责超时未支付订单自动取消

  • 一个任务负责长时间未完成的派送订单自动归档

  • 这是典型的“系统兜底逻辑”


9. 全局异常处理 + 统一返回结果

统一返回结果类

package com.sky.result;

  

import lombok.Data;

import java.io.Serializable;

  

@Data

public class Result<T> implements Serializable {

  

    private Integer code;

    private String msg;

    private T data;

  

    public static <T> Result<T> success() {

        Result<T> result = new Result<>();

        result.code = 1;

        return result;

    }

  

    public static <T> Result<T> success(T object) {

        Result<T> result = new Result<>();

        result.data = object;

        result.code = 1;

        return result;

    }

  

    public static <T> Result<T> error(String msg) {

        Result<T> result = new Result<>();

        result.msg = msg;

        result.code = 0;

        return result;

    }

}

全局异常处理器

@RestControllerAdvice

@Slf4j

public class GlobalExceptionHandler {

  

    @ExceptionHandler

    public Result exceptionHandler(BaseException ex){

        log.error("异常信息:{}", ex.getMessage());

        return Result.error(ex.getMessage());

    }

  

    @ExceptionHandler

    public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){

        String message = ex.getMessage();

        if(message.contains("Duplicate entry")){

            String[] split = message.split("");

            String userName = split[2];

            return Result.error(userName + MessageConstant.ALREADY_EXIST);

        } else{

            return Result.error(MessageConstant.UNKNOWN_ERROR);

        }

    }

}

这里最值得记住的点
  • 所有接口统一返回 Result

  • 业务异常统一走全局异常处理器

  • Controller 更干净,只关心业务成功路径


10. Apache POI:导出运营报表

关键代码

public void export(HttpServletResponse response) {

    LocalDate dateBegin = LocalDate.now().minusDays(30);

    LocalDate dateEnd = LocalDate.now().minusDays(1);

  

    BusinessDataVO businessDataVO = workspaceService.getBusinessData(

            LocalDateTime.of(dateBegin, LocalTime.MIN),

            LocalDateTime.of(dateEnd, LocalTime.MAX)

    );

  

    InputStream in = this.getClass().getClassLoader()

            .getResourceAsStream("template/运营数据报表模板.xlsx");

  

    try {

        XSSFWorkbook excel = new XSSFWorkbook(in);

        XSSFSheet sheet = excel.getSheet("Sheet1");

  

        sheet.getRow(1).getCell(1).setCellValue(dateBegin + " ~ " + dateEnd);

  

        XSSFRow row = sheet.getRow(3);

        row.getCell(2).setCellValue(businessDataVO.getTurnover());

        row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());

        row.getCell(6).setCellValue(businessDataVO.getNewUsers());

  

        ServletOutputStream out = response.getOutputStream();

        excel.write(out);

        out.close();

    } catch (IOException e) {

        throw new RuntimeException(e);

    }

}

这里值得记住的点
  • 先准备 Excel 模板

  • 再把统计数据按单元格填进去

  • 最后通过输出流返回给浏览器下载


五、核心业务流程梳理:用户下单到订单完成

1. 用户提交订单

  • 检查地址是否存在

  • 检查购物车是否为空

  • orders 表插入订单主表数据

  • order_detail 表插入订单明细

  • 清空购物车

2. 用户发起支付

  • 调用微信支付接口创建预支付单

  • 前端拿到支付参数后调起微信支付

3. 支付成功异步回调

  • 微信回调 /notify/paySuccess

  • 服务端解密回调数据

  • 根据商户订单号修改订单状态

  • 用 WebSocket 给管理端推送来单提醒

支付回调核心代码

@RestController

@RequestMapping("/notify")

@Slf4j

public class PayNotifyController {

    @Autowired

    private OrderService orderService;

  

    @Autowired

    private WeChatProperties weChatProperties;

  

    @RequestMapping("/paySuccess")

    public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {

        String body = readData(request);

        String plainText = decryptData(body);

  

        JSONObject jsonObject = JSON.parseObject(plainText);

        String outTradeNo = jsonObject.getString("out_trade_no");

  

        orderService.paySuccess(outTradeNo);

        responseToWeixin(response);

    }

}

4. 商家接单、派送、完成

  • 接单:status 2 -> 3

  • 派送:status 3 -> 4

  • 完成:status 4 -> 5

5. 用户催单

  • 用户点击催单

  • 后端通过 WebSocket 给管理端推送提醒消息


六、架构设计复盘

1. 分层架构


Controller:接收参数、返回结果

Service:业务逻辑、事务边界

Mapper:数据库访问

MySQL / Redis:数据存储与缓存

2. DTO / VO / Entity 三件套

  • Entity:对应数据库表结构

  • DTO:前端传入参数对象

  • VO:返回给前端的展示对象

这样的设计可以降低耦合,也更方便接口演进。

3. 拦截器与 AOP 分工

  • 拦截器:更适合做登录校验、权限校验

  • AOP:更适合做事务、日志、公共字段填充这类横切逻辑

4. ThreadLocal 的作用

登录后解析出的用户 id / 员工 id 放入 BaseContext,Service 层直接读取,避免层层传参。


七、项目亮点总结

亮点 1:自定义注解 + AOP 自动填充公共字段

把重复的 createTime/updateTime/createUser/updateUser 逻辑统一抽离,既减少重复代码,也降低漏填风险。

亮点 2:Spring Cache + Redis 做套餐缓存

针对高频读取的套餐列表,命中缓存时直接返回,明显降低数据库压力。

亮点 3:WebSocket 实现来单提醒和催单推送

避免前端轮询,提高实时性,减少无效请求。

亮点 4:Spring Task 处理超时订单与归档

通过定时任务做系统兜底,保证订单状态最终一致。

亮点 5:Apache POI 导出运营报表

把运营统计数据填充到 Excel 模板中,直接下载,适合实际业务场景。


八、面试高频追问清单

1. 为什么项目里用 JWT 而不是 Session?

参考答案:  

JWT 是无状态认证,不需要在服务端保存会话信息,更适合前后端分离和分布式部署场景。Session 依赖服务端存储,如果项目做集群,还要解决 Session 共享问题。JWT 只需要前端携带 token,后端校验签名即可。


2. @Transactional 为什么同类方法内部调用会失效?

参考答案:  

因为 Spring 事务底层基于 AOP 代理实现,事务增强是在代理对象上生效的。同类内部调用本质上是 this.xxx(),没有走代理对象,所以事务拦截器不会执行,事务就失效了。


3. Redis 缓存与数据库的一致性怎么保证?

参考答案:  

常见思路是“先更新数据库,再删除缓存”,让下一次查询重新加载新数据。对于高并发场景,还可以结合延迟双删、消息队列、Canal 订阅 binlog 等方式提升一致性。苍穹外卖里用的是典型的“读时缓存,写时删缓存”方案。


4. 线程池参数一般怎么设置?

参考答案:  

要看任务类型。CPU 密集型任务线程数一般设置为 CPU 核数 + 1;IO 密集型任务可以适当放大,比如 2 倍 CPU 核数甚至更高。除此之外还要结合任务队列长度、峰值流量、拒绝策略一并考虑。


5. MyBatis 里的 #{}${} 有什么区别?

参考答案:  

#{} 是预编译占位符,对应 PreparedStatement,可以防止 SQL 注入;${} 是字符串直接拼接,存在 SQL 注入风险。一般查询参数必须优先使用 #{},只有像动态表名、排序字段这种特殊场景才会考虑 ${},但要做好白名单校验。


6. Spring AOP 中 JDK 动态代理和 CGLIB 的区别?

参考答案:  

JDK 动态代理要求目标类实现接口,代理对象实际上实现的是接口;CGLIB 是通过生成目标类的子类实现代理,不要求接口。Spring 默认优先用 JDK 动态代理,如果没有接口才用 CGLIB。


7. WebSocket 在集群环境下怎么实现广播?

参考答案:  

单机环境下可以直接遍历本机 sessionMap 推送;如果是多节点集群,需要借助 Redis 发布订阅、MQ 或消息总线,把消息广播给所有节点,再由各节点推给自己维护的 WebSocket 连接。


8. Spring Cache 的底层原理是什么?

参考答案:  

Spring Cache 本质上也是基于 AOP。方法执行前,CacheInterceptor 会根据注解先查缓存;如果命中就直接返回,不再执行目标方法;如果未命中则执行目标方法,并把结果写入缓存。实际缓存存储由 CacheManager 决定,比如可以接 Redis。


9. 定时任务在分布式部署下怎么避免重复执行?

参考答案:  

最简单的方式是加分布式锁,比如 Redis 锁;更完善的方式是使用分布式调度框架,例如 XXL-Job、Quartz 集群版、ShedLock 等。核心思路是保证同一时刻只有一个节点真正执行任务。


10. JWT 怎么实现“退出登录”?

参考答案:  

JWT 本身是无状态的,服务端不保存会话,所以“退出登录”通常有两种做法:一种是前端删除 token;另一种是服务端维护黑名单,把失效 token 加入 Redis,在拦截器校验时额外检查是否在黑名单中。


11. ThreadLocal 为什么会内存泄漏?怎么避免?

参考答案:  

因为 ThreadLocalMap 中 key 是弱引用,但 value 可能还被线程强引用着,如果线程长期不销毁而 value 没移除,就可能造成内存泄漏。解决办法是在请求结束后手动 remove(),或者让框架在过滤器 / 拦截器的 finally 阶段清理。


12. 为什么事务、AOP、缓存这些注解能生效?

参考答案:  

因为 Spring 在启动时注册了相关基础设施,并且通过代理对象增强目标方法。比如 @Transactional@Cacheable@CacheEvict 本质上都是通过 AOP 或拦截器链包装目标方法,在方法执行前后织入增强逻辑。


九、最后的复习建议

我建议按下面的顺序复习:

  1. 先讲清项目整体架构和技术栈

  2. 再讲用户下单到订单完成的业务主线

  3. 然后挑 3 个亮点重点展开:

   - AOP 自动填充

   - Spring Cache + Redis 缓存套餐

   - WebSocket 来单提醒

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值