fix: 命令 8 接受 last_msg_id 作为已读水位

客户端已读请求常只有 conversation_id 和 last_msg_id。任一消息 id 大于 0 即写入 last_read_msg_id 并清 Redis 未读,不再因此返回 40001。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-10-18 19:26:48 +08:00
parent a65d59149c
commit c0ed0d83de
4 changed files with 42 additions and 4 deletions

View File

@ -185,7 +185,7 @@ offset 23 Body bytes UTF-8 JSON
| 5 | SEND_ACK | ← 仅 4 的成功应答 | | 5 | SEND_ACK | ← 仅 4 的成功应答 |
| 6 | MESSAGE | ← 推送 | | 6 | MESSAGE | ← 推送 |
| 7 | PUSH_ACK | → 空;`SequenceID` = 推送帧的 SequenceID | | 7 | PUSH_ACK | → 空;`SequenceID` = 推送帧的 SequenceID |
| 8 | READ_MESSAGE | → `{"conversation_id","msg_id"}`;成功 `data` 同名字段,`msg_id` 为落库后的 `last_read_msg_id`(只增不减)。清该成员 Redis 未读,并把会话从 unread ZSET 挪到 read ZSET。水位前进时给该用户其他在线设备推 Command 8(不系统推送、不写 mailbox)。缺 `conversation_id` 或 `msg_id` 为 `40001` | | 8 | READ_MESSAGE | → `conversation_id`,以及 `msg_id` 或 `last_msg_id`(任一大于 0;两者都有时用 `msg_id`)。成功 `data` 为 `conversation_id` 与 `msg_id`,`msg_id` 为落库后的 `last_read_msg_id`(只增不减)。清该成员 Redis 未读,并把会话从 unread ZSET 挪到 read ZSET。水位前进时给该用户其他在线设备推 Command 8(不系统推送、不写 mailbox)。缺 `conversation_id`,或两个消息 id 都小于等于 0,为 `40001` |
| 9 | RECALL_MESSAGE | → `{"msg_id"}` | | 9 | RECALL_MESSAGE | → `{"msg_id"}` |
| 10 | SYNC_INBOX | → 会话列表增量 | | 10 | SYNC_INBOX | → 会话列表增量 |
| 12 | SYNC_MESSAGES | → 某会话消息增量 | | 12 | SYNC_MESSAGES | → 某会话消息增量 |

View File

@ -653,7 +653,13 @@ READ 更新 last_read_msg_id,清 Redis 未读,入队通知其他设
{"conversation_id": 1001, "msg_id": 2002} {"conversation_id": 1001, "msg_id": 2002}
``` ```
`msg_id` 是成员已读到的消息 id。成功应答仍是命令 8,Body 为 V3 信封,`data.msg_id` 为写入后的 `last_read_msg_id`(只增不减;更小的 id 不回退)。缺 `conversation_id` 或 `msg_id`(≤0)返回 `40001`。消息不存在为 `40401`;消息不属于该会话为 `40001`;不是成员为 `40301`。 也接受客户端现用的 `last_msg_id`(可同时带 `peer_id`,服务端不使用该字段):
```json
{"conversation_id": 1001, "peer_id": 9, "last_msg_id": 2002}
```
`msg_id` 与 `last_msg_id` 任一大于 0 即是成员已读到的消息 id;两者都大于 0 时用 `msg_id`。成功应答仍是命令 8,Body 为 V3 信封,`data.msg_id` 为写入后的 `last_read_msg_id`(只增不减;更小的 id 不回退)。缺 `conversation_id`,或两个消息 id 都小于等于 0,返回 `40001`。消息不存在为 `40401`;消息不属于该会话为 `40001`;不是成员为 `40301`。
水位前进时入队:`ToUserIDs` 仅为当前用户,`ExcludeDevice` 为当前设备,`NotPush` 与 `NotMailbox` 均为真。其他在线设备收到命令 8。 水位前进时入队:`ToUserIDs` 仅为当前用户,`ExcludeDevice` 为当前设备,`NotPush` 与 `NotMailbox` 均为真。其他在线设备收到命令 8。

View File

