Administrator
Published on 2025-06-05 / 26 Visits
0
0

学成在线分布式学习项目笔记

1.概述

该文档是记录学成在线项目开发中结合实际运用开发的时的新知识,

学成在线项目是一个微服务架构的系统,以功能模块为一个微服务,一个微服务一个数据库。

整个微服务系统有一个parent父工程管理依赖版本,一个base基础依赖模块

一个微服务又拆分成了api模块、model模块,Service模块(包含了mapper)。

2.开发流程

梳理业务流程——>确定数据传输模型(DTO)——>编写请求接口——>编写服务层和持久层

3.系统公共功能

3.1.统一的异常处理和响应

自定义异常类、自定义异常返回模型,自定义异常代码枚举,自定义异常处理类(重点)

自定义异常类

public class XueChengException extends RuntimeException {
    private String errMessage;
    //......
    public static void cast(CommonError commonError){
        throw new XueChengException(commonError.getErrMessage());
    }
    public static void cast(String errMessage){
        throw new XueChengException(errMessage);
    }
}

自定义异常返回模型

@Data
public class RestErrorResponse {
    private String errMessage;
    public RestErrorResponse(String errMessage) {
        this.errMessage = errMessage;
    }
}

自定义异常代码枚举

public enum CommonError {

    UNKNOWN_ERROR("执行过程异常,请重试。"),
    PARAMS_ERROR("非法参数"),
    OBJECT_NULL("对象为空"),
    QUERY_NULL("查询结果为空"),
    REQUEST_NULL("请求参数为空");

    private String errMessage;

    public String getErrMessage() {
        return errMessage;
    }

    private CommonError( String errMessage) {
        this.errMessage = errMessage;
    }
}

自定义异常处理类(重点)

@Slf4j
@RestControllerAdvice  //对所有Controller进行增强
public class GlobalExceptionHandler {
    //自定义系统异常处理
    @ExceptionHandler(XueChengException.class)
    //响应错误代码
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public RestErrorResponse customException(XueChengException e){
        //将错误信息返回前端
        log.error("【自定义系统异常】{}",e.getErrMessage(),e);
        return new RestErrorResponse(e.getErrMessage());
    }

	//系统异常处理
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public RestErrorResponse systemException(Exception e){
        log.error("【系统异常】{}",e.getMessage(),e);
        return new RestErrorResponse(CommonError.UNKNOWN_ERROR.getErrMessage());
    }
}

3.2.统一校验

JavaEE6规范中就定义了参数校验的规范——JSR303校验规范,Contoller中校验请求参数的合法性,包括:必填项校验,数据格式校验,比如:是否是符合一定的日期格式,等。

SpringBoot提供了JSR-303的支持,它就是spring-boot-starter-validation,它的底层使用Hibernate Validator,Hibernate Validator是Bean Validation 的参考实现。

spring-boot-starter-validation依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

校验时使用注解定义校验规则即可,既然是在Contoller中校验请求参数的合法性,所以下面这些合法性校验注解应该写在数据传输模型(DTO)当中。。

image-20250514151312432

注意:@NntEmpty用于字符和集合校验,用于其他类型将会校验失败

@Data
@ApiModel(value="AddCourseDto", description="新增课程基本信息")
public class AddCourseDto {

 @NotEmpty(message = "课程名称不能为空")
 @ApiModelProperty(value = "课程名称", required = true)
 private String name;

 @NotEmpty(message = "适用人群不能为空")
 @Size(message = "适用人群内容过少",min = 10)
 @ApiModelProperty(value = "适用人群", required = true)
 private String users;

...
}

在controller中添加上@Validated注解,开启规则校验

public AddCourseBaseDto addCourseBaseInfo(@RequestBody @Validated AddCourseBaseDto addCourseBaseDto){
        return courseBaseInfoService.addCourseBaseInfo(addCourseBaseDto);
    }

对规则检验抛出的异常进行捕获处理,返回给前端,这里用到的和前一个章节的异常处理响应一样。

/**
     * 校验框架异常处理方法
     * @param e 校验框架抛出的异常类
     * @return
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public RestErrorResponse customValidatedException(MethodArgumentNotValidException e){
        //得到异常结果
        BindingResult bindingResult = e.getBindingResult();
        List<String> msgList = new ArrayList<>();
        //将所有的异常信息遍历拼接为一个字符串
        bindingResult.getFieldErrors().stream().forEach(item->{
            msgList.add(item.getDefaultMessage());
        });
        String message = StringUtils.join(msgList, ",");
        log.error("【参数校验异常】{}",message,e);
        //响应给前端
        return new RestErrorResponse(message);
    }

规则校验分组,

规则校验分组主要用于需要共用传输模型时的校验,例如添加和更改都是用的同一个模型类,通过groups属性指定校验分组

@NotEmpty(groups = {ValidationGroups.Inster.class},message = "添加课程名称不能为空")
@NotEmpty(groups = {ValidationGroups.Update.class},message = "修改课程名称不能为空")
// @NotEmpty(message = "课程名称不能为空")
 @ApiModelProperty(value = "课程名称", required = true)
 private String name;

添加校验分组类

/**
 * @Author gc
 * @Description 校验规则分类
 * @DateTime: 2025/5/14 16:56
 **/
public class ValidationGroups {
    public interface Inster{};
    public interface Update{};
    public interface Delete{};
}

在请求接口中指定分组,通过 @Validated(ValidationGroups.Inster.class)属性进行指定分组

public AddCourseBaseDto addCourseBaseInfo(@RequestBody @Validated(ValidationGroups.Inster.class) AddCourseBaseDto addCourseBaseDto){
        return courseBaseInfoService.addCourseBaseInfo(addCourseBaseDto);
    }

3.3.注册中心nacos

在nacos中有几个非常重要的配置:命令空间(namespace)、分组(Group)、数据id(Data ID)。

**命令空间(namespace)**决定了当前微服务的所属哪个一个类别,需要自己先在nacos中创建,微服务注册时,需要在配置文件中指定名命名空间

分组(Group)是名称空间的里面的分组,微服务注册时,需要在配置文件中指定分组信息,因为分组是微服务注册时配置的所以不需要自己在nacos中创建但是当****微服务配置注解时,在创建配置信息时需要手动设置分组信息

**数据id(Data ID)**是微服务配置文件名称,dataid有三部分组成,比如:content-service-dev.yaml配置文件 由(content-service)-(dev). (yaml)三部分组成

1)服务发现(注册服务)

服务发现(注册服务)是指将自己注册到服务中心,如果需要将微服务注册到nacos注册中心中,需要先添加nacos服务注册依赖

<!--nacos服务注册依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

配置nacos连接信息

spring:
  application:
    name: content-api #微服务名称
  profiles:
    active: dev #环境名
  cloud:
    nacos:
      server-addr: 192.168.72.65:8848 #nacos连接地址
      discovery: #服务远程注册设置
        namespace: dev_gc #命名空间
        group: xuecheng #命名空间中的分组信息

2)配置发现(配置注入)

配置发现(配置注入)是指将微服务的配置文件迁移到服务注册中心nacos中,然后再从服务注册中心nacos中获取配置文件注入到微服务中,如果需要将微服务的配置迁移到到nacos注册中心中,需要先添加nacos配置注入依赖

<!--nacos服务配置注册依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

配置nacos连接信息

spring:
  application:
    name: content-service #微服务名称
  profiles:
    active: dev #环境名
  cloud:
    nacos:
      server-addr: 192.168.72.65:8848 #nacos连接地址
      config: #服务远程注入配置相关设置
        namespace: dev_gc  #命名空间
        group: xuecheng #分组信息
        file-extension: yml #配置文件后缀
        refresh-enabled: true #是否刷新

配置扩展:如果需要将配置分隔到不同的配置文件中最后再组合的话,需要通过extension-configs进行扩展

spring:
  application:
    name: content-api #微服务名称
  profiles:
    active: dev #环境名
  cloud:
    nacos:
      server-addr: 192.168.72.65:8848 #nacos连接地址
      discovery: #服务远程注册设置
        namespace: dev_gc #命名空间
        group: xuecheng #命名空间中的分组信息
      config: #服务远程配置相关设置
        namespace: dev_gc
        group: xuecheng
        file-extension: yaml
        refresh-enabled: true
        extension-configs: #扩展配置文件,注意data-id的值必须要和nacos注册中心的data-id保持完全一致
          - data-id: content-service-${spring.profiles.active}.yml
            group: xuecheng #分组信息
            refresh: true

公共配置扩展:如果公共的配置注入到微服务中,需要通过shared-configs进行扩展

spring:
  application:
    name: content-api #微服务名称
  profiles:
    active: dev #环境名
  cloud:
    nacos:
      server-addr: 192.168.72.65:8848 #nacos连接地址
      discovery: #服务远程注册设置
        namespace: dev_gc #命名空间
        group: xuecheng #命名空间中的分组信息
      config: #服务远程配置相关设置
        namespace: dev_gc
        group: xuecheng
        file-extension: yaml
        refresh-enabled: true
        extension-configs:
          - data-id: content-service-${spring.profiles.active}.yml
            group: xuecheng
            refresh: true
        shared-configs: #公共配置
          - data-id: swagger-${spring.profiles.active}.yaml
            group: xuecheng-common
            refresh: true
          - data-id: logging-${spring.profiles.active}.yaml
            group: xuecheng-common
            refresh: true

配置优先级级:如果本地和nacos中都有配置文件,实际上nacos上的优先级是大于本地配置的,优先级:项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件,如果需要设置本地配置有限需要在nacos中的配置文件中配置一个override-none: true

server:
  servlet:
    context-path: /content
  port: 63040

#配置本地优先
spring:
  cloud:
    config:
    override-none: true

通过VM options:指定本地配置值

通过-Dserver.port=6666配置启动服务端口,-D后面是本地配置文件的属性值

注意:只有名称空间、分组信息、data-id一致时才能将配置文件注入成功。

3.4.Feign远程调用与熔断降级

feign可以实现通过http协议实现远程接口调用,可以完成文件的传输

引入依赖

<!-- Spring Cloud 微服务远程调用,简单的调用依赖只需要依赖这个依赖就可以 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--feign支持Multipart格式传参,传输文件-->
<dependency>
    <groupId>io.github.openfeign.form</groupId>
    <artifactId>feign-form</artifactId>
    <version>3.8.0</version>
</dependency>
<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-httpclient</artifactId>
</dependency>
<dependency>
    <groupId>io.github.openfeign.form</groupId>
    <artifactId>feign-form-spring</artifactId>
    <version>3.8.0</version>
</dependency>

配置文件转Multipart

@Configuration
public class MultipartSupportConfig {

    @Autowired
    private ObjectFactory<HttpMessageConverters> messageConverters;

    @Bean
    @Primary//注入相同类型的bean时优先使用
    @Scope("prototype")
    public Encoder feignEncoder() {
        return new SpringFormEncoder(new SpringEncoder(messageConverters));
    }

    //将file转为Multipart
    public static MultipartFile getMultipartFile(File file) {
        FileItem item = new DiskFileItemFactory().createItem("file"
                , MediaType.MULTIPART_FORM_DATA_VALUE
                , true
                , file.getName());
        try (FileInputStream inputStream = new FileInputStream(file);
             OutputStream outputStream = item.getOutputStream();) {
            IOUtils.copy(inputStream, outputStream);

        } catch (Exception e) {
            e.printStackTrace();
        }
        return new CommonsMultipartFile(item);
    }
}

定义feingn接口,该接口实现了文件上传,通过fallbackFactory配置了降级方法

