From a32698c385ff8a35e0b33b63d7a3acbd36bdc50c Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:14:01 +0800 Subject: [PATCH] fix: compare duplicate confirmations structurally --- .trellis/spec/project/architecture.md | 7 +- .trellis/spec/project/index.md | 6 +- .../spec/project/structured-value-equality.md | 115 ++++++++++++++++++ .../plugin/flows/send-confirmation-flow.ts | 4 +- apps/server/test/onetalk-websocket.test.ts | 85 ++++++++++++- 5 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 .trellis/spec/project/structured-value-equality.md diff --git a/.trellis/spec/project/architecture.md b/.trellis/spec/project/architecture.md index 0594591..a9d2209 100644 --- a/.trellis/spec/project/architecture.md +++ b/.trellis/spec/project/architecture.md @@ -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` 和导入/链接审计? diff --git a/.trellis/spec/project/index.md b/.trellis/spec/project/index.md index 7e95d69..3b3e031 100644 --- a/.trellis/spec/project/index.md +++ b/.trellis/spec/project/index.md @@ -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)。 diff --git a/.trellis/spec/project/structured-value-equality.md b/.trellis/spec/project/structured-value-equality.md new file mode 100644 index 0000000..c071455 --- /dev/null +++ b/.trellis/spec/project/structured-value-equality.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 = (left: T, right: T) => boolean; + +// 两侧已是同一 schema 的完整、规范化 plain-object 快照。 +const sameSnapshot: Equivalence = (left, right) => + isDeepStrictEqual(left, right); + +// 仅当领域将成员定义为无序且无重复的集合时,另行定义集合等价。 +const sameMemberSet: Equivalence = (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 定义的成员约束。 diff --git a/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts b/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts index d810ad0..e0408ed 100644 --- a/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts +++ b/apps/server/src/websocket/plugin/flows/send-confirmation-flow.ts @@ -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" }; diff --git a/apps/server/test/onetalk-websocket.test.ts b/apps/server/test/onetalk-websocket.test.ts index 499872c..1fe94dd 100644 --- a/apps/server/test/onetalk-websocket.test.ts +++ b/apps/server/test/onetalk-websocket.test.ts @@ -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" },