手记

一次合同同步背后的多阶段流水线:从外部主数据到本地歧义消解

本文基于「内部平台 ↔ 外部订单类系统」对接中的常见实现思路整理,示例代码为教学向伪代码,与任一具体仓库、接口路径、数据表无逐行对应关系。文中公司、合同号、域名为虚构。

1. 业务上的一次点击,工程上的一条流水线

用户在内部平台点「同步合同」,直觉往往是:把外部系统里那份合同的字段原样搬过来。真实系统里,这通常至少涉及:

  • 合同头(主表):名称、周期、客户侧联系人等;

  • 合同子项 / 行(明细):交付单元、数量、现场信息、子项维度的负责人等;

  • 实施计划 / 任务计划(从属于子项):外部系统用稳定业务 ID 标识一条计划,内部平台也有自己的计划主键。

技术难点不在于「调一个 HTTP 接口」,而在于:

  1. 多数据源:头、行、计划可能来自不同查询或不同外部接口;

  2. 多表写入:需要保证「先谁后谁」、失败时如何汇总错误;

  3. 一对多:外部「一条计划 ID」在内部可能对应 0 / 1 / 多条 本地记录(历史重复导入、合并迁移、手工复制等都会制造这种数据形态);

  4. 不能静默瞎选:多条命中时,自动选第一条在审计上往往不可接受,需要 显式歧义列表 + 用户二次提交

下面用一条「单接口、多阶段」的流水线把上述问题串起来,并配上前后端脱敏示例代码,便于你在自己的项目里对照实现。


2. 端到端流程概览

可以把一次同步拆成四个逻辑阶段(仍是一次受权限保护的业务请求,而不是四个随意调用的微服务):

阶段目的关键策略
校验防止串单、防止对不存在的本地合同写入校验本地合同主键;合同编号与请求体一致才继续
A. 合同头对齐主数据映射外部展示字段 → 本地头表
B. 子项对齐明细拉外部子项列表 → 按合同编号过滤仅 update 已存在的行(不自动为陌生外部行建本地行)
C. 实施计划对齐计划对每个「本地已有」子项拉外部计划 → 用 (lineBizKey, externalPlanId) 在本地查 → 0 新建 / 1 更新 / 多 → 歧义
收尾可观测、可排障聚合计数 + 分条错误;歧义项结构化返回

2.1 序列图(概念级)

UserInternalWebAppContractSyncApiExternalOrderSystemLocalDatabaseloop[EachLocalLineUnderContract]alt[HasAmbiguousPlans]ClickSyncContractPOST_SyncRequest(payload)LoadLocalContractByIdAssertContractNumberMatchUpdateContractHeaderFetchRemoteLineItemsLineItemListUpdateExistingLinesOnlyFetchPlansForLine(lineKey)PlanListMatchPlansByLineAndExternalIdResult_with_stats_or_ambiguousChooseCandidatePerRowPOST_SameApi_with_resolutionsFinalResultUserInternalWebAppContractSyncApiExternalOrderSystemLocalDatabase


3. 阶段详解(含思路级伪代码)

3.1 校验:先防串单,再谈字段映射

合同编号(或你们系统中等价的主业务键)是最廉价的「交叉验证」手段:请求里带的编号必须与本地已关联合同一致,否则直接拒绝。这能挡住大量误操作与前端状态过期问题。

java体验AI代码助手代码解读复制代码// 教学伪代码:校验本地合同 + 编号一致性 public SyncResult syncContract(SyncRequest req) {     LocalContract contract = contractRepository.findById(req.getLocalContractId());     if (contract == null) {         return SyncResult.fail("LOCAL_CONTRACT_NOT_FOUND");     }     if (hasText(req.getContractNumber())             && hasText(contract.getContractNumber())             && !normalize(req.getContractNumber()).equals(normalize(contract.getContractNumber()))) {         return SyncResult.fail("CONTRACT_NUMBER_MISMATCH");     }     // ... 继续后续阶段     return SyncResult.ok(); }

3.2 阶段 A:合同头

映射规则因行业而异,本文不展开每个字段。原则只有一条:头信息变更频率相对低,但一旦写错影响面大,所以仍建议放在校验之后、子项之前执行。

3.3 阶段 B:子项——「只更新已存在」+ 双条件触发写库

