《互联网产品的服务端架构·内容、社交与实时互动》8.2 单聊、群聊与系统消息

从系统通知被用户回复、产生没有接收方的会话写起,对照单聊、群聊、系统会话的成员与发送权限,说明群成员上限如何影响写扩散,给出会话校验与 CanSend 的 Go 实现、边界测试和真实运行输出,并列出会话模型设计、服务端发送权限与客户端展示的常见错误。

系统通知下面为什么会出现回复框

「回声」的订单通知由运营平台发送:用户下单后,系统会话里出现一条“你的订单已发货”。客户端最初只有一个消息气泡组件,所有会话共用。某次改版把“是否显示输入框”的判断写成“只要会话有成员就显示”,系统会话的成员是接收用户,于是输入框出现了。

用户回复“好的”,客户端把消息发到系统会话 ID。服务端没有区分会话类型,按普通群聊写入。结果产生了一个只有发送者参与、没有任何人能收到的会话;用户以为客服看到了,实际上消息躺在数据库里。客服侧则完全不知道这条回复存在。

问题不在 UI 组件,而在服务端没有把“会话类型”当成权限边界。类型只用于展示,写入路径就会接受任何成员的任何消息。

三种会话的规则不一样

单聊、群聊、系统会话不只是展示文案不同,成员规则、发送者规则和写扩散成本都不同。

类型成员规则允许发送者典型展示能否回复
single恰好 2 人双方成员一对一会话可以
group1 到 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。业务键格式一旦变化,历史通知和用户回复会落到不同会话。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查