fix: compare duplicate confirmations structurally

This commit is contained in:
YBF
2026-09-12 12:14:01 +08:00
parent 86447cc515
commit a32698c385
5 changed files with 207 additions and 10 deletions
+5 -2
View File
@@ -18,6 +18,7 @@
| 源文件约定 | [source-file-conventions.md](./source-file-conventions.md) | 3.9、3.12;原 4–7 中文件排列和函数风格相关规则、案例、测试和反例 | bottom-up、main-last、文件头/主函数注释、箭头函数和 `fail` 收窄 |
| 格式化 | [formatting.md](./formatting.md) | 3.11;原 4–7 中格式化相关规则、案例和测试 | Oxfmt、VSCode 保存、`.editorconfig` 与提交/CI 一致性 |
| 缺失值与默认值 | [missing-values.md](./missing-values.md) | 3.13 全部七段;原 4–7 中缺失值相关规则、案例、测试和反例 | 缺失值补偿、`BUILD_HASH` 单一生成点和边界错误契约 |
| 结构化值等价性 | [structured-value-equality.md](./structured-value-equality.md) | 新增项目约束 | 事实等价、快照/集合/序列关系与序列化边界 |
| 数据库查询组合 | [database-query-composition.md](./database-query-composition.md) | 新增项目约束 | SQL JOIN 禁止默认、受限查询与内存组合、例外证据门槛 |
## 3. 阅读顺序
@@ -29,8 +30,9 @@
5. 涉及源文件排列、注释、函数语法或类型收窄时读 [源文件约定](./source-file-conventions.md)。
6. 涉及保存格式化或提交门禁时读 [格式化](./formatting.md)。
7. 涉及配置、环境变量、协议 payload、构建标识或默认值时读 [缺失值与默认值](./missing-values.md)。
8. 涉及 SQL/ORM 查询、关系数据读取或投影组合时读 [数据库查询组合](./database-query-composition.md)。
9. 最后阅读目标包和目标层的规范;包级规范可以补充但不能降低本项目级要求
8. 涉及去重、幂等、持久化回读、回执核验、缓存命中或结构化 payload 等价判断时读 [结构化值等价性](./structured-value-equality.md)。
9. 涉及 SQL/ORM 查询、关系数据读取或投影组合时读 [数据库查询组合](./database-query-composition.md)
10. 最后阅读目标包和目标层的规范;包级规范可以补充但不能降低本项目级要求。
## 4. 跨主题检查
@@ -45,5 +47,6 @@
- 手写源文件是否满足文件头职责注释、主函数注释、main-last 和函数语法约定?
- 编辑器保存、提交钩子和 CI 是否产生同一份 Oxfmt 结果,缩进是否统一为 4 个空格且无 Tab?
- 必填边界是否显式失败,补偿默认值是否有证据?`BUILD_HASH` 是否只有 workspace 根命令生成?
- 结构化值的比较是否先声明身份、完整快照、集合或序列关系;是否避免把 JSON 文本或对象键顺序当作业务语义?
- 关系数据是否先按各自 scope/key 受限读取、再按完整事实键在内存组合?若使用 SQL JOIN,是否有本次用户明确要求或可复核的例外证据?
- 结构调整后是否完成目标测试、严格类型检查/构建(适用时)、`pnpm format:check` 和导入/链接审计?
+4 -2
View File
@@ -13,6 +13,7 @@
| [源文件约定](./source-file-conventions.md) | bottom-up、main-last、注释、箭头函数和 `fail` 类型收窄 | 已建立约束 |
| [格式化](./formatting.md) | Oxfmt、VSCode 保存、`.editorconfig` 与 CI 一致性 | 已建立约束 |
| [缺失值与默认值](./missing-values.md) | 缺失值、默认值、`BUILD_HASH` 和补偿逻辑的边界契约 | 已建立约束 |
| [结构化值等价性](./structured-value-equality.md) | 结构化快照、身份、集合与序列的等价边界 | 已建立约束 |
| [数据库查询组合](./database-query-composition.md) | SQL JOIN 禁止默认、受限查询与内存组合、例外证据门槛 | 已建立约束 |
| [Git 分支管理](./git-branch-management.md) | `main` 同步、开发分支 rebase、`--force-with-lease` 与快进合并 | 已建立约束 |
@@ -23,5 +24,6 @@
3. 涉及跨 `await` 的状态、重复请求、延迟回执或最终动作时,必须阅读 [异步流程与状态管理](./async-state-boundaries.md)。
4. 包级规范与项目级规范冲突时,先修正文档或明确例外,不能静默选择更宽松的规则。
5. 涉及配置、环境变量、协议字段、构建标识或默认值时,必须阅读 [缺失值与默认值](./missing-values.md)。
6. 涉及 SQL/ORM 查询、关系数据读取或投影组合时,必须阅读 [数据库查询组合](./database-query-composition.md)。
7. 任务涉及多个 Git 分支、更新 `main` 或合入主干时,先阅读 [Git 分支管理](./git-branch-management.md)。
6. 涉及去重、幂等、持久化回读、回执核验、缓存命中或结构化 payload 的等价判断时,必须阅读 [结构化值等价性](./structured-value-equality.md)。
7. 涉及 SQL/ORM 查询、关系数据读取或投影组合时,必须阅读 [数据库查询组合](./database-query-composition.md)。
8. 任务涉及多个 Git 分支、更新 `main` 或合入主干时,先阅读 [Git 分支管理](./git-branch-management.md)。
@@ -0,0 +1,115 @@
# 结构化值等价性
> 这份规范解决“两个结构化值是否表示同一事实”的判断。它不规定所有领域共享一个 `isEqual` 工具;每个业务判断必须先说明比较的是完整快照、身份、集合还是有序序列。
## 先看正反例
```ts
// 同一完整快照:对象键的插入顺序不是业务语义。
const fromTransport = {
id: "record-1",
payload: { kind: "text", text: "hello", version: 1 },
};
const fromStore = {
id: "record-1",
payload: { version: 1, kind: "text", text: "hello" },
};
isDeepStrictEqual(fromTransport, fromStore); // true
```
```ts
// 身份相同不代表完整事实相同:值变化必须保留为不一致。
const conflictingStoreValue = {
id: "record-1",
payload: { version: 1, kind: "text", text: "different" },
};
isDeepStrictEqual(fromTransport, conflictingStoreValue); // false
```
```ts
// 反例:JSON 文本相同才算相等,把编码细节误当成业务语义。
JSON.stringify(fromTransport) === JSON.stringify(fromStore); // false
```
## 1. Scope / Trigger
在去重、冲突检测、幂等回执、缓存命中、持久化回读核验或安全策略判断中,需要决定两个对象、数组或嵌套 payload 是否等价时应用本规范。先由边界完成验证和规范化;不可把原始 `unknown`、host object 或序列化文本直接带入等价判断。
纯标量比较仍使用领域明确的 `===`/范围比较。不要为了遵守本规范给简单标量、已规范化主键或只需比较一个字段的场景套递归比较。
## 2. Signatures
等价关系由拥有终态或写入决定的领域模块定义,名称必须表达关系,而不是导出无上下文的通用 `isEqual`
```ts
type Equivalence<T> = (left: T, right: T) => boolean;
// 两侧已是同一 schema 的完整、规范化 plain-object 快照。
const sameSnapshot: Equivalence<CanonicalSnapshot> = (left, right) =>
isDeepStrictEqual(left, right);
// 仅当领域将成员定义为无序且无重复的集合时,另行定义集合等价。
const sameMemberSet: Equivalence<readonly MemberId[]> = (left, right) =>
sameValidatedMemberSet(left, right);
```
`CanonicalSnapshot``MemberId` 和集合约束由各自的 contract/model 所有;比较模块不能从序列化结果反推 schema,也不能替业务决定数组是否有序。
## 3. Contracts
1. 先选择业务关系,再选择比较方式:主键相等、完整快照相等、指定字段相等和集合相等是不同契约,不能互相替代。
2. 对已经由同一 schema 验证并规范化的完整 plain-object 快照,键的插入顺序不属于语义;在 Node 服务端使用 `isDeepStrictEqual`,或在其它运行时使用语义等价的结构比较。
3. 结构比较仍比较字段名、值、值类型、嵌套值和数组顺序。数组只有在 contract 明确为无序集合时才可忽略顺序,且必须由领域比较器处理重复值与成员约束。
4. 同一主键而其它规范字段不同是冲突,不是重复;必须走既有领域的不一致/未知/冲突处理,不能伪造成功终态。
5. `JSON.stringify`、数据库 JSON 文本、网络帧文本和日志文本只用于传输、展示或明确约定的 canonical hash 输入;未经显式 canonicalization,它们不得作为结构化业务值的等价判定。
6. 比较逻辑放在拥有该决定的最近领域边界。没有第二个消费者时不要抽成跨项目通用工具;有多个消费者时先把 schema、规范化和等价关系归属写清楚,再共享。
## 4. Validation & Error Matrix
| 输入和领域关系 | 应有行为 |
| --- | --- |
| 两个已验证完整快照仅对象键顺序不同 | 视为等价,继续既有成功/重复分支 |
| 主键相同但任一需要保真的字段、类型或嵌套值不同 | 视为不一致;不得确认、覆盖或发布为同一事实 |
| 数组顺序不同,contract 定义为序列 | 不等价 |
| 数组顺序不同,contract 明确定义为集合 | 用领域集合比较器比较成员和重复约束,不以递归比较或 JSON 文本猜测 |
| 任一输入尚未通过边界验证/规范化 | 先走既有解码或验证失败路径;不执行等价判断 |
| 只有序列化文本可用 | 回到拥有 schema 的边界取得结构化值;不得把文本相等当作事实等价 |
## 5. Good / Base / Bad Cases
- Good:持久化回读和入站回执都已被同一 contract 收窄为完整快照,终态 owner 用结构比较确认它们相同;仅对象键顺序变化不会改变结果。
- Good:成员列表 contract 明确为集合,领域 owner 用无序、无重复的成员比较器;它不把该规则推广到需要保留顺序的步骤列表。
- Base:只需要判断单一 `id` 是否相同,直接比较该标量,不创建递归比较器。
- Bad:用 `JSON.stringify(left) === JSON.stringify(right)` 判断业务事实;嵌套对象、数据库 JSON 重排或不同序列化器都会改变结论。
- Bad:只因复合主键相同就把两个不同完整快照当作重复,或为让比较通过而先全局排序、删除字段或补默认值。
## 6. Tests Required
- 为每一种完整快照等价判断构造两份独立对象:字段和值相同,但至少一层嵌套对象的键顺序不同;经真实判断边界后必须得到相同公开结果。
- 构造同一身份、一个受比较字段不同的对象;断言不会进入成功、提交、ACK 或发布分支。
- 当数组字段参与比较时,分别覆盖 contract 定义为有序序列和无序集合的情形;集合测试必须覆盖重复元素或由 decoder 明确拒绝重复。
- 通过实际 transport parse、持久化回读或 adapter(适用者)构造两侧值,不能让 fake service 和入站帧复用同一个对象字面量掩盖排序问题。
- 运行目标领域测试、严格类型检查、格式检查和 diff 审查;如比较位于服务端 Node 边界,覆盖构建产物测试。
## 7. Wrong vs Correct
```ts
// Wrong:序列化顺序和 formatter 实现不是事实语义。
const sameFact = JSON.stringify(stored) === JSON.stringify(incoming);
// Wrong:身份相同但完整快照不同仍被确认。
const sameFact = stored.id === incoming.id;
```
```ts
// Correct:两侧先被同一 schema 验证为完整快照,再按该关系的语义比较。
const sameFact = isDeepStrictEqual(stored, incoming);
if (!sameFact) return resolveInconsistentFact();
// Correct:仅在 contract 明确无序时,使用领域拥有的集合关系。
const sameMembers = sameValidatedMemberSet(stored.memberIds, incoming.memberIds);
```
这里的 `resolveInconsistentFact``sameValidatedMemberSet` 是领域占位名称;真实实现必须使用该领域既有的稳定错误/终态和其 contract 定义的成员约束。
@@ -1,5 +1,7 @@
// 编排 Plugin 发送确认后的持久化、发布与终态解析。
import { isDeepStrictEqual } from "node:util";
import type { OneTalkSendConfirmationFrame } from "@trade-message-center/onetalk-contract";
import { toOneTalkCenterMessage } from "../../../onetalk/read-projection.ts";
@@ -69,7 +71,7 @@ export const createOneTalkSendConfirmationFlow = (
if (result.status === "duplicate") {
const sameFact =
result.message.direction === "sent" &&
JSON.stringify(result.message) === JSON.stringify(message);
isDeepStrictEqual(result.message, message);
return sameFact
? { status: "duplicate", message: result.message }
: { status: "unknown" };
+80 -5
View File
@@ -1145,12 +1145,21 @@ test("resolves a valid non-confirmation immediately after plugin confirmation",
}
});
test("returns a matching duplicate confirmation without publishing message.created", async () => {
test("returns a structurally matching duplicate confirmation without publishing message.created", async () => {
const events: string[] = [];
const sentMessage = { ...message("duplicate-message-1"), direction: "sent" as const };
const confirmationMessage: OneTalkMessage = {
...message("duplicate-message-1"),
direction: "sent",
content: { kind: "text", text: "hello", version: 1 },
};
const storedMessage: OneTalkMessage = {
...confirmationMessage,
content: { version: 1, kind: "text", text: "hello" },
};
assert.notEqual(JSON.stringify(confirmationMessage), JSON.stringify(storedMessage));
const service = createService(async () => {
events.push("observe");
return { status: "duplicate", message: sentMessage };
return { status: "duplicate", message: storedMessage };
});
const app = createApp(testConfig, {
database: createDatabaseStub(),
@@ -1192,14 +1201,14 @@ test("returns a matching duplicate confirmation without publishing message.creat
requestId: "plugin-send-duplicate",
sendRequestId: "send-request-duplicate",
scope: pluginScope,
payload: { status: "confirmed_sent", message: sentMessage },
payload: { status: "confirmed_sent", message: confirmationMessage },
}),
);
const serverResult = await result;
assert.deepEqual(serverResult.payload, {
status: "confirmed_sent",
message: centerMessage(sentMessage),
message: centerMessage(storedMessage),
});
const decoded = decodeOneTalkFrame(serverResult);
assert.equal(decoded.ok, true);
@@ -1210,6 +1219,72 @@ test("returns a matching duplicate confirmation without publishing message.creat
}
});
test("does not confirm a duplicate when its message content differs", async () => {
const confirmationMessage: OneTalkMessage = {
...message("duplicate-message-mismatch"),
direction: "sent",
};
const storedMessage: OneTalkMessage = {
...confirmationMessage,
content: { version: 1, kind: "text", text: "different" },
};
const service = createService(async () => ({
status: "duplicate" as const,
message: storedMessage,
}));
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
oneTalkService: service,
});
const mind = await openSocket(app, "/ws/mind");
const plugin = await openSocket(app);
try {
await connectMindPageForSend(mind);
const online = nextPluginStatus(mind, "online");
await connectPlugin(plugin);
assert.equal((await online).type, "plugin.status");
const command = nextMessage(plugin);
mind.send(
JSON.stringify({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "mind_page",
type: "send.request",
requestId: "mind-send-duplicate-mismatch",
sendRequestId: "send-request-duplicate-mismatch",
scope: mindScope,
payload: {
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
},
}),
);
assert.equal((await command).type, "send.command");
const result = nextMessage(mind);
plugin.send(
JSON.stringify({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "send.confirmation",
requestId: "plugin-send-duplicate-mismatch",
sendRequestId: "send-request-duplicate-mismatch",
scope: pluginScope,
payload: { status: "confirmed_sent", message: confirmationMessage },
}),
);
assert.deepEqual((await result).payload, {
status: "delivery_unknown",
reason: "database_unavailable",
});
} finally {
await closeApp(app, [mind, plugin]);
}
});
test("continues after anomaly and rejection with independent ACKs", async () => {
const results: OneTalkObservationResult[] = [
{ status: "anomaly", anomalyCode: "invalid_message_observation" },