@FeignClient(value = "media-api",configuration = MultipartSupportConfig.class,fallbackFactory =MediaServiceClientFallbackFactory.class)
public interface MediaServiceClient {
    @PostMapping(value = "/media/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    String uploadFile(@RequestPart("filedata") MultipartFile upload, @RequestParam(value = "objectPath", required = false) String objectName);
}

远程调用的熔断降级

当微服务运行不正常会导致无法正常调用微服务,此时会出现异常,如果这种异常不去处理可能导致雪崩效应。

微服务的雪崩效应表现在服务与服务之间调用,当其中一个服务无法提供服务可能导致其它服务也死掉,比如:服务B调用服务A,由于A服务异常导致B服务响应缓慢,最后B、C等服务都不可用,像这样由一个服务所引起的一连串的多个服务无法提供服务即是微服务的雪崩效应

如何解决由于微服务异常引起的雪崩效应呢?可以采用熔断、降级的方法去解决

熔断

当下游服务异常而断开与上游服务的交互,它就相当于保险丝,下游服务异常触发了熔断,从而保证上游服务不受影响,熔断采用的是hystrix框架,配置启用熔断和熔断超时时间。

feign:
  hystrix:
    enabled: true
  circuitbreaker:
    enabled: true
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 30000  #熔断超时时间
ribbon:
  ConnectTimeout: 60000 #连接超时时间
  ReadTimeout: 60000 #读超时时间
  MaxAutoRetries: 0 #重试次数
  MaxAutoRetriesNextServer: 1 #切换实例的重试次数

降级方法:

当下游服务异常触发熔断后,上游服务就不再去调用异常的微服务而是执行了降级处理逻辑,这个降级处理逻辑可以是本地一个单独的方法。

定义feign远程调用接口时,指定熔断降级方法,fallbackFactory =MediaServiceClientFallbackFactory.class,通过fallbackFactory方法降级可以拿到熔断的异常信息,而fallback不能拿熔断的异常信息。

@FeignClient(value = "media-api",configuration = MultipartSupportConfig.class,fallbackFactory =MediaServiceClientFallbackFactory.class )
public interface MediaServiceClient {
    @PostMapping(value = "/media/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    String uploadFile(@RequestPart("filedata") MultipartFile upload, @RequestParam(value = "objectPath", required = false) String objectName);
}
@Component
@Slf4j
public class MediaServiceClientFallbackFactory implements FallbackFactory<MediaServiceClient> {
    @Override
    public MediaServiceClient create(Throwable throwable) {
        return new MediaServiceClient() {
            @Override
            public String uploadFile(MultipartFile upload, String objectName) {
                log.error("远程调用媒资服务上传文件失败,发生熔断,触发降级方法,{}",throwable.toString(),throwable);
                return null;
            }
        };
    }
}

调用feign接口

@Autowired
MediaServiceClient mediaServiceClient;

public Boolean uploadFileToMinio(Long courseId, File file, String objectPath) {
    if (file==null) {
        return null;
    }
    try {
        MultipartFile multipartFile = MultipartSupportConfig.getMultipartFile(file);
        String res = mediaServiceClient.uploadFile(multipartFile, objectPath);
        if (res==null) {
            return null;
        }
    } catch (Exception e) {
        log.error("文件上传失败,课程id:{}", courseId, e);
        e.printStackTrace();
        return null;
    }
    return true;
}

在启动类中添加@EnableFeignClients来开启远程调用

@SpringBootApplication
@EnableFeignClients(basePackages={"com.xuecheng.content.feignclient"})
public class ContentApplication {
    public static void main(String[] args) {
        SpringApplication.run(ContentApplication.class);
    }
}

3.5.swagger文档生成

通过swagger我们可以直接生成接口文档,并且它会为我们启动一个web服务,允许我们在线调试接口

引入依赖

<!--描述注解 -->
<dependency>
    <groupId>io.swagger</groupId>
    <artifactId>swagger-annotations</artifactId>
</dependency>
<!-- Spring Boot 集成 swagger -->
<dependency>
    <groupId>com.spring4all</groupId>
    <artifactId>swagger-spring-boot-starter</artifactId>
    <version>1.9.0.RELEASE</version>
</dependency>

基本配置

# 接口文档生产配置
swagger:
  title: "学成在线内容管理系统"
  description: "内容系统管理系统对课程相关信息进行管理"
  base-package: com.xuecheng
  enabled: true
  version: 1.0.0

添加一个接口使用swagger注解描述

@Slf4j
@Api(value = "课程信息编辑接口",tags = "课程信息编辑接口")
@RestController
public class CourseBaseInfoController {
    @Autowired
    CourseBaseInfoService courseBaseInfoService;

    @PreAuthorize("hasAuthority('xc_teachmanager_course')")
    @ApiOperation("课程查询列表接口")
    @RequestMapping("/course/list")
    public PageResult<CourseBase> list(PageParams pageParams, @RequestBody(required=false) QueryCourseParamsDto queryCourseParams){
        String companyId = SecurityUtil.getUser().getCompanyId();
        if (companyId==null){
            return null;
        }
        PageResult<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(Long.valueOf(companyId),pageParams, queryCourseParams);
        return courseBasePageResult;
    }
}
  1. @Api:用于描述整个 API 的信息,包括 API 的名称、描述、标签等。
  2. @ApiOperation:用于描述一个 API 操作的信息,包括操作的名称、描述、HTTP 方法等。
  3. @ApiParam:用于描述请求参数的信息,包括参数的名称、描述、数据类型、默认值等。
  4. @ApiResponse:用于描述 API 操作的响应信息,包括响应的 HTTP 状态码、描述、响应的数据类型等。
  5. @ApiModel:用于描述一个数据模型,包括模型的名称、描述、属性等。
  6. @ApiModelProperty:用于描述模型的一个属性,包括属性的名称、描述、数据类型等。
  7. @ApiIgnore:用于忽略某个 API 或 API 操作,让 Swagger 不会生成相应的文档。
    通过使用这些注解,开发人员可以在代码中添加相关的描述和信息,并通过 Swagger 生成相应的 API 文档。这些文档不仅提供给开发人员查阅和理解 API,还可以用于自动生成 API 文档的工具和服务。

Swagger 管理接口有时很不方便,缺乏一定的安全性和团队间的分享协作,所以我更推荐使用 ApifoxIDEA 插件。你可以在 IDEA 中自动同步 Swagger 注解到 Apifox,一键生成接口文档,多端同步,非常方便测试和维护,这样就可以迅速分享 API 给其他小伙伴。

在启动类上添加上@EnableSwagger2Doc来启用swagger

@EnableSwagger2Doc
@SpringBootApplication
public class MediaApplication {
	public static void main(String[] args) {
		SpringApplication.run(MediaApplication.class, args);
	}
}

通过访问swager提供的web界面可以很好的查看接口

http://localhost:63040/content/swagger-ui.html

其中http://localhost:63040/,是一个服务的地址、content/swagger-ui.html,其中conten是服务的虚拟根路径,如果没有虚拟根路径就可以不用设置。

3.6.log4j日志框架

log4j是一个日志框架,它可以用于调试输出一些打印信息,并将日志记录到文件中方便后面分析,使用它可以对日志进行分级输出,不需要日志也可以直接关闭,关闭之后控制台就不会输出日志,可以提高生产环境运行效率(调到info级别就可以了)

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

配置@Slf4j

# 日志文件配置路径
logging:
  config: classpath:log4j2-dev.xml

classpath:log4j2-dev.xml内容如下

<?xml version="1.0" encoding="UTF-8"?>
<Configuration monitorInterval="180" packages="">
    <properties>
        <property name="logdir">logs</property>
        <property name="PATTERN">%date{YYYY-MM-dd HH:mm:ss,SSS} %level [%thread][%file:%line] - %msg%n%throwable</property>
    </properties>
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="${PATTERN}"/>
        </Console>

        <RollingFile name="ErrorAppender" fileName="${logdir}/error.log"
            filePattern="${logdir}/$${date:yyyy-MM-dd}/error.%d{yyyy-MM-dd-HH}.log" append="true">
            <PatternLayout pattern="${PATTERN}"/>
            <ThresholdFilter level="ERROR" onMatch="ACCEPT" onMismatch="DENY"/>
            <Policies>
                <TimeBasedTriggeringPolicy interval="1" modulate="true" />
            </Policies>
        </RollingFile>

        <RollingFile name="DebugAppender" fileName="${logdir}/info.log"
            filePattern="${logdir}/$${date:yyyy-MM-dd}/info.%d{yyyy-MM-dd-HH}.log" append="true">
            <PatternLayout pattern="${PATTERN}"/>
            <ThresholdFilter level="DEBUG" onMatch="ACCEPT" onMismatch="DENY"/>
            <Policies>
                <TimeBasedTriggeringPolicy interval="1" modulate="true" />
            </Policies>
        </RollingFile>
        
        <!--异步appender-->
         <Async name="AsyncAppender" includeLocation="true">
            <AppenderRef ref="ErrorAppender"/>
            <AppenderRef ref="DebugAppender"/>
        </Async>
    </Appenders>
    
    <Loggers>
         <!--过滤掉spring和mybatis的一些无用的debug信息-->
        <logger name="org.springframework" level="INFO">
        </logger>
        <logger name="org.mybatis" level="INFO">
        </logger>
        <logger name="cn.itcast.wanxinp2p.consumer.mapper" level="DEBUG">
        </logger>

        <logger name="springfox" level="INFO">
        </logger>
		<logger name="org.apache.http" level="INFO">
        </logger>
        <logger name="com.netflix.discovery" level="INFO">
        </logger>
        
        <logger name="RocketmqCommon"  level="INFO" >
		</logger>
		
		<logger name="RocketmqRemoting" level="INFO"  >
		</logger>
		
		<logger name="RocketmqClient" level="WARN">
		</logger>

        <logger name="org.dromara.hmily" level="WARN">
        </logger>

        <logger name="org.dromara.hmily.lottery" level="WARN">
        </logger>

        <logger name="org.dromara.hmily.bonuspoint" level="WARN">
        </logger>
		
        
        <!--OFF   0-->
        <!--FATAL   100-->
        <!--ERROR   200-->
        <!--WARN   300-->
        <!--INFO   400-->
        <!--DEBUG   500-->
        <!--TRACE   600-->
        <!--ALL   Integer.MAX_VALUE-->
        <!-- 通过调整level级别来控制打印输出 -->
        <Root level="DEBUG" includeLocation="true">
            <AppenderRef ref="AsyncAppender"/>
            <AppenderRef ref="Console"/>
            <AppenderRef ref="DebugAppender"/>
        </Root>
    </Loggers>
</Configuration>

使用@Slf4j修饰,就可以使用log.info、log.debug、log.error了,

@Slf4j
@Service
public class CourseBaseInfoServiceImpl implements CourseBaseInfoService {

	public CoursePreviewDto getCoursePreviewInfo(Long courseId) {
	    AddCourseBaseDto addCourseBaseDto = courseBaseInfoService.queryCourseBaseById(courseId);
        if (addCourseBaseDto==null) {
            log.debug("查询课程预览信息——》查询不到课程基本信息,课程id:{}",courseId);
            return null;
        }
        //。。。。。
	}
}

4.课程内容微服务

4.1.分布式式事务控制(xxjob)

1)CAP理论

CAP是 Consistency、Availability、Partition tolerance三个词语的缩写,分别表示一致性、可用性、分区容忍性,其中只能满足其中一种组合CP(数据强一致性)或者AP(数据最终一致性)

  • CP(数据强一致性)

使用Seata框架基于AT模式实现

使用Seata框架基于TCC模式实现

  • AP(数据最终一致性)

使用消息队列通知的方式去实现,通知失败自动重试,达到最大失败次数需要人工处理;

使用任务调度的方案,启动任务调度将课程信息由数据库同步到elasticsearch、MinIO、redis中

本项目使用的是xxjob-admin来实现AP(数据最终一致性),通过写入一个消息表,然后使用xxjob去消费这个消息,完整各种数据的最终一致性,用于课程发布时,对课程信息进行模板静态化生成html并将静态化文件html上传minio,将课程索引信息同步到elasticsearch中,将课程信息缓存存入redis中。

引入xxjob依赖

<!--xxljob调度-->
<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
</dependency>

配置xxjob

xxl:
  job:
    admin: 
      addresses: http://192.168.72.65:8088/xxl-job-admin
    executor:
      appname: coursepublish-job # xxjob的名称空间
      address: 
      ip: 
      port: 8999
      logpath: /data/applogs/xxl-job/jobhandler
      logretentiondays: 30
    accessToken: default_token
@Configuration
public class XxlJobConfig {
    private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);

    @Value("${xxl.job.admin.addresses}")
    private String adminAddresses;

    @Value("${xxl.job.accessToken}")
    private String accessToken;

    @Value("${xxl.job.executor.appname}")
    private String appname;

    @Value("${xxl.job.executor.address}")
    private String address;

    @Value("${xxl.job.executor.ip}")
    private String ip;

    @Value("${xxl.job.executor.port}")
    private int port;

    @Value("${xxl.job.executor.logpath}")
    private String logPath;

    @Value("${xxl.job.executor.logretentiondays}")
    private int logRetentionDays;


    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        logger.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
        xxlJobSpringExecutor.setAppname(appname);
        xxlJobSpringExecutor.setAddress(address);
        xxlJobSpringExecutor.setIp(ip);
        xxlJobSpringExecutor.setPort(port);
        xxlJobSpringExecutor.setAccessToken(accessToken);
        xxlJobSpringExecutor.setLogPath(logPath);
        xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

        return xxlJobSpringExecutor;
    }

通过@XxlJob注解来开启方法调度,其中coursePublishTaskHandler为调度方法需要和xxjob中在web调用的方法名匹配

@XxlJob("coursePublishTaskHandler")
public void coursePublishTaskHandler() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();
    String messageType="course_publish";
    int count=30;
    long timeout=60;
    process(shardIndex,shardTotal,messageType,count,timeout);
}

4.4.elasticsearc(搜索服务)

搜索功能是一个系统的重要功能,是信息查询的方式。课程搜索是课程展示的渠道,用户通过课程搜索找到课程信息,进一步去查看课程的详细信息,进行选课、支付、学习。

全文检索是指计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。这个过程类似于通过字典中的检索字表查字的过程。

全文检索可以简单理解为通过索引搜索文章。全文检索的速度非常快,早期应用在搜索引擎技术中,比如:百度、google等,现在通常一些大型网站的搜索功能都是采用全文检索技术。

课程搜索也要将课程信息建立索引,在课程发布时建立课程索引,索引建立好用户可通过搜索网页去查询课程信息。

所以,课程搜索模块包括两部分:课程索引、课程搜索。

课程索引是将课程信息建立索引。

课程搜索是通过前端网页,通过关键字等条件去搜索课程。

image-20250604122648218

安装elasticsearch和kibana,kibana 是 ELK(Elasticsearch , Logstash, Kibana )之一,kibana 一款开源的数据分析和可视化平台,通过可视化界面访问elasticsearch的索引库,开发中主要使用kibana通过api对elasticsearch进行索引和搜索操作。

image-20250604135328287

4.4.1.使用elasticsearc

引入依赖

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

<dependency>
    <groupId>org.elasticsearch</groupId>
    <artifactId>elasticsearch</artifactId>
</dependency>

配置Elasticsearch

elasticsearch:
  hostlist: 192.168.72.65:9200 #多个结点中间用逗号分隔
  course:
    index: course-publish
    source_fields: id,name,grade,mt,st,charge,pic,price,originalPrice,teachmode,validDays,createDate
@Configuration
public class ElasticsearchConfig {

    @Value("${elasticsearch.hostlist}")
    private String hostlist;

