springboot+vue项目实战

一、项目基础架构与公共模块

1.0项目分层架构设计

本项目采用经典的三层架构 + 领域对象分层,职责清晰、便于维护,是企业级 Java 项目的标准范式。

|------------------|-------------------------------------|-----------------------------------|
| 层级/模块 | 职责说明 | 示例 |
| controller 控制层 | 接收 HTTP 请求、参数校验、调用 Service、封装统一结果返回 | ArticleController、LoginController |
| service 业务层 | 核心业务逻辑处理、事务控制、组装数据 | ArticleService、UserService + 实现类 |
| mapper/dao 数据访问层 | 直接与数据库交互,执行 SQL 查询 / 更新 | ArticleMapper、UserMapper |
| entity/DO 数据库实体 | 与数据库表一一对应,字段与表字段映射 | Article、SysUser |
| DTO 数据传输对象 | 前端传入后端的参数载体,与业务绑定 | LoginParam、ArticleDTO |
| VO 视图对象 | 返回给前端的数据载体,裁剪敏感字段、组装展示数据 | ArticleVO、CommentVO |
| common 公共模块 | 统一返回结果、全局异常、工具类、常量、注解 | Result、ErrorCode、JWTUtils |

设计原则

  • 前端请求 → DTO → Controller → Service → Mapper → 数据库;
  • 数据库返回 → DO → Service 组装 → VO → Controller → 前端;
  • 禁止数据库实体直接返回前端,避免字段泄露、前后端耦合。

1.1核心 Maven 依赖

XML 复制代码
<!-- SpringWeb -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.2</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
<!-- AOP -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

1.2统一返回结果封装

作用:所有接口返回格式统一,前端可统一拦截处理,降低前后端联调成本。

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
    private int code;   // 状态码 200成功 其他失败
    private String msg; // 提示信息
    private T data;     // 返回数据

    // 成功响应(带数据)
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }
    // 成功响应(无数据)
    public static <T> Result<T> success() {
        return success(null);
    }
    // 失败响应
    public static <T> Result<T> fail(int code, String msg) {
        return new Result<>(code, msg, null);
    }
    // 基于错误枚举的失败
    public static <T> Result<T> fail(ErrorCode errorCode) {
        return fail(errorCode.getCode(), errorCode.getMsg());
    }
}

代码解析

  • 使用泛型兼容任意返回数据类型;
  • 静态工厂方法简化调用,业务代码只需 Result.success(data) 即可;
  • 配合错误码枚举统一管理所有异常信息,避免魔法值。

配套错误码枚举:

java 复制代码
@Getter
@AllArgsConstructor
public enum ErrorCode {
    PARAMS_ERROR(40000, "请求参数错误"),
    NOT_LOGIN(40100, "未登录"),
    NO_PERMISSION(40300, "无权限"),
    SYSTEM_ERROR(50000, "系统内部异常"),
    ACCOUNT_EXIST(40001, "账号已存在");

    private final int code;
    private final String msg;
}

1.3全局统一异常处理

作用:集中捕获所有异常,避免堆栈信息泄露给前端,统一返回错误格式。

java 复制代码
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 捕获自定义业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<?> businessExceptionHandler(BusinessException e) {
        log.error("业务异常:{}", e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    // 捕获系统运行时异常
    @ExceptionHandler(RuntimeException.class)
    public Result<?> runtimeExceptionHandler(RuntimeException e) {
        log.error("系统异常:", e);
        return Result.fail(ErrorCode.SYSTEM_ERROR);
    }
}

自定义业务异常类

java 复制代码
@Getter
public class BusinessException extends RuntimeException {
    private final int code;

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMsg());
        this.code = errorCode.getCode();
    }
}

代码解析

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody,自动将返回值转为 JSON;
  • 新增参数校验异常捕获,配合@Valid注解自动返回友好提示,无需手动判断字段;
  • 业务层校验失败时直接 throw new BusinessException(ErrorCode.NOT_LOGIN) 即可中断流程。

1.4统一参数校验

替代手动if判空,通过注解实现参数校验,代码更简洁规范。

使用示例:登录参数 DTO

java 复制代码
@Data
public class LoginParam {
    @NotBlank(message = "账号不能为空")
    private String account;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 20, message = "密码长度6-20位")
    private String password;
}

Controller 中启用校验

java 复制代码
@PostMapping("/login")
public Result login(@Valid @RequestBody LoginParam param) {
    return userService.login(param);
}

1.5全局跨域配置

前后端分离项目必配,解决浏览器同源策略限制。推荐使用全局 CORS 配置 ,替代每个 Controller 加@CrossOrigin

java 复制代码
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**") // 所有接口都支持跨域
                .allowedOriginPatterns("*") // 允许所有来源地址
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法
                .allowedHeaders("*") // 允许所有请求头
                .allowCredentials(true) // 允许携带凭证(cookie、token)
                .maxAge(3600); // 预检请求OPTIONS的有效期,1小时内不用重复发预检
    }
}

