原项目:黑马《学成在线》项目,这个项目对SpringBoot熟悉并提升至SpringCloud的同学蛮不错的,bug基本都能在评论区找到解决方法,下面是链接:

黑马程序员Java项目企业级微服务实战《学成在线》,基于SpringCloud、SpringCloudAlibaba技术栈开发,项目搭建到选课支付学习全通关_哔哩哔哩_bilibili

我的SpringCloud理论知识也是黑马入门的,看了老久了,如果直接看上面理论上有困难可以先看下面的:

2024最新SpringCloud微服务开发与实战,java黑马商城项目微服务实战开发(涵盖MybatisPlus、Docker、MQ、ES、Redis高级等)_哔哩哔哩_bilibili

当然如果你刚入门SpringBoot我不建议步子迈太大,接下来就对《学成在线》主要有价值部分进行分析:

知识点部分

后端部分

先来看整体图片,我将分模块讲解和重点标出我认为比较重要的知识点,见下图:

屏幕截图 2024-10-21 231821.png

介绍以下各个模块的功能

xucheng-plus-auth:负责统一认证入口,配置令牌设置相关等

/**
 * @description 授权服务器配置
 * @author Mr.M
 * @date 2022/9/26 22:25
 * @version 1.0
 */
 @Configuration
 @EnableAuthorizationServer
 public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {

  @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)//令牌管理服务
           .allowedTokenEndpointRequestMethods(HttpMethod.POST);
  }

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



 }

上述代码是定义了一个Spring Security OAuth2授权服务器配置类,OAuth介绍以及使用原因:

微信扫码认证,这是一种第三方认证的方式,这种认证方式是基于OAuth2协议实现,

OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明OAUTH标准逐渐成为开放资源授权的标准。

Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用Oauth2,本项目使用Oauth2实现如下目标:

1、学成在线访问第三方系统的资源。

本项目要接入微信扫码登录所以本项目要使用OAuth2协议访问微信中的用户信息。

2、外部系统访问学成在线的资源 。

同样当第三方系统想要访问学成在线网站的资源也可以基于OAuth2协议。

3、学成在线前端(客户端) 访问学成在线微服务的资源。

本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议进行认证。

例如下图

屏幕截图 2024-10-22 090619.png简单来说,OAth2协议是用来解决授权问题的,就和之前的jwt作用差不多,不过它安全性更好,例如上图登陆时不将隐私信息直接告诉服务商,而是通过微信认证后而授权服务商。

/**
 * @author Mr.M
 * @version 1.0
 * @description 安全管理配置
 * @date 2022/9/26 20:53
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {


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

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

    @Autowired
    DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProviderCustom);
    }
    //配置安全拦截机制
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /*这行代码是用来禁用Spring Security中的CSRF(跨站请求伪造)保护的。
        CSRF是一种攻击方式,攻击者诱导受害者访问一个包含恶意请求的页面,
        从而在受害者的不知情下执行恶意操作。
        在Spring Security中,默认情况下会启用CSRF保护,以防止这种攻击。
        但是,在某些情况下,你可能需要禁用CSRF保护*/
//        http.csrf().disable()
//                .authorizeRequests()
//                .antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
//                .anyRequest().permitAll()//其它请求全部放行
//                .and()
//                .formLogin().successForwardUrl("/login-success");//登录成功跳转到/login-success
        http
                .csrf().disable()
                .authorizeRequests()
                .anyRequest().permitAll()
                .and()
                .formLogin()
                .successForwardUrl("/login-success");

    }

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

   

}

重写了SpringSecurity

  1. PasswordEncoder passwordEncoder()

    • 配置密码编码器。这里使用BCryptPasswordEncoder,这是一种强哈希算法,用于密码存储和验证。

    • 注释掉的NoOpPasswordEncoder是明文密码编码器,不推荐在生产环境中使用。

  2. DaoAuthenticationProviderCustom daoAuthenticationProviderCustom

    • 注入自定义的DaoAuthenticationProvider,用于自定义认证逻辑。

  3. configure(AuthenticationManagerBuilder auth)

    • 配置认证管理器,将自定义的认证提供者添加到认证管理器中。

  4. configure(HttpSecurity http)

    • 配置HTTP安全,包括请求的授权规则和登录配置。

    • 注释掉的代码禁用了CSRF保护,并配置了访问/r/**路径需要认证,其它请求全部放行,登录成功后跳转到/login-success

    • 现在的配置是禁用CSRF保护,允许所有请求,登录成功后跳转到/login-success

  5. authenticationManagerBean()

    • 返回AuthenticationManager的Bean,这是Spring Security的核心组件,用于处理认证请求。

@Slf4j
@Controller
public class WxLoginController {

    @Autowired
    WxAuthService wxAuthService;

    @RequestMapping("/wxLogin")
    public String wxLogin(String code, String state) throws IOException {
        log.debug("微信扫码回调,code:{},state:{}",code,state);
        //远程调用请求微信申请令牌,拿到令牌查询用户信息,将用户信息写入本项目数据库
        XcUser xcUser = wxAuthService.wxAuth(code);


        if(xcUser==null){
            return "redirect:http://www.51xuecheng.cn/error.html";
        }
        String username = xcUser.getUsername();
        return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
    }
}

微信登录login如上


@FeignClient(value = "checkcode",fallbackFactory = CheckCodeClientFactory.class)
@RequestMapping("/checkcode")
public interface CheckCodeClient {

    @PostMapping(value = "/verify")
    public Boolean verify(@RequestParam("key") String key,@RequestParam("code") String code);

}

远程调用其他服务

@Slf4j
@Component
public class CheckCodeClientFactory implements FallbackFactory<CheckCodeClient> {
    @Override
    public CheckCodeClient create(Throwable throwable) {
        return new CheckCodeClient() {

            @Override
            public Boolean verify(String key, String code) {
                log.debug("调用验证码服务熔断异常:{}", throwable.getMessage());
                return null;
            }
        };
    }
}

调用失败执行的熔断方法

除此之外比较重要的还有一个service多个serviceImpl实现时的调用问题(Service注解后写别名就行),其他的细枝末节不重要。

xucheng-plus-base:负责通用类,依赖管理

@Configuration
public class LocalDateTimeConfig {

    /*
     * 序列化内容
     *   LocalDateTime -> String
     * 服务端返回给客户端内容
     * */
    @Bean
    public LocalDateTimeSerializer localDateTimeSerializer() {
        return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    /*
     * 反序列化内容
     *   String -> LocalDateTime
     * 客户端传入服务端数据
     * */
    @Bean
    public LocalDateTimeDeserializer localDateTimeDeserializer() {
        return new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    //long转string避免精度损失
    @Bean
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        //忽略value为null 时 key的输出
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        SimpleModule module = new SimpleModule();
        module.addSerializer(Long.class, ToStringSerializer.instance);
        module.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(module);
        return objectMapper;
    }

    // 配置
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> {
            builder.serializerByType(LocalDateTime.class, localDateTimeSerializer());
            builder.deserializerByType(LocalDateTime.class, localDateTimeDeserializer());
        };
    }

}

