JavaWeb 笔记 04 (46 - 49)

SpringBootWeb 案例, 分层解耦相关 (46 - 49)

提前声明:本文章仅作个人学习记录,不涉及商业用途。

内容记录于学习途中, 没有按分类进行总结, 可能存在错误或不完整之处。

视频地址

在线讲义地址

第一章 SpringBootWeb 案例

1. 常用依赖

启动依赖
Developer Tools -> Lombok
// 自动生成 Getter / Setter 与构造函数
Developer Tools -> Spring Boot DevTools
// 提供代码热重启与浏览器实时重载
Developer Tools -> Spring Configuration Processor
// 为自定义配置属性提供 IDE 自动补全提示
Web -> Spring Web
// 构建 RESTful 及 MVC 网络服务
SQL -> JDBC API
// 连接数据库并执行 SQL 语句
SQL -> MyBatis Framework
// 支持自定义 SQL、存储过程的持久层框架
常用依赖
<!-- JSON 对象转换 -->
<dependency>
  <groupId>cn.hutool</groupId>
  <artifactId>hutool-all</artifactId>
  <version>5.8.40</version>
</dependency>

<!-- 阿里云OSS Java SDK V2 -->
<dependency>
  <groupId>com.aliyun</groupId>
  <artifactId>alibabacloud-oss-v2</artifactId>
  <version>0.3.2</version>
</dependency>

<!-- knife4j-->
<dependency>
  <groupId>com.github.xiaoymin</groupId>
  <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
  <version>4.4.0</version>
</dependency>

<!-- MyBatis-Plus -->
<!-- SpringBoot4 需删除 mybatis-spring-boot-starter -->
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-spring-boot4-starter</artifactId>
  <version>3.5.16</version>
</dependency>
<!-- MyBatis-Plus JSqlParser (分页插件) -->
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-jsqlparser</artifactId>
  <version>3.5.16</version>
</dependency>

<!-- JWT -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.13.0</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.13.0</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.13.0</version>
  <scope>runtime</scope>
</dependency>

<!-- AOP -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
  <version>4.0.0-M2</version>
  <scope>compile</scope>
</dependency>
可选依赖
<!-- MySQL JDBC driver -->
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.33</version>
</dependency>

<!-- Junit -->
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>6.0.3</version>
  <scope>test</scope>
</dependency>

<!-- 命令行调试用 -->
<dependency>
  <groupId>commons-cli</groupId>
  <artifactId>commons-cli</artifactId>
  <version>1.11.0</version>
  <scope>compile</scope>
</dependency>

<!-- PageHelper -->
<!-- 已被 MyBatis-Plus JSqlParser 代替 -->
<dependency>
  <groupId>com.github.pagehelper</groupId>
  <artifactId>pagehelper-spring-boot-starter</artifactId>
  <version>2.1.1</version>
</dependency>
plugin
<!-- 显式声明 mockito-->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <argLine>
      -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
    </argLine>
  </configuration>
</plugin>
<plugin>

3. 配置文件 application.yaml

spring:
  application:
    name: tlias-dev

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/tlias
    username: fallw
    password: real-password

  servlet:
    multipart:
      # 单个文件的最大限制(默认 1MB)
      max-file-size: 10MB
      # 单个请求的总大小限制(包含表单其他字段,默认 10MB)
      max-request-size: 50MB

aliyun:
  oss:
    endpoint: https://oss-cn-beijing.aliyuncs.com
    bucketName: real-bucket-name
    region: cn-beijing

jwt:
  secretKey: SVRIRUlNQQ==DefaultFallbackKeyForDevOnly
  expire: 43200000

