微服务中如何保证接口的安全性?[基于DDD和微服务的开发实战]

news2024/11/27 11:14:46

大家好,我是飘渺。如果你的微服务需要向第三方开放接口,如何确保你提供的接口是安全的呢?

1. 什么是安全接口

通常来说,要将暴露在外网的 API 接口视为安全接口,需要实现防篡改防重放的功能。

1.1 什么是篡改问题?

由于 HTTP 是一种无状态协议,服务端无法确定客户端发送的请求是否合法,也不了解请求中的参数是否正确。以一个充值接口为例:

http://localhost/api/user/recharge?user_id=1001&amount=10

如果非法用户通过抓包获取接口参数并修改 user_id 或 amount 的值,就能为任意账户添加余额。

1.1.1 如何解决篡改问题?

虽然使用 HTTPS 协议能对传输的明文进行加密,但黑客仍可截获数据包进行重放攻击。两种通用解决方案是:

  1. 使用 HTTPS 加密接口数据传输,即使被黑客破解,也需要耗费大量时间和精力。
  2. 在接口后台对请求参数进行签名验证,以防止黑客篡改。

签名的实现过程如下图所示:

image-20231126111753565

  • 步骤1:客户端使用约定好的规则对传输的参数进行加密,得到签名值sign1,并且将签名值也放入请求的参数中,随请求发送至服务端。
  • 步骤2:服务端接收到请求后,使用约定好的规则对请求的参数再次进行签名,得到签名值 sign2。
  • 步骤3:服务端比对 sign1 和 sign2 的值,若不一致,则认定为被篡改,判定为非法请求。

1.2. 什么是重放问题?

防重放也叫防复用。简单来说就是我获取到这个请求的信息之后什么也不改,,直接拿着接口的参数去 重复请求这个充值的接口。此时我的请求是合法的, 因为所有参数都是跟合法请求一模一样的。重放攻击会造成两种后果:

  1. 针对插入数据库接口:重放攻击,会出现大量重复数据,甚至垃圾数据会把数据库撑爆。
  2. 针对查询的接口:黑客一般是重点攻击慢查询接口,例如一个慢查询接口1s,只要黑客发起重放攻击,就必然造成系统被拖垮,数据库查询被阻塞死。
1.2.1 如何解决重放问题?

防重放,业界通常基于 nonce + timestamp 方案实现。每次请求接口时生成 timestamp 和 nonce 两个额外参数,其中 timestamp 代表当前请求时间,nonce 代表仅一次有效的随机字符串。生成这两个字段后,与其他参数一起进行签名,并发送至服务端。服务端接收请求后,先比较 timestamp 是否超过规定时间(如60秒),再查看 Redis 中是否存在 nonce,最后校验签名是否一致,是否有篡改。

如果看过我DDD&微服务系列中幂等方案的文章,对于nonce方案肯定比较熟悉,这就是幂等方案中的token机制,只不过此时幂等key是由客户端生成的。

image-20231126163541188

2. 身份认证方案

我们已经了解了如何解决对外接口可能遇到的篡改和重放问题,但还遗漏了最关键的身份认证环节。一般而言,对互联网开放的接口不是任何人都能调用的,只有经过认证的用户或机构才有权限访问。解决身份认证问题通常通过 AppId 和 AppSecret 实现。

2.1 AppId + AppSecret

AppId作为一种全局唯一的标识符,主要用于用户身份识别。为防止其他用户恶意使用别人的 AppId 发起请求,通常采用配对 AppSecret 的方式,类似一种密码。在请求方发起请求时,需将 AppIDAppSecret 搭配上前文提到的安全方案,一并签名提交给提供方验证。

现在,让我们再来梳理一下完整的签名方案。

1、服务方提供一组 AppId 和 AppSecret,并由客户端保存。

2、将timestamp、nonce、AppId 与请求参数一起并按照字典排序,使用URL键值对(key1=value1&key2=value2…)的格式拼接形成字符串StringA。

3、在StringA的最后拼接上AppSecret,得到字符串StringB。

4、使用摘要算法对 StringB 进行加密,并将得到的字符串转为大写,得到签名值 sign,将其与参数一起发送给服务端。

