对应笔记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. 令牌存哪?
-
前端:存储在
localStorage或sessionStorage中 -
后端:无状态设计,不存储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"
九、总结与学习建议
通过前三天的学习,我们已经掌握了苍穹外卖项目的核心技术栈。建议在学习过程中:
-
理解原理:不仅要会使用框架,更要理解其背后的设计思想
-
动手实践:所有代码都要亲手敲一遍,遇到问题先思考再求助
-
举一反三:将学到的技术应用到其他项目中,形成自己的技术体系
-
关注性能:在功能实现的基础上,思考如何优化性能和提高系统稳定性
希望这份总结能够帮助你更好地掌握苍穹外卖项目的核心技术,为后续的学习和工作打下坚实的基础!
延伸阅读:
-
[Spring Security实战指南]
-
[分布式系统设计模式]
-
[MySQL性能优化实践]
1945

被折叠的 条评论
为什么被折叠?