mybatis-plus:
  mapper-locations: classpath:com/fallw/mapper/*.xml
  type-aliases-package: com.fallw.entity

  configuration:
    auto-mapping-behavior: full
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 输出 SQL 到控制台
    map-underscore-to-camel-case: true

  global-config:
    db-config:
      logic-delete-field: isDeleted # 全局逻辑删除字段名
      logic-delete-value: 1 # 逻辑已删除值
      logic-not-delete-value: 0 # 逻辑未删除值

# 日志配置
logging:
  level:
    org.springframework.jdbc.support.JdbcTransactionManager: debug
可选配置
mybatis:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    map-underscore-to-camel-case: true
  mapper-locations: classpath*:mapper/*.xml

pagehelper:
  helper-dialect: mysql
  reasonable: true
  support-methods-arguments: true

2. 入门案例

静态资源存放位置:src/main/resources/static

@ResponseBody 注解的作用:

  1. 将 Controller 方法的返回值直接写入 HTTP 响应体
  2. 如果返回值是对象或集合,SpringMVC 会自动将数据转换为 JSON 再响应
  3. @RestController = @Controller + @ResponseBody
// RestController 将对象/集合 -> JSON -> 响应体
@RestController
public class UserController {
  @RequestMapping("/list")
  public List<User> list() throws Exception {
    // 1. 数据访问:获取用户数据
    InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
    ArrayList<String> lines = IoUtils.readLines(in, StandardCharsets.UTF_8, new ArrayList<>());

    // 2. 逻辑处理:解析数据、封装成对象
    List<User> userList = lines.stream().map(line -> {
      String[] parts = line.split(",");
      Integer id = Integer.parseInt(parts[0]);
      String username = parts[1];
      String password = parts[2];
      String name = parts[3];
      Integer age = Integer.parseInt(parts[4]);
      LocalDateTime updateTime = LocalDateTime.parse(parts[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

      return new User(id, username, password, name, age, updateTime);
    }).collect(Collectors.toList());

    // 3. 接收请求、响应数据
    return userList;
  }
}

第二章 分层解耦

一、三层架构

1. 三层架构

  1. 控制层 (Controller): 接收前端发送的请求,对请求进行处理,并响应数据。
  2. 业务逻辑层 (Service): 处理具体的业务逻辑。
  3. 数据访问层 (Data Acess Object): 持久层,负责数据库访问操作。

2. 分层解耦

  1. 耦合:衡量软件中各个层 / 模块之间的依赖关联程度。
  2. 内聚:软件中各个模块内部的功能关系。
  3. 软件设计原则:高内聚低耦合。

3. MyBatis-Plus:

  1. Service 类继承 IService<T>,ServiceImpl 类继承 ServiceImpl<M, T>
  2. Service 层同模块下使用 LambdaQueryWrapper 查询同模块表。注入 Service 实现跨表调用。
  3. Mapper 类继承 BaseMapper<T>
  4. Mapper 层只写复杂的查询逻辑。增删改永远优先使用 Service。

二、IOC & DI

IOC (Inversion of Control): 控制反转,对象的控制权由程序自身转移到外部(容器)。

DI (Dependency Injection): 依赖注入,容器为应用程序提供运行时,所依赖的资源。

Bean 对象:IOC 容器中创建、管理的对象。

1. Bean 对象定义 Bean Definition (IOC)

注解 说明 位置
@Component 声明 bean 的基础注解 不属于以下三类时,用此注解
使用 @Component(value = "userMapper") 指定 bean 的名称
@Controller @Component 的衍生注解 标注在控制层类上
声明控制器只能用 @Controlller
@RestController 控制层常用注解 @Controller + @ResponseBody
@Service @Component 的衍生注解 标注在业务层类上
@Repository @Component 的衍生注解 标注在数据访问层类上
由于与 Mybatis 整合,用的少
@Mapper MyBatis 提供的持久层注解 标注在 Mapper 接口上
由 MyBatis 框架提供,非 Spring 原生
@ComponentScan 组件扫描注解 @SpringBootApplication 默认扫描范围是启动类所在包 (com.fallw) 及子包

2. 依赖注入 Dependency Injection (DI)

推荐使用 @RequiredArgsConstructor
// @RequiredArgsConstructor: 自动为所有标注为 final 的字段生成构造函数
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
  private final UserMapper userMapper;
  ...
}
属性注入 @Autowired
@Service
public class UserServiceImpl implements UserService {
  // @Autowied: Spring 自动从容器中查找并注入
  @Autowired
  private UserMapper userMapper;
  ...
}
构造器注入
@Service
public class UserServiceImpl implements UserService {
  private final UserMapper userMapper;
  // Spring 4.x 后,如果只有一个构造函数,@Autowired 可省略
  @Autowired
  public UserServiceImpl(UserMapper userMapper) {
      this.userMapper = userMapper;
  }
  ...
}
setter 注入
@Service
public class UserServiceImpl implements UserService {
  private UserMapper userMapper;
  @Autowired
  public void setUserMapper(UserMapper userMapper) {
    this.userMapper = userMapper;
  }
  ...
}

3. 重复注入处理

@Autowired 默认按类型注入,找不到再按名称注入。

@Primary
// @Primary 指定优先注入对象
@Primary
@Service
public class UserServiceImpl implements UserService {
  ...
}
@Autowird + @Qualifier
@Service
public class UserServiceImpl implements UserService {
  // @Qualifier 指定注入对象名称
  @Qualifier("userMapper")
  @Autowired
  private UserMapper userMapper;
  ...
}
@Resource
@Service
public class UserServiceImpl implements UserService {
  // @Resource 指定注入对象名称
  @Resource(name = "userMapper")
  private UserMapper userMapper;
  ...
}
@Autowired @Resource
Spring 框架提供 Java EE 规范提供
默认按类型注入 默认按名称注入

第三章 Tlias 班级管理模块

一、实体类 Entity / VO / DTO

1. 班级类 Course

// com.fallw.entity.Course
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("course")
public class Course {
  @TableId(type = IdType.AUTO)
  private Integer id; //ID

  private String name; //班级名称
  private String room; //班级教室
  private LocalDate beginDate; //开课时间
  private LocalDate endDate; //结课时间
  private Integer masterId; //班主任
  private Integer subject; //学科

  @TableField(fill = FieldFill.INSERT)
  private LocalDateTime createTime; //创建时间
  @TableField(fill = FieldFill.INSERT_UPDATE)
  private LocalDateTime updateTime; //修改时间

  @TableLogic
  private Integer isDeleted;

  @TableField(exist = false)
  private String masterName; //班主任姓名
  @TableField(exist = false)
  private String status; //班级状态 - 未开班 , 在读 , 已结课
}

2. 前端传递的查询参数 CourseQueryParam

// com.fallw.pojo.dto.CourseQueryParam
@Data
public class CourseQueryParam {
  private Integer page = 1;
  private Integer pageSize = 10;
  private String name;
  @DateTimeFormat(pattern = "yyyy-MM-dd")
  private LocalDate begin;
  @DateTimeFormat(pattern = "yyyy-MM-dd")
  private LocalDate end;
}

3. 分页查询结果 PageResult

// com.fallw.pojo.vo.PageResult
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
  private Long total; //总记录数
  private List<T> rows; //当前页数据列表
}

4. 响应类 Result

// com.fallw.pojo.vo.Result
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
  private Integer code;
  private String msg;
  private Object data;

  public static Result success() {
    return new Result(1, "success", null);
  }

  public static Result success(Object object) {
    return new Result(1, "success", object);
  }

  public static Result error(String msg) {
    return new Result(0, msg, null);
  }
}

二、Controller 层

// com.fallw.controller.CourseController
@Slf4j
@RequestMapping("/clazzs")
@RestController
@RequiredArgsConstructor
@Tag(name = "班级管理")
public class CourseController {
  private final CourseService courseService;

  @Operation(summary = "班级列表查询")
  @GetMapping
  public Result pageQuery(CourseQueryParam courseQueryParam) {
    log.info("查询请求参数: {}", courseQueryParam);
    PageResult<Course> pageResult = courseService.pageQuery(courseQueryParam);
    return Result.success(pageResult);
  }

  @Operation(summary = "删除班级信息")
  @DeleteMapping("/{id}")
  public Result deleteById(@PathVariable Integer id) {
    log.info("删除班级信息:{}", id);
    courseService.deleteById(id);
    return Result.success();
  }

  @Operation(summary = "添加班级")
  @PostMapping
  public Result insertCourse(@RequestBody Course course) {
    log.info("添加班级: {}", course);
    courseService.insertCourse(course);
    return Result.success();
  }
  @Operation(summary = "根据ID查询")
  @GetMapping("/{id}")
  public Result getCourseById(@PathVariable Integer id) {
    return Result.success(courseService.getCourseById(id));
  }

  @Operation(summary = "修改班级")
  @PutMapping
  public Result updateCourse(@RequestBody Course course) {
    log.info("修改班级: {}", course);
    courseService.updateCourse(course);
    return Result.success();
  }

  @Operation(summary = "查询所有班级")
  @GetMapping("/list")
  public Result queryList() {
    return Result.success(courseService.queryList());
  }
}

三、Service 层

1. 接口 CourseService

// com.fallw.service.CourseService
public interface CourseService {
  PageResult<Course> pageQuery(CourseQueryParam courseQueryParam);
  void deleteById(Integer id);
  void insertCourse(Course course);
  Course getCourseById(Integer id);
  void updateCourse(Course course);
  List<Course> queryList();
}

2. 实现类 CourseServiceImpl

// com.fallw.service.impl.CourseServiceImpl
@Slf4j
@Service
@RequiredArgsConstructor
public class CourseServiceImpl extends ServiceImpl<CourseMapper, Course> implements CourseService {
  final private StudentService studentService;

  @Transactional(rollbackFor = Exception.class)
  @Override
  public PageResult<Course> pageQuery(CourseQueryParam courseQueryParam) {
    Page<Course> page = new Page<>(courseQueryParam.getPage(), courseQueryParam.getPageSize());
    baseMapper.pageQuery(page, courseQueryParam);
    return new PageResult<>(page.getTotal(), page.getRecords());
  }

  @Override
  public void deleteById(Integer courseId) {
    Long count = studentService.lambdaQuery().eq(Student::getCourseId, courseId).count();
    if (count > 0) {
      throw new BusinessException("对不起, 该班级下有学生, 不能直接删除");
    }
    baseMapper.deleteById(courseId);
  }

  @Transactional(rollbackFor = Exception.class)
  @Override
  public void insertCourse(Course course) {
    baseMapper.insert(course);
  }

  @Override
  public Course getCourseById(Integer id) {
    return baseMapper.selectById(id);
  }

  @Transactional(rollbackFor = Exception.class)
  @Override
  public void updateCourse(Course course) {
    baseMapper.updateById(course);
  }

  @Override
  public List<Course> queryList() {
    Page<Course> page = new Page<>();
    baseMapper.pageQuery(page, new CourseQueryParam());
    return page.getRecords();
  }
}

三、Mapper 层

1. 接口 CourseMapper

// com.fallw.mapper.CourseMapper
@Mapper
public interface CourseMapper extends BaseMapper<Course> {
  IPage<Course> pageQuery(Page<Course> page, @Param("courseQueryParam") CourseQueryParam courseQueryParam);
}

2. XML 文件 CourseMapper.xml

<!-- resources/com/fallw/mapper/CourseMapper.xml -->
<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.fallw.mapper.CourseMapper">
  <select id="pageQuery" resultType="com.fallw.entity.Course">
    select c.*, e.name as masterName,
    case
    when curdate() &lt; c.begin_date then '未开班'
    when curdate() &gt; c.end_date then '已结课'
    else '在读'
    end as status
    from course as c
    left join employee as e on c.master_id = e.id
    <where>
      <if test="courseQueryParam.name != null and courseQueryParam.name != ''">
        and c.name like concat('%',#{courseQueryParam.name},'%')
      </if>
      <if test="courseQueryParam.begin != null and courseQueryParam.end != null">
        and c.begin_date between #{courseQueryParam.begin} and #{courseQueryParam.end}
      </if>
      and c.is_deleted = 0
    </where>
    order by c.update_time desc
  </select>
</mapper>

四、配置类 Config

1. 配置 MP 自动填充

// com.fallw.config.MyMetaObjectHandler
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
  @Override
  public void insertFill(MetaObject metaObject) {
    this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
    this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
  }

  @Override
  public void updateFill(MetaObject metaObject) {
    this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
  }
}

2. 配置 MP 分页插件

// com.fallw.config.MybatisPlusConfig
@Configuration
public class MybatisPlusConfig {
  @Bean
  public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
  }
}

五、异常类 Exception

1. 业务异常类 BusinessException

// com.fallw.exception.BusinessException
public class BusinessException extends RuntimeException {
  public BusinessException(String message) {
    super(message);
  }
}

2. 全局异常捕获 GlobalExceptionHandler

// com.fallw.exception.GlobalExceptionHandler
@Slf4j
@RestControllerAdvice
@Hidden
public class GlobalExceptionHandler {

  @ExceptionHandler(BusinessException.class)
  public Result handleBusinessException(BusinessException e) {
    log.error("业务校验失败: {}", e.getMessage());
    return Result.error(e.getMessage());
  }

  @ExceptionHandler(Exception.class)
  public Result handleException(Exception e) {
    log.error("系统异常: ", e);
    return Result.error("对不起,操作失败,请联系管理员");
  }
}

第四章 工具类存档

0. 启动类注解

@SpringBootApplication
@MapperScan("com.fallw.mapper")
@ServletComponentScan
public class TliasDevApplication {
  public static void main(String[] args) {
    SpringApplication.run(TliasDevApplication.class, args);
  }
}

一、阿里云 OSS 上传图片

1. pom.xml

<!-- 阿里云OSS Java SDK V2 -->
<dependency>
  <groupId>com.aliyun</groupId>
  <artifactId>alibabacloud-oss-v2</artifactId>
  <version>0.3.2</version>
</dependency>

2. application.yaml

aliyun:
  oss:
    endpoint: https://oss-cn-beijing.aliyuncs.com
    bucketName: real-bucket-name
    region: cn-beijing

3. 控制层 UploadController

// com.fallw.controller.UploadController
@Slf4j
@RestController
@Tag(name = "文件上传")
@RequiredArgsConstructor
public class UploadController {
  final private AliyunOSSOperator aliyunOSSOperator;

  @Operation(summary = "上传图片接口")
  @PostMapping("/upload")
  public Result upload(MultipartFile file) throws Exception {
    log.info("上传文件:{}", file);
    if (!file.isEmpty()) {
      // 生成唯一文件名
      String originalFilename = file.getOriginalFilename();
      String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
      String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
      // 上传文件
      String url = aliyunOSSOperator.upload(file.getBytes(), uniqueFileName);
      return Result.success(url);
    }
    return Result.error("上传失败");
  }
}

4. 配置类 AliyunOSSProperties

// com.fallw.config.AliyunOSSProperties
@Component
@Data
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
  private String endpoint;
  private String bucketName;
  private String region;
}

5. 工具类 AliyunOSSOperator

// com.fallw.utils.AliyunOSSOperator
@Slf4j
@Component
@RequiredArgsConstructor
public class AliyunOSSOperator implements InitializingBean, AutoCloseable {
  private static final String TEMP_DIR = "temp";
  private static final String AVATAR_DIR = "employee/avatar";
  private static final String DATE_PATTERN = "yyyy/MM";

  private final AliyunOSSProperties properties;
  private OSSClient ossClient; // 改为长连接成员变量

  @Override
  public void afterPropertiesSet() {
    // 初始化客户端(避免每次调用都重新创建)
    CredentialsProvider provider = new EnvironmentVariableCredentialsProvider();
    this.ossClient = OSSClient.newBuilder()
        .credentialsProvider(provider)
        .region(properties.getRegion())
        .endpoint(properties.getEndpoint())
        .build();
  }

  public String upload(byte[] content, String originalFilename) {
    String dir = String.format("%s/%s", TEMP_DIR, LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_PATTERN)));
    String objectKey = generateObjectKey(dir, originalFilename);

    PutObjectRequest request = PutObjectRequest.newBuilder()
        .bucket(properties.getBucketName())
        .key(objectKey)
        .body(BinaryData.fromBytes(content))
        .build();

    ossClient.putObject(request);
    return buildFullUrl(objectKey);
  }

  public String moveToAvatar(String tempUrl) {
    // 复制
    String avatarUrl = copy(tempUrl, AVATAR_DIR);

    // 尝试删除临时文件(失败仅记录,不阻塞业务逻辑)
    try {
      delete(tempUrl);
    } catch (Exception e) {
      log.warn("OSS临时文件清理失败, path: {}, 错误: {}", tempUrl, e.getMessage());
    }

    return avatarUrl;
  }

  public String copy(String sourceUrl, String targetDir) {
    String bucketName = properties.getBucketName();
    String sourceKey = extractObjectKey(sourceUrl);

    // 提取原文件名并生成新Key
    String fileName = sourceKey.substring(sourceKey.lastIndexOf("/") + 1);
    String targetPath = String.format("%s/%s", targetDir, LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_PATTERN)));
    String targetKey = generateObjectKey(targetPath, fileName);

    CopyObjectRequest copyRequest = CopyObjectRequest.newBuilder()
        .bucket(bucketName)
        .key(targetKey)
        .sourceBucket(bucketName)
        .sourceKey(sourceKey)
        .build();

    ossClient.copyObject(copyRequest);
    return buildFullUrl(targetKey);
  }

  public void delete(String url) {
    if (url == null || url.isBlank()) return;

    try {
      String objectKey = extractObjectKey(url);
      DeleteObjectRequest deleteRequest = DeleteObjectRequest.newBuilder()
          .bucket(properties.getBucketName())
          .key(objectKey)
          .build();

      ossClient.deleteObject(deleteRequest);
      log.info("OSS文件删除成功: {}", objectKey);
    } catch (Exception e) {
      // 关键:记录警告但不抛出异常,防止干扰主业务事务
      log.warn("OSS文件删除失败, URL: {}, 原因: {}", url, e.getMessage());
    }
  }

  public void deleteBatch(List<String> urls) {
    if (CollectionUtils.isEmpty(urls)) return;

    // 1. 提取所有有效 Key
    List<ObjectIdentifier> identifiers = urls.stream()
        .filter(url -> url != null && !url.isBlank())
        .map(url -> {
          try {
            return ObjectIdentifier.newBuilder()
                .key(extractObjectKey(url))
                .build();
          } catch (Exception e) {
            log.warn("批量删除中跳过无效URL: {}", url);
            return null;
          }
        })
        .filter(Objects::nonNull)
        .toList();

    if (identifiers.isEmpty()) return;

    try {
      // 2. 构建批量删除请求
      Delete deleteBody = Delete.newBuilder()
          .objects(identifiers)
          .quiet(true)
          .build();

      DeleteMultipleObjectsRequest batchRequest = DeleteMultipleObjectsRequest.newBuilder()
          .bucket(properties.getBucketName())
          .delete(deleteBody)
          .build();

      // 3. 执行删除
      ossClient.deleteMultipleObjects(batchRequest);
      log.info("成功发起批量删除请求,文件数量: {}", identifiers.size());
    } catch (Exception e) {
      log.error("OSS批量删除异常", e);
    }
  }

  // --- 辅助私有方法 ---

  private String generateObjectKey(String dir, String originalFilename) {
    String ext = originalFilename.contains(".") ? originalFilename.substring(originalFilename.lastIndexOf(".")) : "";
    return String.format("%s/%s%s", dir, UUID.randomUUID(), ext);
  }

  private String buildFullUrl(String objectKey) {
    String cleanEndpoint = properties.getEndpoint().replace("https://", "").replace("http://", "");
    return String.format("https://%s.%s/%s", properties.getBucketName(), cleanEndpoint, objectKey);
  }

  private String extractObjectKey(String url) {
    // 兼容处理:寻找域名后的第一个斜杠
    try {
      java.net.URL parsedUrl = new java.net.URL(url);
      String path = parsedUrl.getPath();
      return path.startsWith("/") ? path.substring(1) : path;
    } catch (Exception e) {
      throw new IllegalArgumentException("无法从URL提取ObjectKey: " + url);
    }
  }

  @Override
  public void close() throws Exception {
    if (ossClient != null) {
      ossClient.close();
    }
  }
}

6. 生命周期

数据管理 \(\rightarrow\) 生命周期 \(\rightarrow\) 创建规则

选项 描述
状态 启动
策略 按前缀匹配
是否允许重叠前缀
前缀 temp/
文件时间策略 指定天数
生命周期管理规则 最后一次 修改时间 7 天后,数据自动转换成 数据删除
碎片过期策略 碎片过期
碎片过期规则 碎片生成时间早于 7 天,碎片数据自动删除

二、JWT 登录认证

1. pom.xml

<!-- JWT -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.13.0</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.13.0</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.13.0</version>
  <scope>runtime</scope>
</dependency>

2. application.yaml

jwt:
  secretKey: SVRIRUlNQQ==DefaultFallbackKeyForDevOnly
  expire: 43200000

3. JwtProperties

// com.fallw.config.JwtProperties
@Component
@Data
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
  private String secretKey;
  private Long expire;
}

4. JwtUtils

// com.fallw.utils.JwtUtils
@Component
@RequiredArgsConstructor
public class JwtUtils {

  final private JwtProperties jwtProperties;

  private SecretKey getSigningKey() {
    return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
  }

  public String generateJwt(Map<String, Object> claims) {
    return Jwts.builder()
        .claims(claims)
        .expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpire()))
        .signWith(getSigningKey())
        .compact();
  }

  public Claims parseJWT(String jwt) {
    return Jwts.parser()
        .verifyWith(getSigningKey())
        .build()
        .parseSignedClaims(jwt)
        .getPayload();
  }
}

5. WebConfig

// com.fallw.config.WebMvcConfigurer
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
  //拦截器对象
  final private TokenInterceptor tokenInterceptor;

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(tokenInterceptor)
        .addPathPatterns("/**")            // 拦截所有
        .excludePathPatterns(
            "/login",                        // 登录
            "/register",                     // 注册
            "/doc.html",                     // Knife4j 主页
            "/webjars/**",                   // Knife4j 静态资源 (JS/CSS)
            "/swagger-resources/**",         // Swagger 资源配置
            "/v3/api-docs/**",               // OpenAPI 3.0 接口定义数据
            "/favicon.ico"                   // 网站图标(防止控制台报错)
        );
  }
}

6. TokenInterceptor

// com.fallw.interceptor.TokenInterceptor
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenInterceptor implements HandlerInterceptor {

  private final JwtUtils jwtUtils;

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 1. 获取令牌 (通常建议从 Authorization 头获取,或者保持你现在的 "token")
    String jwt = request.getHeader("token");

    // 2. 校验令牌是否存在
    if (!StringUtils.hasLength(jwt)) {
      log.info("令牌为空,拒绝访问: {}", request.getRequestURI());
      return handleUnauthorized(response, "未登录,请先登录");
    }

    // 3. 解析令牌
    try {
      jwtUtils.parseJWT(jwt);
      log.info("令牌合法,放行: {}", request.getRequestURI());
      return true;
    } catch (Exception e) {
      // 这里可以细化捕获 ExpiredJwtException 等
      log.error("令牌解析失败: {}", e.getMessage());
      return handleUnauthorized(response, "登录已过期或令牌无效");
    }
  }

  /**
   * 辅助方法:返回统一的 JSON 错误结果
   */
  private boolean handleUnauthorized(HttpServletResponse response, String msg) throws Exception {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setContentType("application/json;charset=utf-8");
    // 这里手动构造 Result.error(...) 的 JSON 字符串返回
    response.getWriter().write("{\"code\":0, \"msg\":\"" + msg + "\", \"data\":null}");
    return false;
  }
}

