forked from baskbuddy/zhangshun
13 changed files with 406 additions and 50 deletions
@ -0,0 +1,68 @@ |
|||||
|
# 轻松聘聊天实时接口 |
||||
|
|
||||
|
本文补充 `openapi-rc.json` 中的消息发送、事件补拉和消息字段。所有 HTTP 路径以 `/api/v1` 为前缀;下文省略该前缀。 |
||||
|
|
||||
|
## 连接和发送 |
||||
|
|
||||
|
通过 STOMP WebSocket 连接 `/api/v1/ws`,在 **STOMP CONNECT 帧**中设置 `Authorization: Bearer <accessToken>`。不得放在 URL 中,refresh token 不可代替 access token。连接成功后订阅唯一用户队列 `/user/queue/chat-events`,收到 `{"type":"CHAT_READY"}` 后开始补拉。业务消息继续通过 HTTP 接口发送,STOMP SEND 不开放。 |
||||
|
|
||||
|
`POST /conversations/messages` 请求: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"conversationId": 1, |
||||
|
"content": "你好", |
||||
|
"clientMessageId": "example-message-001" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`clientMessageId` 为 1–64 位英文字母、数字、下划线或连字符;为兼容旧客户端可省略,但新客户端必须为一次逻辑发送生成固定标识,并在超时、断线或未收到响应时复用。同一会话、同一发送者下,相同标识和正文返回原消息,不重复生成事件;相同标识搭配不同正文返回 400。撤回后重试也不会恢复旧正文。 |
||||
|
|
||||
|
消息响应增加 `clientMessageId`(旧消息可为空)和 `version`(消息版本)。`MessageResponse` 在普通会话返回 `senderUserId`;人才邀请会话继续使用 `senderScope` 隐藏用户标识。撤回消息只返回“消息已撤回”占位。 |
||||
|
|
||||
|
## 事件补拉 |
||||
|
|
||||
|
`GET /conversations/{id}/events?after=0&size=50` 必须携带普通 HTTP Bearer access token,且当前账号必须属于该会话。`after` 是非负事件游标;`size` 默认 50,合法范围 1–100。 |
||||
|
|
||||
|
响应的 `data`: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"items": [ |
||||
|
{ |
||||
|
"eventId": 2, |
||||
|
"type": "MESSAGE_CREATED", |
||||
|
"conversationId": 1, |
||||
|
"occurredAt": "2026-10-15T10:00:00", |
||||
|
"actorScope": "OTHER_PARTICIPANT", |
||||
|
"payload": { |
||||
|
"id": 1, |
||||
|
"clientMessageId": "example-message-001", |
||||
|
"version": 0, |
||||
|
"conversationId": 1, |
||||
|
"content": "你好", |
||||
|
"status": "SENT", |
||||
|
"isRead": false |
||||
|
} |
||||
|
} |
||||
|
], |
||||
|
"nextCursor": 2, |
||||
|
"hasMore": false |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
事件包括 `MESSAGE_CREATED`、`MESSAGE_READ`、`MESSAGE_RECALLED`。`actorScope` 为 `CURRENT_USER` 或 `OTHER_PARTICIPANT`;`payload.isRead` 表示当前接收用户的已读状态。payload 读取消息当前状态,而不是保存历史正文快照。 |
||||
|
|
||||
|
客户端按以下顺序恢复: |
||||
|
|
||||
|
1. 连接、鉴权、订阅并等待 `CHAT_READY`。 |
||||
|
2. 使用该账号、该会话已持久化的补拉游标调用事件接口;首次使用 0。 |
||||
|
3. 以 `eventId` 去重,以消息 `id` 和 `version` 合并,避免旧版本覆盖新状态。 |
||||
|
4. 本页成功合并后才持久化 `nextCursor`;`hasMore=true` 时继续请求,直到为 false。 |
||||
|
5. 补拉期间同时合并实时事件。实时事件 ID 可能乱序,不得直接用其最大值推进补拉游标。同版本的已读状态还需按 `actorScope` 和本地已读操作处理,不能仅按版本丢弃已读事件。 |
||||
|
|
||||
|
断线后重新获取有效 access token、重连、订阅,再重复上述流程。账号切换必须隔离游标、消息缓存和连接。 |
||||
|
|
||||
|
## 运行边界 |
||||
|
|
||||
|
这些接口沿用企业认证、会话成员、屏蔽和现有匹配准入规则;不会改变投递或开聊门槛。目前 WebSocket 使用单实例内存 broker,多实例部署需要共享消息分发能力。Nginx 示例文件须经部署配置和验证后才能使用。 |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** 批量统计每个会话最近 500 条消息内的未读数,兼容 MySQL 5.7。 */ |
||||
|
public interface MessageUnreadQueries { |
||||
|
List<MessageRepository.ConversationUnreadCount> countUnreadByConversationIds( |
||||
|
List<Long> conversationIds, Long readerUserId); |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import jakarta.persistence.EntityManager; |
||||
|
import jakarta.persistence.PersistenceContext; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.stream.IntStream; |
||||
|
import org.hibernate.Session; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
/** Spring Data 仓库片段;只切换 SQL 能力,不改变会话权限和最近 500 条统计规则。 */ |
||||
|
public class MessageUnreadQueriesImpl implements MessageUnreadQueries { |
||||
|
@PersistenceContext private EntityManager entityManager; |
||||
|
private volatile Boolean legacyMysql; |
||||
|
|
||||
|
@Override |
||||
|
@Transactional(readOnly = true) |
||||
|
public List<MessageRepository.ConversationUnreadCount> countUnreadByConversationIds( |
||||
|
List<Long> conversationIds, Long readerUserId) { |
||||
|
// 保持原 IN 查询的集合语义,避免重复会话 ID 使 UNION 分支重复计数。
|
||||
|
conversationIds = conversationIds.stream().distinct().toList(); |
||||
|
if (conversationIds.isEmpty()) return List.of(); |
||||
|
String sql = isLegacyMysql() ? legacySql(conversationIds.size()) : windowSql(conversationIds.size()); |
||||
|
var query = entityManager.createNativeQuery(sql); |
||||
|
for (int i = 0; i < conversationIds.size(); i++) query.setParameter(i + 1, conversationIds.get(i)); |
||||
|
query.setParameter(conversationIds.size() + 1, readerUserId); |
||||
|
List<MessageRepository.ConversationUnreadCount> counts = new ArrayList<>(); |
||||
|
for (Object result : query.getResultList()) { |
||||
|
Object[] row = (Object[]) result; |
||||
|
counts.add(new UnreadCount(((Number) row[0]).longValue(), ((Number) row[1]).longValue())); |
||||
|
} |
||||
|
return counts; |
||||
|
} |
||||
|
|
||||
|
private boolean isLegacyMysql() { |
||||
|
if (legacyMysql == null) { |
||||
|
synchronized (this) { |
||||
|
if (legacyMysql == null) { |
||||
|
legacyMysql = entityManager.unwrap(Session.class).doReturningWork(connection -> { |
||||
|
var metadata = connection.getMetaData(); |
||||
|
return "MySQL".equals(metadata.getDatabaseProductName()) |
||||
|
&& metadata.getDatabaseMajorVersion() < 8; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return legacyMysql; |
||||
|
} |
||||
|
|
||||
|
private static String windowSql(int count) { |
||||
|
String placeholders = String.join(",", IntStream.rangeClosed(1, count).mapToObj(i -> "?" + i).toList()); |
||||
|
return """ |
||||
|
SELECT ranked.conversation_id, COUNT(*) |
||||
|
FROM ( |
||||
|
SELECT m.id, m.conversation_id, m.status, |
||||
|
ROW_NUMBER() OVER (PARTITION BY m.conversation_id |
||||
|
ORDER BY m.created_at DESC, m.id DESC) AS row_no |
||||
|
FROM messages m WHERE m.conversation_id IN (%s) |
||||
|
) ranked |
||||
|
LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = ?%d |
||||
|
WHERE ranked.row_no <= 500 |
||||
|
AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') AND mr.id IS NULL |
||||
|
GROUP BY ranked.conversation_id |
||||
|
""".formatted(placeholders, count + 1); |
||||
|
} |
||||
|
|
||||
|
private static String legacySql(int count) { |
||||
|
// 每个分支在数据库内取最近 500 条;一次 SQL 避免逐会话往返和把全量历史加载进 Java。
|
||||
|
List<String> branches = IntStream.rangeClosed(1, count).mapToObj(i -> """ |
||||
|
SELECT m.id, m.conversation_id, m.status FROM messages m |
||||
|
WHERE m.conversation_id = ?%d ORDER BY m.created_at DESC, m.id DESC LIMIT 500 |
||||
|
""".formatted(i).strip()).toList(); |
||||
|
String bounded = count == 1 ? branches.get(0) |
||||
|
: String.join(" UNION ALL ", branches.stream().map(branch -> "(" + branch + ")").toList()); |
||||
|
return """ |
||||
|
SELECT recent.conversation_id, COUNT(*) FROM (%s) recent |
||||
|
LEFT JOIN message_reads mr ON mr.message_id = recent.id AND mr.reader_user_id = ?%d |
||||
|
WHERE (recent.status IS NULL OR recent.status <> 'RECALLED') AND mr.id IS NULL |
||||
|
GROUP BY recent.conversation_id |
||||
|
""".formatted(bounded, count + 1); |
||||
|
} |
||||
|
|
||||
|
private record UnreadCount(Long conversationId, Long unreadCount) |
||||
|
implements MessageRepository.ConversationUnreadCount { |
||||
|
@Override public Long getConversationId() { return conversationId; } |
||||
|
@Override public Long getUnreadCount() { return unreadCount; } |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue