【智慧出行项目介绍】一个基于javaweb的微服务项目

由于本人更偏向于后端开发,所以本项目的前端部分在此省略,涉及到的一些坑会在后续指出。

目录

一、技术栈

二、技术描述

三、运行环境

四、详细实现方案和技术讲解

1.基于Oauth2.0的分布式权限认证方案

1.1.整体方案

1.2.Oauth2.0的四种授权模式

1.3.通过RBAC模型动态分配资源

1.4.jwt格式的token

1.5.核心配置文件

2.redis缓存

2.1.谈谈一致性

2.2.一致性方案

2.3.Geo存储地理位置

2.4.HyperLogLog记录UV值

2.5.缓存临时数据

3.schedule定时清除

3.1.定时将redis库存刷入mysql

3.2.定时清除垃圾图片 

3.3.定时把redis中的UV值刷入mysql

3.4.定时更新热门城市top5

4.订单操作

4.1.谈一谈消息可靠性

4.2.实现流程图

4.3.创建订单

4.4.取消订单

4.5.支付订单

4.6.退款订单

5.第三方接口

5.1.支付宝沙箱

5.2.网易邮箱

5.3.腾讯云COS&SMS

6.日志模块

五、一些(很多)坑

5.1.gateway和springsecurity整合

5.2.feign远程调用403


一、技术栈

  1. 前端:Vue、ElementUI、Axios
  2. 后端:SpringCloud(Nacos、Seata、Feign)、SpringSecurity(Oauth2.0、Jwt)、Mybatis
  3. 第三方服务:腾讯云SMS、腾讯云COS、163邮箱、支付宝沙箱
  4. 中间件:MySQL、Redis、RabbitMQ
  5. 网关:Nginx、Gateway

二、技术描述

  • 后端使用SpringCloud搭建,划分为网关、用户、旅游、认证、消息五大服务,使用的相关组件为Nacos、Seata、Feign等。
  • 基于Oauth2.0协议,搭建UAA并使用SpringSecurity+Jwt实现分布式认证授权,采用RBAC模型动态分配资源列表。
  • 使用Redis缓存旅游景点等热点数据,合理利用HyperLogLog、Geo缓存网页UV值和城市、景点地理位置。
  • 使用SpringSchedule定时清除垃圾图片、刷新库存和UV值。使用SpringAOP+自定义注解记录相关操作日志。
  • 使用RabbitMQ实现订单延时取消,通过发布订阅模式异步处理订单的附加操作。通过token、本地消息表保证消息幂等性。
  • 整合多个第三方服务:腾讯云COS、腾讯云SMS、163邮箱、支付宝沙箱。
  • 使用Vue+ElementUI+Axios快速搭建前端页面。

三、运行环境

腾讯云CentOS 7.6(1核2G、2核4G)、jdk8、erlang2.2、gcc4.8.5

四、详细实现方案和技术讲解

1.基于Oauth3.0的分布式权限认证方案

1.1.整体方案

        什么是Oauth2.0?简单的讲,就是我们在A应用上可以直接用B应用的账号进行登录,避免了在A应用上再去单独注册一个账号,同时也可以通过授权码模式(后续会讲到)防止A应用直接获取到B应用的密码等,比较常见的例子就是我们可以在csdn上用qq、vx进行登录,qq和vx只需要提供给csdn用户id、头像这些不敏感的信息即可。但在此项目中其实A应用和B应用都是智慧出行这个应用,只是引用了Oauth2.0这个思想。 

        下图为智慧出行网站基于Oauth2.0的分布式认证授权方案,第一步,接入方(其实就是我们自己的网站)请求UAA;第二步,UAA内部进行用户密码、权限的判断和基于RBAC分配资源(后续讲到);第三步,UAA返回给client一个jwt(后续会讲到)格式的token;第四步,前端访问资源时需要在Headers里添加token进行访问;第五步,资源服务器内部进行token的解析,判断token是否被篡改和过期。

1.2.Oauth2.0的四种授权模式

        UAA(User Access authorization)是授权服务器,主要用于注册客户端(client)、验证用户身份、向用户发放令牌(token)和刷新令牌。Oauth2.0涉及到四种授权模式,接下来我们一一讲解:

简单模式

         这种模式client只需要向UAA请求token然后通过此token进行资源访问即可,但它本身已经失去了授权的意义,这种模式更适用于一种程序间内部调用的情景。

