《对接企业批量用户校验接口设计实战复盘:从请求结构、表关系、校验链路到服务落地的完整思路》
2026-03-18
这次我们设计的,不是一个简单的“查用户接口”,而是一套面向企业对接场景的“批量用户实时校验接口”。它的核心目标,不是只看某个人在 user 表里有没有,而是要回答一个更完整的问题:对方传来的一批员工,是否真实存在于我方系统中,是否仍然有效,是否属于指定企业,是否在该企业下处于正常状态,以及其组织链路是否匹配。最后,再把通过校验的人员唯一标识返回给对方,同时把每一条校验过程留痕到日志表,方便后续追查和审计。
这篇文章把整个设计过程完整整理出来,所有涉及企业编码、手机号、用户编号、信用代码等敏感信息,统一做脱敏处理,方便后续你自己复盘,也方便拿去做内部方案沉淀。
一、这个接口到底要解决什么问题
对方系统会把一个企业下的一批员工信息传给我方,希望我方判断这些人是不是我方系统里真实有效的用户。如果是,就返回我方系统中的唯一用户标识;如果不是,就不返回,或者只在日志里记录失败原因。
这里的“有效”,不是单纯指 user 表有记录,而是至少要满足四层意思。
第一层,这个人在我方主用户表里存在。
第二层,这个人不是逻辑删除状态,不是离职状态。
第三层,这个人确实挂载在当前企业下,而不是同名同手机号但属于别的企业。
第四层,如果对方传了组织链路,那么这个人的组织路径还要跟系统中的真实路径一致。
所以这类接口的本质,不是普通查询,而是“企业级身份归属校验”。
二、最初的请求结构是怎么确定的
对方给出的入参结构大致如下,为了便于说明,这里已经全部脱敏:
{
"tenantId": "93xxxxx",
"creditCode": "913xxxxxxxxxxxxxxx",
"userList": [
{
"organizationList": "某某公司/某部门/某一部",
"userName": "张三",
"mobile": "183****5848",
"nmUserId": "nm_xxxxxx123456"
},
{
"organizationList": "某某公司/某部门/某二部",
"userName": "李四",
"mobile": "186****1234",
"nmUserId": "nm_xxxxxx123457"
}
]
}这个结构背后有几个很关键的设计点。
第一,tenantId 和 creditCode 同时传。tenantId 代表租户维度,creditCode 代表企业统一社会信用代码。单独用一个字段,有时会存在歧义;两个一起传,企业识别更稳。
第二,userList 支持批量。因为对方不可能一个用户调一次接口,那样性能、联调成本、调用次数都不合理。批量传输才符合真实业务场景。
第三,单个用户对象里,不只传姓名和手机号,还传了对方自己的 nmUserId。这个字段非常重要。因为对方最终拿回结果时,需要知道“我方返回的这个用户,映射的是我传过来的哪一个人”。所以 nmUserId 的意义不是参与我方主校验,而是做双方系统之间的映射锚点。
第四,organizationList 作为补充校验项。它不是绝对主键,但它能提高匹配准确度,尤其是企业规模一大、同名同手机号历史残留、用户跨组织迁移等情况时,组织链路是非常有价值的辅助信息。
三、返回结构是如何演化出来的
最开始讨论返回结构时,曾经出现过几种思路。
一种思路是返回 key-value,比如姓名对应 true/false。这个方式太弱,因为姓名不能唯一定位人。
另一种思路是返回姓名、手机号、组织链路等更多信息。这种方式信息量大,但没有必要,而且还增加了敏感信息暴露范围。
最终定下来的方向,是只返回通过校验的映射结果,即返回我方唯一标识与对方的 nmUserId。这样最干净,也最利于双方做系统对接。
建议的返回结构如下:
{
"code": 0,
"message": "success",
"data": {
"tenantId": "93xxxxx",
"creditCode": "913xxxxxxxxxxxxxxx",
"userList": [
{
"zpyUserId": 654321,
"nmUserId": "nm_xxxxxx123456"
},
{
"zpyUserId": 654322,
"nmUserId": "nm_xxxxxx123457"
}
]
}
}这里有一个很重要的取舍。返回的是 zpyUserId,而不是中文字段名“智评云唯一id”。因为接口字段名必须程序友好、跨语言友好、序列化稳定。中文字段名看起来直观,但在联调、文档、SDK、序列化兼容性上都不如英文命名稳妥。
另外,返回 user.id 还是 user.Uuid,也专门分析过。最后代码里默认返回的是数值型 id,因为日志表中的 zpy_user_id 字段本身就是 bigint。如果你要返回 Uuid,那么日志表字段类型也要同步调整,否则类型不一致会带来隐患。
四、表关系是怎么一步一步梳理清楚的
这次接口设计真正的难点,不在写 action,而在于先把系统里“用户、企业、组织、对接方、日志”这几类表之间的关系梳理透。
第一张核心表,是 user 表,对应模型 ProUser。
它是用户主表,里面能看到用户的姓名、手机号、逻辑删除标记、离职标记等核心状态。也就是说,一个人是否“物理上存在于系统”,首先看它。
这张表里本次真正用到的关键字段有这些:
Uuid:业务唯一字符串标识
id:数值主键
RealName:真实姓名
Telephone:手机号
IsDeleted:逻辑删除标记
IsLeft:是否离职
第二张核心表,是 User_HeadOffice_Detail,对应模型 UserHeadOfficeDetail。
这张表不是用户主表,而是“用户在企业维度下的归属关系表”。也就是说,一个用户存在于系统中,并不等于他一定属于当前这个企业。这个归属关系,要靠这张表来判断。
本次重点用到这些字段:
UserId:对应 user.id
tenant_id:当前租户,也就是企业维度
NodeId:默认组织节点
IsCurrent:是否当前登录企业
IsDeleted:在当前企业下的状态,0 正常,1 禁用,2 离职,-1 迁移
这里有一个非常重要的业务含义:一个用户可能在系统里存在,但在当前企业下状态已经不正常,或者已经迁移走了。这个时候,不能因为 user 表存在,就直接返回通过。必须联查这张表。
第三张核心表,是 TJ_Organ,对应模型 Frame,命名空间在 TJFrame 下。
这张表是组织架构表。企业本身、部门、分公司、子公司,本质上都是组织节点。企业根节点通常是 NodeType=0,对应统一社会信用代码 USCI。
这张表本次主要承担两个作用。
第一个作用,校验当前企业是否真的存在于组织体系中,并且没有被删除。
第二个作用,沿着 NodeId 找到用户当前所属组织节点,并拼接出完整组织路径,用于和传入的 organizationList 比对。
用到的关键字段有:
Id:组织节点主键
NodeName:组织名称
NodeType:节点类型
IsDeleted:组织是否删除
tenant_id:租户维度
USCI:统一社会信用代码
FullAncestorIds:祖先节点链路
ParentId:父节点
HeadOfficeUid:母公司维度
第四张表,是 bridge_user_verify_log,对应模型 BridgeUserVerifyLog。
这张表是专门为本次实时校验链路做日志留痕的。它不是主业务表,而是审计表、排查表、追踪表。
它记录的是:谁来请求、校验的是谁、结果如何、请求报文是什么、响应报文是什么、我方最终认定该人是否有效。
这张表之所以重要,是因为企业对接一旦出问题,最怕的是无法追溯。日志表的意义,就是让未来任何一条“为什么这个人没通过”的问题,都有据可查。
第五张隐含表,是 bridge_partner,对应模型 BridgePartner。
虽然最初重点放在用户表和组织表,但后来发现只校验 user 和组织还不够,因为接口是对外开放给某个对接企业使用的,那么请求发起方自己也要先被识别。于是又补上了 BridgePartner 校验,用 tenant_id + enterprise_code 判断是否存在有效对接关系。
这一步是“调用方身份合法性校验”,本质上是外部企业维度的白名单识别。
五、为什么 controller 只保留 action,逻辑全部下沉到 service
这次你明确要求,action 要按照现有 token 接口的风格写,不要在 controller 里堆逻辑,逻辑全部放到 service 中,并且新起一个类 PsassUserService。
这个要求非常对,而且从工程化角度看是必要的。
因为 controller 的职责应该尽量单一,只做三件事:接收请求、调用服务、统一返回。
真正复杂的参数解析、业务校验、数据查询、组织路径生成、日志写入,这些都应该在 service 里完成。这样有几个明显好处。
第一,controller 干净。以后你看接口入口,一眼能看清楚,不会被一堆细节淹没。
第二,service 可复用。将来你想做命令行校验、异步重试、内部复用,就能直接调 service,而不用再绕 controller。
第三,方便单元测试。复杂逻辑放 service,测试更方便。
第四,利于迭代。未来如果增加更多校验维度,比如身份证号、邮箱、员工编号、岗位信息,都只需要改 service。
因此最终形成的结构是:
Controller 里只有一个 actionVerifyPartnerUsers
PsassUserService 里承载全部业务逻辑
六、校验链路是怎样一层层定下来的
这次校验流程,不是一下子拍脑袋定的,而是一步一步收敛出来的。最后的顺序非常关键。
第一步,解析请求参数。
先从 Request 中取 bodyParams,如果 bodyParams 为空,再尝试从 rawBody 做 JSON 反序列化。这一步是为了兼容不同请求方式,避免联调时因为 content-type 或框架解析差异导致取不到数据。
第二步,校验最外层必填参数。
tenantId 不能为空
creditCode 不能为空
userList 不能为空且必须是数组
这一步是典型的请求层防线。外层都不合法,后面根本没必要继续查库。
第三步,校验对接企业配置是否存在。
通过 BridgePartner 按 tenant_id + enterprise_code 去查。如果连对接配置都没有,说明这个企业根本不在允许的对接范围里,接口应该立即终止,而不是继续查用户。
第四步,校验企业根组织是否存在。
通过 TJ_Organ 按 tenant_id + USCI + NodeType=0 找企业根节点,再看 IsDeleted 是否为 0。这里实际上是在回答一个问题:这个信用代码对应的企业,在我方组织体系中是否真实存在且有效。
第五步,遍历 userList,逐个校验用户。
这里采用逐条 try-catch 的方式处理,而不是整个批次一旦有一条失败就全失败。因为业务上更合理的方式是“谁通过返回谁,不通过的写日志”,而不是一个脏数据拖死整批调用。
第六步,校验用户主表。
通过 RealName + Telephone 去 user 表里找人。找到后再判断 IsDeleted、IsLeft。
这一步本质上是在确认“这个自然人是否还在系统里有效存在”。
第七步,校验企业归属关系。
通过 User_HeadOffice_Detail 按 tenant_id + UserId + IsDeleted=0 去查当前企业下的关系记录,并优先选当前企业、最近更新的记录。
这一步本质上是在确认“这个人是否属于当前企业,且在该企业下处于正常状态”。
第八步,校验组织节点。
通过 detail.NodeId 去 TJ_Organ 查当前组织节点,要求 tenant_id 匹配、IsDeleted=0。
这一步是在确认“用户挂载的组织节点本身也是有效的”。
第九步,可选校验组织链路。
如果对方传了 organizationList,就要把系统中的真实组织链路拼出来,再做标准化后比较。这里做了路径标准化处理,比如替换不同斜杠、去掉多余空格、压缩多重分隔符,目的是减少因为格式差异造成的误判。
第十步,记录日志。
不论单条成功还是失败,都要写入 bridge_user_verify_log,把请求报文、响应报文、失败原因、来源 IP、请求流水号等写下来。
第十一步,汇总通过名单并返回。
最终返回的 userList 里,只放通过校验的用户映射结果。失败的用户不进入返回集合,但会完整进入日志。
七、为什么错误码要独立定义成类常量,而且要统一前缀
你后面又补充要求:错误码不要用 B 打头,改成 C 打头,而且要和原先 token 验证类保持同一套风格。
这个要求背后的工程思想很清晰:错误码是接口契约的一部分,不能随意散落在代码里,更不能全写成中文提示。必须显式定义成常量。
这样做的好处有三层。
第一层,方便统一管理。以后你看代码顶部,一眼就能知道这个接口有哪些错误类型。
第二层,方便文档输出。错误码表基本可以直接从常量区生成。
第三层,方便联调和排错。前端、后端、第三方看到错误码时,可以快速定位问题,而不是只看一段模糊中文。
这次最终采用的结构,大致分成三层:
参数层:C1001 到 C1008
企业层:C1101 到 C1104
用户校验层:C1201 到 C1207
成功码:C0000
比如:
C1002:tenantId 不能为空
C1101:未找到对接企业配置
C1201:用户不存在
C1207:组织链路不匹配
这种分层方式很有价值,因为后续一看错误码区间,就知道问题出在哪一层。
八、日志设计为什么不能省
你中间多次对日志表非常敏感,这是对的。因为很多时候开发者写接口,只盯着“能跑通”,却忽略了“出了问题怎么查”。
本次设计里,日志不是锦上添花,而是核心组成部分。
每一条用户校验,都要写日志,原因有四个。
第一,对方传的是批量数据,一次请求里可能几十上百人。如果某个人失败,没有日志,就很难知道失败在哪一步。
第二,校验逻辑是跨多张表的,不是单点判断。失败原因可能来自 user、企业关系表、组织表、企业根节点、输入路径不一致等多个层面。没有日志,复盘极其痛苦。
第三,这类接口天然涉及系统边界。只要一旦和外部系统打交道,就必须强调可追溯性。
第四,有些问题不是代码 bug,而是数据问题。日志能帮助区分“是程序错了”还是“是对方传错了”。
因此,日志表里记录这些字段是有意义的:
tenant_id
enterprise_code
partner_id
request_id
oa_user_code
oa_user_name
mobile
zpy_user_id
verify_status
verify_message
source_ip
request_body
response_body
create_time
这里还专门做了一个设计,就是 request_body 和 response_body 直接存 JSON。这样最灵活,将来字段调整时,不需要频繁改表结构。
九、组织链路校验为什么是个关键细节
很多人第一次看这类接口,会觉得 userName + mobile 已经够了,为什么还要传 organizationList。
其实企业场景里,组织链路非常重要。
因为在真实系统里,可能会出现这些情况:
同一个人历史上挂过多个企业
同一个人迁移过部门
手机号复用、导入错误、历史脏数据
同名人员共存
企业组织结构刚调整,部分数据未完全同步
这时候,仅用姓名和手机号,有时会产生误判。组织链路的加入,相当于把用户放回到了企业上下文中,能更精准地验证“这个人是不是你说的那个人”。
当然,组织链路也不能粗暴比对,所以设计时又补了标准化处理。比如:
把中文斜杠、反斜杠统一成 /
去掉前后多余分隔符
去掉空格
压缩多个连续分隔符
这类处理看起来像小细节,实际上是接口稳定性的关键。因为很多联调问题,不是业务错,而是字符串格式不一致。
十、为什么最终采用“逐条成功、逐条记录、汇总通过”的返回策略
这也是本次设计里很重要的一个取舍。
理论上,可以设计成整批事务式处理:只要有一条失败,整个批次都失败。这样做整齐,但不符合真实业务。
更合理的方式,是逐条校验。谁通过就进入返回列表,谁失败就记日志但不进入返回列表。这样有几个优点。
第一,容错性高。对方一批 100 人,即使 3 人有问题,其余 97 人也不受影响。
第二,更贴近业务目标。这个接口的核心目标,是尽可能把可识别的人映射回来,而不是为了“整齐”牺牲可用性。
第三,排障更容易。失败的用户能单独查看日志,不会被整批失败掩盖。
这种设计,本质上是“批量输入、单条判定、聚合成功结果”。
十一、这次设计中几个容易踩坑的点
第一,不能只查 user 表。
这是最容易犯的低级错误。user 表有,不等于这个人在当前企业有效。必须联查 User_HeadOffice_Detail。
第二,不能忽略企业根节点校验。
如果 creditCode 对应的企业在组织表里都不存在,后面一切校验都没有意义。
第三,不能把中文字段名直接放到返回 JSON。
比如“智评云唯一id”这种写法,人看着舒服,但接口不够规范,后续语言兼容性、SDK、文档生成都不友好。
第四,不能省略日志。
没有日志,这类接口一旦出错,后续基本只能靠猜。
第五,不能把复杂逻辑都堆到 controller。
controller 只适合做接口入口,不适合承载复杂业务。
第六,不能把错误提示只写中文不写错误码。
没有错误码,排查效率会明显下降,联调时也不利于对齐。
第七,返回的唯一 ID 类型要和日志、数据库设计保持一致。
如果日志存 bigint,却返回字符串 Uuid,中间会出现语义不一致的问题,后续容易埋坑。
十二、最终形成的代码结构是什么样的
最后落地时,整体结构被整理成了这样:
Controller:
只保留一个 actionVerifyPartnerUsers
只负责拿 Request、调用 PsassUserService、统一返回 code/message/data
Service:
新建 PsassUserService
负责全部核心逻辑
包括参数解析、企业校验、用户校验、组织校验、链路校验、日志写入、结果汇总
错误码:
全部定义为 PsassUserService 类常量
统一使用 C 打头
保持与你原有 token/verify 类一致的编码风格
返回结构:
外层统一 code、message、data
data 中回显 tenantId、creditCode 和通过校验的 userList
十三、这次设计真正体现出来的方法论是什么
回头看,这次不是单纯写了个接口,而是体现出一套很重要的系统设计方法论。
第一,不从“代码怎么写”开始,而从“业务真实要校验什么”开始。
第二,不从单表思维出发,而从“用户、企业、组织、对接方、日志”五类对象关系出发。
第三,不只考虑成功链路,还提前考虑失败链路、日志追踪、后续排查。
第四,不只追求当前可跑通,还考虑后续维护、复用、文档输出、联调稳定性。
第五,不把接口当作一个函数,而把它当作一个对外契约来设计。
这其实就是企业级接口设计和普通 CRUD 最大的差别。普通 CRUD 是“查出来”;企业级接口是“说得清、查得准、扛得住、追得回”。
十四、最后给你的一个实践建议
这次这套接口思路已经比较完整了。后续你可以继续往前再走一步,把它沉淀成三份标准资产。
第一份,接口代码资产。也就是现在的 action + service。
第二份,接口文档资产。包括请求示例、返回示例、错误码表、字段说明、校验规则说明。
第三份,排障资产。把最常见失败原因整理成排查手册,比如:
用户不存在怎么查
用户已离职怎么查
企业归属关系异常怎么查
组织链路不匹配怎么查
日志表怎么看
这样以后不管是你自己,还是同事,还是第三方联调,都能迅速上手,而不是每次都重新口头解释一遍。
发表评论: