前两周跟一个做招聘系统的朋友吃饭,他吐槽说一个Offer审批在钉钉里卡了两天。HR以为流程早就提交了,候选人那边却一直没收到消息,最后发现是审批流压根没推送到正确的审批人。这种故事我听得太多了。很多企业的招聘系统,数据模型做得再漂亮,一旦跟钉钉、Jira、Confluence这三样日常工具是割裂的,它就永远是一座信息孤岛。
招聘系统本质上是个“人才数据中枢”,但中枢的价值不取决于内部数据库,而取决于它跟外部系统有多少条稳定的数据通道。钉钉管人和流程,Jira管任务与协作,Confluence管知识和标准,招聘系统管候选人全生命周期数据。这四者如果能在API层面真正打通,招聘流程从岗位发布到Offer审批再到入职交接,可以完全不落地在任何人的微信或者Excel里。
这篇文章就围绕“与钉钉、Jira、Confluence无缝对接”这个目标,把我在实际项目里的API设计思路、联调经验、踩坑记录完整写出来。适合正在做企业招聘系统、或者打算对接这三类系统的后端工程师、集成架构师阅读。我会尽量把方案讲得具体,包括关键接口设计、数据模型、回调处理、幂等机制、排错链路,确保你看完能直接用在自己的项目里。
1. 先把集成需求拆清楚:四个系统各自该管什么事
1.1 招聘系统不是“万能盒子”,集成前先划清职责边界
大多数集成项目烂尾,不是因为接口不会调,而是因为一开始就没有把“每个系统到底该管什么”定义清楚。招聘系统对接钉钉、Jira、Confluence,看上去是三个方向的技术对接,本质上是一次职责再分工。我习惯在动手前先画一张职责矩阵:
| 系统 | 定位 | 权威数据 | 非权威数据 |
|---|---|---|---|
| 招聘系统 | 人才数据主库 | 候选人档案、面试记录、Offer记录、评估结果 | 审批实例、任务卡片、文档正文 |
| 钉钉 | 组织与流程中枢 | 部门架构、员工身份、审批流 | 候选人状态、面试评价 |
| Jira | 流程与协作看板 | 任务拆解、评论协作、卡片流转 | 候选人履历、Offer薪资明细 |
| Confluence | 知识与标准库 | 岗位JD标准模板、面试官手册、评估SOP | 实时候选人状态、职位进展 |
这张表解决的是“权威源”问题。所谓权威源,就是某个数据在全公司范围内只有一个写入方、其他系统只能读取或引用。比如钉钉的组织架构是权威源,招聘系统不应该自己去维护一套部门树;Offer薪资明细的权威源是招聘系统,Jira上面的卡片只做展示,不应该把薪酬字段同步过去。
还有一个常见反模式:有人想“用Jira当ATS”,把候选人流转全做成Jira工单。短期看是省了开发量,长期一定会后悔。Jira擅长的是项目协作,不是简历解析、不是候选人去重、更不是Offer管理。反过来,也不建议让钉钉审批流程去驱动招聘主流程——审批只是招聘生命周期里一个环节,不应该反过来成为数据流转的引擎。正确的做法是:招聘系统保持核心地位,钉钉、Jira、Confluence都作为它的协作节点存在。
1.2 沿着候选人生命周期找触点
职责矩阵画完之后,第二步是沿着候选人的生命周期,把每一个需要跨系统协作的触点找出来。我是按照六个阶段来梳理的:
- 岗位发布:岗位JD的权威版本放在Confluence,由业务部门维护,招聘系统从Confluence拉取正文并渲染成结构化岗位信息。
- 简历收集:简历进入招聘系统后,通过钉钉工作通知推送给对应HRBP和业务面试官。
- 筛选评估:面试官在Jira看板上反馈面评,招聘系统通过Webhook接收评论内容,回写到候选人档案。
- 面试安排:面试时间确认后,招聘系统给候选人和面试官分别发送钉钉日程提醒,同步在Jira卡片上更新下一轮任务。
- Offer审批:招聘系统发起Offer审批,调用钉钉审批实例接口,审批结果通过事件回调返回。
- 入职交接:Offer审批通过后,招聘系统在Confluence的入职清单页更新候选人信息,同时在钉钉发起入职待办。
每个触点都要回答三个问题:数据从哪来、往哪去、谁有权改。把这张触点地图画出来,API怎么设计基本就清楚了。很多时候开发者在写代码之前跳过了这一步,结果就是接口调着调着发现职责重叠,两个系统各自写了一份“半对”的数据,后面全是数据对账的脏活。
2. 招聘系统API的总设计:模型、网关、事件三件事
2.1 五个核心模型定义好,所有集成围绕它们展开
对接三方系统时,最忌讳的是每个系统各写一套适配代码,最后变成一张蜘蛛网。我的做法是在招聘系统内部先抽象出五个核心实体,所有外部集成都围绕这五个模型进行:
- Candidate:候选人档案,包含姓名、联系方式、简历附件、来源渠道、当前阶段。
- Job:岗位模型,关联Confluence的JD文档ID、负责的HRBP、业务面试官组。
- Interview:面试记录,关联候选人、面试官、轮次、时间、评估结果。
- Offer:Offer审批单,包含薪资结构、入职日期、审批状态、关联钉钉审批实例ID。
- Assessment:评估反馈,关联面试记录,通常由Jira评论/自定义字段回传。
每个实体都预留两个关键字段:external_type和external_id。比如Offer实体里有dingtalk_approval_instance_id,Job实体里有confluence_page_id,Candidate阶段对应Jira的issue_key。这样设计的好处是,外部系统ID永远只作为关联凭证存在,不会干扰招聘系统内部的主键逻辑。
以Candidate为例,推荐的数据结构长这样:
{ "candidate_id": "CAN-2024-000173", "name": "某候选人", "phone": "138****0000", "resume_url": "https://ats.internal/resume/xx.pdf", "source_channel": "内推", "current_stage": "INTERVIEW_ROUND2", "external_refs": { "jira_issue_key": "HIRE-2341", "dingtalk_userid": null, "confluence_contact_id": null }, "timeline": [ { "stage": "SCREENING", "at": "2024-06-01T10:00:00+08:00", "actor": "hr_user_1001" } ] }这里有一个容易被忽略的要点:所有时间字段统一用带时区的ISO 8601格式存储。后面你会发现钉钉返回的时间戳是毫秒级、Jira返回的是字符串日期、Confluence是标准时间格式,如果不统一存储格式,做跨系统时间比对时会非常痛苦。
2.2 统一出口的API网关层与事件发布机制
对接三个外部系统,不能允许每个系统都直接连数据库。正确做法是在招聘系统外层加一个API网关层,所有出站和入站请求都经过这里。我一般用BFF模式(Backend for Frontend,后端的后端),即针对钉钉、Jira、Confluence各自写一个适配器,但对外只暴露统一接口。
这样做的好处是三方系统的差异被隔离在适配器内部。举个例子,钉钉的审批回调要求解密后只返回“success”字符串,Jira的Webhook要求返回200并带上X-Hub-Signature签名校验,Confluence的REST API则要求Bearer Token鉴权。如果这些逻辑散落在业务代码里,下次接一个新系统就是一场灾难。集中到适配器之后,主业务线完全感知不到外部系统的协议差异。
更关键的是事件发布机制。招聘系统内部的状态变更,不应该直接同步给外部系统,而是先发布事件,再由对应的适配器决定要不要消费。典型事件类型我放在下面的表里:
| 事件类型 | 产生时机 | 消费方 |
|---|---|---|
candidate.created | 新简历录入 | 钉钉适配器(发工作通知) |
interview.scheduled | 面试时间确认 | 钉钉适配器、Jira适配器 |
assessment.received | 收到面试评价 | 招聘系统内部更新阶段 |
offer.approved | 钉钉审批通过 | Jira适配器、Confluence适配器 |
offer.rejected | 钉钉审批驳回 | Jira适配器、候选阶段回退 |
事件结构我用的是比较轻量的方式,发布到内部的Redis Stream或者RocketMQ都行。事件体里只放业务ID和变更后的关键字段,下游自己决定要拉多少上下文。举个例子:
{ "event_id": "evt_9f1c2b3a4d5e6f708192", "event_type": "offer.approved", "occurred_at": "2024-06-15T14:32:00+08:00", "payload": { "offer_id": "OFF-2024-0881", "candidate_id": "CAN-2024-000173", "job_id": "JOB-2024-0312", "dingtalk_approval_instance_id": "a1b2c3d4e5f6", "approved_by_dingtalk_userid": "zhangsan_001" } }这里有两个必须坚持的细节。第一,事件ID全局唯一,消费端必须记录已经处理过的事件ID,配合幂等机制防止重复消费。第二,事件体里不要附带完整的业务对象,只放引用和状态变化。因为外部系统回调重试时,你无法保证本地对象还处于事件发生时的状态,按需重构上下文更安全。
3. 钉钉对接:组织、审批、消息三块硬骨头
3.1 组织架构同步:以钉钉为员工主数据源
钉钉是大多数国内企业内部组织的实际主数据源。招聘系统不需要自建员工表,但需要一份“面试官-招聘系统账号-钉钉userid”的映射关系。最稳妥的方案是:以钉钉通讯录为准,通过全量快照加增量回调来同步组织数据。
全量同步建议每天凌晨跑一次,调用钉钉通讯录接口,按部门树递归拉取员工列表,写入本地映射表。增量同步则依赖钉钉的事件订阅,通讯录发生变更时,钉钉会主动推送到你配置的回调地址。需要注意,钉钉的通讯录变更回调使用的是加密推送格式,解密后才能拿到userId列表,然后再调用接口获取最新部门信息。
我踩过的坑是:只做全量不做增量,导致白天入职的新同事当天下午发起面试邀请时,招聘系统里找不到对应面试官。只做增量不做全量,则可能因为回调丢失导致永久性数据漂移。正确做法是“增量为主、全量兜底”。增量更新时写本地operation_log,全量跑完后对比总数,发现不一致就主动拉全量重刷。
看一下增量回调的数据格式(解密后的明文):
{ "EventType": "user_add_org", "UserId": ["zhangsan_001"] }拿到UserId后,立即调用topapi/v2/user/get获取姓名、部门、职位信息,写入映射表:
INSERT INTO employee_mapping (dingtalk_userid, name, department_id, title, ats_account_id, updated_at) VALUES (:uid, :name, :dept_id, :title, :ats_account_id, now()) ON DUPLICATE KEY UPDATE name=VALUES(name), department_id=VALUES(department_id), title=VALUES(title), updated_at=now();这里ats_account_id是招聘系统内部账号ID,需要人工或通过企业微信/钉钉扫码绑定完成。建议先绑定再开放面试官权限,避免出现候选人都进面了,面试官还没账号的尴尬。
3.2 Offer审批流:用钉钉审批实例驱动候选人状态推进
Offer审批是招聘系统与钉钉集成里最核心、也最容易出问题的环节。我的设计思路是,HR在招聘系统确认Offer信息后,由招聘系统作为发起方,调用钉钉审批实例创建接口。审批模板在企业管理员后台配置好,表单里包含候选人姓名、岗位、薪资概算、入职日期等字段,把招聘系统的offer_id作为外部业务单号带进去。
创建审批实例的请求体示例:
{ "process_code": "PROC-XXYYZZ-01", "originator_user_id": "hr_wang_001", "dept_id": 12345678, "form_component_values": [ { "name": "候选人", "value": "某候选人" }, { "name": "岗位", "value": "Java高级工程师" }, { "name": "Offer金额", "value": "28000" }, { "name": "外部单号", "value": "OFF-2024-0881" } ], "approvers": [ { "user_ids": ["zhangsan_001"], "action_type": "AND", "condition_type": "MANAGER" } ] }审批流创建成功后,钉钉返回instance_id,一定要把它存进Offer实体。之后状态推进全靠回调,而不是轮询。回调里会携带审批结果,我们需要订阅bpms_instance_change事件。审批通过时候选人状态推进到“Offer已接受”,审批驳回时状态回退并通知HR调整薪资再发起。
这里要特别强调一个设计原则:招聘系统只能评价审批实例的状态结果,而不是替钉钉决定审批人是谁。审批人列表最好在钉钉模板上通过“发起人主管”或者“按表单内容联动”去配置,把审批人计算逻辑交给钉钉规则引擎,减少招聘系统侧的维护成本。
3.3 工作通知与微应用免登
招聘系统最频繁的钉钉交互其实是消息通知。面试官需要收到面试提醒、HR需要收到简历新到通知、审批人需要收到待办提醒。钉钉工作通知接口用的是模板消息机制,需要先在开发者后台申请消息模板,拿到template_id,然后用企业内部应用的AccessToken发请求:
{ "agent_id": 123456789, "userid_list": "zhangsan_001", "msg": { "msgtype": "template_msg", "template_msg": { "template_id": "TPL-XXX", "url": "https://ats.internal/offer/approval/detail?offer_id=OFF-2024-0881", "data": { "candidate": "某候选人", "position": "Java高级工程师", "action": "请尽快审批" } } } }这类接口有频率限制,尤其在集中批量通知场景下(比如周五下午同时发出几十个面试邀约),容易触发系统限流。我的做法是,消息发送走异步队列,每条消息当独立任务处理,做指数退避重试。同时在钉钉应用后台配置回调地址,关注发送失败的消息状态,把“发送失败”也做成一个业务事件推给管理员。
微应用免登是从钉钉工作台打开招聘系统时的用户身份识别方案。流程是:前端通过dd.ready获取免登授权码,后端拿授权码换用户身份返回的userid,再映射到招聘系统账号。虽然步骤不复杂,但有一个经常被忽略的细节——免登授权码有效期非常短,expires_in大概只有五分钟,后端拿到后要立即换取用户信息,不要存在队列里延迟处理,否则会得到InvalidCode错误。
4. Jira对接:招聘流程看板化的正确姿势
4.1 为什么招聘系统要跟Jira联动:把HR流程变成项目协作
招聘在很多公司本质上是一个“多角色并行推进项目”的活儿。一个岗位从审批通过到候选人入职,涉及HRBP、业务面试官、部门负责人等多个角色。Jira的价值在于把这些角色的任务、评论、状态集中到一个看板上,让整个招聘过程对所有人可见,而不是只活在HR的私聊窗口里。
我的设计是:一个岗位(Job)对应Jira里的一个Epic,每个候选人(Candidate)对应Epic下的一个子任务(Issue),任务的流转状态对应候选人的面试阶段。子任务摘要统一格式为“【候选人姓名】-【岗位名称】”,方便搜索和筛选。
Jira项目的自定义字段可以这样规划:
| 字段 | 用途 | 示例 |
|---|---|---|
| 候选人阶段 | 当前进度 | 待筛选 / 一面 / 二面 / 终面 / Offer审批 |
| 候选人联系方式 | 快速联系 | 138****0000 |
| 简历附件链接 | 查看简历 | https://ats.internal/resume/xxx.pdf |
| 面试官评价 | 结果备注 | 通过,建议下一轮 |
对应地,筛选阶段和面试评估内容通过Jira评论来承载。面试官点击候选人卡片,在评论区写上评价即可,Jira的Webhook会把新增评论事件推给招聘系统,招聘系统解析文本后与本地评估记录进行匹配。
4.2 同步方向与防循环:只在Jira产生面试反馈,其他都回写
Jira集成的第一原则是“单向优先”。招聘系统是候选人数据主库,Jira只是协作视图。同步方向应该是:
- 招聘系统 -> Jira:岗位信息、候选人姓名、面试轮次、Offer状态(创建和更新卡片)。
- Jira -> 招聘系统:面试评论、候选人阶段的手动调整(面试官拖拽卡片)。
- 不允许:Jira直接改候选人薪资、简历原文等招聘数据。
防止数据循环是一个关键设计点。如果Jira评论自动同步回招聘系统,招聘系统又把这条评论格式化成状态更新推回Jira,就会形成无限循环。我的解决方案是:每一条从Jira同步回来的评论,都在招聘系统里记录对应的jira_comment_id作为唯一键,处理前先查本地是否已存在,存在就跳过。同理,招聘系统推送给Jira的每一次更新,也会携带external_update_token,Jira侧Webhook识别到是自己的写入就不做二次回传。
创建候选人对应Jira子任务的代码示例:
def create_candidate_issue(candidate, job): payload = { "fields": { "project": {"key": "HIRE"}, "parent": {"key": job.jira_epic_key}, "summary": f"【{candidate.name}】-【{job.name}】", "issuetype": {"name": "子任务"}, "customfield_10035": {"value": candidate.current_stage}, "customfield_10036": candidate.phone, "customfield_10037": candidate.resume_url } } resp = jira_client.create_issue(payload) candidate.jira_issue_key = resp["key"] candidate.save()处理Jira评论的Webhook回调时,需要验证签名头X-Hub-Signature:
def verify_jira_webhook(raw_body, signature, secret): expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(f"sha256={expected}", signature)验证通过后,解析Webhook的comment字段,关联到本地对应候选人。这里有一个常见问题:Jira Webhook的issue对象里只包含key和id,不包含你的自定义字段,所以回传时需要再调一次Issue接口拉取详情,注意做好接口缓存,避免每次都全量查询。
5. Confluence对接:知识库与岗位体系的双向引用
5.1 明确Confluence在招聘体系里的定位:标准、SOP、储备知识
Confluence在整个招聘体系里的角色,是做“知识与标准”的沉淀。岗位JD模板、面试官手册、面试评估维度、招聘SOP、入职Checklist,这些内容如果散落在招聘系统内部编辑器里,不仅更新麻烦,而且不透明。把它们放到Confluence空间里,业务部门可以直接维护,人事部门可以直接引用,招聘系统只负责“读”和“连线”。
前期规划时,我会建议在每个Confluence空间里创建固定结构:
- 某公司的招聘标准空间
- 岗位JD模板库(每个页面存一类岗位的标准JD)
- 面试官手册(涵盖面试流程、问题参考、评估卡)
- 招聘SOP(Offer审批流程、背景调查流程)
- 入职Checklist(各角色待办清单)
这种结构的好处是权限层级清晰:空间权限里配置好HRBP和部门负责人的编辑权限,普通员工只读;招聘系统用一个专门的服务账号,只分配只读权限,避免误改文档。权限边界一定要在对接前就明确,否则后面权限范围过大,安全审计不过关。
5.2 用Confluence API做JD注入与文档引用
招聘系统与Confluence的集成,本质上是“双向富文本引用”。核心场景有两个:一是从Confluence拉取岗位JD正文,渲染到招聘系统的岗位发布页;二是在Confluence页面里嵌入招聘系统的候选人状态链接。
第一个场景的做法是:招聘系统在创建岗位(Job)时,填写Confluence页面ID,通过Confluence REST API拉取页面正文的Storage Format(存储格式)。Storage Format是Confluence内部的XML/HTML混合结构,需要做一次转换才能展示在招聘系统前端。我一般用atlassian-python-api库的get_page_by_id接口,直接以?expand=body.storage方式获取。
代码示例:
from atlassian import Confluence confluence = Confluence( url="https://wiki.internal.example.com", token="你的API Token" ) page = confluence.get_page_by_id( page_id=318273, expand="body.storage", version=0 ) storage_body = page["body"]["storage"]["value"] html_body = convert_storage_to_html(storage_body) # 转换函数 job.jd_content_html = html_body job.jd_source_page_id = page["id"] job.save()注意转换不是简单替换标签,Confluence的Storage Format里会有很多宏(macro),比如扩展宏、锚点宏、用户提及宏。我建议对不需要的宏直接做丢弃处理,只提取<p>、<h1>、<ul>、<ol>、<table>这类标准标签,保证招聘系统前端样式可控。
第二个场景是“在Confluence里看候选人状态”,一般不做双向实时同步,而是生成一个长链接。推荐做法是Confluence页面里插入外部链接宏,链接指向招聘系统的候选人详情页。不要试图把候选人状态实时同步到Confluence页面,因为文档系统对实时性要求不高,而且频繁写入会给Confluence接口造成无意义压力。清单类文档每天晚上整体刷新一次就够了。
6. 一次真实的对接排错:审批回调丢失的完整排查链路
6.1 现象与初期定位:状态卡在“审批中”,但审批人明明点了通过
有一回集成测试阶段,业务反馈一批Offer审批流程在钉钉上都已经通过了(审批人手机端看到了“已通过”的绿勾),但招聘系统里对应的Offer状态却一直停留在“审批中”,候选人状态没有推进。这个现象很典型,属于“上游已确认、下游未感知”的回调丢失问题。
我的排查第一步永远是先确认“回调到底到没到”。打开服务器日志,按招聘系统的Offer编号查关联的审批实例ID,再按实例ID查回调接收日志。如果日志里根本没有这个实例的任何回调记录,说明钉钉压根没有推送过来,或者推送了但被你的接口拒绝了。如果日志里有回调记录但是处理逻辑报错,那问题出在解析或状态机更新环节。
实操排查第一步的命令大概是这样的:
grep "a1b2c3d4e5f6" /var/log/ats-callback.log | tail -50日志里如果一片空白,那就要去钉钉后台查看“事件订阅”的推送记录。钉钉开发者后台会展示最近一段时间的推送历史,包括推送时间、返回码、错误信息。这块信息非常关键,很多回调丢包其实都能在后台直接看到失败原因。
6.2 一步步定位到“回调加密参数配置错误”
通过钉钉后台的推送记录,我发现回调推送接口返回了HTTP 500,钉钉尝试重试三次后放弃,于是业务上就表现为“审批通过了但系统没反应”。接下来就要在应用自身日志里查为什么500。我的排查顺序是:
- 检查回调地址连通性:确认回调URL暴露在外网且SSL证书正常。
- 检查签名校验:钉钉回调请求会带
signature和timestamp,服务端要用应用的aes_key和token做签名校验,校验失败会直接返回错误码。 - 检查数据解密:回调体是加密过的,需要用
aes_key解密才能拿到事件明文。 - 检查处理逻辑:解密成功后,是否有异常导致请求处理失败。
最后定位到是加密密钥配置不一致。我在本地测试环境用了一套测试应用的密钥,生产环境又是另一套,但事件订阅的回调配置被错误地指向了生产服务器,生产服务却拿着测试环境应用凭证去初始化解密器,结果就是签名校验不通过、解密失败,接口500。这类问题在对接钉钉单聊时不明显,一边对接多个环境时就特别容易出。
修复后的一个自我保护措施是写了一个回调自测脚本,启动时用当前应用的aes_key对一条已知密文样例做解密,解不出来就直接启动失败,避免把错误配置带上线。同时在回调处理函数里加了异常捕获,对解密、签名校验失败的情况返回钉钉规定的错误响应码,并记录原始请求体,方便离线排查。
6.3 修复后还要做的三件事:幂等表、重试队列、监控大盘
问题修复后,我没有直接宣布搞定,而是做了三件补救措施,防止类似问题在别的地方再冒出来。
第一件,建callback_idempotency幂等表,字段包括唯一索引(event_type, instance_id)。钉钉回调在极端情况下可能重复推送,比如网络抖动后重试。如果在处理业务逻辑前不做幂等检查,同一个审批实例通过两次,候选人状态会重复推进,甚至可能生成两条Offer记录。加上幂等唯一索引之后,第二次回调会被直接忽略,安全很多。
第二件,做本地补偿扫描。每天晚上跑一次任务,找出所有状态为“审批中”但审批实例已超过24小时未回调的Offer记录,主动调用钉钉审批实例查询接口,把真实状态拉回来。这个机制兜住的是“没推送+没重试”这种极端场景,保证数据最终一致。
第三件,监控告警。招聘系统对接钉钉的回调成功率应该是一件需要持续观测的指标。我在监控大盘上加了三块内容:回调请求总数、回调处理失败率、待审批Offer的堆积数量。只要驳回率超阈值就告警到集成组值班群。这个动作在实现上不复杂,但极大减少了“事后被业务投诉才知道出了问题”的被动局面。
写在最后的个人体会
每次做这类招聘系统的多方对接,我都会在开工前先花半天时间做一张“权威源与流向表”:每个系统里谁是数据所有者、数据往哪个方向流、谁有权限改、失败之后谁来补偿。这张表一旦画明白,后面的API设计、字段映射、回调机制实现其实都是水到渠成的事,困难永远不在代码本身,而在跨系统的流程共识。
还有一个小技巧想分享给大家:不要一上来就想着把钉钉、Jira、Confluence三个系统全量打通。先挑一条最核心的业务闭环,比如“Offer审批通过”这一个事件,把钉钉审批创建到回调接收再到候选人状态推进整条链路完整跑通,验证幂等、验证重试、验证监控。这条最小闭环稳了之后,再往外面扩“面试提醒”“Jira评论回传”“Confluence JD注入”这些外围功能,风险可控得多。集成工作最怕的不是接口多,而是每一条链路都半懂不懂。闭环先窄后宽,是我在几个项目里实践下来最稳的推进方式。