从外部拉到的往往是批量列表(甚至带默认时间窗)。实现上通常:

  1. 用合同编号过滤出当前合同相关的远程行;

  2. 对每一行,用子项业务键(例如合同行号、或双方约定的唯一编码)查找本地行;

  3. 本地不存在则跳过(计数 skippedNotFound),避免外部多出一行就在内部制造无主数据;

  4. 是否需要 UPDATE:常见做法是 「远程 lastModified 新于本地」 OR 「关键业务字段不一致」 二选一满足即更新。

  • 前者减少无意义写;后者防止「时间戳不可靠或缺失」导致该更未更。

java体验AI代码助手代码解读复制代码// 教学伪代码:子项 update-only + 双条件 void syncLines(LocalContract contract, List<RemoteLineItem> remoteLines) {     String contractNo = firstNonBlank(req.getContractNumber(), contract.getContractNumber());     for (RemoteLineItem r : remoteLines) {         if (!contractNo.equals(r.getContractNumber())) continue;          String lineKey = r.getLineBizKey();         LocalLineItem local = lineRepository.findByLineBizKey(lineKey);         if (local == null) {             stats.incrementSkippedNotFound();             continue;         }          boolean newer = isAfter(r.getLastModified(), local.getLastModified());         boolean diff = differsOnCriticalFields(local, r); // 起止日期、数量、现场标记等          if (!newer && !diff) continue;          LocalLineItem patch = buildPatchFromRemote(local, r);         lineRepository.update(patch);         stats.incrementLinesUpdated();         stats.rememberLineKeyForPlanSync(lineKey);     } }

3.4 阶段 C:实施计划——0 / 1 / 多 与二次提交

对「本合同下已在平台存在的子项」集合(通常还要并入阶段 B 刚更新过的子项,以及用户上一轮歧义解析里涉及的子项),逐个向外部系统拉取计划列表。 0 1 2 3 4 5 6

本地匹配 SQL 逻辑可抽象为:

text体验AI代码助手代码解读复制代码SELECT * FROM local_plans WHERE line_biz_key = :lineKey AND external_plan_id = :externalPlanId
  • 0 行:按你们业务决定是 INSERT 还是跳过(本文假设允许 create-or-update);

  • 1 行:比较 lastModified 或字段 diff,决定更新或跳过;

  • 多行不要自动挑。返回结构化歧义项:

json体验AI代码助手代码解读复制代码{   "ambiguousPlans": [     {       "lineBizKey": "LINE-0007",       "externalPlanId": "PLN-8899",       "externalPlanTitle": "上线割接",       "candidates": [         { "localPlanId": 101, "title": "上线割接(2024Q1)" },         { "localPlanId": 205, "title": "上线割接-副本" }       ],       "recommendedLocalPlanId": 101     }   ] }

前端让用户在每个歧义项上选一个 localPlanId,再调用同一个同步接口,在请求体附加 planResolutions

json体验AI代码助手代码解读复制代码{   "localContractId": 5001,   "contractNumber": "C-2024-001",   "...": "...",   "planResolutions": [     { "lineBizKey": "LINE-0007", "externalPlanId": "PLN-8899", "selectedLocalPlanId": 101 }   ] }

服务端用 (lineBizKey, externalPlanId) -> selectedLocalPlanId 作为显式锚定,只在候选集合内生效,避免 IDOR 类漏洞(仍须配合鉴权与行级权限)。

java体验AI代码助手代码解读复制代码// 教学伪代码:多条命中时用用户解析结果锁定一行 LocalPlan resolveLocalPlan(String lineKey, String externalPlanId, List<LocalPlan> hits, Map<ResolutionKey, Long> resolutions) {     if (hits.size() == 1) return hits.get(0);     if (hits.size() > 1) {         Long chosen = resolutions.get(new ResolutionKey(lineKey, externalPlanId));         if (chosen == null) {             throw new AmbiguousException(buildAmbiguousPayload(hits));         }         return hits.stream().filter(p -> p.getId().equals(chosen)).findFirst()                 .orElseThrow(() -> new IllegalArgumentException("SELECTED_NOT_IN_CANDIDATES"));     }     return null; // 0 条:走新建分支 }

为何倾向复用同一 URL?

  • 歧义解析与首次同步共享上下文(同一合同、同一外部快照版本策略);

  • 减少前端与网关上的「接口爆炸」;

  • 便于在日志里用 requestId 串联两次调用做审计。

若你们更在意幂等键或长事务隔离,也可以拆第二个端点——这是工程权衡,没有唯一正确答案。


4. 前端契约与健壮解析(约占实现心力的三成)