7. TokenFilter

// com.fallw.filter.TokenFilter
@Slf4j
@WebFilter(urlPatterns = "/*")
@RequiredArgsConstructor
public class TokenFilter implements Filter {
  private final JwtUtils jwtUtils;

  private static final String[] WHITE_LIST = {
      "/doc.html",
      "/webjars/",
      "/swagger-resources",
      "/v3/api-docs",
      "/favicon.ico"
  };
  @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse) servletResponse;

    // 获取请求的url地址
    String uri = request.getRequestURI(); // /employee/login
    //String url = request.getRequestURL().toString(); // http://localhost:8080/employee/login

    // 检查当前路径是否在白名单中
    for (String path : WHITE_LIST) {
      if (uri.contains(path)) {
        filterChain.doFilter(request, response);
        return;
      }
    }

    // 判断是否是登录请求, 如果url地址中包含 login, 则说明是登录请求, 放行
    if (uri.contains("login")) {
      log.info("登录请求, 放行");
      filterChain.doFilter(request, response);
      return;
    }

    // 获取请求中的token
    String token = request.getHeader("token");

    // 判断token是否为空, 如果为空, 响应401状态码
    if (token == null || token.isEmpty()) {
      log.info("token为空, 响应401状态码");
      response.setStatus(401); // 响应401状态码
      return;
    }

    // 如果token不为空, 调用JWtUtils工具类的方法解析token, 如果解析失败, 响应401状态码
    try {
      Claims claims = jwtUtils.parseJWT(token);
      Integer empId = Integer.valueOf(claims.get("id").toString());
      CurrentHolder.setCurrentId(empId);
      log.info("token解析成功, 放行");
    } catch (Exception e) {
      log.info("token解析失败, 响应401状态码");
      response.setStatus(401);
      return;
    }

    // 放行
    filterChain.doFilter(request, response);

    // 清空当前线程绑定的id
    CurrentHolder.remove();
  }
}