处理精度问题和时间转换问题,顺便设置一些util和exception类型,通用组件pom方便后续调用时直接

xucheng-plus-checkcode:负责验证相关实现

/**
 * Kaptcha图片验证码配置类
 * @author 攀博课堂(www.pbteach.com)
 * @version 1.0
 **/
@Configuration
public class KaptchaConfig {

    //图片验证码生成器,使用开源的kaptcha
    @Bean
    public DefaultKaptcha producer() {
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "10");
        properties.put("kaptcha.textproducer.char.length","4");
        properties.put("kaptcha.image.height","34");
        properties.put("kaptcha.image.width","138");
        properties.put("kaptcha.textproducer.font.size","25");

        properties.put("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

验证码图片生成

@Component
public class PhoneMessageUtils {
    @Autowired
    RedisCheckCodeStore redisCheckCodeStore;

    public void SendPhoneMessage(String phone, String code) {
        if(StringUtils.isEmpty(phone)||StringUtils.isEmpty(code)){
            throw new RuntimeException("手机号或验证码为空");
        }
        Config config = new Config()
                // 您的AccessKey ID
                .setAccessKeyId(StaticParam.accessKeyId)
                // 您的AccessKey Secret	(这两个还不知道的去我前两次关于阿里云的有教程哪里找)
                .setAccessKeySecret(StaticParam.accessKeySecret);
        config.endpoint = "dysmsapi.aliyuncs.com";
        Client client = null;
        try {
            client = new Client(config);
            SendSmsRequest request = new SendSmsRequest();

            request.setSignName(StaticParam.SignName);//签名名称
            request.setTemplateCode(StaticParam.TemplateCode);//模版Code
            request.setPhoneNumbers(phone);//电话号码
            request.setTemplateParam("{\"code\":\""+code+"\"}");//模版参数
            SendSmsResponse response = client.sendSms(request);
            redisCheckCodeStore.set(phone,code,60);
            System.out.println("发送成功:"+new Gson().toJson(response));
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

}

向手机发送验证邮件信息

@Component
public class EmailMessageUtils {
    @Autowired
    JavaMailSender sender;

    @Autowired
    RedisCheckCodeStore redisCheckCodeStore;

    public void SentMessage(String mailbox, String code){
        //SimpleMailMessage是一个比较简易的邮件封装,支持设置一些比较简单内容
        SimpleMailMessage message = new SimpleMailMessage();
        //设置邮件标题
        message.setSubject("在线学习系统注册验证码");
        //设置邮件内容
        //拿到验证码
//        String code=redisCheckCodeStore.get(mailbox);
        message.setText("尊敬的 在线学习系统用户:\n" +
                "\n" +
                "我们收到了一项请求,要求通过您的电子邮件地址访问您的邮箱:"+mailbox+"。您的验证码为:\n" +code+
                "\n" +
                "如果您并未请求此验证码,则可能是他人正在尝试访问此邮箱。请勿将此验证码转发给或提供给任何人。\n" +
                "\n" +
                "此致\n" +
                "\n" +
                "徐星海管理员敬上");
        //设置邮件发送给谁,可以多个,这里就发给你的QQ邮箱
        message.setTo(mailbox);
        //邮件发送者,这里要与配置文件中的保持一致
        message.setFrom("haoxie80769@163.com");
        //OK,万事俱备只欠发送
        sender.send(message);
//        storageRedis(mailbox,code);
        redisCheckCodeStore.set(mailbox, code, 60);
    }
}

向邮箱发验证邮件

xucheng-plus-content:负责课程内容管理


    @ApiOperation("课程分页查询接口")
//    @PreAuthorize("hasAuthority('xc_teachmanager_course_list')")//指定权限标识符,拥有此权限才可以访问此方法
    @PostMapping("/course/list")
    public PageResult<CourseBase> list(PageParams pageParams, @RequestBody(required = false) QueryCourseParamsDto queryCourseParams){
        SecurityUtil.XcUser user = SecurityUtil.getUser();
        //用户所属机构id
        Long companyId = null;
        if(StringUtils.isNotEmpty(user.getCompanyId())){
            companyId = Long.parseLong(user.getCompanyId());
        }

        PageResult<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(companyId,pageParams, queryCourseParams);

        return courseBasePageResult;

    }

基操的分页查询

/**
 * xxl-job config
 *
 * @author xuxueli 2017-04-28
 */
@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;
    }

    /**
     * 针对多网卡、容器内部署等情况,可借助 "spring-cloud-commons" 提供的 "InetUtils" 组件灵活定制注册IP;
     *
     *      1、引入依赖:
     *          <dependency>
     *             <groupId>org.springframework.cloud</groupId>
     *             <artifactId>spring-cloud-commons</artifactId>
     *             <version>${version}</version>
     *         </dependency>
     *
     *      2、配置文件,或者容器启动变量
     *          spring.cloud.inetutils.preferred-networks: 'xxx.xxx.xxx.'
     *
     *      3、获取IP
     *          String ip_ = inetUtils.findFirstNonLoopbackHostInfo().getIpAddress();
     */


}

比较重要的xxl-job,可以设置定时任务(执行器,java代码实现也可以,但麻烦且难以控制),值得深入学习

以下是控制面板

屏幕截图 2024-10-22 234733.png

屏幕截图 2024-10-22 234816.png屏幕截图 2024-10-22 234759.png

    <select id="selectTreeNodes" resultType="com.xuecheng.content.model.dto.CourseCategoryTreeDto" parameterType="string">
        with recursive t1 as (
            select * from  course_category p where  id= #{id}
            union all
            select t.* from course_category t inner join t1 on t1.id = t.parentid
        )
        select *  from t1 order by t1.id, t1.orderby

    </select>

屏幕截图 2024-10-22 225026.png

树形递归查询,或许下次问ai好了,脑壳痛...

xucheng-plus-gateway:负责网关认证(看门的)

/**
 * @author Mr.M
 * @version 1.0
 * @description 网关认证过虑器
 * @date 2022/9/27 12:10
 */
@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) {
        //请求的url
        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;
    }
}

负责配合SpringSecurity和token检查权限

xucheng-plus-generator:负责前端的一些实现和类初始化

这个模块还看不太懂,但没什么相关的重要的技术相关类

xucheng-plus-learning:负责选课学习功能实现