4.1 HTTP 200 ≠ 业务成功

网关、鉴权过滤器、统一异常包装层都可能让响应仍是 200,但 body 里 success: false。前端必须以业务字段为准提示用户,而不是 axios 没进 catch 就当成功。

typescript体验AI代码助手代码解读复制代码// 教学示例:以业务 success 为准 async function syncContract(payload: SyncContractPayload) {   const res = await http.post<OperationEnvelope<SyncCallback>>('/api/contract/sync', payload)   const data = res.data   if (!data.success) {     showError(data.message ?? 'SYNC_FAILED')     return { ok: false as const, data }   }   return { ok: true as const, data } }

路径 /api/contract/sync 仅为占位;真实项目请使用你们自己的路由前缀,且务必配合鉴权。

4.2 operateCallBackObj 可能是字符串,字段可能是 snake_case

序列化层、历史兼容、不同客户端中间件,都会导致「回调对象其实是 JSON 字符串」或「字段名下划线风格」。前端最好集中做一次归一化解析,避免歧义列表偶发为空。

typescript体验AI代码助手代码解读复制代码type SyncCallback = {   itemsUpdatedCount?: number   itemsSkippedNotExistCount?: number   plansCreatedCount?: number   plansUpdatedCount?: number   plansNoChangeCount?: number   plansErrorCount?: number   ambiguousPlans?: AmbiguousPlanRow[] }  function parseSyncCallback(data: OperationEnvelope<unknown>): {   cb: SyncCallback   ambiguous: AmbiguousPlanRow[] } {   let raw: unknown = data.operateCallBackObj   if (typeof raw === 'string') {     try {       raw = JSON.parse(raw)     } catch {       raw = {}     }   }   const cb = (raw && typeof raw === 'object' ? raw : {}) as SyncCallback    const ambiguous =     cb.ambiguousPlans ??     (cb as { ambiguous_plans?: AmbiguousPlanRow[] }).ambiguous_plans ??     (data as { ambiguousPlans?: AmbiguousPlanRow[] }).ambiguousPlans ??     []    return { cb, ambiguous: Array.isArray(ambiguous) ? ambiguous : [] } }

4.3 弹窗时机:await fetchData() + nextTick

歧义返回后,往往需要先刷新列表再打开对话框,否则表格仍展示旧状态,用户会在错误上下文里做选择。典型写法:

typescript体验AI代码助手代码解读复制代码async function onSyncSuccessShowAmbiguous(data: OperationEnvelope<unknown>) {   const { ambiguous } = parseSyncCallback(data)   if (ambiguous.length === 0) return    await reloadContractTable()   await nextTick()   openPlanResolveDialog(ambiguous) }

4.4 二次提交

把首次请求的 payload 缓存到对话框上下文,用户确认后合并 planResolutions 再次调用同一 syncContract 函数即可。


5. 可观测性:结构化计数优于单句文案

建议响应中同时包含:

  • 计数器:子项更新数、跳过数;计划新建 / 更新 / 无变化 / 失败;

  • 分条错误:带 lineBizKeyexternalPlanId 前缀,便于客服复制给研发;

  • 人可读摘要 message:用于 toast,但不要只有它。

前端成功路径可以把 message 与计数拼接展示(注意长度与国际化)。


6. 测试清单(不写具体用例代码)

场景期望
合同编号与本地不一致拒绝同步,明确错误码/文案
外部子项多一行、本地从未建档跳过并计数,不脏写
远程 lastModified 缺失但字段已变仍能触发更新(diff 兜底)
同一 (lineKey, externalPlanId) 本地多条返回歧义;不带解析重试仍歧义
用户选择不在候选集失败并提示,不静默改错行
外部子项/计划接口失败部分阶段失败时,错误收集与成功计数并存
仅 HTTP 200、success: false前端必须红色错误提示

7. 小结

  • 跨系统合同同步天然是多阶段流水线:校验 → 头 → 行(update-only + 双条件)→ 计划(0/1/多 + 歧义二次提交)→ 结构化观测。

  • 一对多不是异常数据的小概率边角,而是上线后几乎一定会遇到的常态;产品与技术应在方案层就接纳 「机器推荐 + 人工确认」

  • 前端务必吃透 业务 success、回调形态兼容、刷新与 nextTick,否则极易出现「后端已返回歧义,界面却像没发生」的体验问题。

0人推荐
随时随地看视频
慕课网APP