本文基于「内部平台 ↔ 外部订单类系统」对接中的常见实现思路整理,示例代码为教学向伪代码,与任一具体仓库、接口路径、数据表无逐行对应关系。文中公司、合同号、域名为虚构。
1. 业务上的一次点击,工程上的一条流水线
用户在内部平台点「同步合同」,直觉往往是:把外部系统里那份合同的字段原样搬过来。真实系统里,这通常至少涉及:
合同头(主表):名称、周期、客户侧联系人等;
合同子项 / 行(明细):交付单元、数量、现场信息、子项维度的负责人等;
实施计划 / 任务计划(从属于子项):外部系统用稳定业务 ID 标识一条计划,内部平台也有自己的计划主键。
技术难点不在于「调一个 HTTP 接口」,而在于:
多数据源:头、行、计划可能来自不同查询或不同外部接口;
多表写入:需要保证「先谁后谁」、失败时如何汇总错误;
一对多:外部「一条计划 ID」在内部可能对应 0 / 1 / 多条 本地记录(历史重复导入、合并迁移、手工复制等都会制造这种数据形态);
不能静默瞎选:多条命中时,自动选第一条在审计上往往不可接受,需要 显式歧义列表 + 用户二次提交。
下面用一条「单接口、多阶段」的流水线把上述问题串起来,并配上前后端脱敏示例代码,便于你在自己的项目里对照实现。
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:子项——「只更新已存在」+ 双条件触发写库
从外部拉到的往往是批量列表(甚至带默认时间窗)。实现上通常:
用合同编号过滤出当前合同相关的远程行;
对每一行,用子项业务键(例如合同行号、或双方约定的唯一编码)查找本地行;
本地不存在则跳过(计数
skippedNotFound),避免外部多出一行就在内部制造无主数据;是否需要
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. 可观测性:结构化计数优于单句文案
建议响应中同时包含:
计数器:子项更新数、跳过数;计划新建 / 更新 / 无变化 / 失败;
分条错误:带
lineBizKey或externalPlanId前缀,便于客服复制给研发;人可读摘要
message:用于 toast,但不要只有它。
前端成功路径可以把 message 与计数拼接展示(注意长度与国际化)。
6. 测试清单(不写具体用例代码)
| 场景 | 期望 |
|---|---|
| 合同编号与本地不一致 | 拒绝同步,明确错误码/文案 |
| 外部子项多一行、本地从未建档 | 跳过并计数,不脏写 |
远程 lastModified 缺失但字段已变 | 仍能触发更新(diff 兜底) |
同一 (lineKey, externalPlanId) 本地多条 | 返回歧义;不带解析重试仍歧义 |
| 用户选择不在候选集 | 失败并提示,不静默改错行 |
| 外部子项/计划接口失败 | 部分阶段失败时,错误收集与成功计数并存 |
仅 HTTP 200、success: false | 前端必须红色错误提示 |
7. 小结
跨系统合同同步天然是多阶段流水线:校验 → 头 → 行(update-only + 双条件)→ 计划(0/1/多 + 歧义二次提交)→ 结构化观测。
一对多不是异常数据的小概率边角,而是上线后几乎一定会遇到的常态;产品与技术应在方案层就接纳 「机器推荐 + 人工确认」。
前端务必吃透 业务
success、回调形态兼容、刷新与nextTick,否则极易出现「后端已返回歧义,界面却像没发生」的体验问题。