2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 消息服务
|
|
|
|
|
|
* 处理私信/聊天消息功能
|
|
|
|
|
|
* 使用新的 API 接口: /api/v1/conversations (RESTful action 风格)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { api } from './api';
|
|
|
|
|
|
import {
|
|
|
|
|
|
ConversationListResponse,
|
|
|
|
|
|
ConversationResponse,
|
|
|
|
|
|
ConversationDetailResponse,
|
|
|
|
|
|
MessageListResponse,
|
|
|
|
|
|
MessageResponse,
|
|
|
|
|
|
SendMessageRequest,
|
|
|
|
|
|
UnreadCountResponse,
|
|
|
|
|
|
ConversationUnreadCountResponse,
|
|
|
|
|
|
SystemMessageListResponse,
|
2026-03-20 23:00:27 +08:00
|
|
|
|
SystemMessageResponse,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
SystemUnreadCountResponse,
|
|
|
|
|
|
MessageSegment,
|
2026-03-20 23:00:27 +08:00
|
|
|
|
CursorPaginationRequest,
|
|
|
|
|
|
CursorPaginationResponse,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from '../types/dto';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getConversationCache,
|
|
|
|
|
|
getConversationListCache,
|
|
|
|
|
|
saveConversationCache,
|
2026-03-25 17:08:11 +08:00
|
|
|
|
saveConversationsWithRelatedCache,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
updateConversationCacheUnreadCount,
|
2026-03-26 16:46:22 +08:00
|
|
|
|
saveUsersCache,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from './database';
|
|
|
|
|
|
|
2026-03-25 04:07:48 +08:00
|
|
|
|
/** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */
|
|
|
|
|
|
export interface RemoteConversationListPageResult {
|
|
|
|
|
|
items: ConversationResponse[];
|
|
|
|
|
|
hasMore: boolean;
|
|
|
|
|
|
/** cursor 模式:下一页游标;offset 模式固定为 null */
|
|
|
|
|
|
nextCursor: string | null;
|
|
|
|
|
|
/** offset 模式:下一次 loadNext 应请求的页码;cursor 模式为 undefined */
|
|
|
|
|
|
nextPage?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 消息服务类
|
|
|
|
|
|
class MessageService {
|
2026-03-25 04:07:48 +08:00
|
|
|
|
/** 与会话 offset 列表一致:将本页会话写入 SQLite,避免游标路径不落库导致冷启动未读与线上一致 */
|
|
|
|
|
|
private async persistConversationListPageToLocalCache(
|
|
|
|
|
|
list: ConversationResponse[]
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
if (!list.length) return;
|
2026-03-25 17:08:11 +08:00
|
|
|
|
const users = list.flatMap(conv => [
|
|
|
|
|
|
...(conv.participants || []),
|
|
|
|
|
|
...(conv.last_message?.sender ? [conv.last_message.sender] : []),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const groups = list
|
|
|
|
|
|
.map(conv => conv.group)
|
|
|
|
|
|
.filter((group): group is NonNullable<typeof group> => Boolean(group));
|
|
|
|
|
|
await saveConversationsWithRelatedCache(list, users, groups);
|
2026-03-25 04:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** offset 会话列表单页:请求 + 落库 */
|
|
|
|
|
|
private async requestOffsetConversationsPage(
|
|
|
|
|
|
page: number,
|
|
|
|
|
|
pageSize: number
|
|
|
|
|
|
): Promise<ConversationListResponse> {
|
|
|
|
|
|
const response = await api.get<ConversationListResponse>('/conversations', {
|
|
|
|
|
|
page,
|
|
|
|
|
|
page_size: pageSize,
|
|
|
|
|
|
});
|
|
|
|
|
|
const list = response.data.list || [];
|
|
|
|
|
|
await this.persistConversationListPageToLocalCache(list);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
return response.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 04:07:48 +08:00
|
|
|
|
/** cursor 会话列表单页:请求 + 落库 */
|
|
|
|
|
|
private async requestCursorConversationsPage(
|
|
|
|
|
|
params: CursorPaginationRequest
|
|
|
|
|
|
): Promise<CursorPaginationResponse<ConversationResponse>> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get<CursorPaginationResponse<ConversationResponse>>(
|
|
|
|
|
|
'/conversations/cursor',
|
|
|
|
|
|
params
|
|
|
|
|
|
);
|
|
|
|
|
|
const raw = response.data;
|
|
|
|
|
|
const list = Array.isArray(raw?.list) ? raw.list : [];
|
|
|
|
|
|
await this.persistConversationListPageToLocalCache(list);
|
|
|
|
|
|
return {
|
|
|
|
|
|
list,
|
|
|
|
|
|
next_cursor: raw?.next_cursor ?? null,
|
|
|
|
|
|
prev_cursor: raw?.prev_cursor ?? null,
|
|
|
|
|
|
has_more: raw?.has_more ?? false,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取会话列表失败:', error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
list: [],
|
|
|
|
|
|
next_cursor: null,
|
|
|
|
|
|
prev_cursor: null,
|
|
|
|
|
|
has_more: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async refreshConversationsFromServer(
|
|
|
|
|
|
page = 1,
|
|
|
|
|
|
pageSize = 20
|
|
|
|
|
|
): Promise<ConversationListResponse> {
|
|
|
|
|
|
return this.requestOffsetConversationsPage(page, pageSize);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 远端会话列表单页(offset / cursor 统一入口)
|
|
|
|
|
|
* 两种模式均先写 SQLite 再返回,行为与 NetworkRemoteConversationListPagedSource 配套。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async fetchRemoteConversationListPage(args: {
|
|
|
|
|
|
mode: 'offset' | 'cursor';
|
|
|
|
|
|
pageSize: number;
|
|
|
|
|
|
/** offset:本次页码,默认 1 */
|
|
|
|
|
|
page?: number;
|
|
|
|
|
|
/** cursor:本次游标;首屏不传或 null */
|
|
|
|
|
|
cursor?: string | null;
|
|
|
|
|
|
direction?: CursorPaginationRequest['direction'];
|
|
|
|
|
|
}): Promise<RemoteConversationListPageResult> {
|
|
|
|
|
|
if (args.mode === 'offset') {
|
|
|
|
|
|
const page = args.page ?? 1;
|
|
|
|
|
|
const data = await this.requestOffsetConversationsPage(page, args.pageSize);
|
|
|
|
|
|
const totalPages = Math.max(0, data.total_pages ?? 0);
|
|
|
|
|
|
const currentPage = data.page ?? page;
|
|
|
|
|
|
const hasMore = totalPages > 0 && currentPage < totalPages;
|
|
|
|
|
|
return {
|
|
|
|
|
|
items: data.list || [],
|
|
|
|
|
|
hasMore,
|
|
|
|
|
|
nextCursor: null,
|
|
|
|
|
|
nextPage: currentPage + 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const raw = await this.requestCursorConversationsPage({
|
|
|
|
|
|
page_size: args.pageSize,
|
|
|
|
|
|
...(args.cursor != null && args.cursor !== ''
|
|
|
|
|
|
? { cursor: args.cursor }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
...(args.direction ? { direction: args.direction } : {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const items = raw.list || [];
|
|
|
|
|
|
const nextCursor = raw.next_cursor ?? null;
|
|
|
|
|
|
const hasMore = Boolean(raw.has_more && nextCursor != null);
|
|
|
|
|
|
return { items, hasMore, nextCursor };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
private async refreshConversationDetailFromServer(id: string): Promise<ConversationDetailResponse> {
|
|
|
|
|
|
const response = await api.get<ConversationDetailResponse>(
|
2026-03-10 20:52:50 +08:00
|
|
|
|
`/conversations/${encodeURIComponent(id)}`
|
2026-03-09 21:29:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
await saveConversationCache(response.data);
|
|
|
|
|
|
if (response.data.participants?.length) {
|
|
|
|
|
|
await saveUsersCache(response.data.participants);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (response.data.last_message?.sender) {
|
|
|
|
|
|
await saveUsersCache([response.data.last_message.sender]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private normalizeCachedDetail(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
cached: any
|
|
|
|
|
|
): ConversationDetailResponse {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: String(cached?.id || id),
|
|
|
|
|
|
type: cached?.type || 'private',
|
|
|
|
|
|
is_pinned: Boolean(cached?.is_pinned),
|
|
|
|
|
|
last_seq: Number(cached?.last_seq || 0),
|
|
|
|
|
|
last_message: cached?.last_message,
|
|
|
|
|
|
last_message_at: cached?.last_message_at || cached?.updated_at || new Date().toISOString(),
|
|
|
|
|
|
unread_count: Number(cached?.unread_count || 0),
|
|
|
|
|
|
participants: Array.isArray(cached?.participants) ? cached.participants : [],
|
|
|
|
|
|
my_last_read_seq: Number(cached?.my_last_read_seq || 0),
|
|
|
|
|
|
other_last_read_seq: Number(cached?.other_last_read_seq || 0),
|
|
|
|
|
|
created_at: cached?.created_at || new Date().toISOString(),
|
|
|
|
|
|
updated_at: cached?.updated_at || new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 会话相关 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取会话列表
|
|
|
|
|
|
* GET /api/v1/conversations/list
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getConversations(
|
|
|
|
|
|
page = 1,
|
|
|
|
|
|
pageSize = 20,
|
|
|
|
|
|
forceRefresh = false
|
|
|
|
|
|
): Promise<ConversationListResponse> {
|
|
|
|
|
|
// 如果强制刷新,直接从服务器获取,不使用缓存
|
|
|
|
|
|
if (!forceRefresh) {
|
|
|
|
|
|
const cachedList = await getConversationListCache();
|
|
|
|
|
|
if (cachedList.length > 0) {
|
|
|
|
|
|
// 后台刷新数据,但不阻塞当前返回
|
|
|
|
|
|
this.refreshConversationsFromServer(page, pageSize).catch(error => {
|
|
|
|
|
|
console.error('后台刷新会话列表失败:', error);
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
list: cachedList,
|
|
|
|
|
|
total: cachedList.length,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
page_size: cachedList.length,
|
|
|
|
|
|
total_pages: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 强制刷新或没有缓存时,直接从服务器获取
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await this.refreshConversationsFromServer(page, pageSize);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取会话列表失败:', error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
list: [],
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
page_size: pageSize,
|
|
|
|
|
|
total_pages: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 创建私聊会话
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* POST /api/v1/conversations
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* @param userId 目标用户ID (UUID格式)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async createConversation(userId: string): Promise<ConversationResponse> {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
const response = await api.post<ConversationResponse>('/conversations', {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
user_id: userId,
|
|
|
|
|
|
});
|
|
|
|
|
|
await saveConversationCache(response.data);
|
|
|
|
|
|
if (response.data.participants?.length) {
|
|
|
|
|
|
await saveUsersCache(response.data.participants);
|
|
|
|
|
|
}
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取会话详情
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* GET /api/v1/conversations/:id
|
2026-03-09 21:29:03 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async getConversationById(id: string): Promise<ConversationDetailResponse> {
|
|
|
|
|
|
const cached = await getConversationCache(id);
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
this.refreshConversationDetailFromServer(id).catch(error => {
|
|
|
|
|
|
console.error('后台刷新会话详情失败:', error);
|
|
|
|
|
|
});
|
|
|
|
|
|
return this.normalizeCachedDetail(id, cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await this.refreshConversationDetailFromServer(id);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取会话详情失败:', error);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 仅自己删除会话
|
|
|
|
|
|
* DELETE /api/v1/conversations/:id/self
|
|
|
|
|
|
*/
|
|
|
|
|
|
async deleteConversationForSelf(conversationId: string): Promise<void> {
|
|
|
|
|
|
await api.delete(`/conversations/${encodeURIComponent(conversationId)}/self`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置会话置顶
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* PUT /api/v1/conversations/:id/pinned
|
2026-03-09 21:29:03 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async setConversationPinned(conversationId: string, isPinned: boolean): Promise<void> {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
await api.put(`/conversations/${encodeURIComponent(conversationId)}/pinned`, {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
is_pinned: isPinned,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 消息相关 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取消息列表(支持增量同步和历史消息加载)
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* GET /api/v1/conversations/:id/messages
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param afterSeq 获取此序号之后的消息(增量同步,获取新消息)
|
|
|
|
|
|
* @param beforeSeq 获取此序号之前的历史消息(下拉加载更多,获取旧消息)
|
|
|
|
|
|
* @param limit 返回消息数量限制
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getMessages(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
afterSeq?: number,
|
|
|
|
|
|
beforeSeq?: number,
|
|
|
|
|
|
limit?: number
|
|
|
|
|
|
): Promise<MessageListResponse> {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
const params: Record<string, any> = {};
|
2026-03-09 21:29:03 +08:00
|
|
|
|
if (afterSeq !== undefined) {
|
|
|
|
|
|
params.after_seq = afterSeq;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (beforeSeq !== undefined) {
|
|
|
|
|
|
params.before_seq = beforeSeq;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (limit !== undefined) {
|
|
|
|
|
|
params.limit = limit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get<any>(
|
2026-03-10 20:52:50 +08:00
|
|
|
|
`/conversations/${encodeURIComponent(conversationId)}/messages`,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
params
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理增量同步响应(after_seq 或 before_seq)
|
|
|
|
|
|
if ((afterSeq !== undefined || beforeSeq !== undefined) && response.data.messages !== undefined) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
messages: response.data.messages,
|
|
|
|
|
|
total: response.data.messages.length,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
page_size: limit || 100,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理分页响应(后端返回 list 字段)
|
|
|
|
|
|
const data = response.data;
|
|
|
|
|
|
return {
|
|
|
|
|
|
messages: data.list || [],
|
|
|
|
|
|
total: data.total || 0,
|
|
|
|
|
|
page: data.page || 1,
|
|
|
|
|
|
page_size: data.page_size || 20,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取消息列表失败:', error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
messages: [],
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
page_size: 20,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送消息(新格式)
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* POST /api/v1/conversations/:id/messages
|
|
|
|
|
|
* Body: { detail_type, segments }
|
2026-03-09 21:29:03 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async sendMessage(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
data: SendMessageRequest,
|
|
|
|
|
|
detailType: 'private' | 'group' = 'private'
|
|
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
|
|
// 直接使用传入的 segments
|
|
|
|
|
|
if (!data.segments || data.segments.length === 0) {
|
|
|
|
|
|
throw new Error('消息内容不能为空');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return this.sendMessageByAction(
|
|
|
|
|
|
detailType,
|
|
|
|
|
|
conversationId,
|
2026-03-10 20:52:50 +08:00
|
|
|
|
data.segments,
|
|
|
|
|
|
data.reply_to_id
|
2026-03-09 21:29:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通过 HTTP POST 发送消息(新格式)
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* 请求格式: POST /api/v1/conversations/:id/messages
|
|
|
|
|
|
* Body: { "detail_type": "private", "segments": [...] }
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* @param detailType 消息类型: 'private' 私聊, 'group' 群聊
|
|
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param message 消息段数组 [{"type": "text", "data": {"text": "xxx"}}]
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* @param replyToId 回复的消息ID(可选)
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* @returns 消息响应
|
|
|
|
|
|
*/
|
|
|
|
|
|
async sendMessageByAction(
|
|
|
|
|
|
detailType: 'private' | 'group',
|
|
|
|
|
|
conversationId: string,
|
2026-03-10 20:52:50 +08:00
|
|
|
|
message: MessageSegment[],
|
|
|
|
|
|
replyToId?: string
|
2026-03-09 21:29:03 +08:00
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
|
|
try {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
const body: Record<string, any> = {
|
|
|
|
|
|
detail_type: detailType,
|
|
|
|
|
|
segments: message,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (replyToId) {
|
|
|
|
|
|
body.reply_to_id = replyToId;
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const response = await api.post<MessageResponse>(
|
2026-03-10 20:52:50 +08:00
|
|
|
|
`/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
|
|
|
|
body
|
2026-03-09 21:29:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('发送消息失败:', error);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送文本消息(便捷方法)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async sendTextMessage(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
content: string,
|
|
|
|
|
|
detailType: 'private' | 'group' = 'private'
|
|
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
|
|
return this.sendTextMessageByAction(detailType, conversationId, content);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 通过 HTTP POST 发送文本消息
|
|
|
|
|
|
* @param detailType 消息类型: 'private' 私聊, 'group' 群聊
|
|
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param text 文本内容
|
|
|
|
|
|
* @returns 消息响应
|
|
|
|
|
|
*/
|
|
|
|
|
|
async sendTextMessageByAction(
|
|
|
|
|
|
detailType: 'private' | 'group',
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
text: string
|
|
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
|
|
const message: MessageSegment[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
text: text,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
return this.sendMessageByAction(detailType, conversationId, message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送图片消息(便捷方法)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async sendImageMessage(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
mediaUrl: string,
|
|
|
|
|
|
content?: string,
|
|
|
|
|
|
detailType: 'private' | 'group' = 'private'
|
|
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
|
|
const message: MessageSegment[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
url: mediaUrl,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
// 如果有文本说明,添加一个文本段
|
|
|
|
|
|
if (content) {
|
|
|
|
|
|
message.unshift({
|
|
|
|
|
|
type: 'text',
|
|
|
|
|
|
data: { text: content },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.sendMessageByAction(detailType, conversationId, message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送 segments 格式消息(新版统一格式)
|
|
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param segments 消息链数组
|
|
|
|
|
|
* @param detailType 消息类型
|
|
|
|
|
|
*/
|
|
|
|
|
|
async sendSegmentsMessage(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
segments: MessageSegment[],
|
|
|
|
|
|
detailType: 'private' | 'group' = 'private'
|
|
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
|
|
return this.sendMessageByAction(detailType, conversationId, segments);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 发送带回复的 segments 消息
|
|
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param segments 消息链数组
|
|
|
|
|
|
* @param replyToId 被回复消息的ID
|
|
|
|
|
|
* @param detailType 消息类型
|
|
|
|
|
|
*/
|
|
|
|
|
|
async sendReplySegmentsMessage(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
segments: MessageSegment[],
|
|
|
|
|
|
replyToId: string,
|
|
|
|
|
|
detailType: 'private' | 'group' = 'private'
|
|
|
|
|
|
): Promise<MessageResponse> {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
// 使用 reply_to_id 参数发送回复消息
|
|
|
|
|
|
return this.sendMessageByAction(detailType, conversationId, segments, replyToId);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 撤回/删除消息
|
|
|
|
|
|
* POST /api/v1/messages/delete_msg
|
|
|
|
|
|
* Body: { "message_id": "xxx" }
|
|
|
|
|
|
*/
|
|
|
|
|
|
async recallMessage(messageId: string): Promise<void> {
|
|
|
|
|
|
await api.post('/messages/delete_msg', {
|
|
|
|
|
|
message_id: messageId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 删除消息(同撤回)
|
|
|
|
|
|
* POST /api/v1/messages/delete_msg
|
|
|
|
|
|
*/
|
|
|
|
|
|
async deleteMessage(messageId: string): Promise<void> {
|
|
|
|
|
|
await this.recallMessage(messageId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 已读相关 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 标记已读
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* POST /api/v1/conversations/:id/read
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param seq 已读到的消息序号
|
|
|
|
|
|
*/
|
|
|
|
|
|
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
last_read_seq: Number(seq),
|
|
|
|
|
|
});
|
2026-03-25 04:07:48 +08:00
|
|
|
|
// 立即清零本地缓存中的 unread_count,并写入已读游标,避免冷启动仍显示旧红点
|
|
|
|
|
|
updateConversationCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
console.error('更新本地会话未读数失败:', error);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 上报输入状态
|
2026-03-10 20:52:50 +08:00
|
|
|
|
* POST /api/v1/conversations/:id/typing
|
2026-03-10 12:58:23 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async sendTyping(conversationId: string): Promise<void> {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
await api.post(`/conversations/${encodeURIComponent(conversationId)}/typing`);
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取未读总数
|
|
|
|
|
|
* GET /api/v1/conversations/unread/count
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getUnreadCount(): Promise<UnreadCountResponse> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get<UnreadCountResponse>(
|
|
|
|
|
|
'/conversations/unread/count'
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取未读总数失败:', error);
|
|
|
|
|
|
return { total_unread_count: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取单个会话未读数
|
|
|
|
|
|
* GET /api/v1/conversations/unread/count?conversation_id=xxx
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getConversationUnreadCount(
|
|
|
|
|
|
conversationId: string
|
|
|
|
|
|
): Promise<ConversationUnreadCountResponse> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get<ConversationUnreadCountResponse>(
|
|
|
|
|
|
'/conversations/unread/count',
|
|
|
|
|
|
{ conversation_id: conversationId }
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取会话未读数失败:', error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
conversation_id: conversationId,
|
|
|
|
|
|
unread_count: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 系统消息相关 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取系统消息列表
|
|
|
|
|
|
* GET /api/v1/messages/system
|
|
|
|
|
|
* @param limit 返回数量限制
|
|
|
|
|
|
* @param beforeId 获取此ID之前的消息(用于分页)
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getSystemMessages(
|
|
|
|
|
|
pageSize: number = 20,
|
|
|
|
|
|
page: number = 1
|
|
|
|
|
|
): Promise<SystemMessageListResponse> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get<{
|
|
|
|
|
|
list: any[];
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
page_size: number;
|
|
|
|
|
|
total_pages: number;
|
|
|
|
|
|
}>('/messages/system', {
|
|
|
|
|
|
page,
|
|
|
|
|
|
page_size: pageSize,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const data = response.data;
|
|
|
|
|
|
return {
|
|
|
|
|
|
messages: data.list || [],
|
|
|
|
|
|
total: data.total || 0,
|
|
|
|
|
|
has_more: data.page < data.total_pages,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取系统消息列表失败:', error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
messages: [],
|
|
|
|
|
|
total: 0,
|
|
|
|
|
|
has_more: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取系统消息未读数
|
|
|
|
|
|
* GET /api/v1/messages/system/unread-count
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getSystemUnreadCount(): Promise<SystemUnreadCountResponse> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get<SystemUnreadCountResponse>(
|
|
|
|
|
|
'/messages/system/unread-count'
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取系统消息未读数失败:', error);
|
|
|
|
|
|
return { unread_count: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 标记单条系统消息已读
|
|
|
|
|
|
* PUT /api/v1/messages/system/:messageId/read
|
|
|
|
|
|
* @param messageId 消息ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
async markSystemMessageRead(messageId: string): Promise<void> {
|
|
|
|
|
|
await api.put(`/messages/system/${encodeURIComponent(messageId)}/read`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 标记所有系统消息已读
|
|
|
|
|
|
* PUT /api/v1/messages/system/read-all
|
|
|
|
|
|
*/
|
|
|
|
|
|
async markAllSystemMessagesRead(): Promise<void> {
|
|
|
|
|
|
await api.put('/messages/system/read-all');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 23:00:27 +08:00
|
|
|
|
// ==================== 游标分页方法 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取消息列表(游标分页)
|
|
|
|
|
|
* GET /api/v1/conversations/:id/messages/cursor
|
|
|
|
|
|
* @param conversationId 会话ID
|
|
|
|
|
|
* @param params 游标分页请求参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getMessagesCursor(
|
|
|
|
|
|
conversationId: string,
|
|
|
|
|
|
params: CursorPaginationRequest = {}
|
|
|
|
|
|
): Promise<CursorPaginationResponse<MessageResponse>> {
|
|
|
|
|
|
try {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
const response = await api.get<any>(
|
2026-03-20 23:00:27 +08:00
|
|
|
|
`/conversations/${encodeURIComponent(conversationId)}/messages/cursor`,
|
2026-03-23 23:38:53 +08:00
|
|
|
|
params
|
2026-03-20 23:00:27 +08:00
|
|
|
|
);
|
2026-03-23 03:58:26 +08:00
|
|
|
|
const raw = response.data;
|
|
|
|
|
|
return {
|
|
|
|
|
|
list: Array.isArray(raw?.list) ? raw.list : [],
|
|
|
|
|
|
next_cursor: raw?.next_cursor ?? null,
|
|
|
|
|
|
prev_cursor: raw?.prev_cursor ?? null,
|
|
|
|
|
|
has_more: raw?.has_more ?? false,
|
|
|
|
|
|
};
|
2026-03-20 23:00:27 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取消息列表失败:', error);
|
|
|
|
|
|
return {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
list: [],
|
2026-03-20 23:00:27 +08:00
|
|
|
|
next_cursor: null,
|
|
|
|
|
|
prev_cursor: null,
|
|
|
|
|
|
has_more: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取系统消息列表(游标分页)
|
|
|
|
|
|
* GET /api/v1/messages/system/cursor
|
|
|
|
|
|
* @param params 游标分页请求参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getSystemMessagesCursor(
|
|
|
|
|
|
params: CursorPaginationRequest = {}
|
|
|
|
|
|
): Promise<CursorPaginationResponse<SystemMessageResponse>> {
|
|
|
|
|
|
try {
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页
|
|
|
|
|
|
const requestParams: Record<string, any> = {
|
|
|
|
|
|
cursor: params.cursor || '',
|
|
|
|
|
|
page_size: params.page_size,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const response = await api.get<any>('/messages/system', requestParams);
|
|
|
|
|
|
|
|
|
|
|
|
const data = response.data;
|
|
|
|
|
|
|
2026-03-23 03:58:26 +08:00
|
|
|
|
// 游标或带 page/total_pages 的分页(统一为 CursorPaginationResponse:list)
|
2026-03-21 03:22:28 +08:00
|
|
|
|
if (data && typeof data === 'object') {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
const list = Array.isArray(data.list) ? data.list : null;
|
|
|
|
|
|
if (list) {
|
|
|
|
|
|
const isPageShape =
|
|
|
|
|
|
typeof data.page === 'number' &&
|
|
|
|
|
|
typeof data.total_pages === 'number' &&
|
|
|
|
|
|
data.next_cursor === undefined &&
|
|
|
|
|
|
data.prev_cursor === undefined;
|
|
|
|
|
|
if (isPageShape) {
|
|
|
|
|
|
const currentPage = data.page || 1;
|
|
|
|
|
|
const totalPages = data.total_pages || 1;
|
|
|
|
|
|
return {
|
|
|
|
|
|
list,
|
|
|
|
|
|
next_cursor: currentPage < totalPages ? String(currentPage + 1) : null,
|
|
|
|
|
|
prev_cursor: currentPage > 1 ? String(currentPage - 1) : null,
|
|
|
|
|
|
has_more: currentPage < totalPages,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-21 03:22:28 +08:00
|
|
|
|
return {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
list,
|
2026-03-21 03:22:28 +08:00
|
|
|
|
next_cursor: data.next_cursor ?? null,
|
|
|
|
|
|
prev_cursor: data.prev_cursor ?? null,
|
|
|
|
|
|
has_more: data.has_more ?? false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-23 03:58:26 +08:00
|
|
|
|
|
2026-03-21 03:22:28 +08:00
|
|
|
|
return {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
list: [],
|
2026-03-21 03:22:28 +08:00
|
|
|
|
next_cursor: null,
|
|
|
|
|
|
prev_cursor: null,
|
|
|
|
|
|
has_more: false,
|
|
|
|
|
|
};
|
2026-03-20 23:00:27 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取系统消息列表失败:', error);
|
|
|
|
|
|
return {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
list: [],
|
2026-03-20 23:00:27 +08:00
|
|
|
|
next_cursor: null,
|
|
|
|
|
|
prev_cursor: null,
|
|
|
|
|
|
has_more: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 导出消息服务实例
|
|
|
|
|
|
export const messageService = new MessageService();
|
|
|
|
|
|
|
|
|
|
|
|
// 导出类型以方便使用
|
|
|
|
|
|
export type {
|
|
|
|
|
|
ConversationListResponse,
|
|
|
|
|
|
ConversationResponse,
|
|
|
|
|
|
ConversationDetailResponse,
|
|
|
|
|
|
MessageListResponse,
|
|
|
|
|
|
MessageResponse,
|
|
|
|
|
|
SendMessageRequest,
|
|
|
|
|
|
UnreadCountResponse,
|
|
|
|
|
|
ConversationUnreadCountResponse,
|
|
|
|
|
|
SystemMessageListResponse,
|
|
|
|
|
|
SystemUnreadCountResponse,
|
|
|
|
|
|
};
|