三、AOP 操作日志

1. pom.xml

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
  <version>4.0.0-M2</version>
  <scope>compile</scope>
</dependency>

2. OperateLog

// com.fallw.entity.OperateLog
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("operate_log")
public class OperateLog {
  @TableId(type = IdType.AUTO)
  private Integer id; //ID
  private Integer operateEmpId; //操作人ID
  private LocalDateTime operateTime; //操作时间
  private String className; //操作类名
  private String methodName; //操作方法名
  private String methodParams; //操作方法参数
  private String returnValue; //操作方法返回值
  private Long costTime; //操作耗时
  // @TableField(exist = false)
  // private String operateEmpName;
}

3. OperateLogMapper

// com.fallw.mapper.OperateLogMapper
@Mapper
public interface OperateLogMapper extends BaseMapper<OperateLog> {
  // IPage<OperateLog> pageQuery(Page<OperateLog> page);
}

4. LogOperation

// com.fallw.annotation.LogOperation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation { }

5. OperationLogAspect

// com.fallw.aspect.OperationLogAspect
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
  final private OperateLogMapper operateLogMapper;

  // 移除参数中的 LogOperation log,让 execution 独立生效
  @Around("execution(* com.fallw.controller..*.insert*(..)) || " +
      "execution(* com.fallw.controller..*.delete*(..)) || " +
      "execution(* com.fallw.controller..*.update*(..))")
  public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    long startTime = System.currentTimeMillis();

    // 执行原始方法
    Object result = joinPoint.proceed();

    long costTime = System.currentTimeMillis() - startTime;

    // 构建日志对象
    OperateLog operateLog = new OperateLog();
    operateLog.setOperateEmpId(CurrentHolder.getCurrentId());
    operateLog.setOperateTime(LocalDateTime.now());
    operateLog.setClassName(joinPoint.getTarget().getClass().getName());
    operateLog.setMethodName(joinPoint.getSignature().getName());
    operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));

    // 安全处理:防止 void 方法导致 result 为空
    operateLog.setReturnValue(result != null ? result.toString() : "void");
    operateLog.setCostTime(costTime);

    operateLogMapper.insert(operateLog);
    return result;
  }

  // 示例方法,获取当前用户ID
  private int getCurrentUserId() {
    return CurrentHolder.getCurrentId();
  }
}