    @Bean
    public RestHighLevelClient restHighLevelClient(){
        //解析hostlist配置信息
        String[] split = hostlist.split(",");
        //创建HttpHost数组,其中存放es主机和端口的配置信息
        HttpHost[] httpHostArray = new HttpHost[split.length];
        for(int i=0;i<split.length;i++){
            String item = split[i];
            httpHostArray[i] = new HttpHost(item.split(":")[0], Integer.parseInt(item.split(":")[1]), "http");
        }
        //创建RestHighLevelClient客户端
        return new RestHighLevelClient(RestClient.builder(httpHostArray));
    }

}

启动kibana,在kibana中通过命令操作测试

首先要做的就是创建索引(指向映射)并关联创建其映射结构(相当于MySQL中表结构)

put /索引名称

PUT /my-index-000001
{
  "settings": {
    "index": {
      "number_of_shards": 3,  
      "number_of_replicas": 2
    }
  },
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "companyId": {
        "type": "keyword"
      },
      "companyName": {
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "type": "text"
      },
      "name": {
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "type": "text"
      },
	}
   }	
}

为索引添加/更新文档

put /索引名称/类型/文档id,不指定文档id时,会自动生成,当文档id存在则更新文档

PUT /my-index-000001/_doc/102
{
  "companyId" : 100000,
  "companyName" : "北京黑马程序",
  "id" : 102,
  "name" : "Spring编程思想",
}

对应的javaAPI

@Autowired
RestHighLevelClient client;

@Override
public Boolean addCourseIndex(String indexName, String id, Object object) {
    String jsonString = JSON.toJSONString(object);
    IndexRequest indexRequest = new IndexRequest(indexName).id(id);
    // 指定索引文档内容
    indexRequest.source(jsonString, XContentType.JSON);
    // 索引响应对象
    IndexResponse indexResponse = null;
    try {
        indexResponse = client.index(indexRequest, RequestOptions.DEFAULT);
    } catch (IOException e) {
        log.error("添加索引出错:{}", e.getMessage());
        e.printStackTrace();
        XueChengException.cast("添加索引出错");
    }
    String name = indexResponse.getResult().name();
    System.out.println(name);
    return name.equalsIgnoreCase("created") || name.equalsIgnoreCase("updated");

}

获取索引对应的文档

GET /{索引名称}/_doc/{文档id},不指定文档id时,查询该索引的所有文档

GET /my-index-000001/_doc/102

删除索引对应的文档

DELETE /{索引库名}/_doc/id值

GET /my-index-000001/_doc/102

搜索文档javaApi

@Value("${elasticsearch.course.index}")
private String courseIndexStore;
@Value("${elasticsearch.course.source_fields}")
private String sourceFields;

@Autowired
RestHighLevelClient client;

public SearchPageResultDto<CourseIndex> queryCoursePubIndex(PageParams pageParams, SearchCourseParamDto courseSearchParam) {

    //设置索引
    SearchRequest searchRequest = new SearchRequest(courseIndexStore);

    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
    //source源字段过虑
    String[] sourceFieldsArray = sourceFields.split(",");
    searchSourceBuilder.fetchSource(sourceFieldsArray, new String[]{});
    if(courseSearchParam==null){
        courseSearchParam = new SearchCourseParamDto();
    }
    //关键字
    if(StringUtils.isNotEmpty(courseSearchParam.getKeywords())){
        //匹配关键字
        MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(courseSearchParam.getKeywords(), "name", "description");
        //设置匹配占比
        multiMatchQueryBuilder.minimumShouldMatch("70%");
        //提升另个字段的Boost值
        multiMatchQueryBuilder.field("name",10);
        boolQueryBuilder.must(multiMatchQueryBuilder);
    }
    //过虑
    if(StringUtils.isNotEmpty(courseSearchParam.getMt())){
        boolQueryBuilder.filter(QueryBuilders.termQuery("mtName",courseSearchParam.getMt()));
    }
    if(StringUtils.isNotEmpty(courseSearchParam.getSt())){
        boolQueryBuilder.filter(QueryBuilders.termQuery("stName",courseSearchParam.getSt()));
    }
    if(StringUtils.isNotEmpty(courseSearchParam.getGrade())){
        boolQueryBuilder.filter(QueryBuilders.termQuery("grade",courseSearchParam.getGrade()));
    }
    //分页
    Long pageNo = pageParams.getPageNo();
    Long pageSize = pageParams.getPageSize();
    int start = (int) ((pageNo-1)*pageSize);
    searchSourceBuilder.from(start);
    searchSourceBuilder.size(Math.toIntExact(pageSize));
    //布尔查询
    searchSourceBuilder.query(boolQueryBuilder);
    //高亮设置
    HighlightBuilder highlightBuilder = new HighlightBuilder();
    highlightBuilder.preTags("<font class='eslight'>");
    highlightBuilder.postTags("</font>");
    //设置高亮字段
    highlightBuilder.fields().add(new HighlightBuilder.Field("name"));
    searchSourceBuilder.highlighter(highlightBuilder);
    //请求搜索
    searchRequest.source(searchSourceBuilder);
    //聚合设置
    buildAggregation(searchRequest);
    SearchResponse searchResponse = null;
    try {
        searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
    } catch (IOException e) {
        e.printStackTrace();
        log.error("课程搜索异常:{}",e.getMessage());
        return new SearchPageResultDto<CourseIndex>(new ArrayList(),0,0,0);
    }

    //结果集处理
    SearchHits hits = searchResponse.getHits();
    SearchHit[] searchHits = hits.getHits();
    //记录总数
    TotalHits totalHits = hits.getTotalHits();
    //数据列表
    List<CourseIndex> list = new ArrayList<>();

    for (SearchHit hit : searchHits) {

        String sourceAsString = hit.getSourceAsString();
        CourseIndex courseIndex = JSON.parseObject(sourceAsString, CourseIndex.class);

        //取出source
        Map<String, Object> sourceAsMap = hit.getSourceAsMap();

        //课程id
        Long id = courseIndex.getId();
        //取出名称
        String name = courseIndex.getName();
        //取出高亮字段内容
        Map<String, HighlightField> highlightFields = hit.getHighlightFields();
        if(highlightFields!=null){
            HighlightField nameField = highlightFields.get("name");
            if(nameField!=null){
                Text[] fragments = nameField.getFragments();
                StringBuffer stringBuffer = new StringBuffer();
                for (Text str : fragments) {
                    stringBuffer.append(str.string());
                }
                name = stringBuffer.toString();

            }
        }
        courseIndex.setId(id);
        courseIndex.setName(name);

        list.add(courseIndex);

    }
    SearchPageResultDto<CourseIndex> pageResult = new SearchPageResultDto<>(list, totalHits.value,pageNo,pageSize);

    //获取聚合结果
    List<String> mtList= getAggregation(searchResponse.getAggregations(), "mtAgg");
    List<String> stList = getAggregation(searchResponse.getAggregations(), "stAgg");

    pageResult.setMtList(mtList);
    pageResult.setStList(stList);

    return pageResult;
}

4.4.2.索引同步(创建/更新索引文档)

通过向索引中添加课程信息最终实现了课程的搜索,我们发现课程信息是先保存在关系数据库中,而后再写入索引,这个过程是将关系数据中的数据同步到elasticsearch索引中的过程,可以简单成为索引同步。

通常项目中使用elasticsearch需要完成索引同步,索引同步的方法很多:

1.索引及时同步

针对实时性非常高的场景需要满足数据的及时同步,可以同步调用,或使用Canal去实现。

1)同步调用即在向MySQL写数据后远程调用搜索服务的接口写入索引,此方法简单但是耦合代码太高。

2)可以使用一个中间的软件canal解决耦合性的问题,但存在学习与维护成本。

canal主要用途是基于 MySQL 数据库增量日志解析,并能提供增量数据订阅和消费,实现将MySQL的数据同步到消息队列、Elasticsearch、其它数据库等,应用场景十分丰富。

Canal基于mysql的binlog技术实现数据同步,什么是binlog,它是一个文件,二进制格式,记录了对数据库更新的SQL语句,向数据库写数据的同时向binlog文件里记录对应的sql语句。当数据库服务器发生了故障就可以使用binlog文件对数据库进行恢复。

所以,使用canal是需要开启mysql的binlog写入功能

image-20250604150841810

2.非及时索引同步

当索引同步的实时性要求不高时可用的技术比较多,比如:MQ、Logstash、任务调度等。

MQ:向mysql写数据的时候向mq写入消息,搜索服务监听MQ,收到消息后写入索引。使用MQ的优势是代码解耦,但是需要处理消息可靠性的问题有一定的技术成本,做到消息可靠性需要做到生产者投递成功、消息持久化以及消费者消费成功三个方面,另外还要做好消息幂等性问题。

Logstash: 开源实时日志分析平台 ELK包括Elasticsearch、Kibana、Logstash,Logstash负责收集、解析和转换日志信息,可以实现MySQL与Elasticsearch之间的数据同步。也可以实现解耦合并且是官方推荐,但需要增加学习与维护成本。

任务调度:向mysql写数据的时候记录修改记录,开启一个定时任务根据修改记录将数据同步到Elasticsearch。

该项目使用的时任务调度方案来实现的索引同步

5.媒资管理微服务

5.1.获取文件mimeType(contentType)

/**
 * 获取文件的二进制文件类型
 * @param fileNameExtension 文件的拓展名(.png)
 * @return
 */
private String getMimeType(String fileNameExtension) {
    if (fileNameExtension == null) {
        return null;
    }
    ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(fileNameExtension);
    String mimeType = extensionMatch.getMimeType();
    return mimeType;
}

5.2.保证事务受控

在非事务控制的方法中调用带事务控制注解的方法,通过在非事务控制的方法中通过代理代理对象进行调用,所有通过@Autowired注入的对象都是代理对象。

@Service
@Slf4j
public class MediaFileServiceImpl implements MediaFileService {
 
    @Autowired
    MediaFileService mediaFileServiceProxy;  //注入自身  
    
    @Autowired
    MediaFilesMapper mediaFilesMapper;
    
    public MediaFilesDTO uploadFile(Long companyId, MediaFilesDTO mediaFilesDTO, String localFilePath) throws Exceptio{
        //..............
        //将信息保存到数据库,通过代理对象调用保证事务受控制。
        MediaFilesDTO resMediaFiles = mediaFileServiceProxy.saveMediaFiles(mediaFilesDTO);
        return resMediaFiles;
    }
    
    /**
     * 保存数据库
     * @param mediaFilesDTO mediaFiles传输模型
     * @return
     */
    @Transactional
    public MediaFilesDTO saveMediaFiles(MediaFilesDTO mediaFilesDTO) {
       	//。。。。。。。。。。
        int insert = mediaFilesMapper.insert(mediaFiles);
        if (insert < 0) {
            XueChengException.cast("文件上传失败,请重试!");
        }
        MediaFiles selectMediaFiles = mediaFilesMapper.selectById(mediaFiles.getId());
        if (selectMediaFiles==null){
            XueChengException.cast("文件上传失败,请重试!");
        }
        BeanUtils.copyProperties(selectMediaFiles,mediaFilesDTO);
        return mediaFilesDTO;
    }
}

5.3.快速创建临时文件并写入内容

public MediaFilesDTO uploadFile(@RequestPart("filedata") MultipartFile multipartFile) throws Exception {
    //创建临时文件
    File tempFile = File.createTempFile("minio", ".temp");
    //方法1
    multipartFile.transferTo(tempFile);
    //方法2
    InputStream inputStream = multipartFile.getInputStream();
    OutputStream outputStream = new FileOutputStream(tempFile);
    IOUtils.copy(inputStream, OutputStream);
    //获取文件的绝对路径
    String localFilePath=tempFile.getAbsolutePath();
}

5.4.多线程与分布式锁

利用数据库主键唯一性的特点,或利用数据库唯一索引、行级锁的特点,多个线程同时去更新相同的记录,谁更新成功谁就抢到锁,简单来说就是通过多线程同时去对某一个字段进行更新操作,成功更新的线程就相当于抢到了分布式锁,因为字段现在已经更新了然后其他线程就无法再对该字段进行更新了,所以就会更新失败也就是抢占分布式锁失败了,例如下面的更新操作,其中的分布式锁字段是status,抢占成功的线程会将status更新为4。

/**
 * 开启一个任务(基于数据库字段的分布式锁)
 * @param id 任务id
 * @return 更新记录数
 */
@Update("update media_process m set m.status='4' where (m.status='1' or m.status='3') and m.fail_count<3 and m.id=#{id}")
int startTask(@Param("id") long id);
@Component
@Slf4j
public class VideoTaskHandler {
    @Autowired
    MediaProcessService mediaProcessService;
    @Autowired
    MediaFileService mediaFileService;
    @Value("${videoprocess.ffmpegpath}")
    String ffmpeg_path;