@Override
    public RestResponse<String> getVideo(String userId, Long courseId, Long teachplanId, String mediaId) {

        //查询课程信息
        CoursePublish coursepublish = contentServiceClient.getCoursepublish(courseId);
        //判断如果为null不再继续
        if(coursepublish == null){
            return RestResponse.validfail("课程不存在");
        }

        //远程调用内容管理服务根据课程计划id(teachplanId)去查询课程计划信息,如果is_preview的值为1表示支持试学
        //也可以从coursepublish对象中解析出课程计划信息去判断是否支持试学
        //todo:如果支持试学调用媒资服务查询视频的播放地址,返回

        //用户已登录
        if(StringUtils.isNotEmpty(userId)){
            //获取学习资格
            XcCourseTablesDto learningStatus = myCourseTablesService.getLearningStatus(userId, courseId);
            //学习资格,[{"code":"702001","desc":"正常学习"},{"code":"702002","desc":"没有选课或选课后没有支付"},{"code":"702003","desc":"已过期需要申请续期或重新支付"}]
            String learnStatus = learningStatus.getLearnStatus();
            if("702002".equals(learnStatus)){
                return RestResponse.validfail("无法学习,因为没有选课或选课后没有支付");
            }else if("702003".equals(learnStatus)){
                return RestResponse.validfail("已过期需要申请续期或重新支付");
            }else{
                //有资格学习,要返回视频的播放地址
                //程调用媒资获取视频播放地址
                RestResponse<String> playUrlByMediaId = mediaServiceClient.getPlayUrlByMediaId(mediaId);
                return playUrlByMediaId;

            }

        }
        //如果用户没有登录
       //取出课程的收费规则
        String charge = coursepublish.getCharge();
        if("201000".equals(charge)){
            //有资格学习,要返回视频的播放地址
            //远程调用媒资获取视频播放地址
            RestResponse<String> playUrlByMediaId = mediaServiceClient.getPlayUrlByMediaId(mediaId);
            return playUrlByMediaId;
        }
        return RestResponse.validfail("课程需要购买");
    }
}

获取视频(实际获取的是视频的网址)

xucheng-plus-media:负责媒资的管理

这是比较最重要的一个模块,也是当初这个项目比较吸引我的地方,其实本作者之前苦阿里云对象存储OSS久矣,三个月的试用期太短了,包括还有这个项目之前的发送手机验证码的阿里云服务(每月100条,连续3个月),好贵(穷学生一个),接下来介绍一下分布式文件系统:

要理解分布式文件系统首先了解什么是文件系统:

15819085-633f-4434-b805-2ac6aa6fed99.png

文件系统是负责管理和存储文件的系统软件,操作系统通过文件系统提供的接口去存取文件,用户通过操作系统访问磁盘上的文件。

下图指示了文件系统所处的位置:

b92151e7-859c-4a02-9482-0c959d297b96.png

常见的文件系统:FAT16/FAT32、NTFS、HFS、UFS、APFS、XFS、Ext4等 。

现在有个问题,一此短视频平台拥有大量的视频、图片,这些视频文件、图片文件该如何存储呢?如何存储可以满足互联网上海量用 户的浏览。

今天讲的分布式文件系统就是海量用户查阅海量文件的方案。

我们阅读百度百科去理解分布式文件系统的定义:

e7cb091d-9016-4dba-bd85-812780e9f155.png通过概念可以简单理解为:一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信,如下图:

def7e152-e6f0-4e67-9f02-457250fc0132.png

好处:

1、一台计算机的文件系统处理能力扩充到多台计算机同时处理。

2、一台计算机挂了还有另外副本计算机提供数据。

3、每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度。

市面上有哪些分布式文件系统的产品呢?

1、NFS

特点:

1)在客户端上映射NFS服务器的驱动器。

2)客户端通过网络访问NFS服务器的硬盘完全透明。

2、GFS

1)GFS采用主从结构,一个GFS集群由一个master和大量的chunkserver组成。

2)master存储了数据文件的元数据,一个文件被分成了若干块存储在多个chunkserver中。

3)用户从master中获取数据元信息,向chunkserver存储数据。

3、HDFS

HDFS,是Hadoop Distributed File System的简称,是Hadoop抽象文件系统的一种实现。HDFS是一个高度容错性的系统,适合部署在廉价的机器上。HDFS能提供高吞吐量的数据访问,非常适合大规模数据集上的应用。 HDFS的文件分布在集群机器上,同时提供副本进行容错及可靠性保证。例如客户端写入读取文件的直接操作都是分布在集群各个机器上的,没有单点性能压力。

1)HDFS采用主从结构,一个HDFS集群由一个名称结点和若干数据结点组成。

2) 名称结点存储数据的元信息,一个完整的数据文件分成若干块存储在数据结点。

3)客户端从名称结点获取数据的元信息及数据分块的信息,得到信息客户端即可从数据块来存取数据。

本项目采用MinIO构建分布式文件系统,MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合使用,它兼容亚马逊 S3 云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等。

它一大特点就是轻量,使用简单,功能强大,支持各种平台,单个文件最大5TB,兼容 Amazon S3接口,提供了 Java、Python、GO等多版本SDK支持。

官网:https://min.io

中文:https://www.minio.org.cn/,http://docs.minio.org.cn/docs/

MinIO集群采用去中心化共享架构,每个结点是对等关系,通过Nginx可对MinIO进行负载均衡访问。

去中心化有什么好处?

在大数据领域,通常的设计理念都是无中心和分布式。Minio分布式模式可以帮助你搭建一个高可用的对象存储服务,你可以使用这些存储设备,而不用考虑其真实物理位置。

它将分布在不同服务器上的多块硬盘组成一个对象存储服务。由于硬盘分布在不同的节点上,分布式Minio避免了单点故障。如下图:

6bff6508-a8c2-4b31-9b5c-bc6f500a57c3.png

Minio使用纠删码技术来保护数据,它是一种恢复丢失和损坏数据的数学算法,它将数据分块冗余的分散存储在各各节点的磁盘上,所有的可用磁盘组成一个集合,上图由8块硬盘组成一个集合,当上传一个文件时会通过纠删码算法计算对文件进行分块存储,除了将文件本身分成4个数据块,还会生成4个校验块,数据块和校验块会分散的存储在这8块硬盘上。

使用纠删码的好处是即便丢失一半数量(N/2)的硬盘,仍然可以恢复数据。 比如上边集合中有4个以内的硬盘损害仍可保证数据恢复,不影响上传和下载,如果多于一半的硬盘坏了则无法恢复。

接下来看看项目中怎么使用它:

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.4.3</version>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.8.1</version>
</dependency>

