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

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

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 校验:先防串单,再谈字段映射

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

java 复制代码
// 教学伪代码:校验本地合同 + 编号一致性
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 复制代码
// 教学伪代码:子项 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 逻辑可抽象为:

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

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

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

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

java 复制代码
// 教学伪代码:多条命中时用用户解析结果锁定一行
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 复制代码
// 教学示例:以业务 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 复制代码
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 复制代码
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,否则极易出现「后端已返回歧义,界面却像没发生」的体验问题。
相关推荐
lv__pf2 小时前
springboot原理
java·spring boot·后端
段小二2 小时前
服务一重启全丢了——Spring AI Alibaba Agent 三层持久化完整方案
java·后端
UIUV3 小时前
Go语言入门到精通学习笔记
后端·go·编程语言
lizhongxuan3 小时前
开发 Agent 的坑
后端
段小二3 小时前
Agent 自动把机票改错了,推理完全正确——这才是真正的风险
java·后端
itjinyin3 小时前
ShardingSphere-jdbc 5.5.0 + spring boot 基础配置 - 实战篇
java·spring boot·后端
Victor3563 小时前
MongoDB(91)如何在MongoDB中使用TTL索引?
后端
老王以为3 小时前
前端重生之 - 前端视角下的 Python
前端·后端·python