    @XxlJob("videoTranscoding") //xxljob任务调度
    public void videoTranscoding() {
        int shardIndex = XxlJobHelper.getShardIndex(); //当前机器的序号id
        int shardTotal = XxlJobHelper.getShardTotal(); //集群中的机器总数
        //获取系统核心数
        int count = Runtime.getRuntime().availableProcessors();
        //获取当前机器需要执行的任务,
        List<MediaProcess> mediaProcessList = mediaProcessService.getMediaProcessListByShardIndex(shardIndex, shardTotal, count);
        if (mediaProcessList == null) {
            log.debug("获取任务失败");
            return;
        }
        int size = mediaProcessList.size();
        log.debug("获取到{}个视频处理任务", size);
        if (size <= 0) {
            return;
        }
        //保证任务全部执行完才返回
        CountDownLatch countDownLatch = new CountDownLatch(size);
        //创建线程池,设置线程数为任务数
        ExecutorService threadPool = Executors.newFixedThreadPool(size);
        for (MediaProcess mediaProcess:mediaProcessList) {
            threadPool.execute(() -> {
                try {
                    String fileId = mediaProcess.getFileId();
                    //1.分布式锁控制,开启任务
                    //获取任务id
                    Long taskId = mediaProcess.getId();
                    //进行分布式锁控制,开启任务
                    int ss = mediaProcessService.startTask(taskId);
                    if (ss <= 0) {
                        return;
                    }
                   //.............执行逻辑
                } finally {
                    countDownLatch.countDown();
                    long taskCount = countDownLatch.getCount();
                    log.debug("=========还有{}个任务数待处理!", taskCount);
                }
            });
        }
        try {
            countDownLatch.await(30, TimeUnit.MINUTES); //保证所有任务都执行完成
        } catch (InterruptedException e) {
            log.debug("==========线程计数器退出等待异常!{}", e.getMessage());
            e.printStackTrace();
        }
    }

5.5.保证集群部署的机器不重复执行任务

首先可以对任务进行分片执行操作,简单来说分片操作就是通过将任务进行序号话话,将任务序号对总的机器数求余就能将任务交给不同的机器进行执行处理了。例如下面的语句,就是将序号id%shardTotal机器总数得到对应的机器序号,这样就能让对应的机器获取到对应的任务,但是这样还不能保证任务不重复执行(因为可能因为网络原因这个shardTotal机器总数会发生变化),这时可以通过分布式锁来保证任务任务不重复执行。

@Select("SELECT * FROM media_process " +
        "WHERE id%#{shardTotal}=#{shardIndex} AND (`status`='1' or `status`='3') AND fail_count<3 LIMIT 0,#{count}")
public List<MediaProcess> selectMediaProcessListByShardShardIndex(@Param("shardIndex") int shardIndex
        , @Param("shardTotal") int shardTotal
        , @Param("count") int count);

6.认证授权微服务

6.1.认证(密码认证、OAuth2认证、颁发令牌)

验证用户是否登录,用来识别是哪一个用户(身份识别)。

6.1.1.JWT

JSON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术,通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz,其中前两部分是json串通过Base64Url编码,Base64Url可以进行还原。

第一部分头部(header):头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA),是个json串

第二部分负载(Payload):存放有效信息的地方,它可以存放jwt提供的信息字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。

第三部分是签名(Signature):此部分用于防止jwt内容被篡改,原理是将前两部分通过密钥(不对外公开)进行加密,得到的一个数据(此数据无法被逆向破解),解密就是将前两部分在通过密钥加密一次对比第三部分的内容就可以确定是否被篡改。

6.1.2.security配置JWT令牌

引入security依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

自定义配置JWT增强Token

@Configuration
public class TokenConfig {
    //密钥
    private String SIGNING_KEY = "mq123";
    @Autowired
    TokenStore tokenStore;
    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;

    @Bean
    public TokenStore tokenStore() {
        //使用内存存储令牌(普通令牌)
        //return new InMemoryTokenStore();
        //使用JWT令牌
        return new JwtTokenStore(jwtAccessTokenConverter());
    }


    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        //jwt转token
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        //设置密钥
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }


    //令牌管理服务
    @Bean(name="authorizationServerTokenServicesCustom")
    public AuthorizationServerTokenServices tokenService() {
        DefaultTokenServices service=new DefaultTokenServices();
        service.setSupportRefreshToken(true);//支持刷新令牌
        service.setTokenStore(tokenStore);//令牌存储策略
        service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
        service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
        //令牌增强
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        //设置密钥增强方法
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));
        service.setTokenEnhancer(tokenEnhancerChain);
        return service;
    }

}

配置认证服务器,将刚才配置的jwt令牌设置注入,并设置到认证服务器中

@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
    //注入自定义配置JWT增强Token
    @Resource(name = "authorizationServerTokenServicesCustom")
    private AuthorizationServerTokenServices authorizationServerTokenServices;

    @Autowired
    private AuthenticationManager authenticationManager;

    //客户端详情服务
    @Override
    public void configure(ClientDetailsServiceConfigurer clients)
            throws Exception {
        clients.inMemory()// 使用in-memory存储
                .withClient("XcWebApp")// client_id
                //.secret("XcWebApp")//客户端密钥
                .secret(new BCryptPasswordEncoder().encode("XcWebApp"))//配置客户端密钥,
                .resourceIds("xuecheng-plus")//资源列表
                .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials
                .scopes("all")// 允许的授权范围
                .autoApprove(false)//false跳转到授权页面
                //客户端接收授权码的重定向地址
                .redirectUris("http://www.51xuecheng.cn")
        ;
    }

    //令牌端点的访问配置
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
                .authenticationManager(authenticationManager)//认证管理器
                .tokenServices(authorizationServerTokenServices)//令牌管理服务,设置jwt令牌服务
                .allowedTokenEndpointRequestMethods(HttpMethod.POST);
    }

    //令牌端点的安全配置
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
                .tokenKeyAccess("permitAll()")                    //oauth/token_key是公开
                .checkTokenAccess("permitAll()")                  //oauth/check_token公开
                .allowFormAuthenticationForClients()                //表单认证(申请令牌)
        ;
    }


}

安全管理配置,该类是用于配置哪些链接需要进行认证,如何认证等

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;

    ////配置用户信息服务
    //@Bean
    //public UserDetailsService userDetailsService() {
    //    //这里配置用户信息,这里暂时使用这种方式将用户存储在内存中
    //    //InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    //    //manager.createUser(User.withUsername("张三").password("123").authorities("p1").build());
    //    //manager.createUser(User.withUsername("李四").password("456").authorities("p2").build());
    //    //return manager;
    //    userDetailsService.loadUserByUsername()
    //
    //}

    @Bean
    public PasswordEncoder passwordEncoder() {
//        //密码为明文方式
//        return NoOpPasswordEncoder.getInstance();
        //密码为加密
        return new BCryptPasswordEncoder();
    }

    //配置安全拦截机制
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
                .anyRequest().permitAll()//其它请求全部放行
                .and()
                .formLogin().successForwardUrl("/login-success");//登录成功跳转到/login-success
    }

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


    @Override //设置认证处理类(如何认证,密码比对等),默认是直接密码比对
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProviderCustom);
    }

}

覆盖security框架的默认密码比对

@Component
@Slf4j
public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {
    //设置具体的UserDetailsService类,该类可用加载用户
    @Autowired
    @Override
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        super.setUserDetailsService(userDetailsService);
    }

    /**
     * 禁用框架自己的密码对比
     * @param userDetails
     * @param authentication
     * @throws AuthenticationException
     */
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        //覆盖security框架默认的密码比对
    }
}

自定义用户加载类,实现UserDetailsService的方法

@Service
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    XcUserMapper xcUserMapper;
    @Autowired
    ApplicationContext applicationContext;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        AuthParamsDto authParamsDto = null;
        try {
            authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
        }catch (RuntimeException e){
            log.error("解析用户认证信息失败!");
            e.printStackTrace();
        }
        //直接从数据库中查询
        /*String username=authParamsDto.getUsername();
        LambdaQueryWrapper<XcUser> qw = new LambdaQueryWrapper<>();
        qw.eq(XcUser::getUsername, username);
        XcUser xcUser = xcUserMapper.selectOne(qw);
        if (xcUser == null) {
            return null;
        }
        String password = xcUser.getPassword();
        //添加一个权限
        String[] authorities = {"test"};
        xcUser.setPassword(null);
         //扩展用户认证信息
        String xcUserJSon = JSON.toJSONString(xcUser);
        UserDetails userDetails = User.withUsername(xcUserJSon)
                .password(password)
                .authorities(authorities).build();
        */
        //统一认证,不同的认证方式,工厂模式
        String authType = authParamsDto.getAuthType();
        AuthService authService = applicationContext.getBean(authType + "_authService", AuthService.class);
        XcUserExt xcUserExt = authService.execute(authParamsDto);
        return getUserDetails(xcUserExt);
    }


    private UserDetails getUserDetails(XcUserExt xcUserExt){
        String password = xcUserExt.getPassword();
        xcUserExt.setPassword(null);
        ArrayList<String> permissions = new ArrayList<>();
        permissions.add("p1");
        String[] permissionsArray = permissions.toArray(new String[permissions.size()]);
        String xcUserExtJson = JSON.toJSONString(xcUserExt);
        UserDetails userDetails = User.withUsername(xcUserExtJson).password(password).authorities(permissionsArray).build();
        return userDetails;
    }

}

请求接口时,携带token令牌,得到用户身份

通过SecurityContextHolder类可以获取到用户名(将用户的基本信息可以通过传入name中实现)

Object principalObj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

6.1.3.微信扫码登录

在微信开放平台注册账号,拿到请求参数,在前端引入微信的js生成二维码,用户扫码之后会重定向一个你指定的链接(可以设置一个你的controller,请求参数code和一个状态state),这个链接中包含了请求码和状态码,拿到这个请求码之后,再携带这个请求码去调用微信的接口获取access_token,有了access_token就可以携带这个token去获取用户信息了。

扫码登录需要微信开放平台账号且主体为企业才能使用扫码登录,测试账号只有公众号,公众号测试账号不能直接使用扫码登录,只有通过oauth2链接登录,但是扫码和oauth2是一样的实现流程。

注册微信开放平台账号或者公众号测试号,访问或者扫码一个微信指定链接,成功后会重定向到你指定的请求链接,该链接中包含请求码code。如下面这个方法

@RequestMapping("/wxLogin")
public void wxLogin(String code, String state, HttpServletResponse httpServletResponse){
    Map<String, String> wxUserInfo = weiXInAuthService.getWxUserInfo(code);
    XcUser xcUser = weiXInAuthService.saveWxUser(wxUserInfo);
    try {
        httpServletResponse.sendRedirect("http://www.51xuecheng.cn/sign.html?username="+xcUser.getUsername()+"&authType=wx");
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

根据拿到的code再去微信服务器那token

private Map<String,String> getWxAccessToken(String appid,String secret,String code){
    String urlTemplate="https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
    String url = String.format(urlTemplate, appid, secret, code);
    ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
    String body = exchange.getBody();
    Map<String,String> mapResult = JSON.parseObject(body, Map.class);
    return mapResult;
}

再根据token和openid去微信服务器拿用户信息

private Map<String,String> getWxUserInfo(String access_token,String openid){
    String urlTemplate="https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN";
    String url = String.format(urlTemplate, access_token, openid);
    ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
    String body = new String(exchange.getBody().getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);
    Map<String,String> mapResult = JSON.parseObject(body, Map.class);
    return mapResult;
}

最后保存到数据库中(相当于注册新用户),最后重定向到登录页面完成自动登录即可。

6.2.授权

验证用户是否具有访问资源的权限,具体的微服务接口只做鉴权(授权),具有某个权限才能访问,否则拒绝,而用户认证交由网关进行处理。

配置JWT令牌

@Configuration
public class TokenConfig {

    String SIGNING_KEY = "mq123";


//    @Bean
//    public TokenStore tokenStore() {
//        //使用内存存储令牌(普通令牌)
//        return new InMemoryTokenStore();
//    }

    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }
}

接口资源控制

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class ResouceServerConfig extends ResourceServerConfigurerAdapter {

/*  @Bean
 public UserDetailsService userDetailsService() {
  //这里配置用户信息,这里暂时使用这种方式将用户存储在内存中
  InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
  manager.createUser(User.withUsername("张三").password("123").authorities("p1").build());
  manager.createUser(User.withUsername("李四").password("456").authorities("p2").build());
  return manager;
 } */


    // 资源服务标识
    public static final String RESOURCE_ID = "xuecheng-plus";

    @Autowired
    TokenStore tokenStore;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.resourceId(RESOURCE_ID)// 资源 id
                .tokenStore(tokenStore)
                .stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/course/whole/**").permitAll()
                .antMatchers("/open/course/**").permitAll()//任何都可以访问
                .antMatchers("/course/**").authenticated()//拦截认证
                .anyRequest().permitAll();
    }

}

6.2.1.RBAC授权

如何实现授权?业界通常基于RBAC实现授权。

RBAC分为两种方式:

基于角色的访问控制(Role-Based Access Control)

if(主体.hasRole("总经理角色id") ||  主体.hasRole("部门经理角色id")){
    查询工资
}

基于资源的访问控制(Resource-Based Access Control)

if(主体.hasPermission("查询工资权限标识")){
    查询工资
}

现在都是将权限赋予角色来进行访问,最终还是基于资源的访问控制。

6.2.1.使用RBAC授权

基于spring security框架,首先需要集成spring security,然后只需要在需要授权的接口使用@PreAuthorize("hasAuthority('权限标识符')")进行控制。

例如下面这个接口就需要具有课程管理权限xc_teachmanager_course才能访问

@PreAuthorize("hasAuthority('xc_teachmanager_course')")
@ApiOperation("课程查询列表接口")
@RequestMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody(required=false) QueryCourseParamsDto queryCourseParams){
    PageResult<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParams);
    return courseBasePageResult;
}

写入用户访问权限

image-20250526165154301

private UserDetails getUserDetails(XcUserExt xcUserExt){
    String password = xcUserExt.getPassword();
    xcUserExt.setPassword(null);
    ArrayList<String> permissions = new ArrayList<>();
    //查询用户的资源访问权限
    List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(xcUserExt.getId());
    for (XcMenu xcMenu : xcMenus) {
        permissions.add(xcMenu.getCode());
    }
    String[] permissionsArray = permissions.toArray(new String[permissions.size()]);
    String xcUserExtJson = JSON.toJSONString(xcUserExt);
    UserDetails userDetails = User.withUsername(xcUserExtJson).password(password).authorities(permissionsArray).build();
    return userDetails;
}

细粒度权限

细粒度授权也叫数据范围授权,即不同的用户所拥有的操作权限相同,但是能够操作的数据范围是不一样的。一个例子:用户A和用户B都是教学机构,他们都拥有“我的课程”权限,但是两个用户所查询到的数据是不一样的。

本项目有哪些细粒度授权?

比如:

我的课程,教学机构只允许查询本教学机构下的课程信息。

我的选课,学生只允许查询自己所选课。

6.3.网关统一认证

网关是所有接口的统一入口,所以在网关对身份进行认证再适合不过了。

引入依赖

<!--security框架-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

配置jwt令牌

@Configuration
public class TokenConfig {

    String SIGNING_KEY = "mq123";


//    @Bean
//    public TokenStore tokenStore() {
//        //使用内存存储令牌(普通令牌)
//        return new InMemoryTokenStore();
//    }

    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY);
        return converter;
    }
}

编写请求接口白名单

/auth/**=认证地址
/content/course/whole/**=内容管理公开访问接口
/content/open/**=内容管理公开访问接口
/media/open/**=媒资管理公开访问接口
/learning/open/**=内容管理公开访问接口

配置网关认证过滤器

@Component
@Slf4j
public class GatewayAuthFilter implements GlobalFilter, Ordered {


    //白名单
    private static List<String> whitelist = null;

    static {
        //加载接口白名单
        try (   
                InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties");
        ) {
            Properties properties = new Properties();
            properties.load(resourceAsStream);
            Set<String> strings = properties.stringPropertyNames();
            whitelist= new ArrayList<>(strings);

        } catch (Exception e) {
            log.error("加载/security-whitelist.properties出错:{}",e.getMessage());
            e.printStackTrace();
        }
    }

    @Autowired
    private TokenStore tokenStore;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String requestUrl = exchange.getRequest().getPath().value();
        AntPathMatcher pathMatcher = new AntPathMatcher();
        //白名单放行
        for (String url : whitelist) {
            if (pathMatcher.match(url, requestUrl)) {
                return chain.filter(exchange);
            }
        }

        //检查token是否存在
        String token = getToken(exchange);
        if (StringUtils.isBlank(token)) {
            return buildReturnMono("没有认证",exchange);
        }
        //判断是否是有效的token
        OAuth2AccessToken oAuth2AccessToken;
        try {
            oAuth2AccessToken = tokenStore.readAccessToken(token);

            boolean expired = oAuth2AccessToken.isExpired();
            if (expired) {
                return buildReturnMono("认证令牌已过期",exchange);
            }
            return chain.filter(exchange);
        } catch (InvalidTokenException e) {
            log.info("认证令牌无效: {}", token);
            return buildReturnMono("认证令牌无效",exchange);
        }

    }

    /**
     * 获取token
     */
    private String getToken(ServerWebExchange exchange) {
        String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (StringUtils.isBlank(tokenStr)) {
            return null;
        }
        String token = tokenStr.split(" ")[1];
        if (StringUtils.isBlank(token)) {
            return null;
        }
        return token;
    }
	//异步响应
    private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) {
        ServerHttpResponse response = exchange.getResponse();
        String jsonString = JSON.toJSONString(new RestErrorResponse(error));
        byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = response.bufferFactory().wrap(bits);
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        return response.writeWith(Mono.just(buffer));
    }
    @Override
    public int getOrder() {
        return 0;
    }
}

配置security框架

@Bean
 public SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {

  return http.authorizeExchange()
          .pathMatchers("/**").permitAll()
          .anyExchange().authenticated()
          .and().csrf().disable().build();
 }

}

7.验证码服务

用于下发验证码图片,对用户上传的验证码进行对比,防止人机,该服务还可以用于发送手机短信验证码等,所以该类必须单独进行部署开发。

引入验证码生成依赖

<!-- 生成验证码 -->
<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

7.1.生成验证码的key和code

生成验证码,返回一个base64的图片

@Autowired
private DefaultKaptcha kaptcha;
//传入一个字符串生成一个base64编码的图片
private String createPic(String code) {
    // 生成图片验证码
    ByteArrayOutputStream outputStream = null;
    BufferedImage image = kaptcha.createImage(code);

    outputStream = new ByteArrayOutputStream();
    String imgBase64Encoder = null;
    try {
        // 对字节数组Base64编码
        // BASE64Encoder base64Encoder = new BASE64Encoder();
        ImageIO.write(image, "png", outputStream);
        imgBase64Encoder = "data:image/png;base64," + EncryptUtil.encodeBase64(outputStream.toByteArray());
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            outputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    return imgBase64Encoder;
}

生成验证码的key和code

//这里实现的是内部接口(接口中定义接口)
@Component("RedisCheckCodeStore")
public class RedisCheckCodeStore implements CheckCodeService.CheckCodeStore {

    @Autowired
    RedisTemplate redisTemplate;
    
    @Override
    public void set(String key, String value, Integer expire) {
        redisTemplate.opsForValue().set(key,value,expire, TimeUnit.SECONDS);
    }

    @Override
    public String get(String key) {
        return (String) redisTemplate.opsForValue().get(key);
    }

    @Override
    public void remove(String key) {
        redisTemplate.delete(key);
    }
}

//注入封装类
@Resource(name="RedisCheckCodeStore")
@Override
public void setCheckCodeStore(CheckCodeStore checkCodeStore) {
    this.checkCodeStore = checkCodeStore;
}
//生成code、key、将key存入redis
public GenerateResult generate(CheckCodeParamsDto checkCodeParamsDto,Integer code_length,String keyPrefix,Integer expire){
    //生成四位验证码
    String code = checkCodeGenerator.generate(code_length);//核心是”a~z0~9“中随机挑选4个字符
    log.debug("生成验证码:{}",code);
    //生成一个key
    String key = keyGenerator.generate(keyPrefix);//核心用的uuid去掉'-'

    //存储验证码
    checkCodeStore.set(key,code,expire); //存入redis中
    //返回验证码生成结果
    GenerateResult generateResult = new GenerateResult();
    generateResult.setKey(key);
    generateResult.setCode(code);
    return generateResult;
}

7.2.验证验证码(通过key获取value,对比value)

public boolean verify(String key, String code){
    if (StringUtils.isBlank(key) || StringUtils.isBlank(code)){
        return false;
    }
    String code_l = checkCodeStore.get(key);
    if (code_l == null){
        return false;
    }
    boolean result = code_l.equalsIgnoreCase(code);
    if(result){
        //删除验证码
        checkCodeStore.remove(key);
    }
    return result;
}

8.订单支付微服务

8.1.支付宝支付

支付宝支付的主要流程是,先使用支付宝的sdk配置密钥和appid,这些二可以在支付宝开放平台申请账号创建也该网页移动应用并配置apl管理中配置一个移动网站支付接口,可以使用支付宝的沙箱环境进行支付,相当于测试号(不需要认证企业号),然后配置连接信息创建订单,发送给支付宝即可。

具体做法是写一个专门用于创建订单的接口,将这个接口生成二维码,扫码跳转接口调用支付宝接口在支付宝内部生成订单,支付宝返回订单信息跳转支付宝让客户支付,支付完成后可以主动查询支付结果,也可以被动等支付宝调用你指定的接口返回字符结果,配置信息参数可以看官方文档。

生成支付二维码,其中AddOrderDto是记录了关于订单相关的主要信息(商品id、商品名称、商品价格等),根据这些信息创建订单表和订单支付表,根据订单支付表中的唯一支付编号来创建唯一的支付二维码,这个二维码

@Transactional
@Override
public PayRecordDto generatePayOrder(AddOrderDto addOrderDto, Long userId) {
    // 1.创建商品订单和订单对应商品
    XcOrders xcOrders = saveXcOrders(addOrderDto, userId);
    XcOrdersGoods xcOrdersGoods = saveXcOrdersGoods(xcOrders);
    // 2.生成支付记录
    XcPayRecord xcPayRecord = saveXcPayRecord(xcOrders);
    // 3.根据支付记录生成订单二维码
    if (xcPayRecord==null) {
        return null;
    }
    String payUrl = "http://192.168.0.100:63030/orders/requestpay?payNo=" + xcPayRecord.getPayNo();//二维码指向的请求接口
    String qrCode = null;
    try {
        qrCode = new QRCodeUtil().createQRCode(payUrl, 200, 200);
    } catch (IOException e) {
        XueChengException.cast("生成支付二维码出错。。。");
    }
    PayRecordDto payRecordDto = new PayRecordDto();
    BeanUtils.copyProperties(xcPayRecord, payRecordDto);
    payRecordDto.setQrcode(qrCode);
    return payRecordDto;
}

扫码后跳转指定接口,调用支付宝接口在支付宝内部创建支付订单,然后返回给前端,当客户支付。

@ApiOperation("扫码下单接口")
    @GetMapping("/requestpay")
    public void requestpay(String payNo, HttpServletResponse httpResponse) throws IOException {
        String res = orderService.payOrder(payNo);
        httpResponse.setContentType("text/html;charset=" + alipayConfig.getCharset());
        httpResponse.getWriter().write(res);
        httpResponse.getWriter().flush();
    }
/**
 * 扫码支付订单
 *
 * @param payNo 支付订单号
 */
public String payOrder(String payNo) {
    XcPayRecord xcPayRecord = xcPayRecordMapper
            .selectOne(new LambdaQueryWrapper<XcPayRecord>().eq(XcPayRecord::getPayNo, payNo));
    if (xcPayRecord==null) {
        log.error("查询支付订单时出错,pyaNo={}", payNo);
        XueChengException.cast("扫码支付失败!请联系管理员。");
        return null;
    }
    AlipayClient alipayClient = null;
    String pageRedirectionData = null;
    try {
        alipayClient = new DefaultAlipayClient(alipayConfig);
        AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
        AlipayTradeWapPayModel model = new AlipayTradeWapPayModel();
        model.setOutTradeNo(xcPayRecord.getPayNo().toString());
        model.setTotalAmount(xcPayRecord.getTotalPrice().toString());
        model.setSubject(xcPayRecord.getOrderName());
        model.setProductCode("QUICK_WAP_WAY");
        request.setBizModel(model);
        request.setNotifyUrl("http://alipaytest.594000.xyz/orders/alipayNotify");
        AlipayTradeWapPayResponse response = alipayClient.pageExecute(request, "POST");
        pageRedirectionData = response.getBody();
    } catch (AlipayApiException e) {
        log.error("扫码支付订单时出错,pyaNo={}", payNo);
        XueChengException.cast("扫码支付失败!");
    }
    return pageRedirectionData;
}

主动查询支付结果

/**
 * 查询支付结果
 *
 * @param payNo 支付记录订单
 * @return
 */
@Transactional
public PayRecordDto queryPayResult(String payNo) {
    if (StringUtils.isEmpty(payNo)) {
        return null;
    }
    PayRecordDto payRecordDto = new PayRecordDto();
    XcPayRecord xcPayRecord = xcPayRecordMapper.selectOne(new LambdaQueryWrapper<XcPayRecord>().eq(XcPayRecord::getPayNo, payNo));
    XcOrders xcOrders = xcOrdersMapper.selectById(xcPayRecord.getOrderId());
    if (xcPayRecord.getStatus().equals("601002") && xcOrders.getStatus().equals("601002")) {
        BeanUtils.copyProperties(xcPayRecord, payRecordDto);
        return payRecordDto;
    }
    // 初始化SDK
    AlipayClient alipayClient = null;
    String body = null;
    AlipayTradeQueryResponse response = null;
    try {
        alipayClient = new DefaultAlipayClient(alipayConfig);
        // 构造请求参数以调用接口
        AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
        AlipayTradeQueryModel model = new AlipayTradeQueryModel();
        // 设置订单支付时传入的商户订单号
        model.setOutTradeNo(payNo);
        request.setBizModel(model);
        response = alipayClient.execute(request);
        if (!response.isSuccess()) {
            return null;
        }
        body = response.getBody();
    } catch (AlipayApiException e) {
        throw new RuntimeException(e);
    }

    String tradeStatus = response.getTradeStatus();
    String outTradeNo = response.getOutTradeNo();

    if ("TRADE_SUCCESS".equals(tradeStatus)) {
        //保存外部系统支付记录号
        xcPayRecord.setOutPayNo(response.getTradeNo());
        //保存支付成功消息
        saveAliPayStatus(xcOrders,xcPayRecord);
        
        // 添加支付成功的消息到数据库
        MqMessage payresultNotify = mqMessageService
                .addMessage("payresult_notify", xcOrders.getOutBusinessId(), xcOrders.getOrderType(), null);
        //发送选课支付成功消息到rabbitMQ
        notifyPayResult(payresultNotify);
    }
    BeanUtils.copyProperties(xcPayRecord, payRecordDto);
    return payRecordDto;

}

被动等支付宝回调通知

/**
 * 支付宝支付通知
 *
 * @param params
 */
@Transactional
public void alipayNotify(Map<String, String> params) {
    // 获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以下仅供参考)//
    // 商户订单号
    String out_trade_no = params.get("out_trade_no");
    // 支付宝交易号
    String trade_no = params.get("trade_no");
    // 交易状态
    String trade_status = params.get("trade_status");

    XcPayRecord xcPayRecord = xcPayRecordMapper
            .selectOne(new LambdaQueryWrapper<XcPayRecord>().eq(XcPayRecord::getPayNo, out_trade_no));

    XcOrders xcOrders = xcOrdersMapper.selectById(xcPayRecord.getOrderId());
    // 获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以上仅供参考)//
    // 计算得出通知验证结果
    // boolean AlipaySignature.rsaCheckV1(Map<String, String> params, String publicKey, String charset, String sign_type)
    boolean verify_result = false;
    try {
        verify_result = AlipaySignature.rsaCheckV1(params, alipayConfig.getAlipayPublicKey(), alipayConfig.getCharset(), "RSA2");
    } catch (AlipayApiException e) {
        throw new RuntimeException(e);
    }

    if (verify_result) {// 验证成功
        //////////////////////////////////////////////////////////////////////////////////////////
        // 请在这里加上商户的业务逻辑程序代码
        System.out.println(String.valueOf(verify_result));
        //——请根据您的业务逻辑来编写程序(以下代码仅作参考)——

        if (trade_status.equals("TRADE_FINISHED")) {
            // 判断该笔订单是否在商户网站中已经做过处理
            // 如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
            // 请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
            // 如果有做过处理,不执行商户的业务程序
            System.out.println(trade_status);
            // 注意:
            // 如果签约的是可退款协议,退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
            // 如果没有签约可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
        } else if (trade_status.equals("TRADE_SUCCESS")) {
            // 判断该笔订单是否在商户网站中已经做过处理
            // 如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
            // 请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
            // 如果有做过处理,不执行商户的业务程序
            xcPayRecord.setOutPayNo(params.get("trade_no"));
            saveAliPayStatus(xcOrders,xcPayRecord);

            // 注意:
            // 如果签约的是可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
            
            
            // 添加支付成功的消息到数据库
        MqMessage payresultNotify = mqMessageService
                .addMessage("payresult_notify", xcOrders.getOutBusinessId(), xcOrders.getOrderType(), null);
            //发送选课支付成功消息到rabbitMQ
            notifyPayResult(payresultNotify);
        }

    }
}

8.2.RabbitMQ消息队列通知

当主动查询支付宝订单发现已经支付了,或者支付宝主动通知订单已经支付了,这时候就需要将订单支付的通知传输给学习中心,告知选择的课程已经支付了,需要添加到我的课程表了

添加依赖

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-amqp</artifactId>
 </dependency>
<!-- mq消息队列 -->
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-amqp</artifactId>
 </dependency>

配置连接信息

spring:
  rabbitmq:
    host: 192.168.72.65
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    publisher-confirm-type: correlated #correlated 异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
    publisher-returns: false #开启publish-return功能,同样是基于callback机制,需要定义ReturnCallback
    template:
      mandatory: false #定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息
    listener:
      simple:
        prefetch: 1  #每次只能获取一条消息,处理完成才能获取下一个消息
        acknowledge-mode: none #auto:出现异常时返回unack,消息回滚到mq;没有异常,返回ack ,manual:手动控制,none:丢弃消息,不回滚到mq
        retry:
          enabled: true #开启消费者失败重试
          initial-interval: 1000ms #初识的失败等待时长为1秒
          multiplier: 1 #失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 #最大重试次数
          stateless: true #true无状态;false有状态。如果业务中包含事务,这里改为false

8.2.1.消息通知方(生产者,发送消息)

配置rabeitmp

@Slf4j
@Configuration
public class PayNotifyConfig implements ApplicationContextAware {
    //交换机
    public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
    //支付结果通知消息类型
    public static final String MESSAGE_TYPE = "payresult_notify";
    //支付通知队列
    public static final String PAYNOTIFY_QUEUE = "paynotify_queue";

    //声明交换机,且持久化
    @Bean(PAYNOTIFY_EXCHANGE_FANOUT)
    public FanoutExchange paynotify_exchange_fanout() {
        // 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
        return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
    }
    //支付通知队列,且持久化
    @Bean(PAYNOTIFY_QUEUE)
    public Queue course_publish_queue() {
        return QueueBuilder.durable(PAYNOTIFY_QUEUE).build();
    }

    //交换机和支付通知队列绑定
    @Bean
    public Binding binding_course_publish_queue(@Qualifier(PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange);
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 获取RabbitTemplate
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        //消息处理service
        MqMessageService mqMessageService = applicationContext.getBean(MqMessageService.class);
        // 设置ReturnCallback
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            // 投递失败,记录日志
            log.info("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}",
                    replyCode, replyText, exchange, routingKey, message.toString());
            MqMessage mqMessage = JSON.parseObject(message.toString(), MqMessage.class);
            //将消息再添加到消息表
            mqMessageService.addMessage(mqMessage.getMessageType(),mqMessage.getBusinessKey1(),mqMessage.getBusinessKey2(),mqMessage.getBusinessKey3());

        });
    }

}