已上传图片文件为例:

 @ApiOperation("上传图片")
    @RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadFileResultDto uploadFile(@RequestPart("filedata")MultipartFile filedata,
                                      @RequestParam(value= "objectName",required=false) String objectName) throws IOException {

        //准备上传文件的信息
        UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
        //原始文件名称
        uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
        //文件大小
        uploadFileParamsDto.setFileSize(filedata.getSize());
        //文件类型
        uploadFileParamsDto.setFileType("001001");
        //创建一个临时文件
        File tempFile = File.createTempFile("minio", ".temp");
        filedata.transferTo(tempFile);
        Long companyId = 1232141425L;

        //文件路径
        String localFilePath = tempFile.getAbsolutePath();

        //调用service上传图片-objectname传按照静态页面,不传则按年月日传图片
        UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath,objectName);

        return uploadFileResultDto;
    }

//将文件上传minio
 public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket, String objectName){
  //上传文件的参数信息
     try {
         UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                 .bucket(bucket)
                 .filename(localFilePath)
                 .object(objectName)//添加子目录
                 .contentType(mimeType)//默认根据扩展名确定文件内容类型,也可以指定
                 .build();
         //上传文件
      minioClient.uploadObject(uploadObjectArgs);
      log.debug("上传文件成功,bucket={},objectName={}",bucket,objectName);
      return true;
     } catch (Exception e) {
         e.printStackTrace();
         log.error("上传文件出错:bucket={},objectName={},错误信息={}",bucket,objectName,e.getMessage());

     }

  return false;

 }

//上传文件
 //在进行事务控制的时候,只要里面有网络访问的东西,千万不要用数据库的事务去控制
 // @Transactional
 @Override
 public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectName) {

  //文件名
  String filename = uploadFileParamsDto.getFilename();
  //先得到扩展名
  String extension = filename.substring(filename.lastIndexOf("."));

  //得到mimeType
  String mimeType = getMimeType(extension);

  //子目录
  String defaultFolderPath = getDefaultFolderPath();
  //文件的md5值
  String fileMd5 = getFileMd5(new File(localFilePath));
  if(StringUtils.isEmpty(objectName)){
   //使用默认年月日去存储
   objectName = defaultFolderPath+fileMd5+extension;
  }
  //上传文件到minio
  boolean result = addMediaFilesToMinIO(localFilePath, mimeType, bucket_mediafiles, objectName);
  if(!result){
   XueChengPlusException.cast("上传文件失败");
  }
  //入库文件信息
  MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_mediafiles, objectName);
  if(mediaFiles==null){
   XueChengPlusException.cast("文件上传后保存信息失败");
  }
  //准备返回的对象
  UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
  BeanUtils.copyProperties(mediaFiles,uploadFileResultDto);

  return uploadFileResultDto;
 }

界面如下:

屏幕截图 2024-10-22 234648.png很好,以后就不用担惊受怕了,想传啥传啥

除此之外,还有一个很重要(对我和我的毕设)的技术点,那就是视频的分片上传

另外提一嘴,视频格式mp4支持web端播放,avi就不行,所以在这个项目里需要将avi视频转为mp4格式

项目利用ffmpeg,听说那些玩爬虫爬流媒体视频的人用它合并视频片,而且听说这玩意在音视频程序员邻域蛮厉害的(不太清楚反正牛且好用就对了)

以下是上传大文件代码(例如视频,现实中云盘也是这样做的)

//上传分块
 @Override
 public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
  //分块文件的路径
  String chunkFilePath = getChunkFileFolderPath(fileMd5)+chunk;

  //获取mimeType
  String mimeType = getMimeType(null);

  //将分块文件上传到minio
  boolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_video, chunkFilePath);
  if(!b){
   return RestResponse.validfail(false,"上传分块文件失败");
  }
  //上传成功
  return RestResponse.success(true);
 }

上传完别忘了合并

 //合并分块
 @Override
 public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
  //分块文件所在的目录
  String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
  //找到所有的分块文件调用minio的sdk进行文件合并
      List<ComposeSource> sources = Stream.iterate(0, i -> ++i)
              .limit(chunkTotal)
              .map(i -> ComposeSource.builder()
                      .bucket(bucket_video)
                      .object(chunkFileFolderPath+i)
                      .build())
              .collect(Collectors.toList());

      //合并后文件的objectname
      String filename = uploadFileParamsDto.getFilename();
      //扩展名
      String extension = filename.substring(filename.lastIndexOf("."));
      String objectName = getFilePathByMd5(fileMd5,extension);
      ComposeObjectArgs composeObjectArgs = ComposeObjectArgs
              .builder()
              .bucket(bucket_video)
              .object(objectName)//最终合并后的文件objectname
              .sources(sources)
              .build();
      //=========合并文件
      //报错size 1048576 must be greater than 5242880,minio默认的分块文件大小为5M
      try {
       minioClient.composeObject(composeObjectArgs);
      } catch (Exception e) {
       e.printStackTrace();
       log.error("合并文件失败:bucket:{},objectName:{},错误信息:{}",bucket_video,objectName,e.getMessage());
       return RestResponse.validfail(false,"合并文件异常");
      }

      //=======校验合并后的和源文件是否一致,一致才成功
      //先从minio下载合并后的文件
      File file = downloadFileFromMinIO(bucket_video,objectName);

      try(FileInputStream fileInputStream = new FileInputStream(file)){
          //计算合并后文件的md5
          String mergeFile_md5 = DigestUtils.md5Hex(fileInputStream);
          //校验合并后的文件md5和源文件md5是否一致
       if(!fileMd5.equals(mergeFile_md5)){
        //校验失败
        log.error("合并文件校验失败:源文件md5:{},合并后文件md5:{}",fileMd5,mergeFile_md5);
        return RestResponse.validfail(false,"合并文件校验失败");
       }
       //文件大小
       uploadFileParamsDto.setFileSize(file.length());
      }catch (Exception e){
       e.printStackTrace();
       return RestResponse.validfail(false,"合并文件校验失败");
      }


  //这里不用下载合并后的文件,使用statObject方法,通过返回值调用etag()方法,得到的就是minio中文件的md5值
//     StatObjectResponse statObjectResponse = null;
//     try {
//         statObjectResponse = minioClient.statObject(StatObjectArgs.builder().bucket(bucket_video).object(objectName).build());
//     } catch (Exception e) {
//      log.error("从minio下载文件异常:bucket:{},objectName:{},错误信息:{}",bucket_video,objectName,e.getMessage());
//      return RestResponse.validfail(false,"从minio下载文件异常");
//     }
//     String md5Hex = statObjectResponse.etag();


  //=======将文件信息保存到数据库
  MediaFiles mediaFiles =currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, objectName);
      if(mediaFiles==null){
       return RestResponse.validfail(false,"文件入库失败");
      }
  //=======清理分块文件
      clearChunkFiles(chunkFileFolderPath,chunkTotal);


      return RestResponse.success(true);
 }

最后清除

