【苍穹外卖|day30 -1】

对应笔记day1-day3内容


一、项目架构与环境搭建

1.1 项目技术栈全景

苍穹外卖项目采用经典的前后端分离架构,技术选型如下:

  • 后端技术栈:Spring Boot 2.x + Spring MVC + MyBatis-Plus + MySQL

  • 前端技术栈:Vue.js + Element UI + Axios

  • 辅助工具:Nginx(反向代理)、Swagger/Knife4j(接口文档)、Maven(项目管理)

1.2 环境搭建核心要点

前端环境配置

bash

# Nginx配置示例 - 反向代理设置
server {
    listen 80;
    server_name localhost;
    
    location /api/ {
        proxy_pass http://localhost:8080/;  # 后端服务地址
        proxy_set_header Host $host;
    }
    
    location / {
        root /usr/share/nginx/html;  # 前端静态资源
        index index.html;
    }
}

后端多模块结构

text

sky-take-out (父工程)
├── sky-common    # 公共模块
├── sky-pojo      # 实体类
├── sky-mapper    # 数据访问层  
└── sky-server    # 业务逻辑层

二、登录与鉴权机制深度解析

2.1 JWT令牌三连问

1. 令牌谁签发?

java

@RestController
public class LoginController {
    
    @PostMapping("/login")
    public Result<LoginVO> login(@RequestBody LoginDTO loginDTO) {
        // 1. 校验用户名密码
        Employee employee = employeeService.getByUsername(loginDTO.getUsername());
        if (employee == null || !passwordEncoder.matches(loginDTO.getPassword(), employee.getPassword())) {
            throw new BusinessException("用户名或密码错误");
        }
        
        // 2. 生成JWT令牌 - 这里是签发点!
        String token = JwtUtil.generateToken(employee.getId(), employee.getUsername());
        
        return Result.success(new LoginVO(token, employee.getUsername()));
    }
}

2. 令牌存哪?

  • 前端:存储在localStoragesessionStorage

  • 后端无状态设计,不存储Session,通过解析JWT验证用户身份

3. 令牌怎么刷新?

javascript

// 前端响应拦截器 - 自动刷新令牌
axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response.status === 401) {
            // 调用刷新令牌接口
            return refreshToken().then(() => {
                // 重新发起原始请求
                return axios(error.config);
            });
        }
        return Promise.reject(error);
    }
);

2.2 密码安全进阶策略

Day1中我们使用了MD5+Salt,但在生产环境中这还不够:

java

@Configuration
public class SecurityConfig {
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 线上推荐使用bcrypt,10轮加密
        return new BCryptPasswordEncoder(10);
    }
}

// 应用示例
@Service
public class EmployeeService {
    
    @Value("${security.password.salt}") // 盐值从配置中心获取
    private String passwordSalt;
    
    public void createEmployee(Employee employee) {
        // 多重加密:MD5 + 配置中心盐值 + bcrypt
        String md5WithSalt = DigestUtils.md5DigestAsHex((employee.getPassword() + passwordSalt).getBytes());
        String finalPassword = passwordEncoder.encode(md5WithSalt);
        employee.setPassword(finalPassword);
    }
}

三、全局异常处理与日志规范

3.1 统一返回体设计

java

@Data
public class Result<T> implements Serializable {
    private Integer code;      // 状态码
    private String msg;       // 提示信息
    private T data;           // 响应数据
    private String traceId;   // 链路追踪ID - 使用UUID生成
    
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMsg("成功");
        result.setData(data);
        result.setTraceId(MDC.get("traceId")); // 从MDC中获取
        return result;
    }
}

3.2 敏感信息脱敏策略

java

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
    @ExceptionHandler(Exception.class)
    public Result<String> handleException(Exception e, HttpServletRequest request) {
        String traceId = UUID.randomUUID().toString();
        MDC.put("traceId", traceId);
        
        // 对外只返回模糊信息
        if (e instanceof SQLException) {
            logger.error("数据库异常, traceId: {}", traceId, e);
            return Result.error("系统繁忙,请稍后重试", traceId);
        }
        
        if (e instanceof BusinessException) {
            return Result.error(e.getMessage(), traceId);
        }
        
        logger.error("系统异常, traceId: {}, 请求路径: {}", traceId, request.getRequestURI(), e);
        return Result.error("系统异常", traceId);
    }
}

四、ThreadLocal实战与内存泄漏防范

4.1 ThreadLocal的正确用法

java

@Component
public class UserContext {
    
    private static final ThreadLocal<CurrentUser> USER_CONTEXT = new ThreadLocal<>();
    
    public static void setCurrentUser(CurrentUser user) {
        USER_CONTEXT.set(user);
    }
    
    public static CurrentUser getCurrentUser() {
        return USER_CONTEXT.get();
    }
    
    // 🔥 关键:必须remove,防止内存泄漏
    public static void remove() {
        USER_CONTEXT.remove();
    }
}

// 在拦截器中清理
@Component
public class UserInterceptor implements HandlerInterceptor {
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
                               Object handler, Exception ex) {
        // 请求完成后必须清理ThreadLocal
        UserContext.remove();
    }
}

4.2 内存泄漏的根本原因

为什么必须remove?

  • 线程复用:Tomcat使用线程池,线程会处理多个请求

  • 数据串扰:如果不清理,用户A可能看到用户B的数据

  • 内存泄漏:ThreadLocalMap中的Entry key是弱引用,但value是强引用,如果不remove,value永远不会被回收

五、数据层设计与优化实战

5.1 分页查询的隐藏坑

错误写法

sql

-- 可能出现重复数据
SELECT * FROM employee ORDER BY create_time DESC LIMIT 0, 10;

正确写法

sql

-- 增加第二排序字段,确保稳定性
SELECT * FROM employee 
ORDER BY create_time DESC, id DESC 
LIMIT 0, 10;

5.2 唯一约束的分布式思考

单机方案

sql

-- 单库唯一索引
ALTER TABLE employee ADD UNIQUE INDEX uk_username (username);

分布式方案

java

@Service
public class EmployeeService {
    
    // 方案1:分布式ID + 联合唯一索引
    public void createEmployee(Employee employee) {
        employee.setId(snowflakeIdGenerator.nextId()); // 雪花算法
        // 数据库层面:UNIQUE KEY uk_username_phone (username, phone)
    }
    
    // 方案2:Redis分布式锁
    public void createEmployeeWithLock(Employee employee) {
        String lockKey = "employee:lock:" + employee.getUsername();
        try {
            Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
            if (!locked) {
                throw new BusinessException("操作过于频繁");
            }
            // 执行业务逻辑
            employeeMapper.insert(employee);
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
}

5.3 文件上传最佳实践

java

@Service
public class FileUploadService {
    
    public String uploadImage(MultipartFile file) {
        // 1. 校验文件类型和大小
        validateFile(file);
        
        // 2. 生成唯一文件名:年月目录 + UUID
        String originalFilename = file.getOriginalFilename();
        String extension = StringUtils.getFilenameExtension(originalFilename);
        String yearMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
        String fileName = yearMonth + "/" + UUID.randomUUID() + "." + extension;
        
        // 3. 存储文件
        Path filePath = Paths.get(uploadDir, fileName);
        Files.createDirectories(filePath.getParent());
        file.transferTo(filePath.toFile());
        
        return fileName;
    }
}

Nginx CDN配置

nginx

server {
    listen 80;
    server_name image.xxx.com;
    
    location / {
        root /data/upload/images;
        # 设置长期缓存,CDN友好
        expires 365d;
        add_header Cache-Control "public, immutable, max-age=31536000";
    }
}

六、MyBatis-Plus高级特性应用

6.1 公共字段自动填充的陷阱

java

@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "createUser", Long.class, UserContext.getCurrentUser().getId());
    }
    
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
        this.strictUpdateFill(metaObject, "updateUser", Long.class, UserContext.getCurrentUser().getId());
    }
}

// 🔥 注意:手写XML时需要额外处理
public interface EmployeeMapper extends BaseMapper<Employee> {
    
    // 自动填充生效
    @Override
    int insert(Employee entity);
    
    // 手写SQL需要加注解
    @Update("UPDATE employee SET status = #{status} WHERE id = #{id}")
    @FieldFill(fill = FieldFill.UPDATE) // 需要自定义注解处理器
    int updateStatus(@Param("id") Long id, @Param("status") Integer status);
}

6.2 多表关联与JSON字段处理

口味表设计

java

@Data
@TableName("dish_flavor")
public class DishFlavor {
    private Long id;
    private Long dishId;
    
    // MySQL JSON类型存储
    @TableField(typeHandler = JsonTypeHandler.class)
    private List<Flavor> flavors;
}

// 自定义TypeHandler
public class JsonTypeHandler extends BaseTypeHandler<List<Flavor>> {
    
    private static final ObjectMapper mapper = new ObjectMapper();
    
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, 
                                  List<Flavor> parameter, JdbcType jdbcType) {
        ps.setString(i, mapper.writeValueAsString(parameter));
    }
    
    @Override
    public List<Flavor> getNullableResult(ResultSet rs, String columnName) {
        return mapper.readValue(rs.getString(columnName), 
               new TypeReference<List<Flavor>>() {});
    }
}