6. CurrentHolder

// com.fallw.utils.CurrentHolder
public class CurrentHolder {

  private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();

  public static void setCurrentId(Integer employeeId) {
    CURRENT_LOCAL.set(employeeId);
  }

  public static Integer getCurrentId() {
    return CURRENT_LOCAL.get();
  }

  public static void remove() {
    CURRENT_LOCAL.remove();
  }
}

四、日志配置文件

1. logback.xml 存档

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <!-- 控制台输出 -->
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
      <!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度  %logger{50}: 最长50个字符(超出.切割)  %msg:日志消息,%n是换行符 -->
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
    </encoder>
  </appender>
  <!-- 按照每天生成日志文件 -->
  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
      <!-- 日志文件输出的文件名, %i表示序号 -->
      <FileNamePattern>D:/Project_log/tlias-%d{yyyy-MM-dd}-%i.log</FileNamePattern>
      <!-- 最多保留的历史日志文件数量 -->
      <MaxHistory>30</MaxHistory>
      <!-- 最大文件大小,超过这个大小会触发滚动到新文件,默认为 10MB -->
      <maxFileSize>10MB</maxFileSize>
    </rollingPolicy>

    <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
      <!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
    </encoder>
  </appender>
  <!-- 日志输出级别 -->
  <root level="INFO">
    <!--输出到控制台-->
    <appender-ref ref="STDOUT"/>
    <!--输出到文件-->
    <appender-ref ref="FILE"/>
  </root>