@ -18,6 +18,15 @@ type msgIDBody struct {
type readBody struct { type readBody struct {
ConversationID int64 `json:"conversation_id"` ConversationID int64 `json:"conversation_id"`
MsgID int64 `json:"msg_id"` MsgID int64 `json:"msg_id"`
LastMsgID int64 `json:"last_msg_id"`
}
// readCursor accepts msg_id or last_msg_id. Either value above zero is the read cursor.
func (b readBody) readCursor() int64 {
if b.MsgID > 0 {
return b.MsgID
}
return b.LastMsgID
} }
type syncInboxBody struct { type syncInboxBody struct {
@ -55,11 +64,11 @@ func (s *Server) handleReadMessage(ctx context.Context, conn *session.Conn, fram
return nil, errs.ErrAvailable return nil, errs.ErrAvailable
} }
var in readBody var in readBody
if err := json.Unmarshal(frame.Body, &in); err != nil || in.ConversationID <= 0 || in.MsgID <= 0 { if err := json.Unmarshal(frame.Body, &in); err != nil || in.ConversationID <= 0 || in.readCursor() <= 0 {
return nil, errs.ErrInvalidArgument return nil, errs.ErrInvalidArgument
} }
out, err := s.messages.MarkRead(ctx, conn.UserID(), conn.DeviceID(), &msglogic.ReadInput{ out, err := s.messages.MarkRead(ctx, conn.UserID(), conn.DeviceID(), &msglogic.ReadInput{
ConversationID: in.ConversationID, MsgID: in.MsgID, ConversationID: in.ConversationID, MsgID: in.readCursor(),
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -37,6 +37,13 @@ func TestReadMessageRegistered(t *testing.T) {
if missing != nil || !errors.Is(err, errs.ErrInvalidArgument) { if missing != nil || !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("missing fields frame=%v err=%v", missing, err) t.Fatalf("missing fields frame=%v err=%v", missing, err)
} }
onlyLast, err := srv.router.Dispatch(t.Context(), conn, protocol.Frame{
Version: protocol.CurrentVersion, Command: protocol.CmdReadMessage,
Body: []byte(`{"conversation_id":` + strconv.FormatInt(convID, 10) + `,"peer_id":1,"last_msg_id":0}`),
})
if onlyLast != nil || !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("zero cursor frame=%v err=%v", onlyLast, err)
}
resp, err := srv.router.Dispatch(t.Context(), conn, protocol.Frame{ resp, err := srv.router.Dispatch(t.Context(), conn, protocol.Frame{
Version: protocol.CurrentVersion, Command: protocol.CmdReadMessage, SequenceID: 4, Version: protocol.CurrentVersion, Command: protocol.CmdReadMessage, SequenceID: 4,
@ -53,6 +60,22 @@ func TestReadMessageRegistered(t *testing.T) {
if err := json.Unmarshal(env.Data, &data); err != nil || data.MsgID != msgID || data.ConversationID != convID { if err := json.Unmarshal(env.Data, &data); err != nil || data.MsgID != msgID || data.ConversationID != convID {
t.Fatalf("data %+v %v", data, err) t.Fatalf("data %+v %v", data, err)
} }
alias, err := srv.router.Dispatch(t.Context(), conn, protocol.Frame{
Version: protocol.CurrentVersion, Command: protocol.CmdReadMessage, SequenceID: 5,
Body: []byte(`{"conversation_id":` + strconv.FormatInt(convID, 10) + `,"peer_id":1,"last_msg_id":` + strconv.FormatInt(msgID, 10) + `}`),
})
if err != nil || alias == nil {
t.Fatalf("last_msg_id %+v %v", alias, err)
}
aliasEnv, err := protocol.DecodeEnvelope(alias.Body)
if err != nil || aliasEnv.Code != protocol.CodeOK {
t.Fatalf("last_msg_id envelope %+v %v", aliasEnv, err)
}
var aliasData msglogic.ReadResult
if err := json.Unmarshal(aliasEnv.Data, &aliasData); err != nil || aliasData.MsgID != msgID || aliasData.ConversationID != convID {
t.Fatalf("last_msg_id data %+v %v", aliasData, err)
}
} }
func readFixture(t *testing.T) (*msglogic.Service, int64, int64, int64) { func readFixture(t *testing.T) (*msglogic.Service, int64, int64, int64) {