代码解析

  • allowCredentials=true 时,allowedOrigins 不能写*,需用allowedOriginPatterns("*")
  • 生产环境建议限制allowedOriginPatterns为指定域名,提升安全性;
  • 若项目有拦截器,拦截器直接返回错误时会丢失跨域头,需在拦截器中手动添加响应头,或使用过滤器级别的跨域配置。

1.6MyBatis-Plus 配置

java 复制代码
@Configuration
@MapperScan("com.mszlu.blog.mapper")
public class MybatisPlusConfig {

    // 分页插件
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

代码解析

  • @MapperScan 一次性扫描所有 Mapper 接口,无需每个 Mapper 加@Mapper
  • 分页插件是 MP 分页查询的前提,不配置则Page对象不生效,查询结果为全部数据;
  • 可额外配置乐观锁插件、逻辑删除插件,满足更多业务场景。

1.7常量类设计(代码规范)

将 Redis key、过期时间、通用常量抽为常量类,避免魔法值、便于统一维护。

java 复制代码
public interface RedisConstants {
    String ARTICLE_HOT_KEY = "article:hot";
    String ARTICLE_DETAIL_KEY = "article:detail:";
    String TOKEN_BLACK_KEY = "token:black:";
    long ARTICLE_CACHE_TIMEOUT = 300; // 文章缓存过期时间 5分钟
    long TOKEN_EXPIRE = 86400; // token有效期 1天
}

二、数据库设计与优化

2.1 用户表 ms_user

sql 复制代码
CREATE TABLE `ms_user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `account` varchar(64) NOT NULL COMMENT '账号',
  `password` varchar(64) NOT NULL COMMENT '密码(加盐MD5)',
  `nickname` varchar(64) NOT NULL COMMENT '昵称',
  `avatar` varchar(255) DEFAULT NULL COMMENT '头像',
  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
  `last_login` datetime DEFAULT NULL COMMENT '最后登录时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_account` (`account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 文章表 ms_article

sql 复制代码
CREATE TABLE `ms_article` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `title` varchar(128) NOT NULL COMMENT '标题',
  `summary` varchar(256) DEFAULT NULL COMMENT '摘要',
  `comment_counts` int DEFAULT 0 COMMENT '评论数',
  `view_counts` int DEFAULT 0 COMMENT '浏览量',
  `author_id` bigint NOT NULL COMMENT '作者id',
  `body_id` bigint NOT NULL COMMENT '文章内容id',
  `category_id` bigint DEFAULT NULL COMMENT '分类id',
  `create_date` bigint NOT NULL COMMENT '创建时间戳',
  `weight` int DEFAULT 0 COMMENT '置顶权重',
  PRIMARY KEY (`id`),
  KEY `idx_category_id` (`category_id`),
  KEY `idx_author_id` (`author_id`),
  KEY `idx_create_date` (`create_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.3 其他核心表

  • ms_article_body:存储文章正文 Markdown/HTML 内容,与文章一对一分离;
  • ms_category:文章分类表;
  • ms_tag:标签表 + ms_article_tag 文章标签中间表(多对多);
  • ms_comment:评论表,含parent_id实现多级评论;
  • ms_sys_userms_rolems_permissionms_role_permission:后台 RBAC 权限四表。

2.4 表设计优化思路

  1. 文章内容分离:正文内容大、列表页无需查询,单独建表减少单表数据量,提升列表查询性能。
  2. 索引设计原则
    • 主键默认聚簇索引;
    • 常用查询条件(分类 id、作者 id、创建时间)建立普通索引;
    • 唯一字段(账号)建立唯一索引;
    • 避免索引过多:索引会占用磁盘空间,降低写入性能。
  3. 字段类型选择
    • 金额用decimal,不用 float/double;
    • 状态、类型用tinyint,不用字符串;
    • 时间可用datetimebigint时间戳,本项目用时间戳避免时区问题。
  4. 默认值设置:浏览量、评论数默认 0,避免 null 值引发的计算异常。

三、登录认证体系(核心重点)

3.1 MD5 加盐加密工具

java 复制代码
public class MD5Utils {
    // 固定盐值,混入密码一起加密,防止彩虹表反解
    private static final String SALT = "mszlu_blog_!@#";

    public static String md5(String password) {
        String saltPassword = password + SALT;
        return DigestUtils.md5Hex(saltPassword);
    }
}

代码解析

  • 数据库绝不存储明文密码,加密后即使数据库泄露也无法直接获取原始密码;
  • 加盐是为了对抗彩虹表攻击,相同密码加盐后哈希值不同;
  • 生产环境推荐每个用户独立盐值,进一步提升安全性。

3.2 JWT 工具类

java 复制代码
public class JWTUtils {
    // 密钥,生产环境务必配置化,不可硬编码
    private static final String SECRET = "mszlu!@#$%^&*()";

    // 生成token,载荷存入userId
    public static String createToken(Long userId) {
        JwtBuilder builder = Jwts.builder();
        return builder
                .setSubject("blog-user")
                .claim("userId", userId)
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) // 1天过期
                .signWith(SignatureAlgorithm.HS256, SECRET)
                .compact();
    }

    // 解析token,获取userId;失败返回null
    public static Long getUserId(String token) {
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(SECRET)
                    .parseClaimsJws(token)
                    .getBody();
            return claims.get("userId", Long.class);
        } catch (Exception e) {
            return null;
        }
    }

    // 校验token是否有效
    public static boolean checkToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

代码解析

  • JWT 由头部、载荷、签名三部分组成,载荷存放非敏感信息(userId),不可存密码;
  • 密钥是签发和校验的核心,泄露后任何人都能伪造 token,生产环境必须配置在环境变量中;
  • 解析失败直接返回 null,由拦截器判断是否登录。

3.3 ThreadLocal 用户上下文

java 复制代码
public class UserThreadLocal {
    private static final ThreadLocal<SysUser> LOCAL = new ThreadLocal<>();

    public static void set(SysUser user) {
        LOCAL.set(user);
    }
    public static SysUser get() {
        return LOCAL.get();
    }
    public static void remove() {
        LOCAL.remove();
    }
}

代码解析

  • ThreadLocal 利用线程隔离特性,在同一条请求线程内共享用户信息,Service/Controller 任意位置都能直接获取登录用户;
  • 必须在请求结束时调用 remove (),否则 Tomcat 线程池复用线程时,旧用户数据会残留,造成数据错乱与内存泄漏。

3.4 登录拦截器

java 复制代码
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 请求进入Controller之前执行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 放行OPTIONS预检请求
        if ("OPTIONS".equals(request.getMethod())) {
            return true;
        }
        // 1. 从请求头获取token
        String token = request.getHeader("Authorization");
        // 2. 校验token非空、格式合法
        if (StringUtils.isBlank(token) || !JWTUtils.checkToken(token)) {
            return writeError(response, ErrorCode.NOT_LOGIN);
        }
        // 3. 校验token是否在黑名单(退出登录时加入)
        if (Boolean.TRUE.equals(redisTemplate.hasKey(RedisConstants.TOKEN_BLACK_KEY + token))) {
            return writeError(response, ErrorCode.NOT_LOGIN);
        }
        // 4. 解析出userId,查询用户信息
        Long userId = JWTUtils.getUserId(token);
        SysUser user = userService.getById(userId);
        if (user == null) {
            return writeError(response, ErrorCode.NOT_LOGIN);
        }
        // 5. 存入ThreadLocal
        UserThreadLocal.set(user);
        return true;
    }

    // 请求完成后执行,无论是否异常都会执行
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 关键:清除ThreadLocal,防止内存泄漏
        UserThreadLocal.remove();
    }

    // 响应JSON错误信息
    private boolean writeError(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        // 手动添加跨域头,避免拦截器返回时跨域失效
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.getWriter().print(JSON.toJSONString(Result.fail(errorCode)));
        return false;
    }
}

3.5 拦截器注册配置

java 复制代码
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/user/login",
                        "/user/register",
                        "/articles/**",
                        "/tags/**",
                        "/categorys/**"
                );
    }
}

代码解析

  • 放行登录、注册、公开文章列表等无需登录的接口;
  • 写文章、发评论、用户中心等接口必须经过拦截器校验;
  • 注意放行 OPTIONS 请求,否则跨域预检失败。

3.6 登录业务逻辑

java 复制代码
@Override
public Result login(LoginParam param) {
    String account = param.getAccount();
    String password = param.getPassword();
    // 1. 参数已通过@Valid校验,此处可省略基础判空
    // 2. 密码加密后查询用户
    String pwd = MD5Utils.md5(password);
    LambdaQueryWrapper<SysUser> wrapper = Wrappers.lambdaQuery();
    wrapper.eq(SysUser::getAccount, account).eq(SysUser::getPassword, pwd);
    SysUser user = this.getOne(wrapper);
    if (user == null) {
        throw new BusinessException(400, "账号或密码错误");
    }
    // 3. 生成token返回
    String token = JWTUtils.createToken(user.getId());
    // 4. 更新最后登录时间
    user.setLastLogin(new Date());
    this.updateById(user);
    return Result.success(token);
}

3.7 退出登录实现

将 token 加入 Redis 黑名单,实现 JWT 主动失效。

java 复制代码
@Override
public Result logout(String token) {
    // 计算token剩余过期时间,黑名单key与token同生命周期
    long expire = JWTUtils.getExpire(token);
    redisTemplate.opsForValue().set(
            RedisConstants.TOKEN_BLACK_KEY + token,
            "1",
            expire,
            TimeUnit.SECONDS
    );
    return Result.success();
}

代码解析

  • JWT 本身无法主动作废,通过黑名单机制实现退出登录功能;
  • 黑名单 key 设置与 token 一致的过期时间,过期后自动删除,节省 Redis 内存。

四、核心业务模块实现

4.1 首页文章分页列表

java 复制代码
@Override
public Result listArticle(PageParams pageParams) {
    Page<Article> page = new Page<>(pageParams.getPage(), pageParams.getPageSize());
    LambdaQueryWrapper<Article> wrapper = Wrappers.lambdaQuery();
    // 按权重置顶 + 创建时间倒序
    wrapper.orderByDesc(Article::getWeight, Article::getCreateDate);
    page(page, wrapper);
    // 组装VO:只返回前端需要的字段,屏蔽敏感信息
    List<ArticleVO> voList = copyList(page.getRecords());
    return Result.success(new PageResult<>(voList, page.getTotal()));
}

代码解析

  • 数据库实体 Article 不直接返回前端,通过 VO(视图对象)做字段裁剪,避免泄露内部字段;
  • 分页 + 排序是列表类接口的标准写法,权重高的文章优先置顶

4.2 文章详情 + 异步阅读量更新

线程池配置

java 复制代码
@Configuration
@EnableAsync
public class ThreadPoolConfig {
    @Bean("taskExecutor")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("blog-task-");
        // 拒绝策略:调用者执行,避免任务丢失
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

异步更新阅读量

java 复制代码
@Service
@Slf4j
public class ArticleServiceImpl implements ArticleService {

    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;

    @Override
    public Result findArticleById(Long id) {
        Article article = this.getById(id);
        if (article == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        // 1. Redis中阅读量+1(内存操作,性能极高)
        redisTemplate.opsForValue().increment("article:view:" + id);
        // 2. 异步同步到MySQL
        updateViewCountAsync(id);
        // 3. 组装详情VO返回
        return Result.success(copyDetail(article));
    }

    @Async("taskExecutor")
    public void updateViewCountAsync(Long articleId) {
        try {
            // 简化版:异步更新数据库;生产环境配合定时任务批量同步
            Article article = new Article();
            article.setId(articleId);
            article.setViewCounts(this.getById(articleId).getViewCounts() + 1);
            this.updateById(article);
        } catch (Exception e) {
            log.error("更新阅读量失败", e);
        }
    }
}

代码解析

  • 热点文章访问量高,每次直接 update 数据库会造成大量写压力;
  • 先写 Redis 保证计数实时性,再由线程池异步落库,实现读写分离、削峰填谷;
  • 拒绝策略设置为调用者执行,避免线程池满了之后任务直接丢弃。

4.3 树形评论结构构建

java 复制代码
@Override
public Result commentsByArticleId(Long articleId) {
    // 1. 查询该文章所有一级评论(parentId = 0)
    List<Comment> commentList = commentMapper.selectList(
            Wrappers.lambdaQuery(Comment.class)
                    .eq(Comment::getArticleId, articleId)
                    .eq(Comment::getParentId, 0)
                    .orderByDesc(Comment::getCreateDate)
    );
    // 2. 递归填充子评论
    List<CommentVO> voList = commentList.stream().map(comment -> {
        CommentVO vo = copy(comment);
        vo.setChildren(findChildren(comment.getId()));
        return vo;
    }).collect(Collectors.toList());
    return Result.success(voList);
}

// 递归查询子评论
private List<CommentVO> findChildren(Long parentId) {
    List<Comment> children = commentMapper.selectList(
            Wrappers.lambdaQuery(Comment.class).eq(Comment::getParentId, parentId)
    );
    return children.stream().map(c -> {
        CommentVO vo = copy(c);
        vo.setChildren(findChildren(c.getId()));
        return vo;
    }).collect(Collectors.toList());
}

代码解析

  • 采用自关联表设计,通过parent_id实现无限级评论;
  • 递归构建树形结构,适合评论层级不深的场景;层级过深时建议限制最大层级或扁平化处理;
  • 评论量较大时,可一次性查询所有评论再在内存中组装树,减少数据库查询次数。

4.4 发布文章(事务控制)

发布文章需要同时操作文章表、文章内容表、文章标签中间表,必须保证事务一致性。

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public Result publishArticle(ArticleDTO dto) {
    // 1. 获取当前登录用户
    SysUser user = UserThreadLocal.get();
    
    // 2. 保存文章正文
    ArticleBody body = new ArticleBody();
    body.setContent(dto.getContent());
    body.setContentHtml(dto.getContentHtml());
    articleBodyService.save(body);
    
    // 3. 保存文章主表
    Article article = new Article();
    BeanUtils.copyProperties(dto, article);
    article.setAuthorId(user.getId());
    article.setBodyId(body.getId());
    article.setCreateDate(System.currentTimeMillis());
    article.setViewCounts(0);
    article.setCommentCounts(0);
    this.save(article);
    
    // 4. 保存文章-标签关联
    List<Long> tagIds = dto.getTagIds();
    if (CollectionUtils.isNotEmpty(tagIds)) {
        List<ArticleTag> articleTagList = tagIds.stream().map(tagId -> {
            ArticleTag articleTag = new ArticleTag();
            articleTag.setArticleId(article.getId());
            articleTag.setTagId(tagId);
            return articleTag;
        }).collect(Collectors.toList());
        articleTagService.saveBatch(articleTagList);
    }
    
    // 5. 发布成功后清除相关缓存
    redisTemplate.delete(RedisConstants.ARTICLE_HOT_KEY);
    return Result.success(article.getId());
}

代码解析

