[C-04][high] 退群、踢人、解散、停用、删除作废投递时不写回执、不收尾:消息永远停在 dispatched、正文不删、占发送方配额 #35

Closed
opened 2026-09-30 13:56:59 +08:00 by nixevol · 1 comment
Owner

编号:C-04 严重级:high 工作线:消息核心(internal/app/message、serve 的 messageLoops) 来源:审查 M-06、I-02
依赖:无 被依赖:U-02 (#40)(同改 group 包,本条先合)、C-07 (#38) 第 4 点

结论与统一方案

两份审查结论一致,采用统一终态函数方案,由消息线实现并改完所有调用方:

  1. message 包导出两个在事务内调用的函数:
    • RejectPendingTx(tx, seq, 编号, reason, nowMs):条件更新;消息要求回执且发送方存在时写回执;返回是否已推送,供调用方发 revoked。
    • TryFinalizeTx(tx, seq, nowMs, retentionDays):没有 pending 投递时 completed、删正文;保留 0 天时同一事务删行。
  2. internal/app/group/void.go 的两个作废函数,以及 internal/app/identity/lifecycle.go 的作废与收尾函数,改为调用它们,删除各自复制的 SQL。group 引入 message 不会形成循环依赖。
  3. 每小时兜底:分批把"dispatched 且已没有 pending 投递"的消息收尾,同时修复已部署库里已经卡住的数据。
  4. 解散时 scheduled 消息的消息级回执保持 rejected(#6 的修复)。

改动文件

internal/app/message/dispatch.go(导出函数)、recover.go(兜底);internal/app/group/void.go;internal/app/identity/lifecycle.go(作废与收尾函数)。

与其他问题的交互 / 冲突说明

只改 void.go 与 lifecycle.go 中的作废、收尾函数:B-04 只删 lifecycle.go 第 131–138 行的断开 goroutine;U-03 只在删除端路径加"清除锁定";U-02 改 group/app.go 的校验逻辑,互不重叠。group 包按 C-04 → U-02 顺序合入。

验收与测试

  • 群里只有 B、B 离线且消息保留,B 退群:消息变 completed、正文删除、发送方收到 rejected / left_group 回执。
  • 解散时有 pending:全部写回执、全部收尾。
  • 停用接收方同理;发送方未完成数回到 0;保留 0 天时消息行也删。

问题明细(各区审查原文,证据含文件与行号)

以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。

[M-06] 退群、踢人、解散作废投递时不写回执、不收尾:消息永远停在 dispatched,正文永不删除(跨模块:身份线 group)

  • 严重级:high
  • 分类:逻辑 / 数据 / 与 PRD 不符
  • 现象与影响:
    • group 包的 voidMemberDeliveriesTx 和 voidGroupAllTx 只把 pending 投递改成 rejected。
    • 两者都既不写回执,也不检查这条消息是否已没有 pending 投递、不做收尾。
    • 如果被作废的是最后一个 pending 投递:
      • 消息永远停在 dispatched,正文永不删除,违背 F18。
      • 永久占用发送方 max_pending_per_sender 配额,最终导致这个发送方持续 quota_exceeded。
      • 保留期清理只删 completed 的消息,这些消息永远不会被清掉。
    • 发送方也收不到 left_group 或 group_dissolved 的回执。
    • identity 包里自己的那份同名函数会做收尾,但同样不写回执。
  • 证据:
	for _, r := range list {
		if _, execErr := tx.Exec(`
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ? WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
			reason, nowMs, r.seq, endpointID); execErr != nil {
			return execErr
		}
		if r.pushed.Valid && revokes != nil {
			*revokes = append(*revokes, revokeItem{
				endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: reason,
			})
		}
	}
	return nil
  • group/void.go:93-105:解散时的 pending 投递同样处理。
  • group/app.go:252-258、289-295、380-389:踢人、退群、解散调用上面两个函数后没有收尾。
  • 管理后台的群操作复用同一套函数(DEVIATIONS A3 第 2 条)。
  • 文档依据:
    • DEVELOPMENT 7.6:「投递进入 accepted、expired、dropped、rejected 时写回执」「收尾:没有 pending 投递时,消息 completed,删除正文行」。
    • PRD F14「每个接收端一条最终回执」;F18「全部投递进入最终状态……立即删除正文」。
  • 为何不是故意设计:DEVIATIONS I2/I3/I4 第 8 条原话是「与后续 M 作废路径需保持同语义」,这里并没有做到。
  • 解决方案:
    1. message 包导出一个唯一的终态迁移函数,例如 RejectPendingTx(tx, seq, 编号, reason, nowMs, lim):条件更新、写回执(消息要求回执且发送方存在)、调用 finalizeMessageTx(包括保留 0 天的处理),返回是否已推送,供调用方发 revoked。group 和 identity 改为调用它,去掉各自复制的 SQL。
    2. message 侧加兜底:每小时分批把「dispatched 且已没有 pending 投递」的消息收尾,同时修复已部署库里已经卡住的数据。
  • 改动文件:internal/app/message/dispatch.go(导出函数)、recover.go(兜底);internal/app/group/void.go、internal/app/identity/lifecycle.go(身份线)。
  • 交互/冲突风险:group 引入 message 包不会形成循环依赖(message 不依赖 group)。需要身份线配合改。
  • 需补测试:
    • 群里只有 B、B 离线且消息保留,B 退群:消息变 completed、正文删除、发送方收到 rejected/left_group 回执。
    • 解散时有 pending:全部写回执、全部收尾。
    • 发送方配额恢复。
  • 置信度:代码阅读确定。

[I-02] 退群、踢人、解散作废投递时不写回执也不收尾,正文永远不删,还占发送方配额

  • 严重级:high
  • 分类:数据 / 与PRD不符
  • 现象与影响:group.leave、group.remove、group.dissolve(包括后台踢人和解散)把 pending 投递改成 rejected 之后:
    • 不给发送方写这些接收端的回执,违反 F14「每个接收端一条最终回执」。
    • 如果被作废的是最后一条 pending(解散时一定是),消息不会改成 completed,message_bodies 也不删。后果有三个:
      • 正文永远留在库里,违反 F18。
      • 消息一直算「未完成」,计入 max_pending_per_sender,时间长了发送方会莫名收到 quota_exceeded。
      • 记录清理只删 completed,这些行永远清不掉;record_retention_days=0 也不生效。
    • identity 自己的 tryFinalizeTx 也没有处理保留 0 天的情况:停用、删除收尾后,消息和投递行仍然保留。
  • 证据
    • internal/app/group/void.go:20-61 和 93-105 只做 UPDATE、收集 revoked 列表。
    • 调用处:internal/app/group/app.go:252-258(踢人)、289-295(退群)、380-389(解散)。
    • 对照正确写法:internal/app/message/dispatch.go:217-245。
    • 配额计入 dispatched:message/submit.go:460-475;清理只删 completed:message/recover.go:81-89。
    • identity 的收尾:identity/lifecycle.go:601-624。
    • 测试只检查了 reason:group/group_test.go:234-246。
	for _, r := range list {
		if _, execErr := tx.Exec(`
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ? WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
			reason, nowMs, r.seq, endpointID); execErr != nil {
			return execErr
		}
		if r.pushed.Valid && revokes != nil {
			*revokes = append(*revokes, revokeItem{
				endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: reason,
			})
		}
	}
	return nil
  • 文档依据
    • DEVELOPMENT 7.6(694):投递进入 rejected 时写回执。
    • DEVELOPMENT 7.6(696):没有 pending 投递时收尾、删正文,保留 0 天时同一事务删行。
    • DEVELOPMENT 7.6(704-705):作废规则表。
    • PRD F16(345)、F18(378-379)。
  • 为何不是故意设计:DEVIATIONS I2/I3/I4 第 8 条只说在 group 包里写作废逻辑,并要求「与后续消息线的作废路径保持同语义」。
  • 解决方案
    1. 消息线导出两个可在事务里调用的函数:
      • RejectPendingTx(tx, seq, ep, reason, nowMs):条件更新,并在消息要回执、发送方仍存在时写回执。
      • TryFinalizeTx(tx, seq, nowMs, retentionDays):没有 pending 时收尾,保留 0 天时删行。
    2. group 包的两个作废函数和 identity 的三个作废函数都改成调用它们,删掉各自复制的收尾代码。
    3. 如果暂时不想跨包,就在 group/void.go 里补上回执写入和收尾,group.Config 增加 RecordRetentionDays,由 serve 注入。
  • 改动文件:group/void.go、group/app.go、identity/lifecycle.go、message/dispatch.go、cmd/nixmsg/serve.go。
  • 与其他模块的交互/冲突风险
    • 已推送的投递照常发 revoked。
    • 回执由推送循环里的 pushReceipts 每秒推出,不需要额外唤醒。
    • 解散时 scheduled 消息的消息级回执保持 rejected(#6 的修复)。
  • 需补测试
    • 场景:A 发一条要回执的群消息,B、C 各有一条 pending,其中一条已推送;B 退群,然后解散。
    • 断言 receipts 表有两条 rejected,原因分别是 left_group 和 group_dissolved。
    • 断言消息变为 completed、正文已删;保留 0 天时消息行也删;A 的未完成数回到 0。
  • 置信度:代码阅读确定。

复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。

**编号**:C-04 **严重级**:high **工作线**:消息核心(internal/app/message、serve 的 messageLoops) **来源**:审查 M-06、I-02 **依赖**:无 **被依赖**:U-02 (#40)(同改 group 包,本条先合)、C-07 (#38) 第 4 点 ### 结论与统一方案 两份审查结论一致,采用统一终态函数方案,由消息线实现并改完所有调用方: 1. message 包导出两个在事务内调用的函数: - `RejectPendingTx(tx, seq, 编号, reason, nowMs)`:条件更新;消息要求回执且发送方存在时写回执;返回是否已推送,供调用方发 revoked。 - `TryFinalizeTx(tx, seq, nowMs, retentionDays)`:没有 pending 投递时 completed、删正文;保留 0 天时同一事务删行。 2. `internal/app/group/void.go` 的两个作废函数,以及 `internal/app/identity/lifecycle.go` 的作废与收尾函数,改为调用它们,删除各自复制的 SQL。group 引入 message 不会形成循环依赖。 3. 每小时兜底:分批把"dispatched 且已没有 pending 投递"的消息收尾,同时修复已部署库里已经卡住的数据。 4. 解散时 scheduled 消息的消息级回执保持 rejected(#6 的修复)。 ### 改动文件 `internal/app/message/dispatch.go`(导出函数)、`recover.go`(兜底);`internal/app/group/void.go`;`internal/app/identity/lifecycle.go`(作废与收尾函数)。 ### 与其他问题的交互 / 冲突说明 只改 void.go 与 lifecycle.go 中的作废、收尾函数:B-04 只删 lifecycle.go 第 131–138 行的断开 goroutine;U-03 只在删除端路径加"清除锁定";U-02 改 group/app.go 的校验逻辑,互不重叠。group 包按 C-04 → U-02 顺序合入。 ### 验收与测试 - 群里只有 B、B 离线且消息保留,B 退群:消息变 completed、正文删除、发送方收到 rejected / left_group 回执。 - 解散时有 pending:全部写回执、全部收尾。 - 停用接收方同理;发送方未完成数回到 0;保留 0 天时消息行也删。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [M-06] 退群、踢人、解散作废投递时不写回执、不收尾:消息永远停在 dispatched,正文永不删除(跨模块:身份线 group) - 严重级:high - 分类:逻辑 / 数据 / 与 PRD 不符 - 现象与影响: - group 包的 `voidMemberDeliveriesTx` 和 `voidGroupAllTx` 只把 pending 投递改成 rejected。 - 两者都既不写回执,也不检查这条消息是否已没有 pending 投递、不做收尾。 - 如果被作废的是最后一个 pending 投递: - 消息永远停在 `dispatched`,正文永不删除,违背 F18。 - 永久占用发送方 `max_pending_per_sender` 配额,最终导致这个发送方持续 `quota_exceeded`。 - 保留期清理只删 `completed` 的消息,这些消息永远不会被清掉。 - 发送方也收不到 left_group 或 group_dissolved 的回执。 - identity 包里自己的那份同名函数会做收尾,但同样不写回执。 - 证据: ```48:60:e:\code\NixMsg\internal\app\group\void.go for _, r := range list { if _, execErr := tx.Exec(` UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ? WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`, reason, nowMs, r.seq, endpointID); execErr != nil { return execErr } if r.pushed.Valid && revokes != nil { *revokes = append(*revokes, revokeItem{ endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: reason, }) } } return nil ``` - `group/void.go:93-105`:解散时的 pending 投递同样处理。 - `group/app.go:252-258`、`289-295`、`380-389`:踢人、退群、解散调用上面两个函数后没有收尾。 - 管理后台的群操作复用同一套函数(DEVIATIONS A3 第 2 条)。 - 文档依据: - DEVELOPMENT 7.6:「投递进入 accepted、expired、dropped、rejected 时写回执」「收尾:没有 pending 投递时,消息 completed,删除正文行」。 - PRD F14「每个接收端一条最终回执」;F18「全部投递进入最终状态……立即删除正文」。 - 为何不是故意设计:DEVIATIONS I2/I3/I4 第 8 条原话是「与后续 M 作废路径需保持同语义」,这里并没有做到。 - 解决方案: 1. message 包导出一个唯一的终态迁移函数,例如 `RejectPendingTx(tx, seq, 编号, reason, nowMs, lim)`:条件更新、写回执(消息要求回执且发送方存在)、调用 `finalizeMessageTx`(包括保留 0 天的处理),返回是否已推送,供调用方发 revoked。group 和 identity 改为调用它,去掉各自复制的 SQL。 2. message 侧加兜底:每小时分批把「dispatched 且已没有 pending 投递」的消息收尾,同时修复已部署库里已经卡住的数据。 - 改动文件:`internal/app/message/dispatch.go`(导出函数)、`recover.go`(兜底);`internal/app/group/void.go`、`internal/app/identity/lifecycle.go`(身份线)。 - 交互/冲突风险:group 引入 message 包不会形成循环依赖(message 不依赖 group)。需要身份线配合改。 - 需补测试: - 群里只有 B、B 离线且消息保留,B 退群:消息变 completed、正文删除、发送方收到 rejected/left_group 回执。 - 解散时有 pending:全部写回执、全部收尾。 - 发送方配额恢复。 - 置信度:代码阅读确定。 #### [I-02] 退群、踢人、解散作废投递时不写回执也不收尾,正文永远不删,还占发送方配额 - **严重级**:high - **分类**:数据 / 与PRD不符 - **现象与影响**:`group.leave`、`group.remove`、`group.dissolve`(包括后台踢人和解散)把 pending 投递改成 rejected 之后: - 不给发送方写这些接收端的回执,违反 F14「每个接收端一条最终回执」。 - 如果被作废的是最后一条 pending(解散时一定是),消息不会改成 completed,`message_bodies` 也不删。后果有三个: - 正文永远留在库里,违反 F18。 - 消息一直算「未完成」,计入 `max_pending_per_sender`,时间长了发送方会莫名收到 `quota_exceeded`。 - 记录清理只删 completed,这些行永远清不掉;`record_retention_days=0` 也不生效。 - identity 自己的 `tryFinalizeTx` 也没有处理保留 0 天的情况:停用、删除收尾后,消息和投递行仍然保留。 - **证据** - `internal/app/group/void.go:20-61` 和 `93-105` 只做 UPDATE、收集 revoked 列表。 - 调用处:`internal/app/group/app.go:252-258`(踢人)、`289-295`(退群)、`380-389`(解散)。 - 对照正确写法:`internal/app/message/dispatch.go:217-245`。 - 配额计入 dispatched:`message/submit.go:460-475`;清理只删 completed:`message/recover.go:81-89`。 - identity 的收尾:`identity/lifecycle.go:601-624`。 - 测试只检查了 reason:`group/group_test.go:234-246`。 ```48:60:e:\code\NixMsg\internal\app\group\void.go for _, r := range list { if _, execErr := tx.Exec(` UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ? WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`, reason, nowMs, r.seq, endpointID); execErr != nil { return execErr } if r.pushed.Valid && revokes != nil { *revokes = append(*revokes, revokeItem{ endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: reason, }) } } return nil ``` - **文档依据** - DEVELOPMENT 7.6(694):投递进入 rejected 时写回执。 - DEVELOPMENT 7.6(696):没有 pending 投递时收尾、删正文,保留 0 天时同一事务删行。 - DEVELOPMENT 7.6(704-705):作废规则表。 - PRD F16(345)、F18(378-379)。 - **为何不是故意设计**:DEVIATIONS I2/I3/I4 第 8 条只说在 group 包里写作废逻辑,并要求「与后续消息线的作废路径保持同语义」。 - **解决方案** 1. 消息线导出两个可在事务里调用的函数: - `RejectPendingTx(tx, seq, ep, reason, nowMs)`:条件更新,并在消息要回执、发送方仍存在时写回执。 - `TryFinalizeTx(tx, seq, nowMs, retentionDays)`:没有 pending 时收尾,保留 0 天时删行。 2. group 包的两个作废函数和 identity 的三个作废函数都改成调用它们,删掉各自复制的收尾代码。 3. 如果暂时不想跨包,就在 `group/void.go` 里补上回执写入和收尾,`group.Config` 增加 `RecordRetentionDays`,由 serve 注入。 - **改动文件**:`group/void.go`、`group/app.go`、`identity/lifecycle.go`、`message/dispatch.go`、`cmd/nixmsg/serve.go`。 - **与其他模块的交互/冲突风险** - 已推送的投递照常发 revoked。 - 回执由推送循环里的 `pushReceipts` 每秒推出,不需要额外唤醒。 - 解散时 scheduled 消息的消息级回执保持 rejected(#6 的修复)。 - **需补测试** - 场景:A 发一条要回执的群消息,B、C 各有一条 pending,其中一条已推送;B 退群,然后解散。 - 断言 receipts 表有两条 rejected,原因分别是 `left_group` 和 `group_dissolved`。 - 断言消息变为 completed、正文已删;保留 0 天时消息行也删;A 的未完成数回到 0。 - **置信度**:代码阅读确定。 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P1-highlane/messagereview-2026-09-30 labels 2026-09-30 13:56:59 +08:00
Author
Owner

已合入 origin/main 0c9b459。落地提交 f139b9e fix: 作废投递走统一终态函数并写回执收尾 (#35)。

已合入 origin/main `0c9b459`。落地提交 `f139b9e` fix: 作废投递走统一终态函数并写回执收尾 (#35)。
Sign in to join this conversation.