5、服务端接收请求后,对接口进行校验(时间、随机字符串、身份验证、签名)。

在这个流程中,**AppID 参与本地加密和网络传输,而 AppSecret 仅作本地加密使用,不参与网络传输。**服务端拿到 AppID 后,从存储介质中获取对应的 AppSecret,然后采用与客户端相同的签名规则生成服务端签名,最后比较客户端签名和服务端签名是否一致。

3. 代码实现

“Talk is cheap. Show me the code.” 说了这么久,现在让我们从代码的角度来看看如何在 DailyMart 中将上面的理论知识串联起来,安全地对外提供接口。

本文涉及到的所有代码都已上传至github,如果需要请参考文末方式进行获取。

3.1 AppId 和 AppSecret的生成

在生成 AppId 和 AppSecret 时,只需确保 AppId 的全局唯一性,然后将生成的 AppId 和 AppSecret 进行绑定。在 DailyMart 中,我们使用短链的生成算法来生成 AppId,再对 AppId 进行 SHA 加密后得到对应的 AppSecret。

 private static String getAppKey() {
	long num = IdUtils.nextId();
	StringBuilder sb = new StringBuilder();
	do {
		int remainder = (int) (num % 62);
		sb.insert(0, BASE62_CHARACTERS.charAt(remainder));
		num /= 62;
	} while (num != 0);
	return sb.toString();
}

通过这个算法生成的 AppId 和 AppSecret 形如:

appKey=6iYWoL2hBk9, appSecret=5de8bc4d8278ed4f14a3490c0bdd5cbe369e8ec9

3.2 API校验器

在一个系统中可能存在多种认证逻辑,比如既要支持今天所讲的开放接口校验逻辑,还需要支持内部服务的 JWT 认证逻辑。为了方便处理,我们抽象一个 API 认证接口,各种认证逻辑独立到自己的实现中,对于今天所讲的开放接口认证,主要关注 ProtectedApiAuthenticator

image-20231126202844179

//认证接口
public interface ApiAuthenticator {
  AuthenticatorResult auth(ServerWebExchange request); 
}

//具体实现
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {
  ...
}

3.2 网关过滤器

接口的安全校验很适合放在网关层实现,因此我们需要在网关服务中创建一个过滤器 ApiAuthenticatorFilter

@Component
@Slf4j
public class ApiAuthenticatorFilter implements GlobalFilter, Ordered {
    ...
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
       
        // 获取认证逻辑
        ApiAuthenticator apiAuthenticator = getApiAuthenticator(rawPath);
        AuthenticatorResult authenticatorResult = apiAuthenticator.auth(exchange);
      
        if (!authenticatorResult.isResult()) {
            return Mono.error(new HttpServerErrorException(
                    HttpStatus.METHOD_NOT_ALLOWED, authenticatorResult.getMessage()));
        }
        
        return chain.filter(exchange);
        
    }
    
   
 		/**
     * 确定认证策略
     * @param rawPath 请求路径
     */
    private ApiAuthenticator getApiAuthenticator(String rawPath) {
        String[] parts = rawPath.split("/");
        if (parts.length >= 4) {
            String parameter = parts[3];
              return switch (parameter) {
                case PROTECT_PATH ->   new ProtectedApiAuthenticator();
                case PRIVATE_PATH ->   new PrivateApiAuthenticator();
                case PUBLIC_PATH ->    new PublicApiAuthenticator();
                case DEFAULT_PATH ->   new DefaultApiAuthenticator();
                default -> throw new IllegalStateException("Unexpected value: " + parameter);
              };
        }
        return new DefaultApiAuthenticator();
    }
    
}

上面提到过,不同类型的服务其接口认证不一样,为了便于区分,可以规定对于外部请求都增加一个特定的请求前缀 /pt/,如 apigw.xxx.com/order-service/api/pt/creadeOrder。这样在过滤器内部就需要通过 getApiAuthenticator() 方法确定认证逻辑。

3.3 接口安全认证

正如上文所说,服务端获取到请求参数以后需要检查请求时间是否过期,nonce是否已经被使用,签名是否正确。

image-20231130195356437