密码模式

        密码模式就比简单模式多了一个步骤,就是需要client携带用户名和密码请求token,但弊端就是我们的用户名和密码可能会因此泄露给client,假设一种情况,此时你需要在智慧出行网站上通过支付宝登录,但这个登录页面是智慧出行网站提供的,你会放心大胆地在这个页面上输入你的支付宝账号和密码吗?

隐式模式

        相比于密码模式,隐式模式在用户进行登录验证时,它会重定向到验证服务器,也就是我们用支付宝登录,那么就会redirect到支付宝的验证服务器,此时你就可以很放心地输入密码了,但缺点就是返回的token可能被盗取。在智慧出行网站中,我是采用了隐式模式模拟的第三方登录,为了防止token被盗取后篡改,我使用了jwt格式的token,jwt防止篡改的原理我会在后续讲到。

授权码模式         最安全的模式,也是需要步骤最多的模式,具体的步骤为:client在第三方(支付宝)提供的应用服务器上进行登陆验证后,验证服务器会返回给client一个授权码(code),此时这个code已经在验证服务器中完成了注册,我们访问其他资源的时候就需要携带这个code进行访问,此时就算code被盗取,那也毫无意义,因为想要获取token,是需要code+secret一起去生成token的,而这个secret是应用服务器和验证服务器内部之间已经约定好的一个密钥,外界是无法感知的。

1.3.通过RBAC模型动态分配资源

        RBAC(Resource Basic Access Control)资源访问控制是一种模型,此模型将用户和权限之间进行了分离,减少了耦合,接下来我以智慧出行网站为例,讲述如何实现了RBAC以及RBAC的好处。

        RBAC中包含用户(User)、角色(Role)、权限(Resource)这几种实体,一个用户可以对应一个角色或者多个角色,一个角色又对应多种权限。

        那么此时可能会有一些疑问,搞这么些实体干什么,有什么用? 我这里通过反向假设来进行验证。假如没有这三个实体,那么肯定至少有两个实体,其中一个是用户,一个是角色或者权限。

        如果是用户+角色来进行验证,假如此时智慧出行的后台管理只有拥有超级管理员这个role的用户才可以访问,那么有一天来了一个需求,需要新加一个角色,名为后台游客(就是可以访问后台,但不可以进行修改),那么我们就需要改动代码,在访问接口时,判断是否为超级管理员的时候还要判断是否是后台游客(如下图)。这肯定是不行的啊!那么多接口,你仅用一句话说加一个角色,我就要改一整天啊!所以这样的可维护性是极低的。

         如果是用户+权限来进行验证,这个就更不用说了,智慧出行网站目前有30多项权限,你要我都写到这个过滤器判断里一个一个判断?这显然不合理,况且我们在存储用户和权限的关系表时也是很费力的,光一个用户就对应那么多权限,维护的时候效率也极低而且容易出错。假如有这么一种情况,在智慧出行中,一般的用户是可以查询所有的城市的,假如说此时甲方说,不行,一般用户不可以获取我们全部的城市,那这个时候你是不是崩溃了?你不得一个一个去改用户的权限哈哈。所以这种也是不合理的。

        最后,如果是用户+角色+权限来进行验证,这就舒服多了,甲方你随便加角色,我只需要新建一个角色,然后给这个角色绑定上应有的权限,最后给这个用户分配角色就好了。而且维护的时候也很简单,修改权限只需要修改角色和权限的绑定关系即可。

1.4.jwt格式的token

        JWT(Json Web Token)是一种token的格式,它内部含有一些编码格式和加密算法。它分为三部分,分别是:头部(header)、负载(payload)、签名(signature)

        头部:存储声明类型(type=jwt)和加密算法(RS256、HS256等)。

        负载:存储用户的基本信息(这里不可以出现密码等敏感信息,否则会泄露密码),如用户名、邮箱、地址、角色、权限等。

        签名:签名的话是把上述的头部和负载进行Base64编码后再通过加密算法生成,其中secret存储在服务器,注意,这里就是jwt防止篡改的重点,因为client是不知道secret的,所以我们无法解密signature中的信息,当我们携带此token请求后端服务时,服务器通过secret把signature解密后获得的sinature中的header和payload与整个jwt中的header和payload进行比较,如果两者不一样,说明此token已被篡改,直接报错。

        下图为智慧出行网站的token,可以看出jwt格式的token还是很长的,payload的信息越多就越长。 jwt极大的提高了token的安全性,唯一的缺点就是太长,不过这也不算什么大事。