/**
  * 清除分块文件
  * @param chunkFileFolderPath 分块文件路径
  * @param chunkTotal 分块文件总数
  */
 private void clearChunkFiles(String chunkFileFolderPath,int chunkTotal){
  Iterable<DeleteObject> objects =  Stream.iterate(0, i -> ++i).limit(chunkTotal).map(i -> new DeleteObject(chunkFileFolderPath+ i)).collect(Collectors.toList());;
  RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket(bucket_video).objects(objects).build();
  Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
  //要想真正删除
  results.forEach(f->{
   try {
    DeleteError deleteError = f.get();
   } catch (Exception e) {
    e.printStackTrace();
   }
  });

 }

以及从minio下载文件回来

/**
  * 从minio下载文件
  * @param bucket 桶
  * @param objectName 对象名称
  * @return 下载后的文件
  */
 public File downloadFileFromMinIO(String bucket,String objectName){
  //临时文件
  File minioFile = null;
  FileOutputStream outputStream = null;
  try{
   InputStream stream = minioClient.getObject(GetObjectArgs.builder()
           .bucket(bucket)
           .object(objectName)
           .build());
   //创建临时文件
   minioFile=File.createTempFile("minio", ".merge");
   outputStream = new FileOutputStream(minioFile);
   IOUtils.copy(stream,outputStream);
   return minioFile;
  } catch (Exception e) {
   e.printStackTrace();
  }finally {
   if(outputStream!=null){
    try {
     outputStream.close();
    } catch (IOException e) {
     e.printStackTrace();
    }
   }
  }
  return null;
 }

对了,又漏一嘴,这个项目的文件分块是文件传给前端,前端传给后端,后端传给minio这个过程,后两个过程采用md5对文件加密进行保密。

 //获取文件的md5
 private String getFileMd5(File file) {
  try (FileInputStream fileInputStream = new FileInputStream(file)) {
   String fileMd5 = DigestUtils.md5Hex(fileInputStream);
   return fileMd5;
  } catch (Exception e) {
   e.printStackTrace();
   return null;
  }
 }

半年以前我还以为md5只能用于字符串之类的加密,未想到连文件也可以,没事,文件也是数据流文件,合理。

还有一个比较重要的知识点,java创建异步线程池(使劲霍霍资源)

@XxlJob("videoJobHandler")
    public void videoJobHandler() throws Exception {

        // 分片参数
        int shardIndex = XxlJobHelper.getShardIndex();//执行器的序号,从0开始
        int shardTotal = XxlJobHelper.getShardTotal();//执行器数量

        //确定cpu的核心数
        int processors = Runtime.getRuntime().availableProcessors();

        //查询待处理的任务
        List<MediaProcess> mediaProcessList = mediaFileProcessService.getMediaProcessList(shardIndex, shardTotal, processors);
        //任务数量
        int size = mediaProcessList.size();
        log.debug("取到的视频处理任务数:{}",size);
        if(size<=0){
            return;
        }
        //创建一个线程池
        ExecutorService executorService = Executors.newFixedThreadPool(size);
        //使用的计数器
        CountDownLatch countDownLatch = new CountDownLatch(size);

        mediaProcessList.forEach(mediaProcess -> {
            //将任务加入线程池
            executorService.execute(()->{
                try {
                    //任务id
                    Long taskId = mediaProcess.getId();
                    //文件id就是MD5
                    String fileId = mediaProcess.getFileId();
                    //争抢任务-乐观锁
                    boolean b = mediaFileProcessService.startTask(taskId);
                    if (!b) {
                        log.debug("任务抢占失败,taskId:{}", taskId);
                        return;
                    }
                    //下载minio视频到本地
                    //桶
                    String bucket = mediaProcess.getBucket();
                    //objectName
                    String objectName = mediaProcess.getFilePath();

                    File file = mediaFileService.downloadFileFromMinIO(bucket, objectName);
                    if (file == null) {
                        log.debug("下载视频出错,taskId:{},bucket:{},objectName:{}", taskId, bucket, objectName);
                        //保存任务处理失败的结果
                        mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "下载视频到本地失败");
                        return;
                    }

                    //源avi视频的路径
                    String video_path = file.getAbsolutePath();
                    //转换后mp4文件的名称
                    String mp4_name = fileId + ".mp4";
                    //转换后mp4文件的路径
                    //先创建一个临时文件,作为转换后的文件
                    File mp4File = null;
                    try {
                        mp4File = File.createTempFile("minio", ".mp4");
                    } catch (IOException e) {
                        log.debug("创建临时文件异常,{}", e.getMessage());
                        //保存任务处理失败的结果
                        mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "创建临时文件异常");
                        return;
                    }
                    String mp4_path = mp4File.getAbsolutePath();
                    //创建工具类对象
                    Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpegpath, video_path, mp4_name, mp4_path);
                    //开始视频转换,成功将返回success
                    String result = videoUtil.generateMp4();
                    //开始视频转换,成功success,失败返回失败原因
                    if (!result.equals("success")) {

                        log.debug("视频转码失败,原因:{},taskId:{},bucket:{},objectName:{}", result, taskId, bucket, objectName);
                        mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, result);
                        return;
                    }
                    //上传mp4文件到minio todo:有疑问-objectName
                    objectName = getFilePath(fileId, ".mp4");
                    boolean b1 = mediaFileService.addMediaFilesToMinIO(mp4File.getAbsolutePath(), "video/mp4", bucket, objectName);
                    if (!b1) {

                        log.debug("上传mp4视频到minio失败,taskId:{},bucket:{},objectName:{}", taskId, bucket, objectName);
                        //保存任务处理失败的结果
                        mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "上传mp4视频到minio失败");
                        return;
                    }

                    //MP4文件的url
                    String url = "/"+bucket+"/"+getFilePath(fileId, ".mp4");
                    //todo:有疑问-url"/"+bucket+"/"+objectName
                    //更新任务状态为成功
                    mediaFileProcessService.saveProcessFinishStatus(taskId, "2", fileId, url, null);
                  //  mp4File.delete();
                }finally{
                    //计数器减去1
                    countDownLatch.countDown();
                }

            });
        });

                //阻塞,等所有线程都完成任务-整流,为0放行,最多等30分钟,指定一个最大限度的等待时间
                //阻塞最多等待一定的时间后就解除阻塞
                //只有线程释放了下一轮任务才能继续
                countDownLatch.await(30,TimeUnit.MINUTES);

    }

异步线程池的作用

在Java中,ExecutorService接口代表一个异步执行任务的服务。通过Executors.newFixedThreadPool(size)创建的线程池,其中的任务会在后台线程中异步执行,不会阻塞主线程的执行。

具体来说,当你调用executorService.execute(Runnable command)方法时,任务会被提交到线程池中,并立即返回,不会等待任务 执行完成。任务会在后台线程中异步执行,你可以继续执行其他代码,而不需要等待当前任务完成。

