系统通知下面为什么会出现回复框
「回声」的订单通知由运营平台发送:用户下单后,系统会话里出现一条“你的订单已发货”。客户端最初只有一个消息气泡组件,所有会话共用。某次改版把“是否显示输入框”的判断写成“只要会话有成员就显示”,系统会话的成员是接收用户,于是输入框出现了。
用户回复“好的”,客户端把消息发到系统会话 ID。服务端没有区分会话类型,按普通群聊写入。结果产生了一个只有发送者参与、没有任何人能收到的会话;用户以为客服看到了,实际上消息躺在数据库里。客服侧则完全不知道这条回复存在。
问题不在 UI 组件,而在服务端没有把“会话类型”当成权限边界。类型只用于展示,写入路径就会接受任何成员的任何消息。
三种会话的规则不一样
单聊、群聊、系统会话不只是展示文案不同,成员规则、发送者规则和写扩散成本都不同。
| 类型 | 成员规则 | 允许发送者 | 典型展示 | 能否回复 |
|---|---|---|---|---|
| single | 恰好 2 人 | 双方成员 | 一对一会话 | 可以 |
| group | 1 到 MaxMembers 人 | 群成员 | 群聊消息流 | 可以 |
| system | 至少 1 个接收者 | 仅 SystemSender | 通知卡片,无输入框 | 不可以 |
单聊必须恰好两个人
单聊的成员数不是“建议两个”,而是硬约束。少于两个说明会话不完整,多于两个说明客户端或迁移脚本把群聊错标成了单聊。两种错误都会让权限判断失效:三个人的“单聊”里,第三个人能看到本应对两个人可见的消息。
成员还要去重。重复的 u1 会让长度检查通过,但实际只有一个参与者;如果代码用 len(Members) 判断人数,就会把无效会话当成合法会话。
群聊上限决定写扩散成本
群成员上限不是随便填的数字。每发一条群消息,系统可能要写 N 份收件箱、推 N 个在线连接、更新 N 个未读计数。上限 500 和上限 5000,写扩散成本差一个数量级。
因此 MaxMembers 必须在创建群时确定,并由服务端校验,不能只依赖客户端限制。成员数超过上限时,要么拒绝加人,要么升级为大群模式;两种策略都比“先放进来再说”安全。
系统消息只能由系统发送
系统会话的成员是接收者,不是发送者。普通用户即使出现在成员列表里,也不能向这个会话写入。发送权限应该由 Type 决定,而不是由“是否在 Members 里”决定。
这条规则同时解决了展示问题:客户端看到 system 类型就不渲染输入框;即使旧版客户端仍然发送,服务端也会拒绝,不会产生没有接收方的消息。
失败场景:把系统消息当普通消息回复
那次改版后,客服系统每天收到几十条“用户说已回复但客服没看到”的工单。排查发现,回复消息的 ConvID 指向系统会话,SenderID 是用户,成员列表里只有这个用户。写入路径只检查了“发送者是否在成员里”,没有检查会话类型。
更麻烦的是未读数。系统会话被计入用户的总未读,用户回复后未读没有清零,客户端反复提示“有 1 条未读”,点进去却只有自己的消息。运营侧看到的是通知送达率正常,完全不知道存在这些孤立回复。
案例假设值:当时系统会话约占全部会话的 8%,每天新增回复约 300 条。数量不大,但每一条都意味着一次客服失联。修复分两层:服务端在 CanSend 里拒绝普通用户向系统会话写入;客户端对 system 类型隐藏输入框。服务端校验是底线,客户端隐藏只是体验优化。
Go 实现:会话校验与发送权限
代码把会话类型、成员规则和发送权限放在一起。Validate 负责结构合法性,CanSend 在结构合法的基础上判断具体发送者。
// file: internal/im/conversation.go
// tier: L1
// verify: go test ./internal/im/
package im
import (
"errors"
"fmt"
)
// ConvType 是会话类型。单聊、群聊和系统会话的成员规则不同。
type ConvType string
const (
ConvSingle ConvType = "single"
ConvGroup ConvType = "group"
ConvSystem ConvType = "system"
// SystemSender 是唯一允许向系统会话写入的发送方。
SystemSender = "system"
)
// Conversation 描述一个会话及其成员。MaxMembers 只对群聊生效。
type Conversation struct {
ID string
Type ConvType
Members []string
MaxMembers int
}
// Validate 检查会话类型、成员和人数上限。
func (c Conversation) Validate() error {
if c.ID == "" {
return errors.New("im: conversation id is empty")
}
if len(c.Members) == 0 {
return errors.New("im: conversation has no members")
}
seen := make(map[string]struct{}, len(c.Members))
for _, m := range c.Members {
if m == "" {
return errors.New("im: conversation has an empty member")
}
if _, ok := seen[m]; ok {
return fmt.Errorf("im: duplicate member %q", m)
}
seen[m] = struct{}{}
}
switch c.Type {
case ConvSingle:
if len(c.Members) != 2 {
return fmt.Errorf("im: single conversation needs 2 members, got %d", len(c.Members))
}
case ConvGroup:
if c.MaxMembers <= 0 {
return fmt.Errorf("im: group max members %d must be positive", c.MaxMembers)
}
if len(c.Members) > c.MaxMembers {
return fmt.Errorf("im: group has %d members, limit %d", len(c.Members), c.MaxMembers)
}
case ConvSystem:
// 系统会话的成员是接收者,发送方固定为 SystemSender。
default:
return fmt.Errorf("im: unknown conversation type %q", c.Type)
}
return nil
}
// CanSend 判断 sender 能否向会话写入消息。
func CanSend(c Conversation, sender string) error {
if err := c.Validate(); err != nil {
return err
}
if sender == "" {
return errors.New("im: sender is empty")
}
if c.Type == ConvSystem {
if sender != SystemSender {
return fmt.Errorf("im: system conversation rejects sender %q", sender)
}
return nil
}
for _, m := range c.Members {
if m == sender {
return nil
}
}
return fmt.Errorf("im: sender %q is not a member of %q", sender, c.ID)
}
几个关键点:
- 先校验
Members非空、成员 ID 非空、成员不重复,再按类型检查人数。顺序很重要:如果先检查单聊人数,重复成员可能让长度刚好等于 2,从而绕过重复检查。 - 单聊要求恰好 2 人,群聊要求 1 到
MaxMembers人。群聊允许 1 人,是为了支持“先建群再拉人”的产品流程;如果业务不允许空群,应该在上层限制,而不是把Validate写成只接受 2 人以上。 MaxMembers <= 0直接报错。把 0 解释成“不限制”会让上限失去意义,也会让容量规划失去依据。- 系统会话只接受
SystemSender。普通用户即使在Members里也会被拒绝,这正是系统通知不可回复的服务端保证。 CanSend先调用Validate,避免在非法会话上做权限判断。调用方不需要记得“先校验再发送”。
测试:人数、上限与系统发送者
测试覆盖三类边界:单聊人数、群聊上限、系统会话发送者。
// file: internal/im/conversation_test.go
// tier: L1
// verify: go test ./internal/im/
package im
import "testing"
func TestValidateSingleNeedsExactlyTwo(t *testing.T) {
ok := Conversation{ID: "c1", Type: ConvSingle, Members: []string{"u1", "u2"}}
if err := ok.Validate(); err != nil {
t.Fatalf("Validate(two members) = %v, want nil", err)
}
for _, members := range [][]string{{"u1"}, {"u1", "u2", "u3"}} {
c := Conversation{ID: "c1", Type: ConvSingle, Members: members}
if err := c.Validate(); err == nil {
t.Fatalf("Validate(single with %d members) = nil, want error", len(members))
}
}
}
func TestValidateGroupMemberLimit(t *testing.T) {
cases := []struct {
name string
members []string
max int
wantErr bool
}{
{"one member", []string{"u1"}, 3, false},
{"at limit", []string{"u1", "u2", "u3"}, 3, false},
{"over limit", []string{"u1", "u2", "u3", "u4"}, 3, true},
{"zero max", []string{"u1"}, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := Conversation{ID: "g1", Type: ConvGroup, Members: tc.members, MaxMembers: tc.max}
err := c.Validate()
if tc.wantErr && err == nil {
t.Fatal("Validate() = nil, want error")
}
if !tc.wantErr && err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
})
}
}
func TestValidateRejectsUnknownAndEmpty(t *testing.T) {
cases := []Conversation{
{ID: "c1", Type: ConvSingle},
{ID: "c1", Type: ConvSingle, Members: []string{"u1", ""}},
{ID: "c1", Type: ConvSingle, Members: []string{"u1", "u1"}},
{ID: "c1", Type: "channel", Members: []string{"u1"}},
{ID: "", Type: ConvGroup, Members: []string{"u1"}, MaxMembers: 5},
}
for i, c := range cases {
if err := c.Validate(); err == nil {
t.Fatalf("case %d: Validate(%+v) = nil, want error", i, c)
}
}
}
func TestCanSendChecksMembership(t *testing.T) {
single := Conversation{ID: "c1", Type: ConvSingle, Members: []string{"u1", "u2"}}
if err := CanSend(single, "u1"); err != nil {
t.Fatalf("CanSend(member) = %v, want nil", err)
}
if err := CanSend(single, "u3"); err == nil {
t.Fatal("CanSend(non-member) = nil, want error")
}
group := Conversation{ID: "g1", Type: ConvGroup, Members: []string{"u1", "u2"}, MaxMembers: 10}
if err := CanSend(group, "u2"); err != nil {
t.Fatalf("CanSend(group member) = %v, want nil", err)
}
}
func TestCanSendSystemOnlyFromSystemSender(t *testing.T) {
system := Conversation{ID: "s1", Type: ConvSystem, Members: []string{"u1"}}
if err := CanSend(system, SystemSender); err != nil {
t.Fatalf("CanSend(system) = %v, want nil", err)
}
if err := CanSend(system, "u1"); err == nil {
t.Fatal("CanSend(user to system conversation) = nil, want error")
}
}
TestValidateSingleNeedsExactlyTwo 同时检查 1 人和 3 人,防止实现只挡住其中一个方向。
TestValidateGroupMemberLimit 用表驱动覆盖 1 人、刚好到上限、超过上限和上限为 0 四种情况。刚好到上限必须通过,这是最容易写成 >= 的地方。
TestValidateRejectsUnknownAndEmpty 覆盖空成员、空成员 ID、重复成员、未知类型和空会话 ID。这些输入如果漏掉,会在更深的写入路径里变成难以定位的错误。
TestCanSendSystemOnlyFromSystemSender 是整个模块最重要的用例:系统发送者可以通过,普通成员必须被拒绝。它直接对应“系统通知不能被回复”的产品规则。
运行命令与真实输出
cd .local/checks/webapp-lab && go test ./internal/im/ -run '^(TestValidateSingleNeedsExactlyTwo|TestValidateGroupMemberLimit|TestValidateRejectsUnknownAndEmpty|TestCanSendChecksMembership|TestCanSendSystemOnlyFromSystemSender)$' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateSingleNeedsExactlyTwo
--- PASS: TestValidateSingleNeedsExactlyTwo (0.00s)
=== RUN TestValidateGroupMemberLimit
=== RUN TestValidateGroupMemberLimit/one_member
=== RUN TestValidateGroupMemberLimit/at_limit
=== RUN TestValidateGroupMemberLimit/over_limit
=== RUN TestValidateGroupMemberLimit/zero_max
--- PASS: TestValidateGroupMemberLimit (0.00s)
--- PASS: TestValidateGroupMemberLimit/one_member (0.00s)
--- PASS: TestValidateGroupMemberLimit/at_limit (0.00s)
--- PASS: TestValidateGroupMemberLimit/over_limit (0.00s)
--- PASS: TestValidateGroupMemberLimit/zero_max (0.00s)
=== RUN TestValidateRejectsUnknownAndEmpty
--- PASS: TestValidateRejectsUnknownAndEmpty (0.00s)
=== RUN TestCanSendChecksMembership
--- PASS: TestCanSendChecksMembership (0.00s)
=== RUN TestCanSendSystemOnlyFromSystemSender
--- PASS: TestCanSendSystemOnlyFromSystemSender (0.00s)
PASS
ok webapp/internal/im 0.333s
PASS 表示会话结构、人数上限和发送权限都符合预期。输出中的子测试名称对应表驱动用例,排查失败时可以直接定位到具体分支。
小结
单聊、群聊、系统会话的差异必须落到服务端校验,而不能只停留在客户端展示。单聊恰好 2 人,群聊在 1 到上限之间,系统会话只允许系统发送者写入;成员要去重,未知类型要报错。
Conversation、Validate 和 CanSend 把这些规则集中到一处。下一节讨论消息如何存储和漫游:为什么按会话分片、按时间分区,以及一次拉取三年记录为什么会把数据库打满。
阅读导航:上一节:8.1 会话、消息与序号 · 下一节:8.3 消息存储与漫游 。
常见错误与边界
- 只靠客户端隐藏输入框。旧版本、抓包工具和脚本都可以绕过 UI,服务端必须拒绝普通用户向系统会话写入。
- 用成员数判断会话类型。三个人也可能被标成
single,类型字段和成员规则必须交叉校验。 - 群聊上限只写在产品文档里。创建、加人、迁移都要经过同一个校验函数,否则总有路径能突破上限。
- 把
MaxMembers = 0当成不限制。上限失去意义,写扩散成本无法估算。 - 允许重复成员。长度检查会通过,实际人数却不对,权限和未读计数都会出错。
- 系统会话没有成员。通知没有接收者,发送成功但无人可见;
Validate应该直接拒绝。 - 把系统发送者混进普通成员列表。权限判断会退化成“是否在成员里”,系统消息又变得可回复。
- 在客户端拼接系统会话 ID。业务键格式一旦变化,历史通知和用户回复会落到不同会话。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。