  • @Transactional(rollbackFor = Exception.class) 声明事务,任何异常都回滚;默认只回滚 RuntimeException;
  • 事务生效前提:方法必须是 public、不能被同类调用、异常不能被内部捕获不抛出;
  • 多表操作必须加事务,防止部分成功部分失败导致数据错乱。

4.5 图片上传实现

本地上传方案(开发测试用)

java 复制代码
@Service
public class UploadServiceImpl implements UploadService {

    @Value("${upload.path}")
    private String uploadPath;
    @Value("${upload.url-prefix}")
    private String urlPrefix;

    @Override
    public Result uploadImage(MultipartFile file) throws IOException {
        // 1. 校验文件类型
        String contentType = file.getContentType();
        if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) {
            throw new BusinessException(400, "仅支持jpg/png图片");
        }
        // 2. 生成唯一文件名,防止重名覆盖
        String originalFilename = file.getOriginalFilename();
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
        String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
        // 3. 保存文件
        File dest = new File(uploadPath + fileName);
        if (!dest.getParentFile().exists()) {
            dest.getParentFile().mkdirs();
        }
        file.transferTo(dest);
        // 4. 返回访问url
        String url = urlPrefix + fileName;
        return Result.success(url);
    }
}
云存储方案(生产环境推荐)

接入阿里云 OSS / 七牛云 / 腾讯云 COS,步骤:

  1. 引入对应云存储 SDK;
  2. 配置 accessKey、secretKey、bucket、域名;
  3. 调用 SDK 上传文件,返回外网访问 URL。

优势:不占用服务器磁盘、自带 CDN 加速、访问速度快、支持图片处理(压缩、水印)。

4.6 文章搜索功能

基础版使用 MySQL 模糊查询,满足简单搜索需求。

java 复制代码
@Override
public Result searchArticle(String keyword) {
    LambdaQueryWrapper<Article> wrapper = Wrappers.lambdaQuery();
    wrapper.like(Article::getTitle, keyword)
            .or().like(Article::getSummary, keyword)
            .orderByDesc(Article::getCreateDate);
    List<Article> list = this.list(wrapper);
    return Result.success(copyList(list));
}

代码解析

  • 默认 JDK 序列化存入 Redis 是二进制乱码,可读性差且序列化体积大;
  • JSON 序列化后 Redis 客户端可直观查看数据,便于调试与排查。

5.2 通用缓存工具封装

java 复制代码
@Component
public class CacheUtils {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 通用缓存查询:先查缓存,命中返回;未命中查库并写入缓存
     * @param key 缓存key
     * @param timeout 过期时间 秒
     * @param dbQuery 数据库查询函数(函数式编程)
     */
    public Object getCache(String key, long timeout, Supplier<Object> dbQuery) {
        // 1. 查缓存
        Object cache = redisTemplate.opsForValue().get(key);
        if (cache != null) {
            return cache;
        }
        // 2. 查数据库
        Object data = dbQuery.get();
        if (data != null) {
            // 3. 写入缓存并设置过期时间
            redisTemplate.opsForValue().set(key, data, timeout, TimeUnit.SECONDS);
        } else {
            // 缓存空值,防止缓存穿透
            redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS);
        }
        return data;
    }
}

代码解析

  • 使用 Supplier 函数式接口,将数据库查询逻辑作为参数传入,实现缓存逻辑与业务解耦;
  • 统一设置过期时间,避免永久缓存导致的数据不一致;
  • 新增空值缓存,解决缓存穿透问题。

5.3 业务层使用示例

java 复制代码
@Override
public Result hotArticle() {
    Object cache = cacheUtils.getCache(
            RedisConstants.ARTICLE_HOT_KEY,
            RedisConstants.ARTICLE_CACHE_TIMEOUT,
            () -> {
                // 查询浏览量最高的前5篇文章
                List<Article> list = this.list(
                        Wrappers.lambdaQuery(Article.class)
                                .orderByDesc(Article::getViewCounts)
                                .last("limit 5")
                );
                return copyList(list);
            }
    );
    return Result.success(cache);
}

5.4 缓存三大经典问题及解决方案

1. 缓存穿透

