feat(messaging): implement optimistic updates with retry for failed messages
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m24s
Frontend CI / ota (ios) (push) Successful in 3m22s
Frontend CI / build-and-push-web (push) Successful in 21m12s
Frontend CI / build-android-apk (push) Successful in 41m40s

- Add pending/failed message status to track send state
- Implement optimistic UI: show message immediately, update on server response
- Add retry functionality for failed messages via tap-to-retry
- Change send button from icon to text "发送" for clarity
- Add MessageSendService with temp ID generation to prevent collisions

feat(notification): add JPush notification deduplication and clear on tap

- Deduplicate notificationArrived events from dual JPush/vendor channels
- Clear all notifications on tap (QQ-style behavior)

feat(post): add client-side idempotency key for post creation

- Generate client_request_id per publish intent to prevent duplicates on retry
- Pass idempotency key through to postService and voteService
This commit is contained in:
lafay
2026-06-26 17:01:09 +08:00
parent 2bad59afbb
commit be77a9d04c
20 changed files with 451 additions and 134 deletions

View File

@@ -1,6 +1,7 @@
/**
* 消息发送服务
* 处理消息发送相关逻辑
* 实现乐观更新:先显示消息,再发送到服务器
*/
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
@@ -9,6 +10,12 @@ import { messageRepository } from '@/database';
import type { IMessageSendService } from '../types';
import { useMessageStore } from '../store';
// 临时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;
@@ -17,47 +24,84 @@ export class MessageSendService implements IMessageSendService {
}
/**
* 发送消息
* 生成临时消息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 }
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);
// 3. 更新会话最后消息(乐观更新)
this.updateConversationWithNewMessage(conversationId, tempMessage);
try {
// 调用API发送
// 4. 调用API发送
const response = await messageService.sendMessage(conversationId, {
segments,
reply_to_id: options?.replyToId,
});
}, detailType);
if (response) {
const currentUserId = this.getCurrentUserId();
// 原子性合并新消息到内存(防止并发 WS 消息丢失)
const newMessage: MessageResponse = {
// 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.mergeMessages(conversationId, [newMessage]);
// 删除临时消息,添加正式消息
store.removeMessage(conversationId, tempId);
store.addOrReplaceMessage(conversationId, successMessage);
// 更新会话最后消息
this.updateConversationWithNewMessage(conversationId, newMessage);
this.updateConversationWithNewMessage(conversationId, successMessage);
// 保存到本地数据库
const textContent = segments
?.filter((s: any) => s.type === 'text')
.map((s: any) => s.data?.text || '')
.join('') || '';
messageRepository.saveMessage({
id: response.id,
conversationId,
@@ -65,22 +109,65 @@ export class MessageSendService implements IMessageSendService {
content: textContent,
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
isRead: true,
createdAt: newMessage.created_at,
createdAt: successMessage.created_at,
seq: response.seq,
status: 'normal',
segments,
}).catch(console.error);
return newMessage;
return successMessage;
}
// 如果服务器没有返回有效响应,标记为失败
const failedMessage: MessageResponse = {
...tempMessage,
status: 'failed',
};
store.addOrReplaceMessage(conversationId, failedMessage);
return null;
} catch (error) {
console.error('[MessageSendService] 发送消息失败:', error);
// 6. 发送失败:更新消息状态为失败
const failedMessage: MessageResponse = {
...tempMessage,
status: 'failed',
};
store.addOrReplaceMessage(conversationId, 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));
// 重新发送sendMessage 会重新插入 pending 临时消息并完成后续流程
return this.sendMessage(conversationId, failedMessage.segments, {
replyToId: failedMessage.reply_to_id,
});
}
/**
* 更新会话的最后消息信息
*/
@@ -96,7 +183,7 @@ export class MessageSendService implements IMessageSendService {
// 更新现有会话
const updatedConv: ConversationResponse = {
...conversation,
last_seq: message.seq,
last_seq: message.seq > 0 ? message.seq : conversation.last_seq,
last_message: message,
last_message_at: createdAt,
updated_at: createdAt,
@@ -105,4 +192,4 @@ export class MessageSendService implements IMessageSendService {
store.updateConversation(updatedConv);
}
}
}
}