按照这个逻辑我们很容易在ProtectedApiAuthenticator认证器中写出这样的代码。

@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {

    @Override
    public AuthenticatorResult auth(ServerWebExchange exchange)  {
        
        // 1. 校验参数
        boolean checked = preAuthenticationCheck(requestHeader);
        if (!checked) {
            return new AuthenticatorResult(false, "请携带正确参数访问");
        }

        // 2 . 重放校验
        // 判断timestamp时间戳与当前时间是否操过60s(过期时间根据业务情况设置),如果超过了就提示签名过期。
        long now = System.currentTimeMillis() ;      
         if (now - Long.parseLong(requestHeader.getTimestamp()) > 60000) {
            return new AuthenticatorResult(false, "请求超时,请重新访问");
         }

        // 3. 判断nonce
        boolean nonceExists = distributedCache.hasKey(NONCE_KEY + requestHeader.getNonce());
        if (nonceExists) {
            return new AuthenticatorResult(false, "请勿重复提交请求");
        } else {
            distributedCache.put(NONCE_KEY + requestHeader.getNonce(), requestHeader.getNonce(), 60000);
        }
      
        // 4. 签名校验
       SortedMap<String, Object> requestBody = CachedRequestUtil.resolveFromBody(exchange);
       String sign = buildSign(requestHeader,requestBody);
      if(!sign.equals(requestHeader.getSign())){
        return new AuthenticatorResult(false, "签名错误");
      }
      
      return new AuthenticatorResult(true, "");
}

这样的写法虽然能够完成校验逻辑,但稍显不够优雅。在这种场景中,使用设计模式中的责任链模式是非常合适的选择。通过责任链模式,将校验逻辑分解为多个责任链节点,每个节点专注于一个方面的校验,使得代码更加清晰和易于维护。

责任链模式已经在我星球设计模式专栏中有详细介绍与说明,感兴趣的可以翻翻~

@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {

    @Override
    public AuthenticatorResult auth(ServerWebExchange exchange)  {
        ...
        //构建校验对象
        ProtectedRequest protectedRequest = ProtectedRequest.builder()
                .requestHeader(requestHeader)
                .requestBody(requestBody)
                .build();

				//责任链上下文
        SecurityVerificationChain securityVerificationChain = SpringBeanUtils.getInstance().getBean(SecurityVerificationChain.class);

        return securityVerificationChain.handler(protectedRequest);

    }

}

3.4 基于责任链的认证实现

image-20231126210059535

3.4.1 创建责任链的认证接口
public interface SecurityVerificationHandler extends Ordered {
    /**
     * 请求校验
     */
    AuthenticatorResult handler(ProtectedRequest protectedRequest);
}
3.4.2 实现参数校验逻辑
@Component
public class RequestParamVerificationHandler implements SecurityVerificationHandler {

    @Override
    public AuthenticatorResult handler(ProtectedRequest protectedRequest) {

        boolean checked = checkedHeader(protectedRequest.getRequestHeader());

        if(!checked){
            return new AuthenticatorResult(false,"请携带正确的请求参数");
        }
        return new AuthenticatorResult(true,"");
    }

    private boolean checkedHeader(RequestHeader requestHeader) {
        return Objects.nonNull(requestHeader.getAppId()) &&
                Objects.nonNull(requestHeader.getSign()) &&
                Objects.nonNull(requestHeader.getNonce()) &&
                Objects.nonNull(requestHeader.getTimestamp());
    }

    @Override
    public int getOrder() {
        return 1;
    }
}
3.4.3 实现nonce的校验
@Component
public class NonceVerificationHandler implements SecurityVerificationHandler {
    private static final String NONCE_KEY = "x-nonce-";

    @Value("${dailymart.sign.timeout:60000}")
    private long expireTime ;
  
    @Resource
    private DistributedCache distributedCache;

    @Override
    public AuthenticatorResult handler(ProtectedRequest protectedRequest) {
        String nonce = protectedRequest.getRequestHeader().getNonce();
        boolean nonceExists = distributedCache.hasKey(NONCE_KEY + nonce);

        if (nonceExists) {
            return new AuthenticatorResult(false, "请勿重复提交请求");
        } else {
            distributedCache.put(NONCE_KEY + nonce, nonce, expireTime);
            return new AuthenticatorResult(true, "");
        }
    }

    @Override
    public int getOrder() {
        return 3;
    }
}
3.4.4 实现签名认证
@Component
@Slf4j
public class SignatureVerificationHandler implements SecurityVerificationHandler {
    @Override
    public AuthenticatorResult handler(ProtectedRequest protectedRequest) {

        //1. 服务端按照规则重新签名
        String serverSign = sign(protectedRequest);
        log.info("服务端签名结果: {}", serverSign);

        String clientSign = protectedRequest.getRequestHeader().getSign();
        // 2、获取客户端传递的签名
        log.info("客户端签名: {}", clientSign);

        if (!Objects.equals(serverSign,clientSign)) {
            return new AuthenticatorResult(false, "请求签名无效");
        }
        return new AuthenticatorResult(true, "");
    }

    /**
     * 服务端重建签名
     * @param protectedRequest 请求体
     * @return 签名结果
     */
    private String sign(ProtectedRequest protectedRequest) {
        RequestHeader requestHeader = protectedRequest.getRequestHeader();
        String appId = requestHeader.getAppId();

        String appSecret = getAppSecret(appId);
        // 1、 按照规则对数据进行签名
        SortedMap<String, Object> requestBody = protectedRequest.getRequestBody();
        requestBody.put("app_id",appId);
        requestBody.put("nonce_number",requestHeader.getNonce());
        requestBody.put("request_time",requestHeader.getTimestamp());

        StringBuilder signBuilder = new StringBuilder();
        for (Map.Entry<String, Object> entry : requestBody.entrySet()) {
            signBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
        }
        signBuilder.append("appSecret=").append(appSecret);

        return DigestUtils.md5DigestAsHex(signBuilder.toString().getBytes()).toUpperCase();
    }


    @Override
    public int getOrder() {
        return 4;
    }

}
3.4.5 责任链上下文
@Component
@Slf4j
public class SecurityVerificationChain {
    @Resource
    private List<SecurityVerificationHandler> securityVerificationHandlers;

    public AuthenticatorResult handler(ProtectedRequest protectedRequest){
        AuthenticatorResult authenticatorResult = new AuthenticatorResult(true,"");
        for (SecurityVerificationHandler securityVerificationHandler : securityVerificationHandlers) {
            AuthenticatorResult result = securityVerificationHandler.handler(protectedRequest);
            // 有一个校验不通过理解返回
            if(!result.isResult()){
                return result;
            }
        }
        return authenticatorResult;

    }

}

组合所有的校验逻辑,任意一个校验逻辑不通过则直接返回。

小结

在本文中,我们深入研究了微服务架构中对外开放接口的安全性保障机制。我们着重关注了那些暴露在外网的API接口面临的两个关键安全问题:篡改和重放。为了应对篡改问题,我们引入了双重手段:采用HTTPS进行加密传输,并结合接口参数签名验证,以确保数据传输的完整性和安全性。对于重放问题,我们采纳了基于noncetimestamp的方案,以保证请求的唯一性和有效性。

在具体的代码实现中,我们不仅考虑了文章中提到的安全认证逻辑,还充分考虑了其他可能的校验规则。为了更好地组织和管理这些校验规则,我们将它们拆分成独立的模块,根据请求路径动态选择相应的接口校验器。在第三方接口校验逻辑中,我们通过责任链的设计模式实现了具体的校验规则,使得代码逻辑更为模块化和可扩展。这样的结构不仅使得每个校验步骤聚焦于特定的安全性验证,而且提供了良好的可维护性和可扩展性。

最后给大家一个小建议:对外提供的接口协议尽量简单,不要使用Restful接口风格,全部使用post+json或post+form风格的接口协议即可,这样对客户端和服务端都方便。

DailyMart是一个基于 DDD 和Spring Cloud Alibaba的微服务商城系统,同时还会在该系统中整合其他专栏的精华内容,譬如分库分表、设计模式、老鸟系列,开发实践等…希望能通过此专栏为开发者提供一个集成式的学习体验,并将其无缝地运用于实际项目中。如果你对这个系列感兴趣可以在本公众号回复关键词 DDD 以获取完整文档以及相关源码。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1307869.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Linux】多线程编程

目录 1. 线程基础知识 2. 线程创建 3. 线程ID&#xff08;TID&#xff09; 4. 线程终止 5. 线程取消 6. 线程等待 7. 线程分离 8. 线程互斥 8.1 初始化互斥量 8.2 销毁互斥量 8.3 互斥量加锁和解锁 9. 可重入和线程安全 10. 线程同步之条件变量 10.1 初始化条件变…

k8s安装Ingress-Nginx

目前&#xff0c;DHorse(https://gitee.com/i512team/dhorse)只支持Ingress-nginx的Ingress实现&#xff0c;下面介绍Ingress-nginx的安装过程。 下载安装文件 首先&#xff0c;需要匹配Ingress-nginx版本和kubernetes版本。 在https://github.com/kubernetes/ingress-nginx可…

【UE5 Niagara】烟雾弹效果

效果 步骤 1. 新建一个工程&#xff0c;创建Basic关卡 2. 新建一个Actor蓝图&#xff0c;这里命名为“BP_SmokeBomb” 打开“BP_SmokeBomb”&#xff0c;添加一个静态网格体和一个发射物移动组件&#xff0c;静态网格体使用圆柱模型 选中发射物移动组件&#xff0c;设置初始速…

XSS防御:内容安全策略 CSP工作原理、配置技巧与最佳实践

前言 公司部门安全合规改造计划&#xff0c;要求所有的Web站点统一添加CSP规则。对于CSP机制我只是之前在应付面试的时候背过相关的概念&#xff0c;并没有真正在项目中实践过。所以希望借助本次改造任务好好理解并实践CSP机制。 什么是CSP CSP的全称是 Content Security Po…

算法通关村第十三关—数字与数学高频问题(白银)

数字与数学高频问题 一、数组实现加法专题 1.1 数组实现整数加法 先看一个用数组实现逐个加一的问题。LeetCode66.具体要求是由整数组成的非空数组所表示的非负整数&#xff0c;在其基础上加一。这里最高位数字存放在数组的首位&#xff0c;数组中每个元素只存储单个数字。并且…

TrustGeo代码理解(二)test.py

代码链接&#xff1a;https://github.com/ICDM-UESTC/TrustGeo 一、加载检查点&#xff08;checkpoint&#xff09;并进行测试 # -*- coding: utf-8 -*-"""load checkpoint and then test """ 该脚本的目的是加载之前训练过的模型的检查点&am…

Visual Studio使用Web Deploy发布.NET Web应用到指定服务器的IIS中

前言 今天要讲的是在Window 2008 R2版本的服务器下如何配置Web Deploy&#xff0c;和Visual Studio使用Web Deploy发布.NET Web应用到指定服务器的IIS中。 因为历史原因项目只能使用这个版本的服务器&#xff0c;当然使用其他服务器版本配置流程也是一样的。 Web Deploy介绍 …

c语言结构体调用格式与对齐

1.声明形式&#xff1a; struct 结构体名字 { 结构体成员 }结构体变量名&#xff1b; 2.赋值方法 3.结构体对齐&#xff1a; 1.起始偏移量&#xff1a;默认结构体第一个元素对齐0起始偏移量&#xff0c;第一个元素占一个字节&#xff0c;此时偏移量为1. 2.标准数&#xff…

数据结构之Map/Set讲解+硬核源码剖析

&#x1f495;"活着是为了活着本身而活着"&#x1f495; 作者&#xff1a;Mylvzi 文章主要内容&#xff1a;数据结构之Map/Set讲解硬核源码剖析 一.搜索树 1.概念 二叉搜索树又叫二叉排序树&#xff0c;他或者是一颗空树&#xff0c;或者是具有以下性质的树 若它…

现代雷达车载应用——第2章 汽车雷达系统原理 2.4节

经典著作&#xff0c;值得一读&#xff0c;英文原版下载链接【免费】ModernRadarforAutomotiveApplications资源-CSDN文库。 2.4 雷达波形和信号处理 对于连续波雷达来说&#xff0c;波形决定了其基本信号处理流程以及一些关键功能。本节将以FMCW波形为例&#xff0c;讨论信号…

【深度学习】Pytorch 系列教程(一):PyTorch数据结构:1、Tensor(张量)及其维度(Dimensions)、数据类型(Data Types)

文章目录 一、前言二、实验环境三、PyTorch数据结构0、分类1、Tensor&#xff08;张量&#xff09;1. 维度&#xff08;Dimensions&#xff09;0维&#xff08;标量&#xff09;1维&#xff08;向量&#xff09;2维&#xff08;矩阵&#xff09;3维张量 2. 数据类型&#xff08…

预测性维护对制造企业设备管理的作用

制造企业设备管理和维护对于生产效率和成本控制至关重要。然而&#xff0c;传统的维护方法往往无法准确预测设备故障&#xff0c;导致生产中断和高额维修费用。为了应对这一挑战&#xff0c;越来越多的制造企业开始采用预测性维护技术。 预测性维护是通过传感器数据、机器学习和…

06-React组件 Redux React-Redux

React组件化&#xff08;以Ant-Design为例&#xff09; 组件化编程&#xff0c;只需要去安装好对应的组件&#xff0c;然后通过各式各样的组件引入&#xff0c;实现快速开发 我们这里学习的是 Ant-design &#xff08;应该是这样&#xff09;&#xff0c;它有很多的组件供我们…

ConcurrentHashMap并发

ConcurrentHashMap 并发 概述 jdk1.7概述 ConcurrentHashMap我们通过名称也知道它也是一个HashMap, 但是它底层JDK1.7与1.8的实现原理并不相同 在1.7中它内部维护一个Segment[]的数组, 加载因子0.75, 在创建一个长度为2的小数组HashEntry[], 在0索引处创建 根据键的哈希值计…

【强化学习-读书笔记】有限马尔可夫决策过程

参考 Reinforcement Learning, Second Edition An Introduction By Richard S. Sutton and Andrew G. BartoMDP 是强化学习问题在数学上的理想化形式&#xff0c;因为在这个框架下我们可以进行精确的理论说明 智能体与环境的交互 智能体与环境交互&#xff0c;会得到轨迹&…

【教3妹学编程-算法题】消除相邻近似相等字符

插&#xff1a; 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到网站。 坚持不懈&#xff0c;越努力越幸运&#xff0c;大家一起学习鸭~~~ 3妹&#xff1a;好冷啊&#xff0c; 冻得瑟瑟发抖啦 2…

【开源软件】最好的开源软件-2023-第22名 Apache Iceberg

自我介绍 做一个简单介绍&#xff0c;酒架年近48 &#xff0c;有20多年IT工作经历&#xff0c;目前在一家500强做企业架构&#xff0e;因为工作需要&#xff0c;另外也因为兴趣涉猎比较广&#xff0c;为了自己学习建立了三个博客&#xff0c;分别是【全球IT瞭望】&#xff0c;【…

感知机(perceptron)

一、感知机 1、相关概念介绍 感知机&#xff08;perceptron&#xff09;是二分类的线性分类模型&#xff0c;属于监督学习算法。输入为实例的特征向量&#xff0c;输出为实例的类别&#xff08;取1和-1&#xff09;。 2、&#xff08;单层&#xff09;感知机存在的问题 感知机…

C语言指针基础题(二)

目录 例题一题目解析及答案 例题二题目解析及答案 例题三题目解析及答案 例题四题目解析及答案 例题五题目解析及答案 感谢各位大佬对我的支持,如果我的文章对你有用,欢迎点击以下链接 &#x1f412;&#x1f412;&#x1f412; 个人主页 &#x1f978;&#x1f978;&#x1f…

C++ Java 嵌入式选哪个,该走哪个方向?

看一下最近几年的C Java 嵌入式薪资水平 图片来源&#xff1a;牛客2023校招春季与秋季白皮书 总结一下各届平均值&#xff0c;供大家参考&#xff0c;整体上薪资还是能反应一定的供需关系。 浅浅分析下&#xff1a; C领跑&#xff0c;整体波动不大&#xff0c;主要因为岗位较…