问题 :查询不存在的数据,缓存和数据库都没有,请求直接打到数据库,大量恶意请求会压垮数据库。 本项目解决方案

  • 缓存空值:查询结果为 null 时,也在 Redis 中存入空值,设置较短过期时间;
  • 进阶方案:布隆过滤器,将所有存在的 id 存入布隆过滤器,不存在的直接拦截。
2. 缓存击穿

问题 :某个热点 key 过期瞬间,大量并发请求同时涌入数据库,导致数据库压力骤增。 解决方案

  • 互斥锁:缓存失效时,只有一个线程去查数据库并重建缓存,其他线程等待;
  • 热点数据永不过期:物理上不设过期时间,更新数据时主动删除缓存;
  • 本项目热门文章可采用永不过期 + 更新时主动删除的方案。
3. 缓存雪崩

问题 :大量 key 同一时间集中过期,或者 Redis 宕机,导致所有请求转发到数据库,数据库崩溃。 解决方案

  • 过期时间加随机值:过期时间 = 基础时间 + 随机数(0~300秒),避免同时过期;
  • Redis 集群部署,保证高可用;
  • 服务降级:非核心接口直接返回默认数据,保护核心业务。

5.5 定时任务:阅读量批量同步

替代单条异步更新,定时批量将 Redis 中的阅读量同步到 MySQL,降低数据库写入频率。

java 复制代码
@Component
@EnableScheduling
@Slf4j
public class ViewCountTask {

    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    @Autowired
    private ArticleService articleService;

    // 每5分钟执行一次
    @Scheduled(cron = "0 */5 * * * ?")
    public void syncViewCount() {
        log.info("开始同步阅读量...");
        // 1. 匹配所有阅读量key
        Set<String> keys = redisTemplate.keys("article:view:*");
        if (CollectionUtils.isEmpty(keys)) {
            return;
        }
        // 2. 批量更新数据库
        for (String key : keys) {
            Long articleId = Long.valueOf(key.split(":")[2]);
            Integer count = redisTemplate.opsForValue().get(key);
            if (count != null && count > 0) {
                Article article = new Article();
                article.setId(articleId);
                article.setViewCounts(count);
                articleService.updateById(article);
            }
        }
        // 3. 删除已同步的key
        redisTemplate.delete(keys);
        log.info("阅读量同步完成,共{}篇", keys.size());
    }
}

代码解析

  • 定时任务批量写入,将多次数据库操作合并为周期性操作,大幅降低数据库压力;
  • @EnableScheduling 开启定时任务,cron表达式定义执行时机;
  • 生产环境注意加分布式锁,防止集群部署时重复执行。

六、AOP 切面:操作日志实现

6.1 自定义日志注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {
    String module() default "";    // 模块名
    String operation() default ""; // 操作描述
}

6.2 日志切面

java 复制代码
@Aspect
@Component
@Slf4j
public class LogAspect {

    // 切入点:所有加了@LogAnnotation的方法
    @Pointcut("@annotation(com.mszlu.blog.common.aop.LogAnnotation)")
    public void logPointCut() {}

    // 环绕通知
    @Around("logPointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        long beginTime = System.currentTimeMillis();
        // 执行原方法
        Object result = joinPoint.proceed();
        // 计算耗时
        long cost = System.currentTimeMillis() - beginTime;
        // 记录日志
        recordLog(joinPoint, cost);
        return result;
    }

    private void recordLog(ProceedingJoinPoint joinPoint, long cost) {
        // 获取注解信息
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        LogAnnotation annotation = signature.getMethod().getAnnotation(LogAnnotation.class);
        // 获取请求IP、用户
        HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
        String ip = IpUtils.getIpAddr(request);
        SysUser user = UserThreadLocal.get();

        log.info("=================操作日志=================");
        log.info("模块:{}", annotation.module());
        log.info("操作:{}", annotation.operation());
        log.info("请求IP:{}", ip);
        log.info("操作用户:{}", user != null ? user.getNickname() : "未登录");
        log.info("请求参数:{}", JSON.toJSONString(joinPoint.getArgs()));
        log.info("执行耗时:{}ms", cost);
    }
}

代码解析

  • AOP 实现日志与业务代码完全解耦,业务方法只需加一个注解即可自动记录日志;
  • 环绕通知可以统计方法执行耗时,便于性能监控;
  • 生产环境可将日志持久化到数据库,配合后台做操作审计。

6.3 配套工具类

HttpContextUtils:获取当前请求对象

java 复制代码
public class HttpContextUtils {
    public static HttpServletRequest getHttpServletRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attributes == null ? null : attributes.getRequest();
    }
}

IpUtils:获取客户端真实 IP

java 复制代码
public class IpUtils {
    public static String getIpAddr(HttpServletRequest request) {
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        // 多级代理时,第一个IP为真实IP
        return ip.split(",")[0].trim();
    }
}

七、消息队列:RocketMQ 解决缓存一致性

7.1 问题背景

更新文章时,先更新数据库再删除缓存,如果删除缓存失败,会导致数据库是新数据、缓存是旧数据,出现数据不一致。使用 MQ 异步解耦,保证最终一致性。