例如,在你的代码中,mediaProcessList.forEach(mediaProcess -> { ... })中的每个任务都会被提交到线程池中异步执行,主线 程会继续执行后续代码,而不需要等待每个任务完成。

串行便并行的效率提升巨大

xucheng-plus-message-sdk:负责消息队列(MQ)

这个项目主要利用MQ集成订单服务,我没有实现支付功能(以为我发现前端代码有问题,不过后台传来的base64编码的二维码倒是可以正常显示)

这里依旧先来介绍MQ:

什么是MQ

  • 消息队列(Message Queue,简称MQ),从字面意思上看,本质是个队列,FIFO先入先出,只不过队列中存放的内容是message而已。
    其主要用途:不同进程Process/线程Thread之间通信。

为什么会产生消息队列?有几个原因:

  • 不同进程(process)之间传递消息时,两个进程之间耦合程度过高,改动一个进程,引发必须修改另一个进程,为了隔离这两个进程,在两进程间抽离出一层(一个模块),所有两进程之间传递的消息,都必须通过消息队列来传递,单独修改某一个进程,不会影响另一个;

  • 不同进程(process)之间传递消息时,为了实现标准化,将消息的格式规范化了,并且,某一个进程接受的消息太多,一下子无法处理完,并且也有先后顺序,必须对收到的消息进行排队,因此诞生了事实上的消息队列;

本项目采用RabbitMQ:

RabbitMQ是一款使用Erlang语言开发的,实现AMQP(高级消息队列协议)的开源消息中间件。首先要知道一些RabbitMQ的特点,官网可查:

  • 可靠性。支持持久化,传输确认,发布确认等保证了MQ的可靠性。

  • 灵活的分发消息策略。这应该是RabbitMQ的一大特点。在消息进入MQ前由Exchange(交换机)进行路由消息。分发消息策略有:简单模式、工作队列模式、发布订阅模式、路由模式、通配符模式。

  • 支持集群。多台RabbitMQ服务器可以组成一个集群,形成一个逻辑Broker。

  • 多种协议。RabbitMQ支持多种消息队列协议,比如 STOMP、MQTT 等等。

  • 支持多种语言客户端。RabbitMQ几乎支持所有常用编程语言,包括 Java、.NET、Ruby 等等。

  • 可视化管理界面。RabbitMQ提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker。

  • 插件机制。RabbitMQ提供了许多插件,可以通过插件进行扩展,也可以编写自己的插件。

界面:

944b810ac435c9be1f3847b3e6e0e67e.png

@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);
    }


}

xucheng-plus-orders:负责支付相关功能实现

这个模块也绑定了RabbitMq,不过支付宝(沙箱环境)扫码支付前端并未实现,但后端代码依旧有借鉴意义

 @RequestMapping("/alipaytest")
    public void doPost(HttpServletRequest httpRequest,
                       HttpServletResponse httpResponse) throws ServletException, IOException, AlipayApiException {
        AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, AlipayConfig.FORMAT, AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY,AlipayConfig.SIGNTYPE);
        //获得初始化的AlipayClient
        AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
//        alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");//官方不建议->重定向
        alipayRequest.setNotifyUrl("http://tjxt-user-t.itheima.net/xuecheng/orders/paynotify");//在公共参数中设置回跳和通知地址
        alipayRequest.setBizContent("{" +
                "    \"out_trade_no\":\"202219899910105667\"," +
                "    \"total_amount\":0.1," +
                "    \"subject\":\"Iphone999 999G\"," +
                "    \"product_code\":\"QUICK_WAP_WAY\"" +
                "  }");//填充业务参数
        String form = alipayClient.pageExecute(alipayRequest).getBody(); //调用SDK生成表单
        httpResponse.setContentType("text/html;charset=" + AlipayConfig.CHARSET);
        httpResponse.getWriter().write(form);//直接将完整的表单html输出到页面
        httpResponse.getWriter().flush();
    }

    //支付结果通知
    @PostMapping("/paynotifytest")
    public void paynotify(HttpServletRequest request,HttpServletResponse response) throws IOException, AlipayApiException {

        //获取支付宝POST过来反馈信息
        Map<String,String> params = new HashMap<String,String>();
        Map requestParams = request.getParameterMap();
        for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext();) {
            String name = (String) iter.next();
            String[] values = (String[]) requestParams.get(name);
            String valueStr = "";
            for (int i = 0; i < values.length; i++) {
                valueStr = (i == values.length - 1) ? valueStr + values[i]
                        : valueStr + values[i] + ",";
            }
            //乱码解决,这段代码在出现乱码时使用。如果mysign和sign不相等也可以使用这段代码转化
            //valueStr = new String(valueStr.getBytes("ISO-8859-1"), "gbk");
            params.put(name, valueStr);
        }
        //获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以下仅供参考)//

        //获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以上仅供参考)//
        //计算得出通知验证结果
        //boolean AlipaySignature.rsaCheckV1(Map<String, String> params, String publicKey, String charset, String sign_type)
        boolean verify_result = AlipaySignature.rsaCheckV1(params, ALIPAY_PUBLIC_KEY, AlipayConfig.CHARSET, "RSA2");

        if(verify_result){//验证成功
            //////////////////////////////////////////////////////////////////////////////////////////
            //请在这里加上商户的业务逻辑程序代码
            //商户订单号

            String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8");
            //支付宝交易号

            String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8");

            //交易状态
            String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"),"UTF-8");


            //——请根据您的业务逻辑来编写程序(以下代码仅作参考)——

            if(trade_status.equals("TRADE_FINISHED")){
                //判断该笔订单是否在商户网站中已经做过处理
                //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
                //请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
                //如果有做过处理,不执行商户的业务程序

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

            //——请根据您的业务逻辑来编写程序(以上代码仅作参考)——
            response.getWriter().write("success");

            //////////////////////////////////////////////////////////////////////////////////////////
        }else{//验证失败
            response.getWriter().write("fail");
        }

}

整体看起来挺简洁的

xucheng-plus-parent:所有模块的父模块

所有模块的依赖项,通过它将一些通用配置传递,本项目的根基

xucheng-plus-search:负责搜索功能实现

还记得我上一个项目还用的mysql语句实现搜索,还只是一些字段,速度也堪忧,后来学了才知道,mysql内部的innodb搜索引擎处理此类请求使用遍历方法查找...

这里使用ElasticSearch(大名鼎鼎,某个上市公司的招牌):

ES是一个开源的高扩展的分布式全文检索引擎,它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理PB级别(大数据时代)的数据。ES也使用Java开发并使用Lucene作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的RestFul API来隐藏Lucene的复杂性,从而让全文检索变得简单。

现今,ES已经是全世界排名第一的搜索引擎类应用!

上代码:

@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));
    }


}

 @Override
    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;
    }

@Slf4j
@Service
public class IndexServiceImpl implements IndexService {



