跳至主要內容

苍穹外卖-练手项目

chenxi编程项目大约 7 分钟

项目总结

项目定位: 学习 Java 后做的第一个项目,用于练手。

用时: 2024.01.28 - 2024.02.13

完成度:

  • 视频中前 day10 的内容,跳过了最后两天的数据统计部分。
  • 因为没有商户号,跳过了微信支付功能。
  • 未通过百度地图 API 实现检验收货地址是否超出配送范围功能。

项目核心业务逻辑:
项目核心业务逻辑

项目具体业务模块:
blob

项目收获:

  • 更熟练的增删改查,提高了使用 Spring Boot 进行开发的熟练度。
  • 明确写项目时的代码规范。
  • 学会使用 Swagger 进行接口调试
  • 学会使用 ThreadLocal 储存各层都会用到的变量。
  • 学会通过 AOP + 反射 实现公共字段填充。
  • 学会微信小程序的微信登录功能,服务端的实现方式。
  • 学会使用 Redis 对数据库中频繁查询的数据进行缓存。
  • 学会使用 Spring Task 实现一些定时的业务。
  • 了解通过 WebSocket 让服务端主动向客户端推送数据。
  • 了解外卖类项目的业务实现思路。

项目核心点

通过 ThreadLocal 储存登录用户 ID

用户在管理端或用户端成功登录后,会收到来自服务端发回的 JWT 令牌,JWT 令牌中的自定义内容为用户的 ID。

//登录成功后,生成jwt令牌
Map<String, Object> claims = new HashMap<>();
// 设置令牌自定义内容
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
// 生成jwt令牌
String token = JwtUtil.createJWT(
        jwtProperties.getAdminSecretKey(),
        jwtProperties.getAdminTtl(),
        claims);

客户端在后续的请求中会携带该令牌,而服务端通过拦截器对除登录外的请求进行拦截,并在拦截器中对 JWT 令牌进行解析,以确定令牌合法性,此时我们可以取得 JWT 令牌中的自定义内容,即用户 ID。

我们在拦截器中取得用户的 ID,如何在其他层获取到该 ID 呢? 通过 ThreadLocal。

blob
客户端发起的每一个请求,我们的处理都位于同一线程,不同请求位于不同线程。 那么针对客户端的每一次请求,我们通过拦截器从 JWT 令牌中获取到用户的 ID,在需要用到的地方再取出即可。

将 ThreadLocal 封装为工具类:

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

}
// 从JWT令牌中解析出登录者id后,存入ThreadLocal中
BaseContext.setCurrentId(empId);
// 例如在Service层中需要获取到当前登录者的id
employee.setCreateUser(BaseContext.getCurrentId());

新增用户账号时,用户密码通过 md5 加密后储存

使用工具类:DigestUtils

// 设置账号默认密码:123456,通过md5加密储存
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));

// 用户登录时,对密码进行md5加密后再与数据库中信息进行对比
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
    //密码错误
    throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}

通过 AOP 进行公共字段填充

blob
项目中存在非核心代码冗余的问题,就应该要考虑到使用 AOP 了。

代码实现:

  • 自定义注解
/**
 * 自定义注解,表示某个方法需要使用AOP填充公共字段
 */
@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 autoFillPoinCut() {
    }

    /**
     * 前置增强方法,用于公共字段填充
     */
    @Before("autoFillPoinCut()")
    public void autoFill(JoinPoint joinPoint) {
        log.info("进行公共字段填充...");

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

        // 根据对数据库操作的不同类型,对响应公共字段进行赋值
        if (operationType == OperationType.INSERT) {
            try {
                // 通过反射对公共字段赋值
                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

                setCreateTime.invoke(entity, now);
                setCreateUser.invoke(entity, currentId);
                setUpdateTime.invoke(entity, now);
                setUpdateUser.invoke(entity, currentId);

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        if (operationType == OperationType.UPDATE) {
            try {
                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();
            }
        }
    }
}
  • 为相关 Mapper 方法添加注解,且去除 Servce 层相关方法中的为公共字段赋值的部分。
/**
 * 新增员工
 * 将员工数据插入到employee表中,id属性为自增,不用插入
 *
 * @param employee
 */
@AutoFill(OperationType.INSERT)
@Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user) " +
"values " +
        "(#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
void save(Employee employee);

/**
 * 员工信息更新
 *
 * @param employee
 */
@AutoFill(OperationType.UPDATE)
void update(Employee employee);

微信登录

微信登录流程:
blob
微信官方 API:
blob Java 中通过 HttpClient 向微信接口服务发起请求。

/**
 * 用户登录
 * @param userLoginDTO
 * @return
 */
@Override
public User wxLogin(UserLoginDTO userLoginDTO) {
    // 获取openId
    Map<String, String> map = new HashMap<>();
    map.put("appid", weChatProperties.getAppid());
    map.put("secret", weChatProperties.getSecret());
    map.put("js_code", userLoginDTO.getCode());
    map.put("grant_type", "authorization_code");
    String json = HttpClientUtil.doGet(WX_LOGIN, map);

    JSONObject jsonObject = JSON.parseObject(json);
    String openid = jsonObject.getString("openid");

    // 如果openid不正确则抛出异常
    if (openid == null) {
        throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
    }
    // 判断该openid用户是否已注册
    User user = userMapper.getByOpenid(openid);
    // 未注册则为其注册,即向数据库表中插入一条新数据
    if (user == null) {
        user = User.builder()
                .openid(openid)
                .createTime(LocalDateTime.now())
                .build();
        userMapper.insert(user);
    }
    // 返回
    return user;
}

使用 Redis 对用户端展示的商品进行缓存

blob 对菜品进行缓存(不使用 Spring Cache 框架):
/**
 * 根据分类id查询菜品
 *
 * @param categoryId
 * @return
 */
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
    // 先查询Redis中是否有菜品数据
    String dishId = "dish_" + categoryId;
    ValueOperations valueOperations = redisTemplate.opsForValue();
    List<DishVO> list = (List<DishVO>)valueOperations.get(dishId);

    if (list != null && list.size() > 0) {
        // Redis中有菜品数据,直接返回
        return Result.success(list);
    }
    // Redis中没有菜品数据,从数据库中查询
    list = dishService.listWithFlavor(categoryId);
    // 将菜品数据加入Redis中
    valueOperations.set(dishId, list);

    return Result.success(list);
}

对套餐进行缓存(使用 Spring Cache 框架):

/**
 * 根据分类id查询套餐
 *
 * @param categoryId
 * @return
 */
@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
@Cacheable(cacheNames = "setmeal", key = "#categoryId") // 通过Spring Cache框架简化缓存操作
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 Task 对订单状态定时处理

@Component
@Slf4j
public class OrderTask {
    @Autowired
    private OrderMapper orderMapper;
    /**
     * 处理用户支付超时的订单
     */
    @Scheduled(cron = "0 * * * * ?") // 每分钟处理一次
    public void processTimeoutOrder() {
        log.info("处理用户支付超时的订单");

        LocalDateTime time = LocalDateTime.now().plusMinutes(-15);
        // 从表中查到超时的订单
        List<Orders> orders = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time);
        // 将订单状态设置为已取消
        if (orders != null && orders.size() > 0) {
            for (Orders order : orders) {
                order.setStatus(Orders.CANCELLED);
                order.setCancelReason("用户支付订单超时,自动取消");
                order.setCancelTime(LocalDateTime.now());
                orderMapper.update(order);
            }
        }
    }

    /**
     * 处理一直处于派送中的订单
     */
    @Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点处理
    public void processDeliveryOrder() {
        log.info("处理一直处于派送中的订单");

        LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
        // 从表中查到截至昨日24点,仍处于派送中的订单
        List<Orders> orders = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time);
        // 将订单状态设置为已完成
        if (orders != null && orders.size() > 0) {
            for (Orders order : orders) {
                order.setStatus(Orders.COMPLETED);
                orderMapper.update(order);
            }
        }
    }
}

通过 WebSocket 实现来单提醒与客户催单

代码跟着敲倒是把功能给实现了,但整体对于 WebSocket 还是有点懵,等后续再来填这一部分吧。

上次编辑于:
贡献者: chenxi,chenxi