7.2 生产者:更新文章后发送消息

java 复制代码
@Service
public class ArticleServiceImpl {
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Result updateArticle(Article article) {
        // 1. 更新数据库
        this.updateById(article);
        // 2. 更新标签关联(略)
        // 3. 发送消息,通知删除缓存
        rocketMQTemplate.convertAndSend("article-cache-topic", article.getId());
        return Result.success();
    }
}

7.3 消费者:监听消息删除缓存

java 复制代码
@Component
@RocketMQMessageListener(topic = "article-cache-topic", consumerGroup = "blog-cache-group")
public class CacheConsumer implements RocketMQListener<Long> {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onMessage(Long articleId) {
        // 删除文章详情缓存
        redisTemplate.delete(RedisConstants.ARTICLE_DETAIL_KEY + articleId);
        // 同时删除首页列表、热门文章等相关缓存
        redisTemplate.delete(RedisConstants.ARTICLE_HOT_KEY);
        log.info("已清除文章缓存,id:{}", articleId);
    }
}

代码解析

  • 消息队列实现了业务主流程与缓存删除的解耦,即使删除缓存失败,MQ 也会重试;
  • 保证最终一致性,适用于对实时性要求不是极高的场景;
  • 本项目作为练手项目,也可直接同步删除缓存,MQ 属于进阶优化点。

7.4 接口幂等性设计

防止用户重复提交、MQ 重复消费导致的数据重复问题。

防重提交方案(Redis 令牌)

  1. 进入发布页面前,后端生成唯一防重 token,存入 Redis 并返回前端;
  2. 提交请求时,请求头携带该 token;
  3. 后端校验 token 是否存在,存在则删除 token 并执行业务,不存在则返回重复提交。
java 复制代码
// 生成防重token
public String generateToken() {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("submit:token:" + token, "1", 300, TimeUnit.SECONDS);
    return token;
}

// 校验防重token
public boolean checkToken(String token) {
    String key = "submit:token:" + token;
    Boolean delete = redisTemplate.delete(key);
    return Boolean.TRUE.equals(delete);
}

八、后台管理:Spring Security + RBAC 权限

8.1 核心配置类

java 复制代码
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级权限注解
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsService;
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/admin/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin().disable()
                // 前后端分离,用JWT替代session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // 添加JWT认证过滤器
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

8.2 自定义用户权限加载

java 复制代码
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AdminUserMapper adminUserMapper;
    @Autowired
    private PermissionMapper permissionMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 查询管理员用户
        AdminUser user = adminUserMapper.selectOne(
                Wrappers.lambdaQuery(AdminUser.class).eq(AdminUser::getUsername, username)
        );
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }
        // 2. 根据用户id查询所有权限标识
        List<String> perms = permissionMapper.selectPermsByUserId(user.getId());
        List<SimpleGrantedAuthority> authorities = perms.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        // 3. 返回Spring Security需要的用户对象
        return new User(user.getUsername(), user.getPassword(), authorities);
    }
}

8.3 方法级权限控制

java 复制代码
@RestController
@RequestMapping("/admin/article")
public class AdminArticleController {

    // 只有拥有 sys:article:delete 权限的用户才能调用
    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('sys:article:delete')")
    public Result delete(@PathVariable Long id) {
        articleService.removeById(id);
        return Result.success();
    }
}

代码解析

  • RBAC 模型:用户 → 角色 → 权限,通过中间表关联,灵活分配权限;
  • UserDetailsService 是 Spring Security 的核心扩展点,从数据库加载用户与权限;
  • @PreAuthorize 注解实现细粒度接口权限控制,权限标识与数据库对应。

九、项目部署与环境配置

9.1 多环境配置

SpringBoot Profile 实现开发 / 生产环境隔离。

application.yml 主配置

java 复制代码
spring:
  profiles:
    active: dev  # 切换环境:dev开发 prod生产

application-dev.yml 开发环境

java 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
  redis:
    host: localhost
    port: 6379

application-prod.yml 生产环境

java 复制代码
spring:
  datasource:
    url: jdbc:mysql://内网地址:3306/blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: 生产账号
    password: 生产密码
  redis:
    host: 内网Redis地址
    port: 6379
    password: Redis密码

9.2 项目打包

bash 复制代码
# Maven打包,跳过测试
mvn clean package -DskipTests

打包后在target目录生成 jar 包,如blog-admin.jar

9.3 Docker 部署

Dockerfile

bash 复制代码
# 基础镜像
FROM openjdk:8-jdk-alpine
# 设置时区
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
# 工作目录
WORKDIR /app
# 复制jar包
COPY target/blog-admin.jar app.jar
# 暴露端口
EXPOSE 8080
# 启动命令
ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","app.jar"]

构建镜像并启动

bash 复制代码
# 构建镜像
docker build -t blog-app:v1 .
# 启动容器
docker run -d -p 8080:8080 --name blog blog-app:v1

