从“能跑就行”到“可对接、可审计、可落地”:我们把第三方批量用户校验接口一步步打磨成生产级方案
2026-03-19
这两天我们一起打磨的,不只是一个“查用户是否存在”的小接口,而是一条真正能用于第三方系统对接的企业级用户校验链路。表面上看,这个接口的目标很简单:对方传来一批用户,我们判断这些人是不是我方系统里的有效用户,如果是,就返回我方用户唯一 ID。但真正做起来,你会发现这里面牵扯的不是一个字段、一个表、一个查询,而是一整套“参数协议、身份匹配、企业归属、加密传输、分页封装、日志留痕、接口文档”共同组成的对接能力。
很多接口之所以一开始看着简单,后面却越改越乱,根本原因不是代码写得不够快,而是没有在前期把“什么才叫校验通过”想清楚。我们这次最大的价值,就在于不是直接草草给出一版代码,而是通过一轮一轮的沟通,把接口真正的业务边界、输入输出契约、字段含义、校验路径和返回格式都掰开揉碎,最后才落到代码上。这样做出来的东西,才不是 demo,而是可以进生产环境的方案。
一、这个接口一开始看起来像“批量查人”,但本质其实是“第三方身份映射”
这次的接口,业务名义上是“对方批量校验用户”。对方会传入一批人员信息,希望我方帮他们确认:这些用户在智评云里是不是有效人员。如果是,就把我方系统里的唯一用户 ID 返回给他们。这样他们后续在做 token 换取、业务权限映射、单点登录、组织协同的时候,才能把“对方的人”和“我方的人”真正对起来。
所以这不是一个单纯的“名单校验”接口,而是一个“身份映射接口”。这个接口一旦设计错误,后面所有依赖它的功能都会出问题。比如:
第一,对方明明传的是一个企业下的员工,却被误判成另一个企业的同名同手机号用户。
第二,对方把它们自己的 tenantId 传过来,我们如果误把这个当成我方企业主键使用,就会把整个归属关系判断建立在一个不可信字段上。
第三,对方希望拿到的是“能直接建立映射关系的结果”,如果我们返回结构和他们约定的不一致,他们根本没法接。
第四,如果没有日志,后面线上一旦出现“为什么这个人没通过”“为什么这个人被匹配成了另一个用户”,几乎无从排查。
所以这个接口真正的核心,不是“查到用户”,而是“以可追踪、可解释、可审计的方式,完成第三方用户到我方用户的安全映射”。
二、最开始的输入协议,是按 encryption 来走,而不是明文参数直传
我们一开始就明确了,请求不是把 tenantId、creditCode、userList 这些字段明文直接 POST 过来,而是统一打包成一个 JSON,再整体做 base64 编码,最后以参数名 encryption 传入。
这一步很关键。因为它意味着接口的第一层工作不是“取字段”,而是:
先拿到请求体。
然后读取 encryption。
再做 base64 解码。
再把解码后的字符串转成 JSON。
最后才开始真正的业务校验。
看起来只是多套了一层壳,但这个动作实际上把接口的协议层和业务层分开了。协议层负责判断:你传来的东西是不是完整、是不是能解、是不是合法 JSON。业务层才负责判断:你传来的企业、用户、组织信息到底对不对。
这就带来了第一批错误码设计。比如:
没有请求参数。
没有 encryption。
encryption 解码失败。
解码后不是合法 JSON。
creditCode 缺失。
userList 为空。
page 或 size 非法。
这些错误都不是“用户没查到”,而是“你连一个合法请求都没构造出来”。这类错误如果不单独拎出来,后续接口排错会非常痛苦,因为调用方永远分不清到底是参数错了,还是业务没通过。
三、最早的校验思路里,tenantId 曾经被当作核心条件,但这个点后来被我们彻底推翻
这个过程,是整个接口设计里最关键的一次转向。
最早的版本里,解码出参数后会读取:
tenantId
creditCode
page
size
userList
然后在后续的查询中大量使用 tenantId。包括:
用 tenantId + creditCode 去查 BridgePartner。
用 tenantId + creditCode 去查企业组织。
用 tenantId 去查 UserHeadOfficeDetail。
用 tenantId 去查 Frame 组织节点。
甚至在构造组织路径时,也会把 tenantId 作为组织查询的重要条件。
这种写法初看没有问题,因为“tenantId 听起来就像企业唯一标识”。但你很快指出了一个非常关键的业务事实:这里面传入的 tenantId 是对方的验证码,我们不能用。
这句话非常重要。它一旦成立,意味着前面那套“tenantId 驱动的归属校验逻辑”整体都不可靠。原因很简单:
tenantId 不是我方系统内部可信主键。
它只是第三方系统侧自己的企业编码。
它可以拿来记录日志、做原始透传,但不能作为我方判定企业归属的依据。
如果继续用它做查询条件,本质上就是把我方主数据校验建立在对方的一个外部字段上。
这在安全和数据一致性上都是有隐患的。
这个时候,接口逻辑就必须重构。我们不能再说“你传了 tenantId,所以我用 tenantId 判断你属于哪个企业”,而应该改成“你传了 creditCode,我再通过我方内部真实的组织与任职关系,反推出这个用户到底属于哪个企业”。
这就是后来整版代码重构的根本原因。
四、用户匹配条件也被我们做了收敛:最终只认 userName + mobile
你接着又明确了一件事:对于单个用户的匹配,我们只用两个字段做校验:
userName
mobile
这一点同样非常重要。因为 userList 里本来还能看到 organizationList、nmUserId 等字段。如果不提前讲清楚,开发很容易在代码里做“想当然的增强”,比如:
拿 organizationList 去强校验组织路径。
把 nmUserId 当成我方检索条件。
甚至混合 tenantId、organizationList、userName、mobile 四五个字段一起做 where 条件。
看似严谨,实则风险很大。因为第三方传来的组织链路、编码规则、用户 ID 命名方式,都不一定跟我方内部是一致的。字段一多,耦合就会迅速提高,接口反而更容易误判。
所以最后我们把“识别这个用户是谁”这件事收敛成最基础、最稳定的两个字段:姓名和手机号。也就是说,单个人员命中的核心逻辑,就是:
用 userName + 加密后的 mobile 去 ProUser 里找人。
找到后,再去看这个人的状态是否正常:
有没有被逻辑删除。
有没有离职。
是不是仍然是一个可用用户。
这样一来,“这个人是不是这个人”和“这个人是不是属于这个企业”就被拆成两步了。第一步先识别人,第二步再识别归属。这个拆分非常专业,也非常符合生产系统的思路。
五、企业归属校验,是这次重构里真正被打磨出来的“骨架”
如果说前面 tenantId 的推翻,是把错误的骨架拆掉,那么后面新的企业归属链路,就是我们重新搭起来的正确骨架。
你明确提出:UserHeadOfficeDetails 如果要验证企业 code,必须连表 TJ_Organ。
这句话实际上把归属校验的正路说清楚了。因为一个用户是不是属于某家企业,不是你看请求里怎么写,而是要看我方内部主数据中,这个用户到底挂载在哪个企业、哪个组织、哪个节点下。
于是最终逻辑就变成了这样:
先根据 userName + mobile 找到 ProUser。
然后去 UserHeadOfficeDetail 里找这个用户当前有效的任职挂载记录。
再根据这些挂载记录里的 NodeId,去 TJ_Organ,也就是 Frame 表里找组织节点。
再从这个节点往上追溯,找到它所属的企业根节点。
最后取企业根节点上的 USCI,也就是统一社会信用代码,去和请求里的 creditCode 做比对。
只有这条链走通了,才说明:
这个人不仅存在,而且确实属于请求所代表的那家企业。
这一步是整个接口从“查人”升级成“企业级归属校验”的关键。如果只查 ProUser,你最多只能回答“系统里有这个人”;但只有连到 UserHeadOfficeDetail 和 TJ_Organ,你才能回答“这个人属于哪家企业、是不是当前企业的人”。
这两句话,业务意义完全不同。
六、为什么组织路径没有被强行拿来当最终通过条件
在这个过程中,organizationList 也是一个反复被碰到的字段。它长得很像一个很适合做校验的字段,因为它看起来能表达“xxx公司/xx部门/xx一部”这样的完整组织链路。很多开发看到这种字段,天然会觉得“既然传了,就应该做全路径严格比对”。
实际上我们一开始也朝这个方向写过逻辑,比如:
从 Frame 节点往上拼出真实组织路径。
再把传入的 organizationList 做标准化处理。
最后比较两边是否完全一致。
这套逻辑从技术上当然能做,而且也不是错。但随着你不断收紧业务定义,这个字段最后没有成为最终通过条件。原因也很现实:
第三方组织名称和我方组织名称未必完全一致。
组织路径的分隔符、空格、简称、别名都可能不一样。
有些系统会给你传“北京分公司/事业二部”,有些则可能是“北京分公司/事业二部/评估一组”。
这种字段太容易引入“看起来更严格,实际上更脆弱”的问题。
所以最后我们把 organizationList 的角色降级成“可保留字段”,而不是“准入硬门槛”。也就是说,这个接口最终认的是:
识别人,用 userName + mobile。
识别企业归属,用 UserHeadOfficeDetail + TJ_Organ + creditCode。
organizationList 保留,但不作为最终必须通过的条件。
这是一次非常典型的企业级取舍。好的系统不是字段越多越安全,而是关键判断链条越少越稳。
七、返回结构的第一次版本是“数组列表”,但后来你要求改成“带 tenantId、creditCode、userList 的整体对象”
这是我们这两天里另一个很关键的交互点。
最开始写服务代码时,返回的是一种比较常见的分页格式:items 里面 base64 编码的是一个数组,数组里每个元素大概长这样:
zpyUserId
nmUserId
然后外层再配 _links 和 _meta。
这种设计从技术上没问题,因为很多分页接口本来就是这么干的。但后面你非常明确地指出:上面的代码返回结构有问题,也就是 items 里有问题。
你要求把 items 编码前的原始结构拼成下面这样:
tenantId
creditCode
userList
也就是说,items 不再只是一个“校验通过列表数组”,而必须是一个带业务上下文的完整对象。对象内部再包含 userList。并且 userList 里的每一项字段名也不是简单的 zpyUserId,而要按约定写成“智评云唯一id”和 nmUserId。
这个改动看似只是“调整返回格式”,其实它反映的是对接接口设计里一个非常重要的原则:返回结果不能只站在我方视角,还要站在对方消费视角。
对方收到 items 后,不只是想拿一个 ID 列表,他们还希望这份结果能天然地带着:
这次请求对应的是哪个 tenantId。
这次请求对应的是哪个 creditCode。
这次通过校验的 userList 到底有哪些人。
每个第三方 nmUserId 映射到了哪个智评云唯一 ID。
这样他们在落地时,就不用把“请求上下文”和“映射结果”再拼一次。也就是说,接口输出本身就已经是一个完整、可消费的映射结果对象了。
于是服务层代码又做了一轮关键修改:
分页仍然保留。_links 和 _meta 保留不变。
但 items 不再编码纯数组。
而是改成编码一个对象:包含 tenantId、creditCode、userList。userList 里每个元素长成:智评云唯一id + nmUserId。
这就是一次典型的“从技术正确走向接口契约正确”的升级。
八、通过的用户才进入返回列表,失败用户写日志但不出现在结果里
在整个设计过程中,我们还逐步明确了一个很关键的输出策略:这个接口不是“逐个返回通过/失败状态明细”,而是“只返回通过项”。
也就是说:
每条用户都要实际校验。
每条用户都要落日志。
每条用户都要记下通过还是失败。
但最终响应里的 userList,只保留通过的那些人。
未通过的人,不进入结果集。
这个设计背后的逻辑很清楚。对方调用这个接口,本质上最关心的是“哪些人能映射成功”。成功的人,需要拿到我方唯一 ID 去建立后续关联;失败的人,更多是异常处理和排查问题,而不是进入正式结果流。
所以我们在循环中做的事情就是:
每一项都 try-catch。
通过了,就组装一个成功返回项,放进 passedUserList。
失败了,就只构造失败日志,不放进 passedUserList。
最后再对 passedUserList 做分页、封装、编码。
这让外部返回保持简洁,而内部日志保持完整。对接方拿到的是干净结果,我方排查问题时又有足够证据链。这种“外部简洁、内部充分”的设计,才是成熟接口应该有的样子。
九、日志不是附属品,而是整个接口可信度的一部分
这次我们一直坚持每个用户校验都要写日志,这个决定非常对。
很多开发做接口时,一开始会觉得“先把主流程跑通再说,日志以后补”。但像这种第三方对接接口,日志绝对不是锦上添花,而是底座之一。因为只要接口一上线,排查问题时你一定会被问到下面这些问题:
为什么这个 nmUserId 没返回?
为什么这个手机号明明有,却提示用户不存在?
为什么这个用户在系统里有,但说不属于当前企业?
为什么同一个人昨天能过,今天过不了?
对方到底传了什么?
我们实际查到了什么?
最终失败在哪一步?
没有日志,这些问题你几乎全靠猜。
有日志,你就能按 request_id 把整条链追出来。
所以我们最后保留的日志内容包括:
请求流水号。
企业信用代码。
partner_id。
第三方用户编码 nmUserId。
用户姓名。
手机号。
我方匹配到的 zpy_user_id。
校验状态。
校验消息。
请求体片段。
响应体片段。
来源 IP。
这套字段,不只是“为了记一下”,而是为了让每一次校验都有证据、有上下文、有结果。你后面做联调、灰度、验收、线上排查,全靠它。
十、分页结构被保留,但它服务的不是数据库分页,而是结果集分页
这次还有一个非常值得注意的设计点,就是分页。
对方约定了 page 和 size,因此接口最终保留了 _links 和 _meta,同时在服务里做了分页处理。但这里的分页,并不是数据库层直接分页捞 userList,而是:
先把整批用户逐条校验。
拿到所有通过用户的列表。
再对通过列表做内存分页切片。
最后生成 currentPage、pageCount、totalCount、perPage。
这个思路在当前业务里是合理的。因为对方一次传上来的 userList,本身就是一批待校验对象,而不是我方数据库里“无限列表”的翻页查询场景。你无法在数据库层先分页,因为你必须先逐条跑校验逻辑,才能知道哪些人最终进入结果集。
所以这里保留分页,不是为了“减少数据库数据量”,而是为了让接口输出形式与对方预期一致,方便他们统一消费。这属于典型的“协议分页”,而不是“存储分页”。
十一、接口文档的整理,不是抄代码,而是把隐性规则显性化
当接口代码基本成型后,我们又继续做了一件很关键的事:把它整理成正式接口文档。
而且这次文档并不是简单地写个 URL、参数、示例就完事,而是把很多代码里隐含的规则明确写出来,比如:
encryption 是 base64 编码的 JSON 字符串。
tenantId 只是第三方企业标识,不参与我方业务归属判断。
我方用户匹配只使用 userName 与 mobile 两个字段。
企业归属是通过我方内部挂载关系和组织树反推的。
nmUserId 不参与匹配,只做透传返回。
items 里的内容是 base64 编码后的结构化对象。
userList 中只返回校验通过的用户。
失败情况会通过错误码或日志体现。
分页默认 page=1、size=100。
size 超过上限会被截断。
这些东西,如果只存在于代码里,别人很难第一时间看懂。只有写进文档里,第三方和你自己的产品、测试、后续维护同学,才能真正理解这个接口到底怎么用、为什么这么设计、哪些字段是真的参与判断、哪些字段只是保留。
这就是为什么接口文档不是“写给别人看的解释”,而是“系统规则的显性表达”。
十二、错误码体系的单独整理,让接口具备了真正的工程可维护性
在你后面又提出“错误码请生成表格,我直接粘贴过去”之后,这个接口的工程化程度又提升了一层。
错误码这件事,看似只是文档美化,实际上它是接口可维护性的保障。因为一旦到了联调阶段,对方不会拿着源码来问你,他们拿到的只有:
code
message
data
如果 message 里没有规范化错误码,他们只会说一句“你们接口报错了”。
但如果 message 里是 C1201|用户不存在,那排查就能立刻聚焦到“用户识别阶段”。
如果是 C1104|用户存在,但不属于当前creditCode对应企业,问题就落在“企业归属阶段”。
如果是 C1010|encryption解码失败,则连业务都还没进。
这套错误码体系,实际上把整个接口拆成了三层问题域:
参数与协议层。
企业配置与组织层。
用户识别与归属层。
这不仅方便第三方,也方便你自己的系统演进。因为后面再扩展时,你知道每一类问题应该归到哪一段,不会越来越乱。
十三、这次最有价值的,不是“把接口写出来”,而是把接口定义正确了
回头看这两天的过程,会发现真正重要的不是某一段 SQL 或某一个 if,而是下面这些设计决策被我们一条条敲定了:
请求必须走 encryption。
参数是 base64(JSON) 而不是明文。
tenantId 不能作为我方业务校验依据。
用户识别只认 userName + mobile。
企业归属必须走 UserHeadOfficeDetail + TJ_Organ。
organizationList 不作为硬门槛。
nmUserId 只做透传映射。
返回结构要按对方消费方式重新组织。
items 必须编码成带 tenantId、creditCode、userList 的对象。
只返回通过用户,失败用户写日志不返还。
分页结构保留。
错误码体系单独整理。
接口文档以对接可落地为目标,而不是以代码实现为目标。
这些决策拼起来,才构成了一个真正成熟的接口。否则你今天能跑通,明天一联调就会发现对不上,对上了后天一上线又查不出问题,最后看起来是“代码有 bug”,其实根本原因是接口定义从一开始就没立稳。
十四、如果把这次工作的本质说透,它其实是一场“从程序员视角到系统视角”的升级
程序员视角最容易关注的是:
怎么解码,怎么查表,怎么返回。
系统视角真正关心的是:
什么字段可信,什么字段不可信。
什么是身份识别,什么是企业归属。
什么是外部协议,什么是内部主数据。
什么要放到返回里,什么只放日志里。
什么属于严格校验,什么属于保留字段。
什么是技术上能做,什么是业务上应该做。
这次我们一路改下来,本质上就是不断把“程序上能写的逻辑”修正成“系统上成立的逻辑”。而企业级开发里最值钱的能力,恰恰就是这种修正能力。
会写查询的人很多。
能把接口跑通的人也很多。
但能在对接过程中,及时识别“tenantId 不能用”“归属必须连表 TJ_Organ”“返回结构不是我自己想怎么给就怎么给”,并且把这些认知落成稳定代码、稳定文档、稳定错误码体系的人,才是真正能做复杂对接系统的人。
十五、最后做个总结:这套接口为什么已经有了生产级雏形
如果要用一句话来概括这两天的成果,我会这样说:
我们不是写了一个“能查人的接口”,而是做出了一条“第三方批量身份映射与企业归属校验链路”。
它已经具备了生产级接口该有的几个核心特征:
输入协议清晰。
加密外壳明确。
参数校验完整。
可信字段与非可信字段边界清楚。
用户识别逻辑收敛。
企业归属链路真实。
返回格式贴合对接方消费方式。
分页结构完整。
错误码体系规范。
日志留痕充分。
接口文档可直接对接。
这意味着它已经不是“临时给对方一个接口试试”,而是可以作为一条稳定能力,继续往下承接更多业务。比如后续你再做:
批量换 token 前置校验。
用户与组织权限联动。
第三方单点登录用户预校验。
企业级同步映射。
异步失败重试。
联调问题追踪。
这套接口都可以直接成为底层基石。
而这,才是这两天这项工作的真正价值。不是多写了几百行代码,而是把一条对接链从“想当然”打磨到了“经得住上线和排查”的程度。
发表评论: