Files
frontend/src/stores/message/services/MessageSendService.ts
lafay f9b0999112
Some checks failed
Frontend CI / build-android-apk (push) Waiting to run
Frontend CI / ota (android) (push) Successful in 1m52s
Frontend CI / ota (ios) (push) Successful in 2m36s
Frontend CI / build-and-push-web (push) Failing after 1m15s
feat(messaging): implement Outbox persistence with three-tier sync strategy
Add Outbox pattern for reliable message sending with offline recovery:

- Persist pending/failed messages to SQLite (status field extended with 'pending'|'failed')
- App restart scans pending messages and auto-retries failed sends
- Add versioned migration system (PRAGMA user_version) for future schema evolution

Implement three-tier sync strategy for precision and efficiency:

- syncByVersion: precise incremental sync using persisted cursor (Matrix-style)
- syncBySeq: fallback seq-based incremental sync
- Full refresh: last resort for recovery

Add conversation sync state machine for UI error visibility:

- 'idle' | 'hydrating' | 'synced' | 'error' states exposed via useMessages hook
- Enables loading/error indicators during hydration pipeline

Standardize error handling across message services using createErrorHandler.

Add tests: messageOutbox.test.ts, syncByVersion.test.ts, syncState.test.ts
2026-06-28 23:19:52 +08:00