1.5.核心配置文件

        在SpringSecurity原有的过滤器链基础上加入Oauth2.0的一些额外的过滤器,所以有两个配置文件,一个是security基础的,一个是oauth2.0额外的。他们的配置规则几乎差不多,差别就是Oauth2.0需要额外配置客户端、端点和令牌的一些东西。

SecurityConfig.java

@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true) //启用方法级别的权限认证注解PreAuthorization
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate UserDetailsService userDetailsService;//BCrypt加盐加密@Beanpublic BCryptPasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}//加入我们自己的userDetailsService@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());}//    @Override
//    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
//        auth
//                .inMemoryAuthentication()   //直接创建一个用户,懒得搞数据库了
//                .passwordEncoder(encoder)
//                .withUser("test")
//                .password(encoder.encode("123"))
//                .roles("USER"); //注意最后生成的权限是ROLE_USER
//    }@Overrideprotected void configure(HttpSecurity http) throws Exception {http//关闭csrf、cors
//                .cors().and().csrf().disable()//允许的请求.authorizeRequests().antMatchers("/oauth/**", "/login/**", "/exit/**").permitAll()
//                .antMatchers("/**").hasAnyRole("COMMON") //这里默认加了ROLE_前缀//其余请求都需要验证.anyRequest().authenticated().and().formLogin().permitAll();    //使用表单登录
//                .and()
//                .logout()
//                .logoutUrl("/oauth/logout")
//                .logoutSuccessHandler(new MyLogoutSuccessHandler())
//                .addLogoutHandler(new MyLogoutHandler())
//                .deleteCookies("JSESSIONID")
//                .invalidateHttpSession(true) //清空session
//                .clearAuthentication(true);//不需要session
//                .and()
//                .sessionManagement()
//                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);//没有权限访问时
//                .and()
//                .exceptionHandling()
//                .authenticationEntryPoint(new MyNoAuthEntryPoint());//        http
//                .authorizeRequests()
//                .anyRequest().authenticated()  //
//                .and()
//                .formLogin().permitAll();    //使用表单登录}//    @Bean
//    public CorsConfigurationSource corsConfigurationSource() {
//        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
//        return source;
//    }@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}

Oauth2Config.java