发送消息到rabbitMQ

@Override
public void notifyPayResult(MqMessage message) {
    // 消息
    String jsonString = JSON.toJSONString(message);
    // 设置消息持久化
    Message msgObj = MessageBuilder.withBody(jsonString.getBytes(StandardCharsets.UTF_8))
            .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
            .build();
    // 2.全局唯一的消息ID,需要封装到CorrelationData中
    CorrelationData correlationData = new CorrelationData(message.getId().toString());
    // 3.添加callback
    correlationData.getFuture().addCallback(result -> {
                if (result.isAck()) {
                    // 3.1.ack,消息成功
                    log.debug("通知支付结果消息发送成功, ID:{}", correlationData.getId());
                    // 删除消息表中的记录
                    mqMessageService.completed(message.getId());
                } else {
                    // 3.2.nack,消息失败
                    log.error("通知支付结果消息发送失败, ID:{}, 原因{}", correlationData.getId(), result.getReason());
                }
            },
            ex -> log.error("消息发送异常, ID:{}, 原因{}", correlationData.getId(), ex.getMessage())
    );
    // 4.发送消息
    rabbitTemplate.convertAndSend(PayNotifyConfig.PAYNOTIFY_EXCHANGE_FANOUT, "", msgObj, correlationData);
}

8.2.2.消息接收方(消费者,接收消息)

配置rabbitMQ

@Slf4j
@Configuration
public class PayNotifyConfig{

    //交换机
    public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
    //支付结果通知消息类型
    public static final String MESSAGE_TYPE = "payresult_notify";
    //支付通知队列
    public static final String PAYNOTIFY_QUEUE = "paynotify_queue";

    //声明交换机,且持久化
    @Bean(PAYNOTIFY_EXCHANGE_FANOUT)
    public FanoutExchange paynotify_exchange_fanout() {
        // 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
        return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
    }
    //支付通知队列,且持久化
    @Bean(PAYNOTIFY_QUEUE)
    public Queue course_publish_queue() {
        return QueueBuilder.durable(PAYNOTIFY_QUEUE).build();
    }

    //交换机和支付通知队列绑定
    @Bean
    public Binding binding_course_publish_queue(@Qualifier(PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange);
    }

}

接收消息

@RabbitListener(queues = PayNotifyConfig.PAYNOTIFY_QUEUE)
public void receive(Message message, Channel channel){
    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    MqMessage mqMessage = JSON.parseObject(message.getBody(), MqMessage.class);
    log.debug("学习中心服务接收支付结果:{}", mqMessage);
    //消息类型
    String messageType = mqMessage.getMessageType();
    //选课id
    String chooseCourseId = mqMessage.getBusinessKey1();
    //订单类型,60201表示购买课程
    String orderType = mqMessage.getBusinessKey2();
    //只处理购买课程成功的消息通知
    if (PayNotifyConfig.MESSAGE_TYPE.equals(messageType)&&"60201".equals(orderType)){
        XcChooseCourse xcChooseCourse = xcChooseCourseMapper.selectById(chooseCourseId);
        if (xcChooseCourse==null){
            log.debug("学习服务接收消息通知,查询选课表记录为空,出错的消息:{}",mqMessage.toString());
            return;
        }
        if (!xcChooseCourse.getStatus().equals("701002")){
            log.debug("学习服务接收消息通知,查询选课表记录状态不为待支付,出错的消息:{}",mqMessage.toString());
            return;
        }
        XcCourseTables xcCourseTables = saveCourseTables(xcChooseCourse);
        if (xcCourseTables==null){
            log.debug("学习服务接收消息通知,保存我的课程表记录出错,出错的消息:{}",mqMessage.toString());
            XueChengException.cast("学习服务接收消息通知,保存我的课程表记录出错,选课记录id:"+chooseCourseId);
            return;
        }
        xcChooseCourse.setStatus("701001");
        int i = xcChooseCourseMapper.updateById(xcChooseCourse);
        if (i<=0){
            XueChengException.cast("学习服务接收消息通知,更改选课表记录出错,选课记录id:"+chooseCourseId);
        }
    }

}

9.项目部署

9.1.人工手动部署

首先根节点工程需要将子模块工程进行聚合

xuecheng-plus-parent根节点

<modules>
    <module>../xuecheng-plus-base</module>
    <module>../xuecheng-plus-checkcode</module>
    <module>../xuecheng-plus-gateway</module>
    <module>../xuecheng-plus-auth</module>
    <module>../xuecheng-plus-content</module>
    <module>../xuecheng-plus-media</module>
    <module>../xuecheng-plus-orders</module>
    <module>../xuecheng-plus-message-sdk</module>
    <module>../xuecheng-plus-search</module>
    <module>../xuecheng-plus-system</module>
    <module>../xuecheng-plus-learning</module>
</modules>