228 lines
8.5 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.
/**
* 消息发送服务
* 处理消息发送相关逻辑
* 实现乐观更新:先显示消息,再发送到服务器
*/
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
import { messageService } from '@/services/message';
import { messageRepository } from '@/database';
import type { IMessageSendService } from '../types';
import { useMessageStore } from '../store';
import { createErrorHandler } from '@/services/core';
const errorHandler = createErrorHandler('MessageSendService');
// 临时ID前缀用于乐观更新
const TEMP_ID_PREFIX = 'temp_';
// 全局自增计数器:与 Date.now() + Math.random() 组合,彻底消除同一毫秒内
// 连续发送导致的临时 ID 碰撞风险addOrReplaceMessage 按 id 幂等,碰撞会吞掉消息)。
let tempIdCounter = 0;
export class MessageSendService implements IMessageSendService {
private getCurrentUserId: () => string | null;
constructor(getCurrentUserId: () => string | null) {
this.getCurrentUserId = getCurrentUserId;
}
/**
* 生成临时消息ID
* 组合 timestamp + 自增计数器 + 随机串,保证全局唯一。
*/
private generateTempId(): string {
return `${TEMP_ID_PREFIX}${Date.now()}_${++tempIdCounter}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* 发送消息(乐观更新模式)
* 1. 立即创建临时消息并显示
* 2. 后台发送到服务器
* 3. 成功:更新为正式消息
* 4. 失败:标记为发送失败
*
* @param detailType 会话类型,'private' 优先走 WebSocket失败降级 HTTP
* 'group' 直接走 HTTP。默认 'private'。
*/
async sendMessage(
conversationId: string,
segments: MessageSegment[],
options?: { replyToId?: string; detailType?: 'private' | 'group' }
): Promise<MessageResponse | null> {
const store = useMessageStore.getState();
const currentUserId = this.getCurrentUserId();
const detailType = options?.detailType ?? 'private';
// 1. 创建临时消息(乐观显示)
const tempId = this.generateTempId();
const tempMessage: MessageResponse = {
id: tempId,
conversation_id: conversationId,
sender_id: currentUserId || '',
seq: 0, // 临时消息seq为0
segments,
reply_to_id: options?.replyToId,
created_at: new Date().toISOString(),
status: 'pending', // 标记为发送中
};
// 2. 立即添加到store用户可以马上看到
store.addOrReplaceMessage(conversationId, tempMessage);
// Outbox临时消息落库status='pending'),保证 App 重启后能恢复未完成的发送
this.persistMessage(tempMessage);
// 3. 更新会话最后消息(乐观更新)
this.updateConversationWithNewMessage(conversationId, tempMessage);
try {
// 4. 调用API发送
const response = await messageService.sendMessage(conversationId, {
segments,
reply_to_id: options?.replyToId,
}, detailType);
if (response) {
// 5. 发送成功:用服务器返回的数据更新临时消息
const successMessage: MessageResponse = {
id: response.id,
conversation_id: conversationId,
sender_id: currentUserId || '',
seq: response.seq,
segments,
reply_to_id: options?.replyToId,
created_at: new Date().toISOString(),
status: 'normal',
};
// 删除临时消息,添加正式消息
store.removeMessage(conversationId, tempId);
store.addOrReplaceMessage(conversationId, successMessage);
// 更新会话最后消息
this.updateConversationWithNewMessage(conversationId, successMessage);
// Outbox成功后持久化正式消息INSERT OR REPLACE 按 id 覆盖),
// 并删除 tempId 的 DB 记录,避免重启后重复恢复
this.persistMessage(successMessage);
messageRepository.delete(tempId).catch((e: unknown) => errorHandler(e));
return successMessage;
}
// 如果服务器没有返回有效响应,标记为失败
const failedMessage: MessageResponse = {
...tempMessage,
status: 'failed',
};
// 注意addOrReplaceMessage 按 id 幂等(已存在则不更新),改用 patchMessages 更新 status
const patches = new Map<string, Partial<MessageResponse>>();
patches.set(String(failedMessage.id), { status: 'failed' });
store.patchMessages(conversationId, patches);
// Outbox失败消息落库
this.persistMessage(failedMessage);
return null;
} catch (error) {
errorHandler(error);
// 6. 发送失败:更新消息状态为失败
const failedMessage: MessageResponse = {
...tempMessage,
status: 'failed',
};
// addOrReplaceMessage 按 id 幂等不更新,改用 patchMessages 更新 status
const patches = new Map<string, Partial<MessageResponse>>();
patches.set(String(failedMessage.id), { status: 'failed' });
store.patchMessages(conversationId, patches);
// Outbox失败消息落库
this.persistMessage(failedMessage);
return null;
}
}
/**
* 重试发送失败的消息
*
* 实现要点:
* 1. 先移除旧的失败消息(临时 ID避免与即将插入的新 pending 消息并存出现两条。
* 2. 复用原 segments 与 replyToId 调用 sendMessage由它重新走完整乐观更新流程
* (插入新 pending → 发送 → 成功替换为正式消息 / 失败再次标记 failed
* 3. detailType 无法从失败消息反推,默认 private
* 群聊失败消息重试走 HTTPsendMessage 的 group 分支)也能成功。
*/
async retrySendMessage(
conversationId: string,
failedMessage: MessageResponse
): Promise<MessageResponse | null> {
if (failedMessage.status !== 'failed') {
console.warn('[MessageSendService] 只能重试发送失败的消息');
return null;
}
const store = useMessageStore.getState();
// 移除旧的失败消息,避免 sendMessage 新插入的 pending 消息与之并存
store.removeMessage(conversationId, String(failedMessage.id));
// Outbox同步删除 DB 中旧的 failed 记录,避免重启后 getPendingOrFailed 重复恢复
// sendMessage 会用新 tempId 重新落库 pending重试成功后正常落库 normal 并删新 tempId
messageRepository.delete(String(failedMessage.id))
.catch((e: unknown) => errorHandler(e));
// 重新发送sendMessage 会重新插入 pending 临时消息并完成后续流程
return this.sendMessage(conversationId, failedMessage.segments, {
replyToId: failedMessage.reply_to_id,
});
}
/**
* 持久化消息到本地数据库Outbox 机制)。
* 把 MessageResponseDTO映射为 CachedMessageDB 行并落库INSERT OR REPLACE 按 id 幂等)。
* 用于 pending/failed/normal 三态消息的持久化,保证 App 重启后发送状态可恢复。
* 落库失败不阻断 UIfire-and-forget + 日志),最坏情况是重启后丢失该条未完成发送。
*/
private persistMessage(message: MessageResponse): void {
const segments = message.segments || [];
const textContent = segments
.filter((s: any) => s.type === 'text')
.map((s: any) => s.data?.text || '')
.join('') || '';
messageRepository.saveMessage({
id: String(message.id),
conversationId: message.conversation_id,
senderId: message.sender_id,
content: textContent,
type: segments.find((s: any) => s.type === 'image') ? 'image' : 'text',
isRead: true,
createdAt: message.created_at,
seq: message.seq,
status: message.status,
segments,
}).catch((e: unknown) => errorHandler(e));
}
/**
* 更新会话的最后消息信息
*/
private updateConversationWithNewMessage(
conversationId: string,
message: MessageResponse
): void {
const store = useMessageStore.getState();
const conversation = store.getConversation(conversationId);
const createdAt = message.created_at || new Date().toISOString();
if (conversation) {
// 更新现有会话
const updatedConv: ConversationResponse = {
...conversation,
last_seq: message.seq > 0 ? message.seq : conversation.last_seq,
last_message: message,
last_message_at: createdAt,
updated_at: createdAt,
};
store.updateConversation(updatedConv);
}
}
}