 @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();
   XueChengPlusException.cast("添加索引出错");
  }
  String name = indexResponse.getResult().name();
  System.out.println(name);
  return name.equalsIgnoreCase("created") || name.equalsIgnoreCase("updated");

 }

 @Override
 public Boolean updateCourseIndex(String indexName,String id,Object object) {

  String jsonString = JSON.toJSONString(object);
  UpdateRequest updateRequest = new UpdateRequest(indexName, id);
  updateRequest.doc(jsonString, XContentType.JSON);
  UpdateResponse updateResponse = null;
  try {
   updateResponse = client.update(updateRequest, RequestOptions.DEFAULT);
  } catch (IOException e) {
   log.error("更新索引出错:{}",e.getMessage());
   e.printStackTrace();
   XueChengPlusException.cast("更新索引出错");
  }
  DocWriteResponse.Result result = updateResponse.getResult();
  return result.name().equalsIgnoreCase("updated");

 }

 @Override
 public Boolean deleteCourseIndex(String indexName,String id) {

  //删除索引请求对象
  DeleteRequest deleteRequest = new DeleteRequest(indexName,id);
  //响应对象
  DeleteResponse deleteResponse = null;
  try {
   deleteResponse = client.delete(deleteRequest, RequestOptions.DEFAULT);
  } catch (IOException e) {
   log.error("删除索引出错:{}",e.getMessage());
   e.printStackTrace();
   XueChengPlusException.cast("删除索引出错");
  }
  //获取响应结果
  DocWriteResponse.Result result = deleteResponse.getResult();
  return result.name().equalsIgnoreCase("deleted");
 }
}

使用起来感觉还行,查询速度快到飞起,他还有自己独立的添加索引的语法,值得学习。

668d5847-b769-4d6b-a11f-7dd801e83cfc.png

9d4539a3-5bff-486b-9db6-f5c238cc4c2b.png 可通过命令:GET /_cat/indices?v 查看所有的索引,通过此命令判断kibana是否正常连接elasticsearch。

索引相当于MySQL中的表,Elasticsearch与MySQL之间概念的对应关系见下表:

9d4539a3-5bff-486b-9db6-f5c238cc4c2b.png

要使用elasticsearch需要建立索引,Mapping相当于表结构,Mapping创建后其字段不能删除,如果要删除需要删除整个索引,下边介绍创建索引、查询索引、删除索引的方法:

1、创建索引,并指定Mapping。

PUT/course-publish

{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  },
  "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"
      },
      "users": {
        "index": false,
        "type": "text"
      },
      "tags": {
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "type": "text"
      },
      "mt": {
        "type": "keyword"
      },
      "mtName": {
        "type": "keyword"
      },
      "st": {
        "type": "keyword"
      },
      "stName": {
        "type": "keyword"
      },
      "grade": {
        "type": "keyword"
      },
      "teachmode": {
        "type": "keyword"
      },
      "pic": {
        "index": false,
        "type": "text"
      },
      "description": {
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "type": "text"
      },
      "createDate": {
        "format": "yyyy-MM-dd HH:mm:ss",
        "type": "date"
      },
      "status": {
        "type": "keyword"
      },
      "remark": {
        "index": false,
        "type": "text"
      },
      "charge": {
        "type": "keyword"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "originalPrice": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "validDays": {
        "type": "integer"
      }
    }
  }
}

2、查询索引

通过 GET /_cat/indices?v 查询所有的索引,查找course-publish是否创建成功。

通过GET /course-publish/_mapping 查询course-publish的索引结构。

{
  "course-publish" : {
    "mappings" : {
      "properties" : {
        "charge" : {
          "type" : "keyword"
        },
        "companyId" : {
          "type" : "keyword"
        },
        "companyName" : {
          "type" : "text",
          "analyzer" : "ik_max_word",
          "search_analyzer" : "ik_smart"
        },
        "createDate" : {
          "type" : "date",
          "format" : "yyyy-MM-dd HH:mm:ss"
        },
        "description" : {
          "type" : "text",
          "analyzer" : "ik_max_word",
          "search_analyzer" : "ik_smart"
        },
        "grade" : {
          "type" : "keyword"
        },
        "id" : {
          "type" : "keyword"
        },
        "mt" : {
          "type" : "keyword"
        },
        "mtName" : {
          "type" : "keyword"
        },
        "name" : {
          "type" : "text",
          "analyzer" : "ik_max_word",
          "search_analyzer" : "ik_smart"
        },
        "originalPrice" : {
          "type" : "scaled_float",
          "scaling_factor" : 100.0
        },
        "pic" : {
          "type" : "text",
          "index" : false
        },
        "price" : {
          "type" : "scaled_float",
          "scaling_factor" : 100.0
        },
        "remark" : {
          "type" : "text",
          "index" : false
        },
        "st" : {
          "type" : "keyword"
        },
        "stName" : {
          "type" : "keyword"
        },
        "status" : {
          "type" : "keyword"
        },
        "tags" : {
          "type" : "text",
          "analyzer" : "ik_max_word",
          "search_analyzer" : "ik_smart"
        },
        "teachmode" : {
          "type" : "keyword"
        },
        "users" : {
          "type" : "text",
          "index" : false
        },
        "validDays" : {
          "type" : "integer"
        }
      }
    }
  }
}

3、删除索引

如果发现创建的course-publish不正确可以删除重新创建。

删除索引后当中的文档数据也同时删除,一定要谨慎操作!

删除索引命令:DELETE /course-publish

xucheng-plus-system:负责数据字典同时负责一些必要的系统配置

没什么好讲的知识点...

前端部分

其实重要的就微信支付和分片上传部分,还有一个静态页面存储(但我觉得没必要)

微信二维码生成:

//请用微信生成二维码
  function generateWxQrcode(token) {
      var wxObj = new WxLogin({
          self_redirect:true,
          id:"login_container",
          appid: "wxed9954c01bb89b47",
          scope: "snsapi_login",
          redirect_uri: "http://localhost:8160/auth/wxLogin",
          state: token,
          style: "",
          href: ""
      });
  }

