Files
frontend/src/services/messageService.ts
lan 4b89b50006
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m4s
Frontend CI / ota-android (push) Successful in 11m9s
Frontend CI / build-android-apk (push) Successful in 1h23m57s
refactor(stores): unify data sources pattern and extract BaseManager base class
- Add BaseManager/CachedManager base classes to eliminate duplicate cache logic
- Add postListSources.ts with IPostListPagedSource interface
- Add groupListSources.ts with IGroupListPagedSource interface
- Refactor postManager to use CachedManager and Sources pattern
- Refactor groupManager to use CachedManager and Sources pattern
- Clean up duplicate methods in groupService.ts
- Fix TypeScript errors and remove mock data
2026-03-26 16:46:22 +08:00

767 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 消息服务
* 处理私信/聊天消息功能
* 使用新的 API 接口: /api/v1/conversations (RESTful action 风格)
*/
import { api } from './api';
import {
ConversationListResponse,
ConversationResponse,
ConversationDetailResponse,
MessageListResponse,
MessageResponse,
SendMessageRequest,
UnreadCountResponse,
ConversationUnreadCountResponse,
SystemMessageListResponse,
SystemMessageResponse,
SystemUnreadCountResponse,
MessageSegment,
CursorPaginationRequest,
CursorPaginationResponse,
} from '../types/dto';
import {
getConversationCache,
getConversationListCache,
saveConversationCache,
saveConversationsWithRelatedCache,
updateConversationCacheUnreadCount,
saveUsersCache,
} from './database';
/** 远端会话列表分页offset / cursor统一结果拉取成功后均已执行本地缓存同步 */
export interface RemoteConversationListPageResult {
items: ConversationResponse[];
hasMore: boolean;
/** cursor 模式下一页游标offset 模式固定为 null */
nextCursor: string | null;
/** offset 模式:下一次 loadNext 应请求的页码cursor 模式为 undefined */
nextPage?: number;
}
// 消息服务类
class MessageService {
/** 与会话 offset 列表一致:将本页会话写入 SQLite避免游标路径不落库导致冷启动未读与线上一致 */
private async persistConversationListPageToLocalCache(
list: ConversationResponse[]
): Promise<void> {
if (!list.length) return;
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);
}
/** 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);
return response.data;
}
/** 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 };
}
private async refreshConversationDetailFromServer(id: string): Promise<ConversationDetailResponse> {
const response = await api.get<ConversationDetailResponse>(
`/conversations/${encodeURIComponent(id)}`
);
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,
};
}
}
/**
* 创建私聊会话
* POST /api/v1/conversations
* @param userId 目标用户ID (UUID格式)
*/
async createConversation(userId: string): Promise<ConversationResponse> {
const response = await api.post<ConversationResponse>('/conversations', {
user_id: userId,
});
await saveConversationCache(response.data);
if (response.data.participants?.length) {
await saveUsersCache(response.data.participants);
}
return response.data;
}
/**
* 获取会话详情
* GET /api/v1/conversations/:id
*/
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`);
}
/**
* 设置会话置顶
* PUT /api/v1/conversations/:id/pinned
*/
async setConversationPinned(conversationId: string, isPinned: boolean): Promise<void> {
await api.put(`/conversations/${encodeURIComponent(conversationId)}/pinned`, {
is_pinned: isPinned,
});
}
// ==================== 消息相关 ====================
/**
* 获取消息列表(支持增量同步和历史消息加载)
* GET /api/v1/conversations/:id/messages
* @param conversationId 会话ID
* @param afterSeq 获取此序号之后的消息(增量同步,获取新消息)
* @param beforeSeq 获取此序号之前的历史消息(下拉加载更多,获取旧消息)
* @param limit 返回消息数量限制
*/
async getMessages(
conversationId: string,
afterSeq?: number,
beforeSeq?: number,
limit?: number
): Promise<MessageListResponse> {
const params: Record<string, any> = {};
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>(
`/conversations/${encodeURIComponent(conversationId)}/messages`,
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,
};
}
}
/**
* 发送消息(新格式)
* POST /api/v1/conversations/:id/messages
* Body: { detail_type, segments }
*/
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,
data.segments,
data.reply_to_id
);
}
/**
* 通过 HTTP POST 发送消息(新格式)
* 请求格式: POST /api/v1/conversations/:id/messages
* Body: { "detail_type": "private", "segments": [...] }
* @param detailType 消息类型: 'private' 私聊, 'group' 群聊
* @param conversationId 会话ID
* @param message 消息段数组 [{"type": "text", "data": {"text": "xxx"}}]
* @param replyToId 回复的消息ID可选
* @returns 消息响应
*/
async sendMessageByAction(
detailType: 'private' | 'group',
conversationId: string,
message: MessageSegment[],
replyToId?: string
): Promise<MessageResponse> {
try {
const body: Record<string, any> = {
detail_type: detailType,
segments: message,
};
if (replyToId) {
body.reply_to_id = replyToId;
}
const response = await api.post<MessageResponse>(
`/conversations/${encodeURIComponent(conversationId)}/messages`,
body
);
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> {
// 使用 reply_to_id 参数发送回复消息
return this.sendMessageByAction(detailType, conversationId, segments, replyToId);
}
/**
* 撤回/删除消息
* 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);
}
// ==================== 已读相关 ====================
/**
* 标记已读
* POST /api/v1/conversations/:id/read
* @param conversationId 会话ID
* @param seq 已读到的消息序号
*/
async markAsRead(conversationId: string, seq: number): Promise<void> {
await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, {
last_read_seq: Number(seq),
});
// 立即清零本地缓存中的 unread_count并写入已读游标避免冷启动仍显示旧红点
updateConversationCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => {
console.error('更新本地会话未读数失败:', error);
});
}
/**
* 上报输入状态
* POST /api/v1/conversations/:id/typing
*/
async sendTyping(conversationId: string): Promise<void> {
await api.post(`/conversations/${encodeURIComponent(conversationId)}/typing`);
}
/**
* 获取未读总数
* 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');
}
// ==================== 游标分页方法 ====================
/**
* 获取消息列表(游标分页)
* GET /api/v1/conversations/:id/messages/cursor
* @param conversationId 会话ID
* @param params 游标分页请求参数
*/
async getMessagesCursor(
conversationId: string,
params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<MessageResponse>> {
try {
const response = await api.get<any>(
`/conversations/${encodeURIComponent(conversationId)}/messages/cursor`,
params
);
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,
};
} catch (error) {
console.error('获取消息列表失败:', error);
return {
list: [],
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 {
// 始终传递 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;
// 游标或带 page/total_pages 的分页(统一为 CursorPaginationResponselist
if (data && typeof data === 'object') {
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,
};
}
return {
list,
next_cursor: data.next_cursor ?? null,
prev_cursor: data.prev_cursor ?? null,
has_more: data.has_more ?? false,
};
}
}
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
} catch (error) {
console.error('获取系统消息列表失败:', error);
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
}
}
}
// 导出消息服务实例
export const messageService = new MessageService();
// 导出类型以方便使用
export type {
ConversationListResponse,
ConversationResponse,
ConversationDetailResponse,
MessageListResponse,
MessageResponse,
SendMessageRequest,
UnreadCountResponse,
ConversationUnreadCountResponse,
SystemMessageListResponse,
SystemUnreadCountResponse,
};