feat: 优化架构
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m39s
Frontend CI / ota-android (push) Successful in 10m22s
Frontend CI / build-android-apk (push) Has been cancelled

This commit is contained in:
lafay
2026-04-13 01:30:37 +08:00
parent 2adc9360a5
commit fe6a03da5d
93 changed files with 386 additions and 211 deletions

View File

@@ -0,0 +1,460 @@
/**
* 群组服务
* 处理群组管理、成员管理、群设置、群公告等功能
* 使用 RESTful API: /api/v1/groups
*/
import { api } from '../core/api';
import {
GroupResponse,
GroupMemberResponse,
GroupAnnouncementResponse,
GroupListResponse,
GroupMemberListResponse,
GroupAnnouncementListResponse,
CreateGroupRequest,
InviteMembersRequest,
TransferOwnerRequest,
SetRoleRequest,
SetNicknameRequest,
SetMuteAllRequest,
SetJoinTypeRequest,
CreateAnnouncementRequest,
MyMemberInfoResponse,
SetGroupAvatarRequest,
HandleGroupRequestAction,
CursorPaginationRequest,
CursorPaginationResponse,
} from '@/types/dto';
function asGroupId(raw: unknown): string {
return String(raw);
}
function normalizeGroup(g: GroupResponse): GroupResponse {
return { ...g, id: asGroupId(g.id) };
}
function normalizeMember(m: GroupMemberResponse): GroupMemberResponse {
return { ...m, group_id: asGroupId(m.group_id) };
}
function normalizeAnnouncement(a: GroupAnnouncementResponse): GroupAnnouncementResponse {
return { ...a, group_id: asGroupId(a.group_id) };
}
function normalizeGroupListResponse(r: GroupListResponse): GroupListResponse {
return { ...r, list: r.list.map(normalizeGroup) };
}
function normalizeMemberListResponse(r: GroupMemberListResponse): GroupMemberListResponse {
return { ...r, list: r.list.map(normalizeMember) };
}
function normalizeAnnouncementListResponse(
r: GroupAnnouncementListResponse
): GroupAnnouncementListResponse {
return { ...r, list: r.list.map(normalizeAnnouncement) };
}
function normalizeGroupCursor(
r: CursorPaginationResponse<GroupResponse>
): CursorPaginationResponse<GroupResponse> {
return { ...r, list: r.list.map(normalizeGroup) };
}
function normalizeMemberCursor(
r: CursorPaginationResponse<GroupMemberResponse>
): CursorPaginationResponse<GroupMemberResponse> {
return { ...r, list: r.list.map(normalizeMember) };
}
function normalizeAnnouncementCursor(
r: CursorPaginationResponse<GroupAnnouncementResponse>
): CursorPaginationResponse<GroupAnnouncementResponse> {
return { ...r, list: r.list.map(normalizeAnnouncement) };
}
// 群组服务类(纯 API 层)
// SQLite/内存缓存编排由 groupManager 统一处理
class GroupService {
// ==================== 群组管理 ====================
/**
* 获取群组列表
* GET /api/v1/groups
*/
async getGroups(page = 1, pageSize = 20): Promise<GroupListResponse> {
const response = await api.get<GroupListResponse>('/groups', {
page,
page_size: pageSize,
});
return normalizeGroupListResponse(response.data);
}
/**
* 获取群组详情
* GET /api/v1/groups/:id
*/
async getGroup(id: string): Promise<GroupResponse> {
const response = await api.get<GroupResponse>(`/groups/${encodeURIComponent(id)}`);
return normalizeGroup(response.data);
}
/**
* 获取群组成员列表
* GET /api/v1/groups/:id/members
*/
async getMembers(groupId: string, page = 1, pageSize = 50): Promise<GroupMemberListResponse> {
const response = await api.get<GroupMemberListResponse>(
`/groups/${encodeURIComponent(groupId)}/members`,
{
page,
page_size: pageSize,
}
);
return normalizeMemberListResponse(response.data);
}
/**
* 创建群组
* POST /api/v1/groups
*/
async createGroup(data: CreateGroupRequest): Promise<GroupResponse> {
const response = await api.post<GroupResponse>('/groups', data);
return normalizeGroup(response.data);
}
/**
* 获取当前用户在群组中的成员信息
* GET /api/v1/groups/:id/me
*/
async getMyMemberInfo(groupId: string): Promise<MyMemberInfoResponse> {
const response = await api.get<MyMemberInfoResponse>(
`/groups/${encodeURIComponent(groupId)}/me`
);
return {
...response.data,
member: normalizeMember(response.data.member),
};
}
/**
* 解散群组
* DELETE /api/v1/groups/:id
*/
async dissolveGroup(id: string): Promise<void> {
await api.delete(`/groups/${encodeURIComponent(id)}`);
}
/**
* 转让群主
* POST /api/v1/groups/:id/transfer
*/
async transferOwner(id: string, data: TransferOwnerRequest): Promise<void> {
await api.post(`/groups/${encodeURIComponent(id)}/transfer`, data);
}
// ==================== 成员管理 ====================
/**
* 邀请成员加入群组
* POST /api/v1/groups/:id/invitations
*/
async inviteMembers(groupId: string, data: InviteMembersRequest): Promise<void> {
await api.post(`/groups/${encodeURIComponent(groupId)}/invitations`, data);
}
/**
* 申请加入群组
* POST /api/v1/groups/:id/join-requests
*/
async joinGroup(groupId: string): Promise<void> {
await api.post(`/groups/${encodeURIComponent(groupId)}/join-requests`);
}
/**
* 响应群邀请/申请
* POST /api/v1/groups/:id/join-requests/respond
*/
async respondInvite(groupId: string, data: HandleGroupRequestAction): Promise<void> {
await api.post(
`/groups/${encodeURIComponent(groupId)}/join-requests/respond`,
data
);
}
/**
* 审批主动加群申请
* POST /api/v1/groups/:id/join-requests/handle
*/
async reviewJoinRequest(groupId: string, data: HandleGroupRequestAction): Promise<void> {
await api.post(
`/groups/${encodeURIComponent(groupId)}/join-requests/handle`,
data
);
}
/**
* 退出群组
* POST /api/v1/groups/:id/leave
*/
async leaveGroup(groupId: string): Promise<void> {
await api.post(`/groups/${encodeURIComponent(groupId)}/leave`);
}
/**
* 移除群成员(踢人)
* POST /api/v1/groups/:id/members/kick
*/
async removeMember(groupId: string, userId: string): Promise<void> {
await api.post(`/groups/${encodeURIComponent(groupId)}/members/kick`, {
user_id: userId,
reject_add_request: false,
});
}
/**
* 设置成员角色(设置/取消管理员)
* PUT /api/v1/groups/:id/members/:user_id/admin
*/
async setMemberRole(
groupId: string,
userId: string,
data: SetRoleRequest
): Promise<void> {
await api.put(
`/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}/admin`,
{
enable: data.role === 'admin',
}
);
}
/**
* 设置群昵称
* PUT /api/v1/groups/:id/members/me/nickname
*/
async setNickname(groupId: string, data: SetNicknameRequest): Promise<void> {
await api.put(`/groups/${encodeURIComponent(groupId)}/members/me/nickname`, data);
}
/**
* 禁言/解禁成员
* POST /api/v1/groups/:id/members/ban
* @param groupId 群组ID
* @param userId 用户ID
* @param duration 禁言时长0表示解除禁言
*/
async muteMember(
groupId: string,
userId: string,
duration: number = 0
): Promise<void> {
await api.post(`/groups/${encodeURIComponent(groupId)}/members/ban`, {
user_id: userId,
duration: duration,
});
}
// ==================== 群设置 ====================
/**
* 设置全员禁言
* PUT /api/v1/groups/:id/ban
*/
async setMuteAll(groupId: string, data: SetMuteAllRequest): Promise<void> {
await api.put(`/groups/${encodeURIComponent(groupId)}/ban`, {
enable: data.mute_all,
});
}
/**
* 设置加群方式
* PUT /api/v1/groups/:id/join-type
*/
async setJoinType(groupId: string, data: SetJoinTypeRequest): Promise<void> {
await api.put(`/groups/${encodeURIComponent(groupId)}/join-type`, data);
}
/**
* 设置群名称
* PUT /api/v1/groups/:id/name
*/
async setGroupName(groupId: string, groupName: string): Promise<void> {
await api.put(`/groups/${encodeURIComponent(groupId)}/name`, {
group_name: groupName,
});
}
/**
* 设置群头像
* PUT /api/v1/groups/:id/avatar
*/
async setGroupAvatar(groupId: string, avatarUrl: string): Promise<GroupResponse> {
const response = await api.put<GroupResponse>(
`/groups/${encodeURIComponent(groupId)}/avatar`,
{
avatar: avatarUrl,
}
);
return normalizeGroup(response.data);
}
// ==================== 群公告 ====================
/**
* 创建群公告
* POST /api/v1/groups/:id/announcements
*/
async createAnnouncement(
groupId: string,
data: CreateAnnouncementRequest
): Promise<GroupAnnouncementResponse> {
const response = await api.post<GroupAnnouncementResponse>(
`/groups/${encodeURIComponent(groupId)}/announcements`,
data
);
return normalizeAnnouncement(response.data);
}
/**
* 获取群公告列表
* GET /api/v1/groups/:id/announcements
*/
async getAnnouncements(
groupId: string,
page = 1,
pageSize = 20
): Promise<GroupAnnouncementListResponse> {
try {
const response = await api.get<GroupAnnouncementListResponse>(
`/groups/${encodeURIComponent(groupId)}/announcements`,
{
page,
page_size: pageSize,
}
);
return normalizeAnnouncementListResponse(response.data);
} catch (error) {
console.error('获取群公告列表失败:', error);
return {
list: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
}
/**
* 删除群公告
* DELETE /api/v1/groups/:id/announcements/:announcement_id
*/
async deleteAnnouncement(groupId: string, announcementId: number): Promise<void> {
await api.delete(
`/groups/${encodeURIComponent(groupId)}/announcements/${encodeURIComponent(String(announcementId))}`
);
}
// ==================== 游标分页方法 ====================
/**
* 获取群组列表(游标分页)
* GET /api/v1/groups/cursor
* @param params 游标分页请求参数
*/
async getGroupsCursor(
params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<GroupResponse>> {
try {
const response = await api.get<CursorPaginationResponse<GroupResponse>>('/groups/cursor', params);
return normalizeGroupCursor(response.data);
} catch (error) {
console.error('获取群组列表失败:', error);
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
}
}
/**
* 获取群组成员列表(游标分页)
* GET /api/v1/groups/:id/members/cursor
* @param groupId 群组ID
* @param params 游标分页请求参数
*/
async getGroupMembersCursor(
groupId: string,
params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<GroupMemberResponse>> {
try {
const response = await api.get<CursorPaginationResponse<GroupMemberResponse>>(
`/groups/${encodeURIComponent(groupId)}/members/cursor`,
params
);
return normalizeMemberCursor(response.data);
} catch (error) {
console.error('获取群组成员列表失败:', error);
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
}
}
/**
* 获取群公告列表(游标分页)
* GET /api/v1/groups/:id/announcements/cursor
* @param groupId 群组ID
* @param params 游标分页请求参数
*/
async getGroupAnnouncementsCursor(
groupId: string,
params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<GroupAnnouncementResponse>> {
try {
const response = await api.get<CursorPaginationResponse<GroupAnnouncementResponse>>(
`/groups/${encodeURIComponent(groupId)}/announcements/cursor`,
params
);
return normalizeAnnouncementCursor(response.data);
} catch (error) {
console.error('获取群公告列表失败:', error);
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
}
}
}
// 导出群组服务实例
export const groupService = new GroupService();
// 导出类型以方便使用
export type {
GroupResponse,
GroupMemberResponse,
GroupAnnouncementResponse,
GroupListResponse,
GroupMemberListResponse,
GroupAnnouncementListResponse,
CreateGroupRequest,
InviteMembersRequest,
TransferOwnerRequest,
SetRoleRequest,
SetNicknameRequest,
SetMuteAllRequest,
SetJoinTypeRequest,
CreateAnnouncementRequest,
SetGroupAvatarRequest,
};

View File

@@ -0,0 +1,42 @@
export { messageService } from './messageService';
export type { RemoteConversationListPageResult } from './messageService';
export type {
ConversationListResponse,
ConversationResponse,
ConversationDetailResponse,
MessageListResponse,
MessageResponse,
SendMessageRequest,
UnreadCountResponse,
ConversationUnreadCountResponse,
SystemMessageListResponse,
SystemUnreadCountResponse,
} from './messageService';
export { groupService } from './groupService';
export type {
GroupResponse,
GroupMemberResponse,
GroupAnnouncementResponse,
GroupListResponse,
GroupMemberListResponse,
GroupAnnouncementListResponse,
CreateGroupRequest,
InviteMembersRequest,
TransferOwnerRequest,
SetRoleRequest,
SetNicknameRequest,
SetMuteAllRequest,
SetJoinTypeRequest,
CreateAnnouncementRequest,
} from './groupService';
export type { CustomSticker } from './stickerService';
export {
getCustomStickers,
addStickerFromUrl,
deleteSticker,
isStickerExists,
reorderStickers,
batchDeleteStickers,
} from './stickerService';

View File

@@ -0,0 +1,804 @@
/**
* 消息服务
* 处理私信/聊天消息功能
* 使用新的 API 接口: /api/v1/conversations (RESTful action 风格)
*/
import { api } from '../core/api';
import { wsService } from '../core/wsService';
import {
ConversationListResponse,
ConversationResponse,
ConversationDetailResponse,
MessageListResponse,
MessageResponse,
SendMessageRequest,
UnreadCountResponse,
ConversationUnreadCountResponse,
SystemMessageListResponse,
SystemMessageResponse,
SystemUnreadCountResponse,
MessageSegment,
CursorPaginationRequest,
CursorPaginationResponse,
} from '@/types/dto';
import { conversationRepository, userCacheRepository } 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 conversationRepository.saveWithRelated(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 conversationRepository.saveCache(response.data);
if (response.data.participants?.length) {
await userCacheRepository.saveBatch(response.data.participants);
}
if (response.data.last_message?.sender) {
await userCacheRepository.save(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 conversationRepository.getListCache();
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 conversationRepository.saveCache(response.data);
if (response.data.participants?.length) {
await userCacheRepository.saveBatch(response.data.participants);
}
return response.data;
}
/**
* 获取会话详情
* GET /api/v1/conversations/:id
*/
async getConversationById(id: string): Promise<ConversationDetailResponse> {
const cached = await conversationRepository.getCache(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: any) {
// 检查是否是权限错误或会话不存在错误 - 这是正常情况,不需要打印错误日志
const errorMessage = error?.message || String(error);
if (
errorMessage.includes('not found') ||
errorMessage.includes('no permission') ||
error?.code === 'NOT_FOUND' ||
error?.code === 'FORBIDDEN'
) {
// 会话不存在或无权限访问,返回空消息列表
// 这可能是因为会话已被删除、用户被踢出群聊等情况
console.warn('[MessageService] 会话不存在或无权限访问:', conversationId);
} else {
// 其他错误才打印错误日志
console.error('[MessageService] 获取消息列表失败:', error);
}
return {
messages: [],
total: 0,
page: 1,
page_size: 20,
};
}
}
/**
* 发送消息(新格式)
* 优先使用WebSocket发送失败时降级到HTTP
* 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('消息内容不能为空');
}
// 优先使用WebSocket发送
try {
const result = await wsService.sendMessage(
conversationId,
detailType,
data.segments,
data.reply_to_id
);
return result;
} catch (error) {
console.log('WebSocket发送失败降级到HTTP:', error);
// 降级到HTTP发送
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);
}
/**
* 撤回/删除消息
* 优先使用WebSocket失败时降级到HTTP
* POST /api/v1/messages/delete_msg
* Body: { "message_id": "xxx" }
*/
async recallMessage(messageId: string): Promise<void> {
try {
await wsService.recallMessage(messageId);
} catch (error) {
console.log('WebSocket撤回失败降级到HTTP:', error);
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);
}
// ==================== 已读相关 ====================
/**
* 标记已读
* 优先使用WebSocket失败时降级到HTTP
* POST /api/v1/conversations/:id/read
* @param conversationId 会话ID
* @param seq 已读到的消息序号
*/
async markAsRead(conversationId: string, seq: number): Promise<void> {
// 使用WebSocket发送已读标记不等待响应
wsService.markRead(conversationId, seq).catch(() => {});
// 同时通过HTTP确保可靠性
await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, {
last_read_seq: Number(seq),
});
// 立即清零本地缓存中的 unread_count并写入已读游标避免冷启动仍显示旧红点
conversationRepository.updateCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => {
console.error('更新本地会话未读数失败:', error);
});
}
/**
* 上报输入状态
* 优先使用WebSocket失败时降级到HTTP
* POST /api/v1/conversations/:id/typing
*/
async sendTyping(conversationId: string): Promise<void> {
// 使用WebSocket发送输入状态
wsService.sendTyping(conversationId, true);
// 同时通过HTTP确保可靠性
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,
};

View File

@@ -0,0 +1,144 @@
/**
* 自定义表情服务
* 管理用户自定义添加的表情包(云端存储)
*/
import { api } from '../core/api';
import { uploadService } from '../upload/uploadService';
// 自定义表情类型
export interface CustomSticker {
id: string;
user_id: string;
url: string;
width: number;
height: number;
sort_order: number;
created_at: string;
updated_at: string;
}
// API 响应类型
interface GetStickersResponse {
stickers: CustomSticker[];
}
interface AddStickerResponse {
sticker: CustomSticker;
}
interface CheckStickerResponse {
exists: boolean;
}
/**
* 获取所有自定义表情
*/
export const getCustomStickers = async (): Promise<CustomSticker[]> => {
try {
const response = await api.get<GetStickersResponse>('/stickers');
return response.data.stickers || [];
} catch (error) {
console.error('获取自定义表情失败:', error);
return [];
}
};
/**
* 添加自定义表情从URL
*/
export const addStickerFromUrl = async (
imageUrl: string,
width?: number,
height?: number
): Promise<CustomSticker | null> => {
try {
let finalUrl = imageUrl;
const lowerUrl = imageUrl.toLowerCase();
// 本地图片 URI 不能直接落库,先上传换成可访问的远程 URL
if (lowerUrl.startsWith('file://') || lowerUrl.startsWith('content://')) {
const uploadResult = await uploadService.uploadImage({ uri: imageUrl });
if (!uploadResult?.url) {
throw new Error('upload sticker image failed');
}
finalUrl = uploadResult.url;
}
const response = await api.post<AddStickerResponse>('/stickers', {
url: finalUrl,
width: width || 0,
height: height || 0,
});
return response.data.sticker;
} catch (error: any) {
if (error.response?.status === 409) {
return null;
}
console.error('添加自定义表情失败:', error);
throw error;
}
};
/**
* 删除自定义表情
*/
export const deleteSticker = async (stickerId: string): Promise<boolean> => {
try {
await api.delete('/stickers', {
data: { sticker_id: stickerId },
});
return true;
} catch (error) {
console.error('删除自定义表情失败:', error);
return false;
}
};
/**
* 检查图片是否已经是自定义表情
*/
export const isStickerExists = async (imageUrl: string): Promise<boolean> => {
try {
const response = await api.get<CheckStickerResponse>('/stickers/check', { url: imageUrl });
return response.data.exists;
} catch (error) {
console.error('检查表情是否存在失败:', error);
return false;
}
};
/**
* 重新排序表情
*/
export const reorderStickers = async (orders: Record<string, number>): Promise<boolean> => {
try {
await api.post('/stickers/reorder', { orders });
return true;
} catch (error) {
console.error('重新排序表情失败:', error);
return false;
}
};
/**
* 批量删除表情
*/
export const batchDeleteStickers = async (stickerIds: string[]): Promise<{ success: number; failed: number }> => {
let success = 0;
let failed = 0;
for (const stickerId of stickerIds) {
try {
await api.delete('/stickers', {
data: { sticker_id: stickerId },
});
success++;
} catch (error) {
console.error(`删除表情 ${stickerId} 失败:`, error);
failed++;
}
}
return { success, failed };
};