/*** 一些端点:* /oauth/authorize:授权端点* /oauth/token:令牌端点* /oauth/confirm_access:用户确认授权提交端点* /oauth/error:授权服务错误信息端点* /oauth/check_token:用于资源服务访问的令牌解析端点* /oauth/token_key:提供共有密钥的端点,如果使用jwt令牌的话* 配置类理解:认证、颁发token、安全约束* 既然要完成认证,那么得需要知道客户端信息在哪读取,所以要对客户端进行配置* 既然颁发token,那必须定义token的相关endpoint,以及token如何存取,以及客户端支持的token* 既然暴露了endpoint,那么就要对endpoint进行安全约束*/
@Configuration
@EnableAuthorizationServer   //开启验证服务器
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {@Autowiredprivate PasswordEncoder passwordEncoder;@Autowiredprivate TokenStore tokenStore; //令牌存储方式//    @Autowired
//    private ClientDetailsService clientDetailsService; //客户端详情服务@Autowiredprivate AuthenticationManager authenticationManager; //security创建令牌的管理类@Qualifier("accessTokenConverter")@Autowiredprivate JwtAccessTokenConverter jwtAccessTokenConverter; //jwt令牌生成的转换器//    @Autowired
//    private AuthorizationCodeServices authorizationCodeServices; //授权码模式服务@Autowiredprivate DataSource dataSource;//授权码模式 使用内存
//    @Bean
//    public AuthorizationCodeServices authorizationCodeServices() {
//        return new InMemoryAuthorizationCodeServices();
//    }//授权码模式 使用数据库@Beanpublic AuthorizationCodeServices authorizationCodeServices(DataSource dataSource) {return new JdbcAuthorizationCodeServices(dataSource); //设置授权码模式的授权码从数据库获取}/*** 这个方法是对客户端进行配置,一个验证服务器可以预设很多个客户端,* 之后这些指定的客户端就可以按照下面指定的方式进行验证** @param clients 客户端配置工具*/@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//        clients
//                .inMemory()   //这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取
//                .withClient("web")   //客户端名称,随便起就行
//                .resourceIds("user") //资源列表
//                .secret(encoder.encode("123"))      //只与客户端分享的secret,随便写,但是注意要加密.autoApprove(false)    //自动审批,这里关闭,要的就是一会体验那种感觉,跳转到授权页面
//                .scopes("all")     //授权范围,这里我们使用全部all
//                .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token")
//                .redirectUris("http://www.baidu.com");//授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式//这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试//现在我们指定的客户端就支持这五种类型的授权方式了clients.withClientDetails(clientDetailsService(dataSource)); //使用数据库存储客户端信息
//                .autoApprove(false);}//数据库存储client信息@Bean("jdbcClientDetailsService")public ClientDetailsService clientDetailsService(DataSource dataSource) {JdbcClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);clientDetailsService.setPasswordEncoder(passwordEncoder);return clientDetailsService;}/*** 配置令牌访问端点(来申请令牌的URL,需要在哪里申请令牌)和令牌服务(令牌怎么发放、怎么生成,需要配置这个服务)*/@Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) {endpoints.authenticationManager(authenticationManager) //由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式.authorizationCodeServices(authorizationCodeServices(dataSource)) //授权码模式.tokenServices(tokenServices()) //令牌服务.allowedTokenEndpointRequestMethods(HttpMethod.POST); //post请求//        // 自定义确认授权页面
//        endpoints.pathMapping("/oauth/confirm_access", "/oauth/confirm_access");
//        // 自定义错误页
//        endpoints.pathMapping("/oauth/error", "/oauth/error");
//        // 自定义异常转换类
//        endpoints.exceptionTranslator(new OpenOAuth2WebResponseExceptionTranslator());}/*** 配置令牌端点的安全约束*/@Overridepublic void configure(AuthorizationServerSecurityConfigurer security) {security.passwordEncoder(passwordEncoder)    //编码器设定为BCryptPasswordEncoder.allowFormAuthenticationForClients()  //允许客户端使用表单验证,一会我们POST请求中会携带表单信息.checkTokenAccess("permitAll()")    //允许所有的Token查询请求,完全公开 /oauth/check_token.tokenKeyAccess("permitAll()"); //jwt且非对称加密时,资源服务器用于获取公钥而开放的,这里指这个endpoint完全公开 /oauth/token_key}/*** 令牌访问的服务*/@Beanpublic AuthorizationServerTokenServices tokenServices() {DefaultTokenServices services = new DefaultTokenServices();services.setClientDetailsService(clientDetailsService(dataSource)); //客户端信息服务services.setSupportRefreshToken(true); //是否刷新令牌services.setTokenStore(tokenStore); //令牌存储策略//令牌增强TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();tokenEnhancerChain.setTokenEnhancers(Collections.singletonList(jwtAccessTokenConverter));services.setTokenEnhancer(tokenEnhancerChain);//数据库存储客户端的话,这里设置的token过期时间就没用了!!!
//        services.setAccessTokenValiditySeconds(60 * 15); //令牌默认有效期2小时
//        services.setRefreshTokenValiditySeconds(259200); //刷新令牌有效期3天return services;}/*** JWT内容增强*/
//    @Bean
//    public TokenEnhancer tokenEnhancer() {
//        return (accessToken, authentication) -> {
//            Map additionalInfo = new HashMap<>();
//            additionalInfo.put("extra", null);
//            Authentication auth = authentication.getUserAuthentication();
//            if (auth != null) {
//                User user = (User) auth.getPrincipal();
//                additionalInfo.put("extra", user);
//            } else {
//                String clientId = authentication.getOAuth2Request().getClientId();
//                String grantType = authentication.getOAuth2Request().getRequestParameters().get("grant_type");
//                if (Objects.equals(clientId, "c1") && Objects.equals(grantType, "client_credentials")) {
//                    additionalInfo.put("userid", "root");
//                }
//            }
//            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
//            return accessToken;
//        };
//    }

2.redis缓存

2.1.谈谈一致性

一致性就是数据保证一致,在分布式系统中,可以理解为多个结点中的数据保持一致。

  • 强一致性:系统写入什么,读取到的就是什么,对于用户较友好,但是对系统性能要求大。
  • 弱一致性:系统写入数据以后,不会承诺立刻可以读取到写入的新数据,也不能承诺具体多久后才可以读取到,但可以保证某个时间级别(比如秒)后可以读取到,数据达到一致性。
  • 最终一致性:是弱一致性的一个特例,系统保证能在一定时间达到数据一致性,这也是目前业界比较推崇的模型。

2.2.一致性方案

2.2.1.先更新数据库,再更新缓存

        先说结论,这套方案是大家普遍反对的。

        从线程安全角度上讲,假如有A,B两个更新线程:  

  1. 线程A更新数据库
  2. 线程B更新数据库
  3. 线程B更新缓存
  4. 线程A更新缓存

        由于网络因素导致B比A先更新了缓存,最终导致数据不一致,缓存中保存了脏数据。

        从业务场景上看,如果是一种写多读少的场景,那么在多次写入后就会进行多次更新缓存的操作,那么前一次更新的数据就会被覆盖,如果这个写入缓存的数据还是需要通过计算得到的话,那么毫无疑问这是对资源的浪费。

2.2.2.先删除缓存,在更新数据库

        还是先从线程安全角度上分析,假如有A,B两个线程,A读取,B写入:

  1. 线程B先删除缓存
  2. 线程A读取数据,发现缓存没有
  3. 线程A读取数据库数据得到旧值
  4. 线程A把旧值写入缓存
  5. 线程B把新值写入数据库

        此时也会导致数据不一致。

2.2.3.先更新数据库,再删除缓存(较为推荐)

        还是先从线程安全角度上分析,假如有A,B两个线程,A读取,B写入:

  1. 缓存刚好失效
  2. 线程A查询数据库得到旧值
  3. 线程B把新值写入数据库
  4. 线程B删除空缓存
  5. 线程A把旧值写入缓存

        此时也会导致数据不一致,不过这种概率是极低的。一般写操作需要的时间是远远大于读操作的,但是发生上述2、3、4的情况下说明写操作需要的时间比读操作还短,这种情况很少发生。

2.2.4.延时双删

步骤为:

  1. 删除缓存
  2. 更新数据库
  3. 延时后再删除缓存

        那么这个延时多长时间怎么确定?一般就是读操作需要的时间+几百ms,保证读操作结束后再删除缓存。

        因为延时的原因可能会导致系统吞吐量降低,这里可以通过异步的方式去进行第二次删除。

2.3.Geo存储地理位置

        Geo其实就是Zset,通过计算经纬度而得到score。在智慧出行中使用redis中的geo存储了城市和景点的地理位置。可以通过该数据结构计算出方圆距离内的城市或景点、两城市之间的距离等。

2.4.HyperLogLog记录UV值

        HyperLogLog采用了一种近似统计大量去重的算法,内部维护了16384个桶来记录各自桶的数量,当一个元素过来时会随机散列到其中一个桶中,同样的元素会散列到同一个桶中,但最后统计的值是不准确的。但他相比于用Set记录uv值,HyperLogLog只需要12kb的存储空间即可,缺点就是UV值不一定准确。

2.5.缓存临时数据

        例如手机短信验证码、创建订单时的token值、商品库存等。

3.schedule定时清除

3.1.定时将redis库存刷入mysql

/*** 定时把redis中的项目库存刷入mysql*/
@Slf4j
@Configuration
@EnableScheduling //开启定时任务
public class SaveProjectCapacityScheduled {@Autowiredprivate ProjectService projectService;@Scheduled(fixedRate = 60 * 30 * 1000) //周期30min "0 0/30 * * * ?"private void configureTasks() {log.debug("======定时刷新项目库存...======");Integer sum = 0;List projects = projectService.getAll();for (Project project : projects) {Integer projectId = project.getId();//redis中的实时库存数量Integer redisCapacity = projectService.getRedisCapacity(projectId);//mysql中的延时库存数量Integer mysqlCapacity = project.getCapacity();//只有redisCapacity和mysqlCapacity不同时才会更新if (mysqlCapacity.equals(redisCapacity) || redisCapacity == null) {continue;}projectService.updateMysqlCapacity(projectId, redisCapacity);sum++;}log.debug("======定时刷新项目库存完成...更新了{}个项目======", sum);}
}

3.2.定时清除垃圾图片 

图片通过腾讯云COS存储,如果存储成功但写数据库失败会造成垃圾图片,此时放入redis,schedule定时进行清除。

@Slf4j
@Configuration
@EnableScheduling //开启定时任务
public class CleanRubbishImgScheduled {@Autowiredprivate RedisUtil redisUtil;@Autowiredprivate MessageClient messageClient;@Scheduled(fixedRate = 60 * 60 * 1000) //周期1h "0 0/60 * * * ?"private void configureTasks() {log.debug("======定时清理垃圾图片...======");Set rubbishHttpImgs = redisUtil.getSetDifference("applyRubbishImg", "applyCleanedImg");Set rubbishFileImgs = new HashSet<>();if (rubbishHttpImgs.size() > 0) {//截取一下文件名称for (Object fileName : rubbishHttpImgs) {String temp = (String) fileName;fileName = temp.substring(temp.lastIndexOf("/") + 1);rubbishFileImgs.add(fileName);}//远程调用cosResult result = messageClient.cleanRubbishImgScheduled(rubbishFileImgs);if (result.getFlag().equals(true)) {//把垃圾图片从rubbish中移到cleaned中for (Object httpName : rubbishHttpImgs) {redisUtil.moveValueToOtherSet("applyRubbishImg", httpName, "applyCleanedImg");}log.debug("垃圾图片清理成功,清理了{}张", rubbishHttpImgs.size());} else {log.error("垃圾图片清理失败");}} else {log.debug("======当前无垃圾图片======");}}
} 

3.3.定时把redis中的UV值刷入mysql

@Slf4j
@Configuration
@EnableScheduling //开启定时任务
public class SaveProjectUvScheduled {@Autowiredprivate ProjectService projectService;@Scheduled(fixedRate = 60 * 30 * 1000) //周期30min "0 0/30 * * * ?"private void configureTasks() {log.debug("======定时刷新项目浏览量...======");Integer sum = 0;List projects = projectService.getAll();for (Project project : projects) {Integer projectId = project.getId();Long redisUv = projectService.getRedisUv(projectId);Long mysqlUv = project.getUv();//只有redis和mysql中uv不同且不为0时才会更新mysqlif (redisUv == 0 || mysqlUv.equals(redisUv)) {continue;}projectService.updateMysqlUv(projectId, redisUv);sum++;}log.debug("======定时刷新项目浏览量完成...更新了{}个项目======", sum);}
}

3.4.定时更新热门城市top5

@Slf4j
@Configuration
@EnableScheduling //开启定时任务
public class CityTop5Scheduled {@Autowiredprivate CityService cityService;@Autowiredprivate ViewpointService viewpointService;@Autowiredprivate RedisUtil redisUtil;@Scheduled(fixedRate = 60 * 30 * 1000) //周期30minprivate void configureTasks() {log.debug("======定时刷新热门城市top5...======");Map map = new HashMap<>();//根据热门viewpointHot5去查询属于哪个城市,然后记录城市的票数Set> places = redisUtil.getDescRangeWithScore("place:top5", 0, 4);for (ZSetOperations.TypedTuple place : places) {Integer cityId = viewpointService.getCityIdByName((String) place.getValue());City city = cityService.getById(cityId);map.put(city.getName(), map.getOrDefault(city.getName(), 0D) + place.getScore());}for (Map.Entry entry : map.entrySet()) {redisUtil.setZset("city:top5", entry.getKey(), entry.getValue());}log.debug("======定时刷新热门城市top5成功...======");}
} 

4.订单操作

4.1.谈一谈消息可靠性

消息可靠性主要通过可靠投递可靠消费来保证。

可靠投递分为两个步骤,第一步是生产者->交换机,第二步是交换机->队列。

  1. 生产者->交换机的可靠保证依靠的是ConfirmCallback(确认回调)机制,默认不开启,需要手动开启,开启这个模式后,交换机收到生产者的消息后回返回给生产者一个确认收到的消息。
  2. 交换机->队列的可靠保证依靠的是ReturnCallback(返回回调)机制,默认不开启,需要手动开启。ReturnCallback和ConfirmCallback不同的是,ConfirmCallback是无论什么情况都会返回值,返回的值中包含是否失败,ReturnCallback是只有交换机->队列失败时才会返回值,当ReturnCallback触发时,说明一定是失败了。

可靠消费只需要把自动ack改为手动ack即可,如果消费者在处理的过程中出现问题,消息队列可能会以为消费者已经成功处理了,从而造成消息丢失。改为手动ack的形式即可避免。        

最后,还有一些持久化的设置,比如对exchange和queue进行持久化,防止消息丢失。

4.2.实现流程图

订单状态分为:未付款、已取消、已超时、已付款、已退款五种状态。以下为实现流程图:

4.3.创建订单

        通过token的方式保证接口幂等性,在进入下单页面后生成唯一token返回给client并保存在redis中,在用户携带token提交订单时删除token。通过编写lua脚本保证在redis中get、compare、deleted这三个步骤的原子性。通过加锁的方式避免超卖。

        生成订单后,订单号加入ttl队列,该队列被死信队列监听,到达过期时间后进入死信队列,程序判断订单状态,如果未支付且没有自主取消订单,那么此时强制关闭订单,并发布恢复库存和恢复积分的消息到相应其他队列中。 

4.4.取消订单

        在30min内用户可自主取消订单,此时通过消息队列退回积分和库存。

4.5.支付订单

        通过支付宝沙箱sdk返回的模拟支付页面进行支付,支付成功后回调接口,由于发邮件调用了第三方接口,响应速度偏慢;增加用户积分这个操作不是那么着急;aop记录日志也会耗时,所以通过fanout模式分发消息至邮件、积分、日志等队列中进行异步操作。

        在发放积分的过程中,为了保证幂等性,使用了本地消息表进行限制。

4.6.退款订单

        在支付完成的七天内可无理由退款,恢复库存和返回积分的操作也是由消息队列完成,同时调用了支付宝沙箱sdk的退款操作,第三方会收到退款。

5.第三方接口

5.1.支付宝沙箱

在订单模块中模拟第三方支付的场景。

Springboot集成支付宝沙箱支付(完整版)_程序员青戈的博客-CSDN博客

5.2.网易邮箱

用户在首次注册后需要绑定自己的邮箱并且在订单模块中用于通知用户支付结果。

这个在SpringBoot中实现比较简单,已经封装好了一个JavaMailSender类型的Bean。

5.3.腾讯云COS&SMS

所有的图片均使用腾讯云COS存储,用户注册时的短信验证码由腾讯云SMS实现。

需要在腾讯云充一点money,也是跟着网上的教程或者官方文档一步一步配置就好了。

6.日志模块

        这里通过自定义注解+aop的方式实现日志记录。这里也用到了SecurityContext中的用户信息,具体什么是SecurityContext?其实就是我们常说的LocalThread,当一次request进入security的过滤器链后,进入到SecurityContextPersistence过滤器时,会从当前session中读取到用户信息然后存放到context中,这样只要是这次请求经过的所有方法都可以通过访问context获取到用户信息。

在需要记录日志的接口上加入自定义注解,传入固定参数即可。 

定义切面类

五、一些(很多)坑

5.1.gateway和springsecurity整合

        在springcloud中我们可以用zuul或者gateway做网关,但这两者是有差别的,zuul底层就是webmvc框架,但gateway是基于webflux框架写的,那些Servlet中的Filter这些API都用不了,也就是说在gateway项目的依赖中,我们不可以引用web-starter这个包,否则启动会报错。而Security底层就是webmvc的过滤器链,所以这两者是没法整合的,要么就换Zuul,要么就自己写gateway中的过滤器。

        在智慧出行中,我是想在网关层完成对token的验证,然后自己封装一个Base64的明文发送到具体的服务中,但由于用的是gateway,不能整合security,所以直接放弃了这个想法,最后的解决方案是网关只起到一个负载均衡、解决跨域的作用,具体的验证放在资源服务器进行。

5.2.feign远程调用403

        由于项目中使用到了security且基于Oauth2.0,所以在发送请求时需要添加请求头传入token,那么在程序内部调用之间也需要加入请求头。

@Component
public class FeignInterceptor implements RequestInterceptor {@Overridepublic void apply(RequestTemplate template) {RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();if (requestAttributes != null) {HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();String authorization = request.getHeader("Authorization");template.header("Authorization", authorization);}}
}

(持续更新中......)


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章