苍穹外卖-练手项目
大约 7 分钟
项目总结
项目定位: 学习 Java 后做的第一个项目,用于练手。
用时: 2024.01.28 - 2024.02.13
完成度:
- 视频中前 day10 的内容,跳过了最后两天的数据统计部分。
- 因为没有商户号,跳过了微信支付功能。
- 未通过百度地图 API 实现检验收货地址是否超出配送范围功能。
项目核心业务逻辑:
项目具体业务模块:
项目收获:
- 更熟练的增删改查,提高了使用 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。
客户端发起的每一个请求,我们的处理都位于同一线程,不同请求位于不同线程。 那么针对客户端的每一次请求,我们通过拦截器从 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 进行公共字段填充
项目中存在非核心代码冗余的问题,就应该要考虑到使用 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);
微信登录
微信登录流程:
微信官方 API:
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;
}
对菜品进行缓存(不使用 Spring Cache 框架): 使用 Redis 对用户端展示的商品进行缓存
/**
* 根据分类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 还是有点懵,等后续再来填这一部分吧。