七、事务管理与接口幂等性

7.1 事务边界的正确划分

java

@Service
public class DishService {
    
    @Transactional(rollbackFor = Exception.class)
    public void saveDishWithFlavors(DishDTO dishDTO) {
        // 保存菜品基本信息
        dishMapper.insert(dishDTO);
        
        // 保存口味信息
        if (dishDTO.getFlavors() != null) {
            dishDTO.getFlavors().forEach(flavor -> {
                flavor.setDishId(dishDTO.getId());
                dishFlavorMapper.insert(flavor);
            });
        }
    }
    
    // 🔥 防止同类方法自调用事务失效
    public void updateDish(DishDTO dishDTO) {
        DishService proxy = (DishService) AopContext.currentProxy();
        proxy.doUpdateDish(dishDTO);
    }
    
    @Transactional(rollbackFor = Exception.class)
    public void doUpdateDish(DishDTO dishDTO) {
        // 实际的更新逻辑
    }
}

7.2 接口幂等性保障

java

@RestController
public class DishController {
    
    @PostMapping("/dishes")
    public Result<String> createDish(@RequestBody DishDTO dishDTO, 
                                   @RequestHeader(value = "Idempotent-Token", required = false) String token) {
        // 方案1:唯一索引兜底
        // 数据库:UNIQUE KEY uk_name (name)
        
        // 方案2:令牌桶幂等
        if (!idempotentService.validateToken(token)) {
            throw new BusinessException("请勿重复提交");
        }
        
        dishService.saveDishWithFlavors(dishDTO);
        idempotentService.consumeToken(token); // 消费令牌
        
        return Result.success("创建成功");
    }
}

八、开发规范与工程化实践

8.1 枚举与前后端统一

java

// 后端枚举定义
public enum EmployeeStatus implements IEnum<Integer> {
    ENABLED(1, "启用"),
    DISABLED(0, "禁用");
    
    private final int code;
    private final String desc;
    
    EmployeeStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }
    
    @Override
    public Integer getValue() {
        return this.code;
    }
    
    // Jackson序列化配置
    @JsonValue
    public Map<String, Object> toJson() {
        Map<String, Object> map = new HashMap<>();
        map.put("code", code);
        map.put("desc", desc);
        return map;
    }
}

8.2 数据字典统一管理

字典表设计

sql

CREATE TABLE sys_dict (
    id BIGINT PRIMARY KEY,
    dict_type VARCHAR(50) NOT NULL COMMENT '字典类型',
    dict_code VARCHAR(50) NOT NULL COMMENT '字典编码', 
    dict_name VARCHAR(100) NOT NULL COMMENT '字典名称',
    sort INT DEFAULT 0
);

-- 示例数据
INSERT INTO sys_dict VALUES 
(1, 'EMPLOYEE_STATUS', '1', '启用', 1),
(2, 'EMPLOYEE_STATUS', '0', '禁用', 2);

8.3 一键部署脚本

bash

#!/bin/bash
# deploy.sh - 苍穹外卖一键部署脚本

set -e

echo "开始部署苍穹外卖项目..."

# 1. 数据库初始化
mysql -h127.0.0.1 -uroot -p123456 < /scripts/init.sql

# 2. 创建图片目录
mkdir -p /data/upload/images/{2025,2024}/{01,02,03,04,05,06,07,08,09,10,11,12}

# 3. 配置Nginx
cp /config/nginx.conf /etc/nginx/nginx.conf
nginx -t && nginx -s reload

# 4. 启动后端服务
nohup java -jar sky-server.jar > server.log 2>&1 &

echo "部署完成!访问地址:http://localhost"

九、总结与学习建议

通过前三天的学习,我们已经掌握了苍穹外卖项目的核心技术栈。建议在学习过程中:

  1. 理解原理:不仅要会使用框架,更要理解其背后的设计思想

  2. 动手实践:所有代码都要亲手敲一遍,遇到问题先思考再求助

  3. 举一反三:将学到的技术应用到其他项目中,形成自己的技术体系

  4. 关注性能:在功能实现的基础上,思考如何优化性能和提高系统稳定性

希望这份总结能够帮助你更好地掌握苍穹外卖项目的核心技术,为后续的学习和工作打下坚实的基础!


延伸阅读

  • [Spring Security实战指南]

  • [分布式系统设计模式]

  • [MySQL性能优化实践]

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值