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