<build>
        <finalName>${project.name}</finalName>
        <!--编译打包过虑配置-->
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>**/*</include>
                </includes>
            </resource>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <!--指定项目源码jdk的版本-->
                    <source>1.8</source>
                    <!--指定项目编译后的jdk的版本-->
                    <target>1.8</target>
                    <!--配置注解预编译-->
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${org.projectlombok.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>

            <!--责处理项目资源文件并拷贝到输出目录,如果有额外的资源文件目录则需要配置-->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <encoding>utf-8</encoding>
                    <!--使用默认分隔符,resource中可以使用分割符定义过虑的路径-->
                    <useDefaultDelimiters>true</useDefaultDelimiters>
                </configuration>
            </plugin>
        </plugins>
    </build>

在需要对外提供http请求接口的AIP服务的pom文件中添加打包插件,以xuecheng-plus-content-api为例

它的项目结构如下

image-20250602182534637

<build>
    <finalName>${project.artifactId}-${project.version}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>${spring-boot.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
    </build>

根节点项目中用maven的package打包,如果需要跳过测试可以使用自定义运行命令clean install -DskipTests -f pom.xml

后面可以使用java -Dfile.encoding=utf-8 -jar xuecheng-plus-checkcode-0.0.1-SNAPSHOT.jar 来运行打包好的项目

部署linux的话可以采用docker来部署

编写Dockerfile文件

# 指定基础镜像
FROM java:8u20 
# 镜像维护者的姓名和邮箱地址(非必须)
MAINTAINER docker_maven docker_maven@email.com
# 指定在创建容器后, 终端默认登录进来的工作目录
WORKDIR /ROOT
# 将宿主机目录下(或远程文件)的文件拷贝进镜像,且会自动处理URL和解压tar压缩包。
ADD xuecheng-plus-checkcode-0.0.1-SNAPSHOT.jar xuecheng-plus-checkcode.jar
# 指定容器启动后要干的事情
CMD ["java", "-version"]
# 镜像中应用的启动命令,容器运行时调用
ENTRYPOINT ["java", "-Dfile.encoding=utf-8","-jar", "xuecheng-plus-checkcode.jar"]
# 指定容器运行时监听的端口,是给镜像使用者看的
EXPOSE 63075

将构建的 xuecheng-plus-checkcode-0.0.1-SNAPSHOT.jar 和 Dockerfile文件放一起,执行下面的命令,生成docker镜像,-t 参数是指定生成后的镜像名字和版本号tag

docker build -t checkcode:1.0 .

最后运行docker run --name xuecheng-plus-checkcode -p 63075:63075 -idt checkcode:1.0 启动镜像生成容器。

9.2.自动化部署

自动化部署就是将手动打包项目、上传打包好的项目、编写Dockerfile、生成docker镜像、运行docker镜像生成镜像容器镜像全自动化,开发者只需要将项目源码上传到代码仓库即可,自动化部署工具就可以根据源码自动话完成以上所有步骤。

自动化部署工具:jenkins,镜像名字:jenkins/jenkins

dockers镜像私服仓库工具:registry,镜像名字:registry

docker服务开启2375远程端口,用于调用该接口上传镜像、远程管理容器等。

在手动部署的基础上添加上自动docker镜像打包插件docker-maven-plugin,并配置Dockerfile文件并生成镜像上传到docker镜像私服

<dependency>
    <groupId>com.spotify</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <version>1.2.2</version>
</dependency>
<build>
    <finalName>${project.artifactId}-${project.version}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>${spring-boot.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <groupId>com.spotify</groupId>
            <artifactId>docker-maven-plugin</artifactId>
            <version>1.2.2</version>
            <configuration>
                <!--修改imageName节点的内容,改为私有仓库地址和端口,再加上镜像id和 TAG,我们要直接传到私服-->
                <!--配置最后生成的镜像名,docker images里的,我们这边取项目名:版本-->
                <!--<imageName>${project.artifactId}:${project.version}</imageName>-->
                <imageName>192.168.72.65:5000/${project.artifactId}:${project.version}</imageName>
                <!--也可以通过以下方式定义image的tag信息。 -->
                <!-- <imageTags>
                     <imageTag>${project.version}</imageTag>
                     &lt;!&ndash;build 时强制覆盖 tag,配合 imageTags 使用&ndash;&gt;
                     <forceTags>true</forceTags>
                     &lt;!&ndash;build 完成后,push 指定 tag 的镜像,配合 imageTags 使用&ndash;&gt;
                     <pushImageTag>true</pushImageTag>
                 </imageTags>-->
                <baseImage>java:8u20</baseImage>
                <maintainer>docker_maven docker_maven@email.com</maintainer>
                <workdir>/root</workdir>
                <cmd>["java", "-version"]</cmd>
                <!--来指明Dockerfile文件的所在目录,如果配置了dockerDirectory则忽略baseImage,maintainer等配置-->
                <!-- <dockerDirectory>./</dockerDirectory> -->
                <!--2375是docker的远程端口,插件生成镜像时连接docker,这里需要指定docker远程端口-->
                <dockerHost>http://192.168.72.65:2375</dockerHost>
                <!--入口点,project.build.finalName就是project标签下的build标签下 的filename标签内容,testDocker-->
                <!--相当于启动容器后,会自动执行java -jar ...-->
                <entryPoint>["java","-Xmx256m","-Xms256m", "-Dfile.encoding=utf-8","-jar", "/root/${project.build.finalName}.jar"]</entryPoint>
                <!--是否推送到docker私有仓库,旧版本插件要配置maven的settings文件。 -->
                <pushImage>true</pushImage>
                <registryUrl>192.168.72.65:5000</registryUrl>  <!-- 这里是复制 jar 包到 docker 容器指定目录配置 -->
                <resources>
                    <resource>
                        <targetPath>/root</targetPath>
                        <directory>${project.build.directory}</directory>
                        <!--把哪个文件上传到docker,相当于Dockerfile里的add app.jar /-->
                        <include>${project.build.finalName}.jar</include>
                    </resource>
                </resources>
            </configuration>
        </plugin>
    </plugins>
</build>

docker镜像下载建议还是用官方源 https://hub.docker.com

设置官方源:vim /etc/docker/daemon.json,registry-mirrors下载源,insecure-registries是私服镜像仓库地址,也就是下面要配置的registry

{
"registry-mirrors": ["https://hub.docker.com"],
"insecure-registries": ["192.168.72.65:5000"]
}

配置docker镜像私服仓库registry

mkdir -p /root/dockerData/registry
docker pull registry:latest
docker run -d --name registry -p 5000:5000 -v /root/dockerData/registry:/var/lib/registry registry:latest
vim /etc/docker/daemon.json
# 添加"insecure-registries": ["192.168.72.65:5000"]

配置自动话部署jenkins

docker安装jenkins

mkdir -p /root/dockerData/jenkins && chmod 777 /root/dockerData/jenkins
docker pull jenkins/jenkins:2.504.2-lts-jdk21
docker run -d --name jenkins -p 8888:8080 -p 50000:50000 -v /root/dockerData/jenkins:/var/jenkins_home jenkins/jenkins:2.346.1-lts

通过8888端口进入控制后台,插件的话直接用社区推荐即可

在系统管理-插件管理中安装一个ssh插件(Publish Over SSH),用于远程发送sh命名

在系统管理-全局工具配置中配置安装一个maven,用于构建源码项目然后进行打包构建镜像上传私服

在系统管理-系统配置中-找到”SSH Servers“,配置服务器地址和ssh远程登录账户,用于对docker容器和镜像进行管理

新建任务---》构建一个自由风格的软件项目

在源码管理中配置代码仓库用于拉取源码(配置创库地址)

在构建步骤(Build Steps)中添加构建步骤

调用顶层 Maven 目标:版本选择刚才安装的maven,目标为根节点中的pom(clean install -DskipTests -f xuecheng-plus-parent/pom.xml),因为需要先使用maven将整个项目镜像打包生成jar包,xuecheng-plus-parent/pom.xml是Jenkins拉取源码后的工作空间里面的路径。

image-20250602202823024

继续添加构建步骤

调用顶层 Maven 目标:版本选择刚才安装的maven,目标为需要对外提供http请求的微服务项目(api层)的pom(-DskipTests docker:build -f xuecheng-plus-content/xuecheng-plus-content-api/pom.xml),这个是mvane调用docke打包插件来生成docket镜像并上传docker私服镜像仓库,xuecheng-plus-content/xuecheng-plus-content-api/pom.xml是Jenkins拉取源码后的工作空间里面的路径指向的是项目的api工程的pom

image-20250602202759555

继续添加构建步骤

选择”Send files or execute commands over SSH“,配置name(这个选择刚才配置的ssh服务),配置Exec command为(docker run --name xuecheng-plus-content-api -p 63040:63040 -idt 192.168.72.65:5000/xuecheng-plus-content-api:0.0.1-SNAPSHOT),这个步骤就是启动镜像

image-20250602202915007

image-20250602202935635

到这儿就完成了一个微服务的自动部署了,其他微服务像这样添加一个”调用顶层 Maven 目标,来打包一个镜像并上传私服“,再添加一个”Send files or execute commands over SSH,用来运行镜像启动容器服务“。

因为每次都需要重复部署,所以可以在部署前添加一个ssh命令,停止正在运行的容器,然后删除容器,删除镜像,再开始进行构建和部署。

image-20250602203005993

10.项目优化(压测、缓存、分布式锁)

做缓存的理想情况是,不管多少请求和并发,只会查询一次数据库,后面的请求全部走缓存。

10.1.redis缓存(基础版,存在问题)

public CoursePreviewDto getCoursePreviewInfoCache(Long courseId) {
        Object obj = redisTemplate.opsForValue().get("courseId:"+courseId);
        if (obj!=null){
            String objString = obj.toString();
            return JSON.parseObject(objString, CoursePreviewDto.class);
        }else {
            //查询数据库
            System.out.println("查询数据库!");
            CoursePreviewDto coursePreviewInfo = getCoursePreviewInfo(courseId);
            if (coursePreviewInfo!=null){
                redisTemplate.opsForValue().set("courseId:"+courseId, JSON.toJSONString(coursePreviewInfo));
            }
            return coursePreviewInfo;
        }

    }

上面的代码存在缓存穿透(缓存完全失效,全部请求打到数据库),高并发下很多请求时很多请求会直接落到数据库查询,并且在当查询一个不存在的课程id时,就一直不会命中缓存,所有请求也就会全部落到查询数据。

如何解决缓存穿透:可以通过一个过滤器(将数据库中的所有的key存入),查询时先查询过滤器判读是否存在该key,进行一次排除,确定该数据是否去查询数据库

具体实现:采用Map进行缓存(少量数据)、采用布隆过滤器(大批量数据,只能保证数据key不存在,不能保证数据一定存在)

实现布隆过滤器技术方案:

  • Google工具包Guava实现
  • redisson (分布式锁)

10.2.redis缓存(缓存空值或特殊值,解决缓存穿透)

原理:既然查询的是一个不存在key时,就一直无法命中缓存,那我们就将这个不存在的key设置进入到缓存中(设置一个特殊值区别与正常值),就能命中缓存了。

缓存雪崩(key同时失效时,所有请求又一并涌入,造成数据库被大量访问),解决办法设置一个随机过期时间

缓存击穿(在高并发下,少量请求绕过了缓存,直接访问了数据库),解决办将访问数据库的代码加锁synchronized (this) {}进行控制,保证只有一个请求查询数据库,让缓存预热。

public CoursePreviewDto getCoursePreviewInfoCache(Long courseId) {
        Object obj = redisTemplate.opsForValue().get("courseId:" + courseId);
        if (obj!=null) {
            if ("null".equals(obj.toString())) {
                return null;
            }
            String objString = obj.toString();
            return JSON.parseObject(objString, CoursePreviewDto.class);
        } else {
            CoursePreviewDto coursePreviewInfo = null;
            synchronized (this) {
                //拿到锁的请求重新查询缓存
                obj = redisTemplate.opsForValue().get("courseId:" + courseId);
                if (obj!=null) {
                    if ("null".equals(obj.toString())) {
                        return null;
                    }
                    String objString = obj.toString();
                    return JSON.parseObject(objString, CoursePreviewDto.class);
                }
                // 查询数据库
                System.out.println("查询数据库!");
                coursePreviewInfo = getCoursePreviewInfo(courseId);
                if (coursePreviewInfo!=null) {
                    redisTemplate.opsForValue().set("courseId:" + courseId, JSON.toJSONString(coursePreviewInfo));
                } else {
                    // 这种写法必须添加一个缓存过期时间,随机时间,避免缓存同时失效
                    // 因为后面新增课程时,原先不存在的key,就会存在,如果不设置过期时间那该存在的key将一直为null
                    // 当然也可以在添加课程时也同步更新缓存。
                    redisTemplate.opsForValue().set("courseId:" + courseId, "null", new Random().nextInt(50) + 30, TimeUnit.SECONDS);
                }
            }
            return coursePreviewInfo;
        }
    }

但是上面的代码只能保证单个虚拟机缓存不被击穿,因为锁只能单个虚拟机有效,在分布式情况下,就无法保证缓存击穿,理想情况下,集群中多个服务只查询一次数据库,完成缓存预热。

10.3.redis分布式锁(解决缓存击穿)

理想情况下,集群中多个服务只需查询一次数据库,完成缓存预热,这是就需要使用分布式锁来完成。

分布式锁的本质就是让多个java虚拟机同时去访问同一个锁(这个锁必须独立于虚拟机)来达到实现锁的效果,实现思路可以用数据库(更新值的方式,高并发下不适用)、redis(SETNX、set nx、redisson)、zookeeper(子目录(节点)创建)等。

10.3.1.Setnx 命令实现分布式锁

SET key value [EX seconds|PX milliseconds|KEEPTTL] [NX|XX] [GET]

  • EX seconds – 设置键key的过期时间,单位时秒
  • PX milliseconds – 设置键key的过期时间,单位时毫秒
  • NX – 只有键key不存在的时候才会设置key的值
  • XX – 只有键key存在的时候才会设置key的值
  • KEEPTTL -- 获取 key 的过期时间
  • GET -- 返回 key 存储的值,如果 key 不存在返回空

注意: 由于SET命令加上选项已经可以完全取代SETNX, SETEX, PSETEX, GETSET,的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令。

redis分布式锁就是通过命令参数的nx(本质是对key上锁)来实现,当多个虚拟机(或者多个线程)来同时设置key,但是只有其中一个能设置成功,设置成功的就表明拿到了分布式锁,而其他没拿到锁的只有等待分布式锁的释放,如果该分布式锁未能释放,将导致其他虚拟机一直等待,所以该锁必须设置一个过期时间,防止某个虚拟机在执行过程中出现问题(系统断电、死机等)未能手动释放锁的问题。

但是使用上面的方法还是存在问题:第一,设置key过期时间到底应该设置多长时间,key过期时间设置短了,当某个虚拟机线程正在执行任务时,这时候key过期了,其他线程将key更新了,当线程执行完任务在手动删除key时就将别人设置的key给删除了【解决办法是在删除key时判断下当前key中的值是否是之前设置的key值,谁说可以解决把别人的key值删除问题,但是存在缓存击穿问题,无法做到只查询一次数据库来缓存预热。】,key过期时间设置长了,虽说能解决key被错删问题,能解决缓存击穿问题,但是手动删除key这个操作是有问题的,因为redis在执行命名时不具有原子性(要么一起成功要么一起失败),在高并发下,当redis内部释放锁准备删除key时,cpu暂时去执行其他命令了,这时redis内部这个key锁已经释放了,但是key却因为cpu调度问题没被删除,其他线程就可能将该key的值更新,导致了key的值被重新设置成了新的值,这时候cpu回来执行后面的删除key操作,就导致了新更新了值的key被删除,就出现了问题。所以redis官方明确表示不推荐直接使用Setnx 命令方式直接实现分布式锁,而是在该方式的基础上配合lua脚本来保证释放key和删除key是原子性操作(要么同时成功,要么同时失败)

lua删除key的一个例子将类似于以下:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

直接使用Setnx 命令实现分布式锁,使用上面解释实现的代码伪代码,存在key过期时间应该设置多长的问题、存在redis内部删除key(释放key的锁、删除key)不具有原子性的问题

if(缓存中有){
  返回缓存中的数据
}else{
  获取分布式锁: set lock 01 NX EX 30  //为lock这个key设置一个编号值设置30s过期,用于判断删除的是不是原来设置的key
  if(获取锁成功){
       try{
         查询数据库
      }finally{
         if(redis.call("get","lock")=="01"){//判断删除的是不是原来设置的key
            释放锁: redis.call("del","lock") //redis内部释放key的锁和删除key这两个操作不具备原子性操作
            //调用lua脚本释放锁,redis官方推荐
         }
      }
   }
}

10.3.2.Redisson实现分布式锁

Redisson是在redis的基础上进行增强,它依赖于redis,主要用于分布式服务,它就很好的解决了Setnx 命令释放锁删除锁的原子性操作。

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) 。

image-20250603155655261

使用Redisson可以非常方便将Java本地内存中的常用数据结构的对象搬到分布式缓存redis中。

也可以将常用的并发编程工具如:AtomicLong、CountDownLatch、Semaphore等支持分布式。

使用RScheduledExecutorService 实现分布式调度服务。

支持数据分片,将数据分片存储到不同的redis实例中。

支持分布式锁,基于Java的Lock接口实现分布式锁,方便开发

下边使用Redisson将Queue队列的数据存入Redis,实现一个排队及出队的接口。

image-20250603155827649

添加pom

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson-spring-boot-starter</artifactId>
   <version>3.11.2</version>
 </dependency>

使用redisson分布式锁

在redisson内部其实就是通过对key续期,通过看门狗监听然后对key值自动续期,释放key锁和删除key内部通过调用lua脚本实现原子性操作,默认情况下key的过期时间是30s。

image-20250603163154044

使用redisson分布式锁,解决分布式下的缓存击穿问题

public CoursePreviewDto getCoursePreviewInfoCache(Long courseId) {
    Object obj = redisTemplate.opsForValue().get("courseId:" + courseId);
    if (obj!=null) {
        if ("null".equals(obj.toString())) {
            return null;
        }
        String objString = obj.toString();
        return JSON.parseObject(objString, CoursePreviewDto.class);
    } else {
        CoursePreviewDto coursePreviewInfo = null;
        //获取redisson分布式锁
        RLock redissonLock = redissonClient.getLock("courseQueryLock:" + courseId);
        //上锁
        redissonLock.lock();
        try {
            // 拿到锁的重新查询缓存
            obj = redisTemplate.opsForValue().get("courseId:" + courseId);
            if (obj!=null) {
                if ("null".equals(obj.toString())) {
                    return null;
                }
                String objString = obj.toString();
                return JSON.parseObject(objString, CoursePreviewDto.class);
            }
            // 查询数据库
            System.out.println("查询数据库!");
            coursePreviewInfo = getCoursePreviewInfo(courseId);
            if (coursePreviewInfo!=null) {
                redisTemplate.opsForValue().set("courseId:" + courseId, JSON.toJSONString(coursePreviewInfo));
            } else {
                // 这种写法必须添加一个缓存过期时间,随机时间,避免缓存同时失效
                // 因为后面新增课程时,原先不存在的key,就会存在,如果不设置过期时间那该存在的key将一直为null
                // 当然也可以在添加课程时也同步更新缓存。
                redisTemplate.opsForValue().set("courseId:" + courseId, "null", new Random().nextInt(50) + 300, TimeUnit.SECONDS);
            }
        } finally {
            //释放锁
            redissonLock.unlock();
        }
        return coursePreviewInfo;
    }
}

11.项目总结

11.1.定义接口

1、确定协议

定义一个接口首先确定接口的协议,Http协议及具体的方法(GET、POST、PUT、DELETE)

2、请求

接下来需要分析请求及响应的数据格式与内容。

get 请求时,前端请求key/value串,SpringMVC采用基本数据类型(String、Integer等)或自定义类型接收。

Post请求时,前端请Form表单数据(application/x-www-form-urlencoded)和Json数据(Content-Type=application/json)、多部件类型数据(multipart/form-data),对于Json数据SpringMVC使用@RequestBody注解解析请求的json数据。

3、响应

基本上都是返回Json格式的响应结果。

4、生成接口文档

使用swagger注解描述接口的内容,使用Swagger生成接口文档。

11.3.专业名称解释

原子性操作:执行多个步骤时,要么这个几个步骤一起成功要么这几个步骤全部失败,例如:转账过程总共涉及两个操作:从A账户中减去1000元,向B账户中加上1000元。如果这两个操作中的任何一个失败,整个事务都将失败回滚;

一致性操作:当多个步骤执行完成时,最后要保证这些步骤最后数据应该保持一致状态,例如,转账前后所有账户的余额总和应该是不变的,不会出现余额不足或超额的情况;

幂等性操作:执行某一个任务,无论操作多少次,但是得到的结果是一样的,比如添加操作,添加01这个学生无论添加多少次都只能成功一次。

缓存穿透:因为一些特殊原因造成缓存完全失败,所有的请求完全打到数据库

缓存雪崩:缓存的key过期或者失效造成缓存的数据没有了,这时候所有的请求打到数据库了。

缓存击穿:在高并发下极个别请求达到数据库了,理想情况下不管是分布式服务架构还是单体服务架构,对于数据库查询只需查询一次,来完成缓存预热。

11.2.项目开发完学到的小知识或工具包

获取文件mimetype

从文件或字节数组中识别出内容类型的小工具

<!--根据扩展名取mimetype-->
<dependency>
    <groupId>com.j256.simplemagic</groupId>
    <artifactId>simplemagic</artifactId>
    <version>1.17</version>
</dependency>
public static void main(String[] args) throws IOException {
    ContentInfoUtil util = new ContentInfoUtil();
    File file = new File("./test.mp4");
    ContentInfo info = util.findMatch(file);
    //ContentInfo info = util.findMatch("/tmp/upload.tmp");
    // 或者
    //ContentInfo info = util.findMatch(inputStream);
    // 或者
    //ContentInfo info = util.findMatch(contentByteArray);
    if (info != null) {
        System.out.println("File type: " + info.getMimeType()); //File type: video/mp4
    } else {
        System.out.println("Unknown file type.");
    }
}

对java工具类的增强

Lang组件主要是一些工具类,涉及到数组工具类,字符串工具类,字符工具类,数学方面,时间日期工具类,异常,事件等工具类。

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.9</version>
</dependency>
public static void main(String[] args) throws IOException {
    //在指定字符串中生成长度为n的随机字符串
    int n=4;
    RandomStringUtils.random(n, "abcdefghijk12345");
    //判断字符串是否为null或者“”
    StringUtils.isNotEmpty("abcd");
    //从数组中选出最大值
    NumberUtils.max(new int[] { 1, 2, 3, 4 });//---4
    //判断字符串是否全是整数
    NumberUtils.isDigits("153.4");//--false
    //判断字符串是否是有效数字
    NumberUtils.isNumber("0321.1");//---false
    //日期加n天
    DateUtils.addDays(new Date(), n);
    //判断是否同一天
    DateUtils.isSameDay(new Date(), new Date());
    // MD5加密
    String encodeStr= DigestUtils.md5Hex("abcd" + "0000");
    // 密钥进行验证
    String md5Text = org.springframework.util.DigestUtils.md5DigestAsHex(("abcd" + "0000").getBytes(StandardCharsets.UTF_8));
    if(encodeStr.equals(md5Text))
    {
        System.out.println("MD5验证通过");
    }
}

自动生成实体类的get和set方法

使用Lombok还需要插件的配合,插件名字Lombok

<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<version>1.18.4</version>
	<scope>provided</scope>
</dependency>
@Slf4j
@Service
public class CourseBaseInfoServiceImpl implements CourseBaseInfoService {

}
@Data
@ToString
@NoArgsConstructor
public class AddCourseBaseDto {
	private long id;
	private String name;
}

@Setter 注解在类或字段,注解在类时为所有字段生成setter方法,注解在字段上时只为该字段生成setter方法。
@Getter 使用方法同上,区别在于生成的是getter方法。
@ToString 注解在类,添加toString方法。
@EqualsAndHashCode 注解在类,生成hashCode和equals方法。
@NoArgsConstructor 注解在类,生成无参的构造方法。
@RequiredArgsConstructor 注解在类,为类中需要特殊处理的字段生成构造方法,比如final和被@NonNull注解的字段。
@AllArgsConstructor 注解在类,生成包含类中所有字段的构造方法。
@Data 注解在类,生成setter/getter、equals、canEqual、hashCode、toString方法,如为final属性,则不会为该属性生成setter方法。
@Slf4j 注解在类,生成log变量,严格意义来说是常量。private static final Logger log = LoggerFactory.getLogger(UserController.class);

发送http请求

通过OkHttpClient可以发送一个Http请求(get、post等),并读取该Http请求的响应

<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.8.1</version>
</dependency>
public static void main(String[] args) throws IOException {
    OkHttpClient client = new OkHttpClient();
    MediaType mediaType = MediaType.parse("application/json; charset=utf-8");
    String json="{\"id\":\"12345\"}";
    RequestBody body = RequestBody.create(mediaType, json);
    Request request = new Request.Builder()
            .url("http://localhost/test")
            .post()
            .build();
    Response response = client.newCall(request).execute();
    System.out.println(response.body().string());
}

http协议内容

自动生成数据库的实体类和持久层映射类

MyBatis-Plus 代码生成器是一个强大的工具,它能够根据数据库表结构自动生成对应的实体类、Mapper接口、XML映射文件以及Service层代码。这个工具极大地简化了基于MyBatis框架的开发流程,提高了开发效率,尤其是在需要处理大量数据库表时。

该工具运行的原理是,通过数据库连接读取表结构,再结合模板文件生成对应的类

添加依赖

<!-- mp 代码生成器 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.5.0</version>
</dependency>
<!-- spring boot 的 freemark 模板引擎 -->
<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
    <version>最新版本</version>
</dependency>
<!-- 数据库连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<!-- 数据库驱动包 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<!-- 只用swagger注解, 缩小依赖的lib包 -->
<dependency>
    <groupId>io.swagger</groupId>
    <artifactId>swagger-annotations</artifactId>
    <version>1.5.20</version>
</dependency>
<!-- <!-- 只用swagger注解, 缩小依赖的lib包 --> -->
spring的mvc框架
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
</dependency>

能用的配置,具体的模板可以去mybaitis生成器源码仓库去下载:https://github.com/baomidou/mybatis-plus

public class CommonCodeGenerator {
    // TODO 数据库驱动类名
    private static String driverName="com.mysql.cj.jdbc.Driver";
    // TODO 数据库连接地址
    private static final String SERVICE_HOST = "192.168.72.65:3306";
    // TODO 数据库连接用户名
    private static final String DATA_SOURCE_USER_NAME  = "root";
    // TODO 数据库连接用户名密码
    private static final String DATA_SOURCE_PASSWORD  = "mysql";
    // TODO 数据库名字
    private static final String SERVICE_NAME = "xcgc_content";

    private static final String[] TABLE_NAMES = new String[]{
          //TODO 需要生成的表的名字
          "mq_message",
          "mq_message_history"
    };

    // TODO 默认生成entity,需要生成DTO修改此变量
    private static final Boolean IS_DTO = false;

    public static void main(String[] args) {
       // 代码生成器
       AutoGenerator mpg = new AutoGenerator();
       // 选择 freemarker 引擎,默认 Velocity
       mpg.setTemplateEngine(new FreemarkerTemplateEngine());
       // 全局配置
       // 全局策略配置提供了一些全局的设置,
       // 如输出目录、文件覆盖、开发者信息等,以及一些高级选项,如 Kotlin 模式、Swagger2 集成、ActiveRecord 模式等。
       GlobalConfig gc = new GlobalConfig();
       //是否覆盖已有文件。
       gc.setFileOverride(true);
       //输出路径
       gc.setOutputDir(System.getProperty("user.dir") + "/xuecheng-plus-generator/src/main/java");
       //作者
       gc.setAuthor("gc");
       gc.setOpen(false);
       gc.setSwagger2(false);
       //设置Service命名方式
       gc.setServiceName("%sService");
       // 开启BaseResultMap
        gc.setBaseResultMap(true);
       // 开启baseColumnList
        gc.setBaseColumnList(true);

       if (IS_DTO) {
          gc.setSwagger2(true);
          gc.setEntityName("%sDTO");
       }
       mpg.setGlobalConfig(gc);

       // 数据库配置
       DataSourceConfig dsc = new DataSourceConfig();
       dsc.setDbType(DbType.MYSQL);
       dsc.setUrl("jdbc:mysql://"+SERVICE_HOST+"/" + SERVICE_NAME
             + "?useUnicode=true&useSSL=false&characterEncoding=utf8");
       dsc.setDriverName(driverName);
       dsc.setUsername(DATA_SOURCE_USER_NAME);
       dsc.setPassword(DATA_SOURCE_PASSWORD);
       mpg.setDataSource(dsc);

       // 包配置
       // 包名配置用于定义生成代码的包结构,确保生成的代码放置在正确的目录中。
       // 通过配置包名,可以控制代码的组织方式,使其符合项目的架构设计。
       PackageConfig pc = new PackageConfig();
       pc.setModuleName(SERVICE_NAME);
       pc.setParent("com.xuecheng");
       pc.setServiceImpl("service.impl");
       pc.setXml("mapper");
       pc.setEntity("model.po");
       mpg.setPackageInfo(pc);


       // 设置模板
       TemplateConfig tc = new TemplateConfig();
       mpg.setTemplate(tc);

       // 策略配置,数据库表配置
       //数据库表配置用于定义生成代码时如何处理数据库表和字段。
       // 通过策略配置,可以指定生成哪些表的代码、如何命名实体类和字段、以及是否包含特定的注解或属性。
       StrategyConfig strategy = new StrategyConfig();
       // 指定需要生成代码的表名
       strategy.setInclude(TABLE_NAMES);
       //数据库表映射到实体的命名策略。默认使用下划线转驼峰命名
       strategy.setNaming(NamingStrategy.underline_to_camel);
       //数据库表字段映射到实体的命名策略。默认和Naming一样
       strategy.setColumnNaming(NamingStrategy.underline_to_camel);
       // 设置实体类使用Lombok模型
       strategy.setEntityLombokModel(true);
       // 设置Controller使用REST风格
       strategy.setRestControllerStyle(true);
       // 驼峰转连字符。
       strategy.setControllerMappingHyphenStyle(true);
       // 表前缀,用于过滤带有特定前缀的表。
       strategy.setTablePrefix(pc.getModuleName() + "_");
       // Boolean类型字段是否移除is前缀处理
       strategy.setEntityBooleanColumnRemoveIsPrefix(true);
       //生成 @RestController 控制器。
       strategy.setRestControllerStyle(true);

       // 自动填充字段配置
       strategy.setTableFillList(Arrays.asList(
             new TableFill("create_date", FieldFill.INSERT),
             new TableFill("change_date", FieldFill.INSERT_UPDATE),
             new TableFill("modify_date", FieldFill.UPDATE)
       ));
       mpg.setStrategy(strategy);

       mpg.execute();
       System.out.println("生成成功!");
    }

}

Comment