9.4 Nginx 反向代理

bash 复制代码
server {
    listen 80;
    server_name yourdomain.com;

    # 前端静态资源
    location / {
        root /usr/share/nginx/html/dist;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    # 后端接口转发
    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

十、常见后端问题与排坑指南

1.MyBatis-Plus 分页不生效

原因:未配置分页插件;解决方案:添加MybatisPlusInterceptor分页拦截器

2.事务不生效

常见原因:方法不是 public;同类中方法调用(没有经过代理对象);异常被 try-catch 吃掉没抛出;@Transactional 没加 rollbackFor。

解决:方法声明为 public;注入自身调用;异常向外抛出;指定 rollbackFor = Exception.class。

3.跨域配置不生效

原因:拦截器提前返回响应,没走 SpringMVC 的跨域处理;

解决:拦截器返回错误时手动添加跨域响应头,或使用 CorsFilter 过滤器级别的跨域配置。

4.Redis 序列化异常

原因:实体类没实现 Serializable,或 Jackson 序列化时类型不匹配;

解决:使用 Jackson2JsonRedisSerializer 序列化,配置 ObjectMapper 支持多态类型。

5.ThreadLocal 数据错乱

原因:线程池复用线程,请求结束未 remove;

解决:在拦截器afterCompletion中强制调用 remove ()。

6.@Async 异步不生效

原因:同类调用,没有走 Spring 代理;

解决:异步方法单独放一个类,通过注入调用。

7.日期时间不一致 / 时区问题

原因:MySQL、JVM、服务器时区不统一;

解决:JDBC 连接加serverTimezone=Asia/Shanghai,JVM 启动参数设置时区。

十一、面试高频问题整理

1.ThreadLocal 原理?为什么会内存泄漏?项目中如何避免?

ThreadLocal 为每个线程创建独立副本,底层是 ThreadLocalMap,key 为弱引用,value 为强引用。线程池复用线程时,如果不手动 remove,value 会一直被线程持有无法回收,造成内存泄漏。本项目在拦截器afterCompletion中强制调用 remove ()。

2.JWT 登录的优缺点?为什么还要配合 Redis?

优点:无状态、服务端无需存储 session、天然支持分布式;缺点:签发后无法主动失效。Redis 用于存储 token 黑名单,实现退出登录、强制下线等功能。

3.项目中如何处理缓存一致性问题?

更新数据库后通过 RocketMQ 发送消息,消费者异步删除缓存,保证最终一致性;同时所有缓存设置过期时间,作为兜底方案。

4.为什么阅读量要异步更新?直接 update 不行吗?

热点文章访问量高,同步写数据库会造成大量 IO 压力,甚至拖垮数据库。Redis + 定时任务批量同步可以极大削峰,降低数据库压力,提升接口响应速度。

5.缓存穿透、击穿、雪崩分别是什么?怎么解决?

穿透:查不存在的数据,打穿缓存;解决:缓存空值、布隆过滤器。

击穿:热点 key 过期,大量请求打 DB;解决:互斥锁、热点永不过期。

雪崩:大量 key 同时过期 / Redis 宕机;解决:随机过期时间、集群、降级。

6.全局异常处理的好处?

统一错误返回格式,避免堆栈信息泄露,集中管理异常日志,业务代码无需大量 try-catch,专注业务逻辑。

7.AOP 在项目中的应用场景?

操作日志、权限校验、性能监控、事务管理,实现非业务逻辑与业务代码解耦。

8.RBAC 权限模型的设计思路?

用户、角色、权限三张主表 + 两张中间表,用户关联角色,角色关联权限;通过给角色分配权限、给用户分配角色实现灵活的权限管控,最小粒度到接口权限标识。

9.@Transactional 事务失效的场景有哪些?

方法非 public、同类内部调用、异常被捕获未抛出、未指定 rollbackFor、数据库引擎不支持事务、多线程环境。

相关推荐
Csvn1 小时前
📊 SQL 入门 Day 25:查询优化实战 —— 从 EXPLAIN 到索引的完整排查流
后端·sql
用户298698530141 小时前
将 Excel 表格转换为图片的三种实用方法
人工智能·后端·excel
阿源聊AI1 小时前
给能退款、改库、跑代码的 AI Agent 加三道安全闸:一次零信任 Demo 实测
人工智能·后端
Csvn1 小时前
📊 SQL 入门 Day 24:触发器与事件
后端·sql
Crawl1 小时前
5.登录与分页功能分析
java·后端
LinMINGJing0071 小时前
简单web服务器示例图
后端
SimonKing1 小时前
SwitchHosts V5大改版,我发现了这些惊喜和坑
java·后端·程序员
小白勇闯网安圈1 小时前
Vue 工程化开发:组件复用、插槽、项目结构与 ES6 模块化
javascript·vue.js·es6
坤岭1 小时前
企业级RAG全链路
后端