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

0 阅读8分钟

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

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

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

  • 合同头(主表):名称、周期、客户侧联系人等;
  • 合同子项 / 行(明细):交付单元、数量、现场信息、子项维度的负责人等;
  • 实施计划 / 任务计划(从属于子项):外部系统用稳定业务 ID 标识一条计划,内部平台也有自己的计划主键。

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

  1. 多数据源:头、行、计划可能来自不同查询或不同外部接口;
  2. 多表写入:需要保证「先谁后谁」、失败时如何汇总错误;
  3. 一对多:外部「一条计划 ID」在内部可能对应 0 / 1 / 多条 本地记录(历史重复导入、合并迁移、手工复制等都会制造这种数据形态);
  4. 不能静默瞎选:多条命中时,自动选第一条在审计上往往不可接受,需要 显式歧义列表 + 用户二次提交

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


2. 端到端流程概览

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

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

2.1 序列图(概念级)

sequenceDiagram
  participant User as User
  participant Web as InternalWebApp
  participant Api as ContractSyncApi
  participant Ext as ExternalOrderSystem
  participant Db as LocalDatabase

  User->>Web: ClickSyncContract
  Web->>Api: POST_SyncRequest(payload)
  Api->>Db: LoadLocalContractById
  Api->>Api: AssertContractNumberMatch
  Api->>Db: UpdateContractHeader
  Api->>Ext: FetchRemoteLineItems
  Ext-->>Api: LineItemList
  Api->>Db: UpdateExistingLinesOnly
  loop EachLocalLineUnderContract
    Api->>Ext: FetchPlansForLine(lineKey)
    Ext-->>Api: PlanList
    Api->>Db: MatchPlansByLineAndExternalId
  end
  Api-->>Web: Result_with_stats_or_ambiguous
  alt HasAmbiguousPlans
    User->>Web: ChooseCandidatePerRow
    Web->>Api: POST_SameApi_with_resolutions
    Api-->>Web: FinalResult
  end

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

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

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

// 教学伪代码:校验本地合同 + 编号一致性
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 「关键业务字段不一致」 二选一满足即更新。
    • 前者减少无意义写;后者防止「时间戳不可靠或缺失」导致该更未更。
// 教学伪代码:子项 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 刚更新过的子项,以及用户上一轮歧义解析里涉及的子项),逐个向外部系统拉取计划列表。

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

SELECT * FROM local_plans
WHERE line_biz_key = :lineKey AND external_plan_id = :externalPlanId
  • 0 行:按你们业务决定是 INSERT 还是跳过(本文假设允许 create-or-update);
  • 1 行:比较 lastModified 或字段 diff,决定更新或跳过;
  • 多行不要自动挑。返回结构化歧义项:
{
  "ambiguousPlans": [
    {
      "lineBizKey": "LINE-0007",
      "externalPlanId": "PLN-8899",
      "externalPlanTitle": "上线割接",
      "candidates": [
        { "localPlanId": 101, "title": "上线割接(2024Q1)" },
        { "localPlanId": 205, "title": "上线割接-副本" }
      ],
      "recommendedLocalPlanId": 101
    }
  ]
}

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

{
  "localContractId": 5001,
  "contractNumber": "C-2024-001",
  "...": "...",
  "planResolutions": [
    { "lineBizKey": "LINE-0007", "externalPlanId": "PLN-8899", "selectedLocalPlanId": 101 }
  ]
}

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

// 教学伪代码:多条命中时用用户解析结果锁定一行
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 就当成功。

// 教学示例:以业务 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 字符串」或「字段名下划线风格」。前端最好集中做一次归一化解析,避免歧义列表偶发为空。

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

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

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,否则极易出现「后端已返回歧义,界面却像没发生」的体验问题。