feat: add message mutation sync
This commit is contained in:
@@ -1420,6 +1420,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
|
||||
| `GET /v1/im/topics/{topic}/message-mutations?since_version=&limit=` | 拉取撤回/编辑等原地消息变更;`since_version` 取本地已应用的会话级 `mutation_version` | `{"topic":"grp...","items":[{"seq":12,"version":2,"event":"message.recalled","operator":"usr...","mutated_at":"2026-08-20T09:00:00Z","mutation_version":3}],"next_cursor":"3","has_more":false,"mutation_version":3,"expired":false}` |
|
||||
| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` |
|
||||
| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` |
|
||||
@@ -1429,6 +1430,225 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。批量撤回按成功项逐条产生 `message.recalled` 事件;失败项不会产生事件。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
|
||||
|
||||
#### 4.21.2 群消息撤回与批量撤回
|
||||
|
||||
群聊撤回使用 Topic 消息操作接口,`topic` 必须传真实群 Topic(`grp...`),不能传用户 ID、群 ID 昵称或本地会话别名。单条撤回和多选批量撤回都要求登录态,Header 至少包含 `Authorization: Bearer <token>`、`X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`。
|
||||
|
||||
单条撤回:
|
||||
|
||||
```http
|
||||
POST /v1/im/topics/{topic}/messages/{seq}/recall
|
||||
```
|
||||
|
||||
Path 参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
|
||||
| `seq` | Int64 | 是 | 要撤回的消息序号。 |
|
||||
|
||||
Body:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `version` | Int | 否 | 期望消息版本。不传时后端使用当前消息版本;传旧版本可能返回版本冲突。 |
|
||||
| `reason` | String | 否 | 撤回原因,当前主要用于客户端/审计扩展,可传空。 |
|
||||
|
||||
单条请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"reason": "mistake"
|
||||
}
|
||||
```
|
||||
|
||||
单条成功响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"seq": 102,
|
||||
"status": "recalled",
|
||||
"version": 2,
|
||||
"updated_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_by": "usr_actor"
|
||||
}
|
||||
```
|
||||
|
||||
多选批量撤回:
|
||||
|
||||
```http
|
||||
POST /v1/im/topics/{topic}/messages/recall
|
||||
```
|
||||
|
||||
Path 参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
|
||||
|
||||
Body:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `items` | Array | 是 | 要撤回的消息列表,长度 1~50。 |
|
||||
| `items[].seq` | Int64 | 是 | 要撤回的消息序号。 |
|
||||
| `items[].version` | Int | 否 | 该消息期望版本。不传时后端使用当前消息版本。 |
|
||||
| `items[].reason` | String | 否 | 单条撤回原因;可不传。 |
|
||||
| `reason` | String | 否 | 批量默认撤回原因;单条 `items[].reason` 可覆盖。 |
|
||||
|
||||
批量请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"seq": 102,
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"seq": 103,
|
||||
"version": 1
|
||||
}
|
||||
],
|
||||
"reason": "mistake"
|
||||
}
|
||||
```
|
||||
|
||||
批量响应始终按条目返回处理结果。一条失败不影响其他条目;成功条目会包含最新 `version`,失败条目会包含该条失败原因。
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"items": [
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"seq": 102,
|
||||
"status": "recalled",
|
||||
"version": 2,
|
||||
"updated_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_by": "usr_actor"
|
||||
},
|
||||
{
|
||||
"seq": 103,
|
||||
"status": "failed",
|
||||
"http": 409,
|
||||
"code": 30006,
|
||||
"reason": "VERSION_CONFLICT",
|
||||
"message": "message version conflict"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
常见失败项:
|
||||
|
||||
| `http` | `code` | `reason` | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `400` | `30000` | `MALFORMED_REQUEST` | `seq` 非法、`items` 为空或超过 50 条。 |
|
||||
| `403` | `30005` | `PERMISSION_DENIED` | 当前用户无权撤回该消息,或消息已超过撤回窗口。超过窗口时 `message` 为 `message recall window expired`。 |
|
||||
| `404` | `30004` | `MALFORMED_REQUEST` | 消息不存在。 |
|
||||
| `409` | `30006` | `VERSION_CONFLICT` | 请求版本与服务端当前消息版本不一致,或消息已撤回/删除。 |
|
||||
|
||||
权限与时限:
|
||||
|
||||
- 发送人可撤回自己的消息。
|
||||
- 群主/管理员可撤回群成员消息。
|
||||
- 当前后端撤回窗口为 120 秒;超过后返回 `message recall window expired`。
|
||||
- 被撤回消息服务端会清空普通正文,历史拉取应展示撤回占位,不继续展示旧内容。
|
||||
|
||||
实时事件:
|
||||
|
||||
撤回成功后,Topic 在线成员会收到 `message.recalled`。批量撤回按成功项逐条发送事件,失败项不会发送。
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"from": "usr_actor",
|
||||
"what": "im",
|
||||
"event": "message.recalled",
|
||||
"payload": {
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"seq": 102,
|
||||
"version": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端处理规则:
|
||||
|
||||
- 多选撤回优先调用批量接口,不要并发打很多单条接口。
|
||||
- 批量接口 HTTP `200` 只表示请求被服务端处理,仍需逐条读取 `items[].status`。
|
||||
- `status="recalled"` 时,用 `topic + seq + version` 更新本地消息为撤回状态。
|
||||
- `status="failed"` 时,只对该条提示失败原因,其他成功项正常生效。
|
||||
- 收到实时事件后也按 `topic + seq + version` 刷新本地消息,避免只依赖发起方本地状态。
|
||||
|
||||
#### 4.21.3 消息变更同步水位
|
||||
|
||||
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。
|
||||
|
||||
会话列表同步时,`me.meta.sub[].private` 会带:
|
||||
|
||||
```json
|
||||
{
|
||||
"mutation_version": 3
|
||||
}
|
||||
```
|
||||
|
||||
客户端按账号 + topic 保存本地已应用水位。若服务端 `mutation_version` 大于本地值,调用:
|
||||
|
||||
```http
|
||||
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
|
||||
```
|
||||
|
||||
响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"items": [
|
||||
{
|
||||
"seq": 102,
|
||||
"version": 2,
|
||||
"event": "message.recalled",
|
||||
"operator": "usr_actor",
|
||||
"mutated_at": "2026-09-15T04:30:00Z",
|
||||
"mutation_version": 3
|
||||
}
|
||||
],
|
||||
"next_cursor": "3",
|
||||
"has_more": false,
|
||||
"mutation_version": 3,
|
||||
"expired": false
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `seq` | 被变更消息的序号。 |
|
||||
| `version` | 变更后的消息版本,用于同一 `seq` 的幂等与乱序保护。 |
|
||||
| `event` | `message.recalled` / `message.edited`,与实时 `info.event` 保持一致。 |
|
||||
| `operator` | 操作人用户 ID。 |
|
||||
| `mutated_at` | 变更发生时间。 |
|
||||
| `mutation_version` | 会话级最新水位。客户端成功应用返回项后推进本地水位。 |
|
||||
| `next_cursor` | 当前实现等同最新 `mutation_version`,客户端原样保存即可。 |
|
||||
| `expired` | 当前轻量实现固定为 `false`;后续若服务端裁剪变更记录,该字段会用于提示客户端降级重拉。 |
|
||||
|
||||
客户端处理规则:
|
||||
|
||||
- `mutation_version` 是会话级水位,不是单条消息的 `version`。
|
||||
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
|
||||
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
|
||||
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
|
||||
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
|
||||
|
||||
## 5. 通讯录、好友关系与黑名单
|
||||
|
||||
### 5.1 通讯录.png
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- IM 消息变更同步水位
|
||||
-- 用于撤回/编辑等 seq 不变的原地消息变更离线补偿。
|
||||
|
||||
ALTER TABLE im_topics
|
||||
ADD COLUMN mutation_version BIGINT NOT NULL DEFAULT 0 AFTER subcnt;
|
||||
@@ -1379,6 +1379,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | 单条撤回;`topic` 可传 `p2p...` 或 `grp...`;Body `{}`,可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `POST /v1/im/topics/{topic}/messages/recall` | 多选批量撤回;`{"items":[{"seq":12,"version":1},{"seq":13}],"reason":"mistake"}`;`items` 1~50 条,`version` 可选 | `{"topic":"grp...","items":[{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"},{"seq":13,"status":"failed","http":409,"code":30006,"reason":"VERSION_CONFLICT","message":"message version conflict"}]}` |
|
||||
| `GET /v1/im/topics/{topic}/message-mutations?since_version=&limit=` | 拉取撤回/编辑等原地消息变更;`since_version` 取本地已应用的会话级 `mutation_version` | `{"topic":"grp...","items":[{"seq":12,"version":2,"event":"message.recalled","operator":"usr...","mutated_at":"2026-08-20T09:00:00Z","mutation_version":3}],"next_cursor":"3","has_more":false,"mutation_version":3,"expired":false}` |
|
||||
| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` |
|
||||
| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` |
|
||||
| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` |
|
||||
@@ -1388,6 +1389,225 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
|
||||
消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。批量撤回按成功项逐条产生 `message.recalled` 事件;失败项不会产生事件。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。
|
||||
|
||||
#### 4.21.2 群消息撤回与批量撤回
|
||||
|
||||
群聊撤回使用 Topic 消息操作接口,`topic` 必须传真实群 Topic(`grp...`),不能传用户 ID、群 ID 昵称或本地会话别名。单条撤回和多选批量撤回都要求登录态,Header 至少包含 `Authorization: Bearer <token>`、`X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`。
|
||||
|
||||
单条撤回:
|
||||
|
||||
```http
|
||||
POST /v1/im/topics/{topic}/messages/{seq}/recall
|
||||
```
|
||||
|
||||
Path 参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
|
||||
| `seq` | Int64 | 是 | 要撤回的消息序号。 |
|
||||
|
||||
Body:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `version` | Int | 否 | 期望消息版本。不传时后端使用当前消息版本;传旧版本可能返回版本冲突。 |
|
||||
| `reason` | String | 否 | 撤回原因,当前主要用于客户端/审计扩展,可传空。 |
|
||||
|
||||
单条请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"reason": "mistake"
|
||||
}
|
||||
```
|
||||
|
||||
单条成功响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"seq": 102,
|
||||
"status": "recalled",
|
||||
"version": 2,
|
||||
"updated_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_by": "usr_actor"
|
||||
}
|
||||
```
|
||||
|
||||
多选批量撤回:
|
||||
|
||||
```http
|
||||
POST /v1/im/topics/{topic}/messages/recall
|
||||
```
|
||||
|
||||
Path 参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `topic` | String | 是 | 群 Topic,例如 `grp6PIoZgdpc2g`。 |
|
||||
|
||||
Body:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `items` | Array | 是 | 要撤回的消息列表,长度 1~50。 |
|
||||
| `items[].seq` | Int64 | 是 | 要撤回的消息序号。 |
|
||||
| `items[].version` | Int | 否 | 该消息期望版本。不传时后端使用当前消息版本。 |
|
||||
| `items[].reason` | String | 否 | 单条撤回原因;可不传。 |
|
||||
| `reason` | String | 否 | 批量默认撤回原因;单条 `items[].reason` 可覆盖。 |
|
||||
|
||||
批量请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"seq": 102,
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"seq": 103,
|
||||
"version": 1
|
||||
}
|
||||
],
|
||||
"reason": "mistake"
|
||||
}
|
||||
```
|
||||
|
||||
批量响应始终按条目返回处理结果。一条失败不影响其他条目;成功条目会包含最新 `version`,失败条目会包含该条失败原因。
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"items": [
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"seq": 102,
|
||||
"status": "recalled",
|
||||
"version": 2,
|
||||
"updated_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_at": "2026-09-15T04:30:00Z",
|
||||
"recalled_by": "usr_actor"
|
||||
},
|
||||
{
|
||||
"seq": 103,
|
||||
"status": "failed",
|
||||
"http": 409,
|
||||
"code": 30006,
|
||||
"reason": "VERSION_CONFLICT",
|
||||
"message": "message version conflict"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
常见失败项:
|
||||
|
||||
| `http` | `code` | `reason` | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `400` | `30000` | `MALFORMED_REQUEST` | `seq` 非法、`items` 为空或超过 50 条。 |
|
||||
| `403` | `30005` | `PERMISSION_DENIED` | 当前用户无权撤回该消息,或消息已超过撤回窗口。超过窗口时 `message` 为 `message recall window expired`。 |
|
||||
| `404` | `30004` | `MALFORMED_REQUEST` | 消息不存在。 |
|
||||
| `409` | `30006` | `VERSION_CONFLICT` | 请求版本与服务端当前消息版本不一致,或消息已撤回/删除。 |
|
||||
|
||||
权限与时限:
|
||||
|
||||
- 发送人可撤回自己的消息。
|
||||
- 群主/管理员可撤回群成员消息。
|
||||
- 当前后端撤回窗口为 120 秒;超过后返回 `message recall window expired`。
|
||||
- 被撤回消息服务端会清空普通正文,历史拉取应展示撤回占位,不继续展示旧内容。
|
||||
|
||||
实时事件:
|
||||
|
||||
撤回成功后,Topic 在线成员会收到 `message.recalled`。批量撤回按成功项逐条发送事件,失败项不会发送。
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"from": "usr_actor",
|
||||
"what": "im",
|
||||
"event": "message.recalled",
|
||||
"payload": {
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"seq": 102,
|
||||
"version": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端处理规则:
|
||||
|
||||
- 多选撤回优先调用批量接口,不要并发打很多单条接口。
|
||||
- 批量接口 HTTP `200` 只表示请求被服务端处理,仍需逐条读取 `items[].status`。
|
||||
- `status="recalled"` 时,用 `topic + seq + version` 更新本地消息为撤回状态。
|
||||
- `status="failed"` 时,只对该条提示失败原因,其他成功项正常生效。
|
||||
- 收到实时事件后也按 `topic + seq + version` 刷新本地消息,避免只依赖发起方本地状态。
|
||||
|
||||
#### 4.21.3 消息变更同步水位
|
||||
|
||||
撤回、编辑属于 `seq` 不变的原地变更。在线且已订阅 Topic 时,客户端会收到 `message.recalled` / `message.edited` 实时事件;离线或未订阅时,需要靠会话级水位补偿。
|
||||
|
||||
会话列表同步时,`me.meta.sub[].private` 会带:
|
||||
|
||||
```json
|
||||
{
|
||||
"mutation_version": 3
|
||||
}
|
||||
```
|
||||
|
||||
客户端按账号 + topic 保存本地已应用水位。若服务端 `mutation_version` 大于本地值,调用:
|
||||
|
||||
```http
|
||||
GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion}&limit=50
|
||||
```
|
||||
|
||||
响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp6PIoZgdpc2g",
|
||||
"items": [
|
||||
{
|
||||
"seq": 102,
|
||||
"version": 2,
|
||||
"event": "message.recalled",
|
||||
"operator": "usr_actor",
|
||||
"mutated_at": "2026-09-15T04:30:00Z",
|
||||
"mutation_version": 3
|
||||
}
|
||||
],
|
||||
"next_cursor": "3",
|
||||
"has_more": false,
|
||||
"mutation_version": 3,
|
||||
"expired": false
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `seq` | 被变更消息的序号。 |
|
||||
| `version` | 变更后的消息版本,用于同一 `seq` 的幂等与乱序保护。 |
|
||||
| `event` | `message.recalled` / `message.edited`,与实时 `info.event` 保持一致。 |
|
||||
| `operator` | 操作人用户 ID。 |
|
||||
| `mutated_at` | 变更发生时间。 |
|
||||
| `mutation_version` | 会话级最新水位。客户端成功应用返回项后推进本地水位。 |
|
||||
| `next_cursor` | 当前实现等同最新 `mutation_version`,客户端原样保存即可。 |
|
||||
| `expired` | 当前轻量实现固定为 `false`;后续若服务端裁剪变更记录,该字段会用于提示客户端降级重拉。 |
|
||||
|
||||
客户端处理规则:
|
||||
|
||||
- `mutation_version` 是会话级水位,不是单条消息的 `version`。
|
||||
- 本地只做 `服务端 mutation_version > 本地 mutation_version` 比较,不要假设连续递增。
|
||||
- 拉到 `message.recalled` 后,将本地 `topic + seq` 标记为撤回,并只在 `version` 更高时覆盖。
|
||||
- 拉到 `message.edited` 后,按 `seq + version` 拉取或覆盖消息内容。
|
||||
- 实时事件和补偿接口可能重复到达,客户端必须按 `topic + seq + version` 幂等处理。
|
||||
|
||||
## 5. 通讯录、好友关系与黑名单
|
||||
|
||||
### 5.1 通讯录.png
|
||||
|
||||
@@ -1317,6 +1317,7 @@ CREATE TABLE IF NOT EXISTS `im_topics` (
|
||||
`seqid` bigint NOT NULL DEFAULT '0',
|
||||
`delid` int DEFAULT '0',
|
||||
`subcnt` int DEFAULT '0',
|
||||
`mutation_version` bigint NOT NULL DEFAULT '0',
|
||||
`public` json DEFAULT NULL,
|
||||
`trusted` json DEFAULT NULL,
|
||||
`tags` json DEFAULT NULL,
|
||||
@@ -1617,4 +1618,3 @@ VALUES (2,'system', 'system merchant', 'system default merchant', 'meeting_im',
|
||||
INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`)
|
||||
VALUES (2, 0, 0, 0, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
|
||||
|
||||
|
||||
@@ -332,6 +332,7 @@ type TenantBusinessAdapter interface {
|
||||
TenantTopicUpdateOnMessage(t.TenantID, string, *t.Message) error
|
||||
TenantTopicUpdateSubCnt(t.TenantID, string) error
|
||||
TenantTopicUpdate(t.TenantID, string, map[string]any) error
|
||||
TenantTopicBumpMutationVersion(t.TenantID, string, time.Time) (int64, error)
|
||||
TenantTopicOwnerChange(t.TenantID, string, t.Uid) error
|
||||
|
||||
TenantSubscriptionGet(t.TenantID, string, t.Uid, bool) (*t.Subscription, error)
|
||||
@@ -351,6 +352,7 @@ type TenantBusinessAdapter interface {
|
||||
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
|
||||
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
|
||||
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
|
||||
TenantMessageMutations(t.TenantID, string, t.Uid, int64, int) ([]t.MessageMutation, string, bool, int64, error)
|
||||
TenantGroupMessageSave(t.TenantID, *t.Message) error
|
||||
TenantGroupMessageGetByClientID(t.TenantID, string, t.Uid, string) (*t.Message, error)
|
||||
TenantGroupMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
|
||||
@@ -387,6 +389,7 @@ type TenantBusinessAdapter interface {
|
||||
TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error)
|
||||
TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error
|
||||
TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error)
|
||||
TenantGroupMuteGet(t.TenantID, string, t.Uid) (*t.GroupMute, error)
|
||||
TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error)
|
||||
TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error
|
||||
TenantGroupFileList(t.TenantID, string, string, string, string, int) ([]t.GroupFile, string, error)
|
||||
|
||||
@@ -2747,7 +2747,7 @@ func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
|
||||
// Fetch topic by name
|
||||
var tt = new(t.Topic)
|
||||
if err := a.db.GetContext(ctx, tt,
|
||||
"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
|
||||
"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted,tags,aux "+
|
||||
"FROM im_topics WHERE name=?", topic); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// Nothing found - clear the error
|
||||
@@ -2901,7 +2901,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
||||
|
||||
// Fetch grp topics and join to subscriptions.
|
||||
if len(topq) > 0 {
|
||||
q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " +
|
||||
q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted " +
|
||||
"FROM im_topics WHERE name IN (?)"
|
||||
q, args, _ = sqlx.In(q, topq)
|
||||
|
||||
@@ -2943,6 +2943,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
|
||||
sub.SetState(top.State)
|
||||
sub.SetTouchedAt(top.TouchedAt)
|
||||
sub.SetSeqId(top.SeqId)
|
||||
sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion)
|
||||
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
|
||||
sub.SetSubCnt(top.SubCnt)
|
||||
sub.SetPublic(common.FromJSON(top.Public))
|
||||
|
||||
@@ -172,6 +172,15 @@ func ensureP2PPrivatePeerRemark(private any, peerID, remark string) any {
|
||||
return data
|
||||
}
|
||||
|
||||
func ensurePrivateMutationVersion(private any, version int64) any {
|
||||
data, _ := private.(map[string]any)
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["mutation_version"] = version
|
||||
return data
|
||||
}
|
||||
|
||||
func clearProfilePublicFields(public map[string]any) {
|
||||
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
|
||||
delete(public, key)
|
||||
@@ -309,6 +318,17 @@ func messageHeadBool(head t.KVMap, key string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adapter) tenantTopicBumpMutationVersionTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, topic string, at time.Time) error {
|
||||
if a.tenantScopedDB {
|
||||
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE name=?",
|
||||
at, at, topic)
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, "UPDATE im_topics SET mutation_version=mutation_version+1,updatedat=?,touchedat=? WHERE tenant_id=? AND name=?",
|
||||
at, at, id, topic)
|
||||
return err
|
||||
}
|
||||
|
||||
func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef {
|
||||
if file != nil {
|
||||
file.TenantID = id
|
||||
@@ -888,7 +908,7 @@ func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error)
|
||||
}
|
||||
var top t.Topic
|
||||
err = a.db.GetContext(ctx, &top,
|
||||
"SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
|
||||
"SELECT tenant_id,createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted,tags,aux "+
|
||||
"FROM im_topics WHERE tenant_id=? AND name=?", id, topic)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -1013,7 +1033,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
||||
return nil, err
|
||||
}
|
||||
if len(topq) > 0 {
|
||||
q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)"
|
||||
q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)"
|
||||
q, args, _ = sqlx.In(q, id, topq)
|
||||
if !keepDeleted {
|
||||
q += " AND state!=?"
|
||||
@@ -1042,6 +1062,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
|
||||
sub.SetState(top.State)
|
||||
sub.SetTouchedAt(top.TouchedAt)
|
||||
sub.SetSeqId(top.SeqId)
|
||||
sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion)
|
||||
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
|
||||
sub.SetSubCnt(top.SubCnt)
|
||||
sub.SetPublic(common.FromJSON(top.Public))
|
||||
@@ -1575,6 +1596,49 @@ func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[stri
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantTopicBumpMutationVersion(id t.TenantID, topic string, at time.Time) (int64, error) {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil || topic == "" {
|
||||
return 0, t.ErrMalformed
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantTopicBumpMutationVersion(id, topic, at)
|
||||
}
|
||||
if at.IsZero() {
|
||||
at = time.Now().UTC().Round(time.Millisecond)
|
||||
}
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, at); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var version int64
|
||||
query := "SELECT mutation_version FROM im_topics WHERE name=?"
|
||||
args := []any{topic}
|
||||
if !a.tenantScopedDB {
|
||||
query = "SELECT mutation_version FROM im_topics WHERE tenant_id=? AND name=?"
|
||||
args = []any{id, topic}
|
||||
}
|
||||
if err = tx.GetContext(ctx, &version, query, args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Uid) error {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil || topic == "" {
|
||||
@@ -2173,6 +2237,9 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, ac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2219,6 +2286,9 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, recalledAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2272,6 +2342,9 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, acto
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2324,6 +2397,9 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = a.tenantTopicBumpMutationVersionTx(ctx, tx, id, topic, editedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2601,6 +2677,129 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
|
||||
return msgs, nextCursor, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageMutations(id t.TenantID, topic string, uid t.Uid, sinceVersion int64, limit int) ([]t.MessageMutation, string, bool, int64, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantMessageMutations(id, topic, uid, sinceVersion, limit)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() || sinceVersion < 0 {
|
||||
return nil, "", false, 0, t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var currentVersion int64
|
||||
if a.tenantScopedDB {
|
||||
if err := a.db.GetContext(ctx, ¤tVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE name=?", topic); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, "", false, 0, t.ErrNotFound
|
||||
}
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
} else {
|
||||
if err := a.db.GetContext(ctx, ¤tVersion, "SELECT IFNULL(mutation_version,0) FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, "", false, 0, t.ErrNotFound
|
||||
}
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
}
|
||||
if sinceVersion >= currentVersion {
|
||||
return []t.MessageMutation{}, strconv.FormatInt(currentVersion, 10), false, currentVersion, nil
|
||||
}
|
||||
if t.GetTopicCat(topic) == t.TopicCatGrp {
|
||||
return a.tenantGroupMessageMutations(ctx, id, topic, uid, limit, currentVersion)
|
||||
}
|
||||
return a.tenantP2PMessageMutations(ctx, id, topic, uid, limit, currentVersion)
|
||||
}
|
||||
|
||||
func (a *adapter) tenantP2PMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
|
||||
rows, err := a.db.QueryxContext(ctx,
|
||||
"SELECT m.seqid,m.version,m.updatedat AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
|
||||
"FROM im_messages AS m "+
|
||||
"LEFT JOIN im_dellog AS d ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
|
||||
"LEFT JOIN im_message_revisions AS rev ON rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
|
||||
"WHERE m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deletedat IS NOT NULL) "+
|
||||
"ORDER BY m.updatedat DESC,m.seqid DESC LIMIT ?",
|
||||
decodeUID(uid), topic, limit+1)
|
||||
if err != nil {
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTenantMessageMutations(rows, limit, currentVersion)
|
||||
}
|
||||
|
||||
func (a *adapter) tenantGroupMessageMutations(ctx context.Context, id t.TenantID, topic string, uid t.Uid, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
|
||||
rows, err := a.db.QueryxContext(ctx,
|
||||
"SELECT m.seqid,m.version,m.updated_at AS mutated_at,m.recalled_at,m.recalled_by,rev.actor "+
|
||||
"FROM im_group_messages AS m "+
|
||||
"LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
|
||||
"LEFT JOIN im_message_revisions AS rev ON rev.topic=m.topic AND rev.seqid=m.seqid AND rev.version=m.version-1 "+
|
||||
"WHERE m.topic=? AND d.deletedfor IS NULL AND (m.version>1 OR m.recalled_at IS NOT NULL OR m.deleted_at IS NOT NULL OR m.status='recalled') "+
|
||||
"ORDER BY m.updated_at DESC,m.seqid DESC LIMIT ?",
|
||||
decodeUID(uid), topic, limit+1)
|
||||
if err != nil {
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTenantMessageMutations(rows, limit, currentVersion)
|
||||
}
|
||||
|
||||
func scanTenantMessageMutations(rows *sqlx.Rows, limit int, currentVersion int64) ([]t.MessageMutation, string, bool, int64, error) {
|
||||
items := make([]t.MessageMutation, 0, limit)
|
||||
hasMore := false
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
SeqId int64 `db:"seqid"`
|
||||
Version int `db:"version"`
|
||||
MutatedAt time.Time `db:"mutated_at"`
|
||||
RecalledAt sql.NullTime `db:"recalled_at"`
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
Actor sql.NullInt64 `db:"actor"`
|
||||
}{}
|
||||
if err := rows.StructScan(&row); err != nil {
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
if len(items) == limit {
|
||||
hasMore = true
|
||||
continue
|
||||
}
|
||||
event := "message.edited"
|
||||
operator := ""
|
||||
if row.Actor.Valid {
|
||||
operator = store.EncodeUid(row.Actor.Int64).String()
|
||||
}
|
||||
if row.RecalledAt.Valid {
|
||||
event = "message.recalled"
|
||||
if row.RecalledBy.Valid {
|
||||
operator = store.EncodeUid(row.RecalledBy.Int64).String()
|
||||
}
|
||||
row.MutatedAt = row.RecalledAt.Time
|
||||
}
|
||||
items = append(items, t.MessageMutation{
|
||||
SeqId: row.SeqId,
|
||||
Version: row.Version,
|
||||
Event: event,
|
||||
Operator: operator,
|
||||
MutatedAt: row.MutatedAt,
|
||||
MutationVersion: currentVersion,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
return items, strconv.FormatInt(currentVersion, 10), hasMore, currentVersion, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
|
||||
+92
-5
@@ -4074,6 +4074,8 @@ func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
|
||||
serveIMTopicDraft(wrt, req)
|
||||
case strings.HasSuffix(req.URL.Path, "/calls"):
|
||||
serveIMTopicCalls(wrt, req)
|
||||
case strings.HasSuffix(req.URL.Path, "/message-mutations"):
|
||||
serveIMMessageMutations(wrt, req)
|
||||
case strings.HasSuffix(req.URL.Path, "/messages/search"):
|
||||
serveIMMessageSearch(wrt, req)
|
||||
case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"):
|
||||
@@ -4198,6 +4200,40 @@ func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor})
|
||||
}
|
||||
|
||||
func serveIMMessageMutations(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessageMutationsPath(req.URL.Path))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sinceVersion, err := parseIMMutationSince(req.URL.Query().Get("since_version"), req.URL.Query().Get("since"))
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "since_version"})
|
||||
return
|
||||
}
|
||||
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
||||
mutations, nextCursor, hasMore, mutationVersion, err := store.Messages.Mutations(principal.TenantID, topic, principal.Uid, sinceVersion, limit)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(mutations))
|
||||
for _, mutation := range mutations {
|
||||
items = append(items, messageMutationEventPayload(mutation))
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"topic": topic,
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"has_more": hasMore,
|
||||
"mutation_version": mutationVersion,
|
||||
"expired": false,
|
||||
})
|
||||
}
|
||||
|
||||
func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -6324,19 +6360,34 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return
|
||||
}
|
||||
isAdmin := (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
|
||||
switch {
|
||||
case tail == "mutes" && req.Method == http.MethodGet:
|
||||
items, nextCursor, err := store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
||||
var items []types.GroupMute
|
||||
nextCursor := ""
|
||||
if isAdmin {
|
||||
var err error
|
||||
items, nextCursor, err = store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
item, err := store.GroupBusiness.GetMute(principal.TenantID, topic, principal.Uid)
|
||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
if item != nil {
|
||||
items = append(items, *item)
|
||||
}
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
|
||||
case (tail == "mutes" || tail == "mutes/batch") && req.Method == http.MethodPost:
|
||||
if !isAdmin {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return
|
||||
}
|
||||
var body imGroupMuteBatchRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
@@ -6377,6 +6428,10 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
|
||||
map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
||||
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
|
||||
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
|
||||
if !isAdmin {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
|
||||
return
|
||||
}
|
||||
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
|
||||
if uid.IsZero() {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
|
||||
@@ -8689,6 +8744,17 @@ func messageSearchPayload(msg types.Message, query string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func messageMutationEventPayload(mutation types.MessageMutation) map[string]any {
|
||||
return map[string]any{
|
||||
"seq": mutation.SeqId,
|
||||
"version": mutation.Version,
|
||||
"event": mutation.Event,
|
||||
"operator": mutation.Operator,
|
||||
"mutated_at": mutation.MutatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"mutation_version": mutation.MutationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func messageContextPayload(msg types.Message) map[string]any {
|
||||
return map[string]any{
|
||||
"seq": msg.SeqId,
|
||||
@@ -11701,6 +11767,12 @@ func topicFromMessagesSearchPath(path string) string {
|
||||
return topicFromPathWithSuffix(path, prefix, suffix)
|
||||
}
|
||||
|
||||
func topicFromMessageMutationsPath(path string) string {
|
||||
const prefix = "/v1/im/topics/"
|
||||
const suffix = "/message-mutations"
|
||||
return topicFromPathWithSuffix(path, prefix, suffix)
|
||||
}
|
||||
|
||||
func topicFromMediaPath(path string) string {
|
||||
const prefix = "/v1/im/topics/"
|
||||
const suffix = "/media"
|
||||
@@ -11728,6 +11800,21 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string {
|
||||
return strings.Trim(rest, "/")
|
||||
}
|
||||
|
||||
func parseIMMutationSince(primary, fallback string) (int64, error) {
|
||||
value := strings.TrimSpace(primary)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(fallback)
|
||||
}
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func messageContextPath(path string) (string, int64, bool) {
|
||||
const prefix = "/v1/im/topics/"
|
||||
idx := strings.Index(path, prefix)
|
||||
|
||||
@@ -548,6 +548,15 @@ func (s *testGroupBusinessStore) ListMutes(_ types.TenantID, _ string, _ string,
|
||||
return s.mutes, "", nil
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) GetMute(_ types.TenantID, _ string, uid types.Uid) (*types.GroupMute, error) {
|
||||
for i := range s.mutes {
|
||||
if s.mutes[i].UserID == uid && s.mutes[i].Status == types.GroupMuteStatusActive {
|
||||
return &s.mutes[i], nil
|
||||
}
|
||||
}
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) UpsertMute(_ types.TenantID, mute *types.GroupMute) (*types.GroupMute, error) {
|
||||
mute.Version = 1
|
||||
s.mutes = append(s.mutes, *mute)
|
||||
@@ -5625,6 +5634,56 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupMutesMemberGetsOwnMuteOnly(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldStore := store.Store
|
||||
oldSubs := store.Subs
|
||||
oldGroupBusiness := store.GroupBusiness
|
||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
|
||||
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
|
||||
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
|
||||
Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
|
||||
store.Store = mockStore
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
memberSub := &types.Subscription{TenantID: 7, User: types.Uid(43).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(memberSub, nil).Times(2)
|
||||
groupStore := &testGroupBusinessStore{mutes: []types.GroupMute{
|
||||
{Topic: "grpAlpha", UserID: types.Uid(42), MutedBy: types.Uid(99), MutedAt: time.Now().UTC(), Status: types.GroupMuteStatusActive, Version: 1},
|
||||
{Topic: "grpAlpha", UserID: types.Uid(43), MutedBy: types.Uid(99), MutedAt: time.Now().UTC(), Reason: "test", Status: types.GroupMuteStatusActive, Version: 2},
|
||||
}}
|
||||
store.GroupBusiness = groupStore
|
||||
defer func() {
|
||||
store.Store = oldStore
|
||||
store.Subs = oldSubs
|
||||
store.GroupBusiness = oldGroupBusiness
|
||||
}()
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/mutes", nil)
|
||||
getReq.Header.Set("Authorization", "Bearer token-1")
|
||||
getRec := httptest.NewRecorder()
|
||||
serveIMGroupResource(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("member mute get status: got %d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, getRec).Data.(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["user_id"] != types.Uid(43).UserId() {
|
||||
t.Fatalf("member should only see own mute item: %#v", items)
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes/batch", bytes.NewBufferString(`{"user_ids":["`+types.Uid(42).UserId()+`"]}`))
|
||||
postReq.Header.Set("Authorization", "Bearer token-1")
|
||||
postRec := httptest.NewRecorder()
|
||||
serveIMGroupResource(postRec, postReq)
|
||||
if postRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("member mute post status: got %d body=%s", postRec.Code, postRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupDissolveConsumesActionToken(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -7647,6 +7706,62 @@ func TestServeIMMessageRecall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageMutations(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
actor := types.Uid(42)
|
||||
expectIMBearer(t, ctrl, "token-1", 7, actor)
|
||||
oldSubs := store.Subs
|
||||
oldMessages := store.Messages
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Messages = messages
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Messages = oldMessages
|
||||
}()
|
||||
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpRecall", actor, false).Return(&types.Subscription{
|
||||
TenantID: 7,
|
||||
User: actor.String(),
|
||||
Topic: "grpRecall",
|
||||
ModeWant: types.ModeCPublic,
|
||||
ModeGiven: types.ModeCPublic,
|
||||
}, nil)
|
||||
mutatedAt := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
|
||||
messages.EXPECT().Mutations(types.TenantID(7), "grpRecall", actor, int64(1), 20).Return([]types.MessageMutation{{
|
||||
SeqId: 9,
|
||||
Version: 2,
|
||||
Event: "message.recalled",
|
||||
Operator: actor.UserId(),
|
||||
MutatedAt: mutatedAt,
|
||||
MutationVersion: 3,
|
||||
}}, "3", false, int64(3), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpRecall/message-mutations?since_version=1&limit=20", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMTopicMessageAction(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["mutation_version"].(float64) != 3 || data["next_cursor"] != "3" {
|
||||
t.Fatalf("unexpected mutation response: %#v", data)
|
||||
}
|
||||
items := data["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("unexpected mutation items: %#v", items)
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["seq"].(float64) != 9 || item["version"].(float64) != 2 || item["event"] != "message.recalled" {
|
||||
t.Fatalf("unexpected mutation item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageBatchRecallPartialSuccess(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
@@ -1008,6 +1008,21 @@ func (mr *MockTopicsPersistenceInterfaceMockRecorder) OwnerChange(tenantID, topi
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OwnerChange", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).OwnerChange), tenantID, topic, newOwner)
|
||||
}
|
||||
|
||||
// BumpMutationVersion mocks base method.
|
||||
func (m *MockTopicsPersistenceInterface) BumpMutationVersion(tenantID types.TenantID, topic string, at time.Time) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "BumpMutationVersion", tenantID, topic, at)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// BumpMutationVersion indicates an expected call of BumpMutationVersion.
|
||||
func (mr *MockTopicsPersistenceInterfaceMockRecorder) BumpMutationVersion(tenantID, topic, at interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BumpMutationVersion", reflect.TypeOf((*MockTopicsPersistenceInterface)(nil).BumpMutationVersion), tenantID, topic, at)
|
||||
}
|
||||
|
||||
// Update mocks base method.
|
||||
func (m *MockTopicsPersistenceInterface) Update(tenantID types.TenantID, topic string, update map[string]any) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1220,6 +1235,24 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic,
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Search), tenantID, topic, forUser, query, messageType, cursor, limit)
|
||||
}
|
||||
|
||||
// Mutations mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Mutations", tenantID, topic, forUser, sinceVersion, limit)
|
||||
ret0, _ := ret[0].([]types.MessageMutation)
|
||||
ret1, _ := ret[1].(string)
|
||||
ret2, _ := ret[2].(bool)
|
||||
ret3, _ := ret[3].(int64)
|
||||
ret4, _ := ret[4].(error)
|
||||
return ret0, ret1, ret2, ret3, ret4
|
||||
}
|
||||
|
||||
// Mutations indicates an expected call of Mutations.
|
||||
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Mutations(tenantID, topic, forUser, sinceVersion, limit interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Mutations", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Mutations), tenantID, topic, forUser, sinceVersion, limit)
|
||||
}
|
||||
|
||||
// GetOne mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -1079,6 +1079,7 @@ type TopicsPersistenceInterface interface {
|
||||
GetSubs(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
|
||||
GetSubsAny(tenantID types.TenantID, topic string, opts *types.QueryOpt) ([]types.Subscription, error)
|
||||
Update(tenantID types.TenantID, topic string, update map[string]any) error
|
||||
BumpMutationVersion(tenantID types.TenantID, topic string, at time.Time) (int64, error)
|
||||
UpdateSubCnt(tenantID types.TenantID, topic string) error
|
||||
OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error
|
||||
Delete(tenantID types.TenantID, topic string, isChan, hard bool) error
|
||||
@@ -1235,6 +1236,18 @@ func (topicsMapper) Update(tenantID types.TenantID, topic string, update map[str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (topicsMapper) BumpMutationVersion(tenantID types.TenantID, topic string, at time.Time) (int64, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
version, err := tenantAdp.TenantTopicBumpMutationVersion(tenantID, topic, at)
|
||||
if err == nil {
|
||||
_ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic)
|
||||
}
|
||||
return version, err
|
||||
}
|
||||
|
||||
// OwnerChange replaces the old topic owner with the new owner.
|
||||
func (topicsMapper) OwnerChange(tenantID types.TenantID, topic string, newOwner types.Uid) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
@@ -1386,6 +1399,7 @@ type MessagesPersistenceInterface interface {
|
||||
GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error)
|
||||
GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)
|
||||
Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error)
|
||||
Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error)
|
||||
}
|
||||
|
||||
// messagesMapper is a concrete type implementing MessagesPersistenceInterface.
|
||||
@@ -1635,6 +1649,7 @@ type GroupBusinessPersistenceInterface interface {
|
||||
PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error)
|
||||
ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error
|
||||
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
|
||||
GetMute(types.TenantID, string, types.Uid) (*types.GroupMute, error)
|
||||
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
|
||||
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
|
||||
ListFiles(types.TenantID, string, string, string, string, int) ([]types.GroupFile, string, error)
|
||||
@@ -2007,6 +2022,17 @@ func (groupBusinessMapper) ListMutes(tenantID types.TenantID, topic, cursor stri
|
||||
return tenantAdp.TenantGroupMuteList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(cursor), limit)
|
||||
}
|
||||
|
||||
func (groupBusinessMapper) GetMute(tenantID types.TenantID, topic string, uid types.Uid) (*types.GroupMute, error) {
|
||||
if strings.TrimSpace(topic) == "" || uid.IsZero() {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantGroupMuteGet(tenantID, strings.TrimSpace(topic), uid)
|
||||
}
|
||||
|
||||
func (groupBusinessMapper) UpsertMute(tenantID types.TenantID, mute *types.GroupMute) (*types.GroupMute, error) {
|
||||
if mute == nil || mute.Topic == "" || mute.UserID.IsZero() || mute.MutedBy.IsZero() {
|
||||
return nil, types.ErrMalformed
|
||||
@@ -4080,6 +4106,20 @@ func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser type
|
||||
return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit)
|
||||
}
|
||||
|
||||
func (messagesMapper) Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error) {
|
||||
if topic == "" || forUser.IsZero() || sinceVersion < 0 {
|
||||
return nil, "", false, 0, types.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", false, 0, err
|
||||
}
|
||||
return tenantAdp.TenantMessageMutations(tenantID, topic, forUser, sinceVersion, limit)
|
||||
}
|
||||
|
||||
// GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list.
|
||||
func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
|
||||
@@ -1151,6 +1151,9 @@ type Topic struct {
|
||||
// Count of topic subscribers.
|
||||
SubCnt int
|
||||
|
||||
// Conversation-level version for in-place message mutations such as recall and edit.
|
||||
MutationVersion int64 `db:"mutation_version" json:"MutationVersion,omitempty" bson:"mutation_version,omitempty"`
|
||||
|
||||
Public any
|
||||
Trusted any
|
||||
|
||||
@@ -1264,6 +1267,16 @@ type MessageRevision struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// MessageMutation describes an in-place message change used for offline compensation.
|
||||
type MessageMutation struct {
|
||||
SeqId int64
|
||||
Version int
|
||||
Event string
|
||||
Operator string
|
||||
MutatedAt time.Time
|
||||
MutationVersion int64
|
||||
}
|
||||
|
||||
// MessageSaveResult describes the outcome of a message persistence attempt.
|
||||
type MessageSaveResult struct {
|
||||
SeqId int64
|
||||
|
||||
Reference in New Issue
Block a user