分片上传部分:

 /**
         * 上传入口类。
         * @class Uploader
         * @constructor
         * @grammar new Uploader( opts ) => Uploader
         * @example
         * var uploader = WebUploader.Uploader({
         *     swf: 'path_of_swf/Uploader.swf',
         *
         *     // 开起分片上传。
         *     chunked: true
         * });
         */
        function Uploader( opts ) {
            this.options = $.extend( true, {}, Uploader.options, opts );
            this._init( this.options );
        }
    
        // default Options
        // widgets中有相应扩展
        Uploader.options = {};
        Mediator.installTo( Uploader.prototype );
    
        // 批量添加纯命令式方法。
        $.each({
            upload: 'start-upload',
            stop: 'stop-upload',
            getFile: 'get-file',
            getFiles: 'get-files',
            addFile: 'add-file',
            addFiles: 'add-file',
            sort: 'sort-files',
            removeFile: 'remove-file',
            cancelFile: 'cancel-file',
            skipFile: 'skip-file',
            retry: 'retry',
            isInProgress: 'is-in-progress',
            makeThumb: 'make-thumb',
            md5File: 'md5-file',
            getDimension: 'get-dimension',
            addButton: 'add-btn',
            predictRuntimeType: 'predict-runtime-type',
            refresh: 'refresh',
            disable: 'disable',
            enable: 'enable',
            reset: 'reset'
        }, function( fn, command ) {
            Uploader.prototype[ fn ] = function() {
                return this.request( command, arguments );
            };
        });
    
        $.extend( Uploader.prototype, {
            state: 'pending',
    
            _init: function( opts ) {
                var me = this;
    
                me.request( 'init', opts, function() {
                    me.state = 'ready';
                    me.trigger('ready');
                });
            },

nginx部分

单独tomcat的并发数并不高,但利用nginx实现反向代理可以极大提高并发数

http {
    include       mime.types;
    default_type  application/octet-stream;
    client_max_body_size 100M; # 设置客户端请求体最大值
    client_body_buffer_size 128k; # 设置请求体缓存区大小
    server_names_hash_bucket_size 64;
    #xxh
    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;
     #文件服务
  upstream fileserver{
    server 192.168.101.65:9000 weight=10;
  } 
  #后台网关
  upstream gatewayserver{
    server 127.0.0.1:63010 weight=10;
  } 
   #前端开发服务
  upstream uidevserver{
    server 127.0.0.1:8601 weight=10;
  } 
server {
        listen       80;
        server_name  www.51xuecheng.cn;
        #rewrite ^(.*) https://$server_name$1 permanent;
        #charset koi8-r;
        ssi on;
        ssi_silent_errors on;
        #access_log  logs/host.access.log  main;

        location / {
            alias   D:/code_xxh/xc-ui-pc-static-portal/;
            index  index.html index.htm;
        }
        #api
        location /api/ {
                proxy_pass http://gatewayserver/;
        }

        # #扫码登录

	# location /xuecheng/ {

        #         proxy_pass http://gatewayserver/;

        # } 

         
        #静态资源
        location /static/img/ {  
                alias  D:/code_xxh/xc-ui-pc-static-portal/img/;
        } 
        location /static/css/ {  
                alias   D:/code_xxh/xc-ui-pc-static-portal/css/;
        } 
        location /static/js/ {  
                alias   D:/code_xxh/xc-ui-pc-static-portal/js/;
        } 
        location /static/plugins/ {  
                alias   D:/code_xxh/xc-ui-pc-static-portal/plugins/;
                add_header Access-Control-Allow-Origin http://ucenter.51xuecheng.cn;  
                add_header Access-Control-Allow-Credentials true;  
                add_header Access-Control-Allow-Methods GET;
        } 
        location /plugins/ {  
                alias   D:/code_xxh/xc-ui-pc-static-portal/plugins/;
        } 
        location /course/preview/learning.html {
                alias D:/code_xxh/xc-ui-pc-static-portal/course/learning.html;
        } 
        location /course/search.html {  
                root   D:/code_xxh/xc-ui-pc-static-portal;
        } 
        location /course/learning.html {  
                root  D:/code_xxh/xc-ui-pc-static-portal;
        } 
        location /course/ {  
                proxy_pass http://fileserver/mediafiles/course/;
        } 

        #openapi
        location /open/content/ {
                proxy_pass http://gatewayserver/content/open/;
        } 
        location /open/media/ {
                proxy_pass http://gatewayserver/media/open/;
        } 


        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ \.php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ \.php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /\.ht {
        #    deny  all;
        #}
    }
    server {
        listen       80;
        server_name  file.51xuecheng.cn;
        #charset koi8-r;
        ssi on;
        ssi_silent_errors on;
        #access_log  logs/host.access.log  main;
        location /video {
            proxy_pass   http://fileserver;
        }

        location /mediafiles {
            proxy_pass   http://fileserver;
        }
   }
   server {
        listen       80;
        server_name  teacher.51xuecheng.cn;
        #charset koi8-r;
        ssi on;
        ssi_silent_errors on;
        #access_log  logs/host.access.log  main;
        #location / {
         #   alias   D:/itcast2022/xc_edu3.0/code_1/dist/;
         #   index  index.html index.htm;
        #}
        location / {
            proxy_pass   http://uidevserver;
        }

        location /api/ {
                proxy_pass http://gatewayserver/;
        } 
        
        
   }

      server {
        listen       80;
        server_name  tjxt-user-t.itheima.net;
        #charset koi8-r;
        ssi on;
        ssi_silent_errors on;
        #access_log  logs/host.access.log  main;
        location /xuecheng/ {
            proxy_pass   http://gatewayserver/;
        }
   }
    server {
        listen       80;
        server_name  ucenter.51xuecheng.cn;
        #charset koi8-r;
        ssi on;
        ssi_silent_errors on;
        #access_log  logs/host.access.log  main;
        location / {
            alias   D:/code_xxh/xc-ui-pc-static-portal/ucenter/;
            index  index.html index.htm;
        }
        location /include {
            proxy_pass   http://127.0.0.1;
        }
        location /img/ {
            proxy_pass   http://127.0.0.1/static/img/;
        }
        location /api/ {
                proxy_pass http://gatewayserver/;
        } 
   }
   # 微信回调

        server {
        listen 8160;
        server_name localhost;
        ssi on;
        ssi_silent_errors on;
        location / {
		proxy_pass http://gatewayserver;
	}

	#静态资源 。注意需要换成自己电脑xc-ui-pc-static-portal对应目录

        location /static/img/ {  

                alias D:/code_xxh/xc-ui-pc-static-portal/img/;

        } 

        location /static/css/ {  

                alias  D:/code_xxh/xc-ui-pc-static-portal/css/;

        } 

        location /static/js/ {  

                alias  D:/code_xxh/xc-ui-pc-static-portal/js/;

        } 

   }


}
 
events {
            worker_connections  1024;    #nginx的最大并发访问量
            
        }

总结及图片展示

最后图片展示

屏幕截图 2024-10-23 092030.png

屏幕截图 2024-10-23 092125.png

屏幕截图 2024-10-23 092147.png

屏幕截图 2024-10-23 092200.png

屏幕截图 2024-10-23 092325.png

屏幕截图 2024-10-23 092229.png

屏幕截图 2024-10-23 092217.png

屏幕截图 2024-10-23 092247.png

屏幕截图 2024-10-23 092305.png

屏幕截图 2024-10-23 092339.png

最后感谢

感谢伟大的互联网开源精神