</configuration>

2. Knif4j 无法访问

  1. WebMvcConfig 中增加排除路径
public void addInterceptors(InterceptorRegistry registry) {
  registry.addInterceptor(tokenInterceptor)
      .addPathPatterns("/**")            // 拦截所有
      .excludePathPatterns(
          "/login", // 登录
          "/register", // 注册
          "/doc.html", // Knife4j 主页
          "/webjars/**", // Knife4j 静态资源 (JS/CSS)
          "/swagger-resources/**", // Swagger 资源配置
          "/v3/api-docs/**", // OpenAPI 3.0 接口定义数据
          "/favicon.ico" // 网站图标(防止控制台报错)
      );
}
  1. TokenFilter 增加白名单
private static final String[] WHITE_LIST = {
    "/doc.html",
    "/webjars/",
    "/swagger-resources",
    "/v3/api-docs",
    "/favicon.ico"
};

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
  HttpServletRequest request = (HttpServletRequest) servletRequest;
  HttpServletResponse response = (HttpServletResponse) servletResponse;

  String uri = request.getRequestURI();

  // 检查当前路径是否在白名单中
  for (String path : WHITE_LIST) {
    if (uri.contains(path)) {
      filterChain.doFilter(request, response);
      return;
    }
  }
  ...
}
  1. 在 Knif4j 中调用登录接口,复制返回的 token 填入 文档管理 \(\rightarrow\) 全局参数设定
posted @ 2026-04-05 13:43  fallw  阅读(15)  评论(0)    收藏  举报