From be77a9d04ce49cf491692d83d7beb6b987640980 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Fri, 26 Jun 2026 17:01:09 +0800 Subject: [PATCH] feat(messaging): implement optimistic updates with retry for failed messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/_layout.tsx | 7 ++ src/screens/create/CreatePostScreen.tsx | 21 +++- src/screens/home/HomeScreen.tsx | 6 +- src/screens/home/PostDetailScreen.tsx | 45 +++---- src/screens/message/ChatScreen.tsx | 3 + .../components/ChatScreen/ChatInput.tsx | 5 +- .../components/ChatScreen/MessageBubble.tsx | 56 +++++++-- .../message/components/ChatScreen/styles.ts | 55 +++++--- .../message/components/ChatScreen/types.ts | 2 + .../components/ChatScreen/useChatScreen.ts | 108 +++++++++------- src/services/notification/jpushService.ts | 40 +++++- src/services/post/postService.ts | 3 +- src/services/post/voteService.ts | 1 + src/stores/message/MessageManager.ts | 9 +- src/stores/message/hooks.ts | 27 +++- .../message/services/MessageSendService.ts | 119 +++++++++++++++--- src/stores/message/types.ts | 6 +- src/types/dto/message.ts | 2 +- src/types/dto/pagination.ts | 2 + src/utils/idempotency.ts | 68 ++++++++++ 20 files changed, 451 insertions(+), 134 deletions(-) create mode 100644 src/utils/idempotency.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 8069ae5..5ae7eaa 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -128,6 +128,13 @@ function NotificationBootstrap() { console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message); if (message.notificationEventType !== 'notificationOpened') return; + // 点击任意一条通知即清除通知栏所有通知(与 QQ 一致:点一条清全部) + // JPush 的 notificationOpened 在通知被点击时触发,此时清除系统通知栏中 + // 本应用的所有通知,避免用户需要逐条点击消除。 + systemNotificationService.clearAllNotifications().catch((error) => { + console.warn('[NotificationBootstrap] clearAllNotifications failed:', error); + }); + const extras = message.extras || {}; const notifType = extras.notification_type || message.notificationType; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 9dbda2e..caccf90 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -58,6 +58,7 @@ import { getImageDisplayUri, uploadAllPendingImages, } from '../../utils/pendingImages'; +import { generateClientRequestId } from '../../utils/idempotency'; // Props 接口 interface CreatePostScreenProps { @@ -163,6 +164,11 @@ export const CreatePostScreen: React.FC = (props) => { const blockEditorRef = React.useRef(null); const [selection, setSelection] = useState({ start: 0, end: 0 }); + // 发帖幂等键:每次进入发布态生成一次,重试时复用同一个,避免重复发帖。 + // - 同一次「发布」意图(含网络重试)复用同一个 ID; + // - 发布成功后立即刷新,便于下一次发布生成新的 ID。 + const clientRequestIdRef = React.useRef(''); + // 动画值 const fadeAnim = React.useRef(new Animated.Value(0)).current; const slideAnim = React.useRef(new Animated.Value(20)).current; @@ -406,7 +412,8 @@ export const CreatePostScreen: React.FC = (props) => { // 发布帖子 const handlePost = async () => { - // 防止重复点击 + // 防止重复点击:posting 为 true 时直接忽略后续点击。 + // 这是第一道防线(UI 层防抖);服务端基于 client_request_id 的幂等检查是第二道防线。 if (posting) return; if (!title.trim()) { @@ -428,6 +435,13 @@ export const CreatePostScreen: React.FC = (props) => { setPosting(true); + // 为本次「发布」意图生成幂等键。同一意图的网络重试复用同一个 ID, + // 使服务端能识别并去重。发布成功后会刷新,供下次发布生成新 ID。 + if (!clientRequestIdRef.current) { + clientRequestIdRef.current = generateClientRequestId(); + } + const clientRequestId = clientRequestIdRef.current; + try { // 普通模式下,发布前上传所有待上传的图片 let uploadedImageUrls: string[] = []; @@ -469,6 +483,7 @@ export const CreatePostScreen: React.FC = (props) => { images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, channel_id: selectedChannelId || undefined, vote_options: validOptions.map(opt => ({ content: opt })), + client_request_id: clientRequestId, }); showPrompt({ type: 'info', @@ -499,6 +514,7 @@ export const CreatePostScreen: React.FC = (props) => { content: editorContent.trim(), segments: finalSegments, channel_id: selectedChannelId || undefined, + client_request_id: clientRequestId, }); showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 }); router.replace(hrefs.hrefHome()); @@ -524,11 +540,14 @@ export const CreatePostScreen: React.FC = (props) => { segments: segments.some(s => s.type !== 'text') ? segments : undefined, images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, channel_id: selectedChannelId || undefined, + client_request_id: clientRequestId, }); showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 }); router.replace(hrefs.hrefHome()); } } + // 发布成功:刷新幂等键,下次发布生成新 ID。 + clientRequestIdRef.current = ''; } catch (error) { console.error('发布帖子失败:', error); Alert.alert('错误', getPublishErrorMessage(error)); diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 1136fda..8aaf38c 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -795,7 +795,7 @@ export const HomeScreen: React.FC = () => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( - + { /> ); - }, [currentUser?.id, stableOnPostAction]); + }, [currentUser?.id, stableOnPostAction, responsiveGap]); // 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式) const renderResponsiveGrid = () => { @@ -820,7 +820,7 @@ export const HomeScreen: React.FC = () => { masonry optimizeItemArrangement contentContainerStyle={{ - paddingHorizontal: responsivePadding, + paddingHorizontal: responsiveGap / 2, paddingBottom: 80 + responsivePadding, }} showsVerticalScrollIndicator={false} diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 1139566..a4731d1 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -1358,21 +1358,15 @@ export const PostDetailScreen: React.FC = () => { ); }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]); - // 渲染空评论 - 类似图片中的样式 + // 渲染空评论 - 使用 Forum 图标 const renderEmptyComments = useCallback(() => ( - {/* 四个方块图标 */} - - - - - - + - 这里空空如也,邀请好友来互动吧! + 评论区空空如也,来发表第一条评论吧~ - ), []); + ), [colors]); const openComposer = () => { setIsComposerVisible(true); @@ -1382,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => { const closeComposer = () => { setIsComposerVisible(false); setShowEmojiPanel(false); + setReplyingTo(null); Keyboard.dismiss(); }; @@ -1522,6 +1517,16 @@ export const PostDetailScreen: React.FC = () => { {/* Toolbar */} + + + = (props) => { handleDeleteMessage, handleReplyMessage, handleCancelReply, + handleRetrySend, handleAvatarPress, handleAvatarLongPress, handleSelectMention, @@ -366,6 +367,7 @@ export const ChatScreen: React.FC = (props) => { onImagePress={handleImagePress} onReply={handleReplyMessage} onReplyPress={handleReplyPreviewPress} + onRetrySend={handleRetrySend} /> ); }, [ @@ -386,6 +388,7 @@ export const ChatScreen: React.FC = (props) => { handleImagePress, handleReplyMessage, handleReplyPreviewPress, + handleRetrySend, ]); const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []); diff --git a/src/screens/message/components/ChatScreen/ChatInput.tsx b/src/screens/message/components/ChatScreen/ChatInput.tsx index 38be5b6..496aaa3 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -216,10 +216,9 @@ export const ChatInput: React.FC - - + 发送 ) : isComposerBusy && !isDisabled ? ( = ({ onImagePress, onReply, onReplyPress, + onRetrySend, }) => { const bubbleRef = useRef(null); const pressPositionRef = useRef({ x: 0, y: 0 }); @@ -93,6 +95,8 @@ const MessageBubbleInner: React.FC = ({ const isMe = message.sender_id === currentUserId; const showTime = shouldShowTime(index); const isRecalled = message.status === 'recalled'; + const isPending = message.status === 'pending'; + const isFailed = message.status === 'failed'; const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq; const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq; @@ -431,18 +435,46 @@ const MessageBubbleInner: React.FC = ({ )} {renderMessageContent()} - - {/* 私聊模式:显示已读标记 */} - {isLastReadMessage && ( - - - - 已读 - + + {/* 发送状态指示器 */} + {isMe && ( + + {isPending && ( + + + 发送中 + + )} + {isFailed && ( + // 点击「发送失败」重试:仅对自己发送的失败消息提供重试入口。 + // 用 Pressable 而非 TouchableOpacity,避免与外层长按手势冲突; + // 失败状态本身是终态,重试期间会被替换为 pending。 + onRetrySend?.(message)} + disabled={!onRetrySend} + activeOpacity={0.6} + > + + 发送失败,点击重试 + + )} + {!isPending && !isFailed && isLastReadMessage && ( + + + + 已读 + + + )} )} diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 609d4c7..e4d2001 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -416,6 +416,33 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: { readStatusTextRead: { color: colors.chat.success, }, + + // 发送状态(乐观更新) + sendStatusContainer: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-end', + marginTop: 2, + marginRight: 2, + }, + sendStatusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + sendStatusIndicator: { + marginRight: 2, + }, + pendingStatusText: { + fontSize: 10, + color: colors.chat.textSecondary, + fontWeight: '500', + }, + failedStatusText: { + fontSize: 10, + color: '#FF3B30', + fontWeight: '500', + }, // 输入框区域 - 与列表背景同色底栏,顶部细分隔 inputWrapper: { @@ -489,34 +516,30 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: { transform: [{ translateY: -1 }], }, - // 发送按钮 - QQ/微信风格 + // 发送按钮 - 微信风格文字按钮 sendButtonActive: { - width: 36, - height: 36, - borderRadius: 18, // 正圆形 + minWidth: 56, + height: 34, + borderRadius: 8, backgroundColor: colors.primary.main, alignItems: 'center', justifyContent: 'center', - // 微信风格:轻微阴影,不夸张 + paddingHorizontal: 12, + // 轻微阴影 shadowColor: colors.primary.main, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, - shadowRadius: 3, + shadowRadius: 2, elevation: 2, }, sendButtonDisabled: { opacity: 0.4, }, - // 发送按钮光泽层(叠加在按钮上模拟渐变) - sendButtonShine: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - height: '50%', - borderTopLeftRadius: 18, - borderTopRightRadius: 18, - backgroundColor: 'rgba(255, 255, 255, 0.08)', + sendButtonText: { + color: '#FFF', + fontSize: 14, + fontWeight: '600', + letterSpacing: 0.5, }, // 输入框 diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 8095bbe..4f94400 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -101,6 +101,8 @@ export interface MessageBubbleProps { onReply?: (message: GroupMessage) => void; // 点击回复预览回调(定位到原消息) onReplyPress?: (messageId: string) => void; + // 点击「发送失败」气泡重试发送(仅对当前用户自己发送的失败消息触发) + onRetrySend?: (message: GroupMessage) => void; } /** 输入框中待发送的本地图片(未上传) */ diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 17b24a1..21a0ef9 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -153,6 +153,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { loadMoreMessages, refreshMessages, sendMessage: sendMessageViaManager, + retrySendMessage: retrySendMessageViaManager, isSending: isSendingViaManager, markAsRead, conversation, @@ -1056,7 +1057,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { return segments; }, []); - // 【新架构】发送消息(支持纯文字、纯多图、图文同条) + // 【新架构】发送消息(支持纯文字、纯多图、图文同条)- 乐观更新模式 const handleSend = useCallback(async () => { const trimmedText = inputText.trim(); const hasPending = pendingAttachments.length > 0; @@ -1082,6 +1083,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { } } + // 图片需要先上传(这个无法乐观,因为需要URL) const uploadedUrls: string[] = []; if (hasPending) { setUploadingAttachments(true); @@ -1108,60 +1110,60 @@ export const useChatScreen = (props?: ChatScreenProps) => { } } - setSending(true); + // 构建消息段 + const segments: MessageSegment[] = (trimmedText || replyingTo) + ? [...buildTextSegments(trimmedText, replyingTo)] + : []; + for (const url of uploadedUrls) { + segments.push({ + type: 'image', + data: { + url, + thumbnail_url: url, + } as ImageSegmentData, + }); + } + if (segments.length === 0) { + Alert.alert('无法发送', '消息内容为空'); + return; + } + + // 乐观更新:立即清空输入框并滚动到底部 + const savedReplyTo = replyingTo; + setInputText(''); + setSelectedMentions([]); + setMentionAll(false); + setReplyingTo(null); + setPendingAttachments([]); + + // 立即滚动到底部显示新消息 + setTimeout(() => { + scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text'); + }, 50); + + // 后台发送消息(私聊与群聊统一走 MessageSendService 的乐观更新): + // 1. 立即在 store 中插入 pending 临时消息 + // 2. 成功 → 替换为正式消息、更新会话 last_message、写入本地数据库 + // 3. 失败 → 标记为 failed,供用户点击重试 try { - // 有文本或回复时才构建文本段,纯图片时不塞空 text - const segments: MessageSegment[] = (trimmedText || replyingTo) - ? [...buildTextSegments(trimmedText, replyingTo)] - : []; - for (const url of uploadedUrls) { - segments.push({ - type: 'image', - data: { - url, - thumbnail_url: url, - } as ImageSegmentData, - }); - } - - if (segments.length === 0) { - Alert.alert('无法发送', '消息内容为空'); - return; - } - - if (isGroupChat && effectiveGroupId) { - await messageService.sendMessageByAction('group', conversationId, segments); - - setInputText(''); - setSelectedMentions([]); - setMentionAll(false); - setReplyingTo(null); - setPendingAttachments([]); - } else { - await sendMessageViaManager(segments); - setInputText(''); - setSelectedMentions([]); - setMentionAll(false); - setReplyingTo(null); - setPendingAttachments([]); - } - - setTimeout(() => { - scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text'); - }, 100); + await sendMessageViaManager(segments, { + replyToId: savedReplyTo?.id, + detailType: isGroupChat ? 'group' : 'private', + }); } catch (error) { console.error('发送消息失败:', error); - Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试')); - } finally { - setSending(false); + // MessageSendService 已将消息置为 failed 并在 UI 上显示状态, + // 这里仅弹窗提示用户(与原行为保持一致)。 + if (isGroupChat) { + Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试')); + } } }, [ inputText, pendingAttachments, conversationId, isGroupChat, - effectiveGroupId, sendMessageViaManager, isMuted, muteAll, @@ -1173,6 +1175,21 @@ export const useChatScreen = (props?: ChatScreenProps) => { scrollToLatest, ]); + // 重试发送失败的消息(点击「发送失败」气泡时触发) + // 仅对当前用户自己发送且状态为 failed 的消息生效; + // 重试期间消息会先变为 pending(发送中),再根据结果转为 normal/failed。 + const handleRetrySend = useCallback(async (message: GroupMessage) => { + if (!conversationId) return; + if (message.sender_id !== currentUserId) return; + if (message.status !== 'failed') return; + try { + await retrySendMessageViaManager(message); + } catch (error) { + console.error('重试发送消息失败:', error); + Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试')); + } + }, [conversationId, currentUserId, retrySendMessageViaManager, getSendErrorMessage]); + // 选择图片(多选进入输入框待发送,点发送时与文字一并发出) const handlePickImage = useCallback(async () => { if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) { @@ -1636,6 +1653,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { shouldShowTime, handleInputChange, handleSend, + handleRetrySend, removePendingAttachment, handleMoreAction, handleInsertEmoji, diff --git a/src/services/notification/jpushService.ts b/src/services/notification/jpushService.ts index 0d659da..ad0ac74 100644 --- a/src/services/notification/jpushService.ts +++ b/src/services/notification/jpushService.ts @@ -45,6 +45,13 @@ class JPushService { private notificationCallback: JPushCallback | null = null; private connectResolved = false; private listenersRegistered = false; + // notificationArrived 去重:JPush 自有通道与厂商通道(如小米)可能对 + // 同一条消息各弹一次通知,按 messageID 去重,只让回调处理第一次到达。 + // 使用 Map 配合 TTL 清理,避免长时间运行后集合无限增长, + // 也避免「淘汰最早一半」策略误删近期仍在被厂商通道二次到达的消息。 + private arrivedMessageTimestamps: Map = new Map(); + private static readonly ARRIVED_DEDUP_TTL_MS = 5 * 60 * 1000; // 5 分钟 + private static readonly ARRIVED_DEDUP_MAX = 500; // 触发清理的容量上限 async initialize(): Promise { if (this.isInitialized) return true; @@ -70,12 +77,40 @@ class JPushService { JPush!.addNotificationListener((result: any) => { console.log('[JPush] notification:', result); - if (result.notificationEventType === 'notificationArrived') { + const eventType: string = result.notificationEventType || ''; + + // notificationArrived 去重: + // JPush 在配置了厂商通道(如小米)的设备上,会对同一条消息同时通过 + // JPush 自有通道和厂商通道各推送一次,导致通知栏出现两条相同通知。 + // 这里按 messageID 去重,只处理第一次到达,避免重复震动/重复弹窗。 + // notificationOpened(用户点击)不去重:点哪条都应响应跳转。 + if (eventType === 'notificationArrived') { + const msgID = String(result.messageID || ''); + if (msgID) { + const now = Date.now(); + // 清理过期记录(每次到达顺手清理,摊销成本,避免泄漏) + if (this.arrivedMessageTimestamps.size > JPushService.ARRIVED_DEDUP_MAX) { + for (const [id, ts] of this.arrivedMessageTimestamps) { + if (now - ts > JPushService.ARRIVED_DEDUP_TTL_MS) { + this.arrivedMessageTimestamps.delete(id); + } + } + } + + if (this.arrivedMessageTimestamps.has(msgID)) { + console.log('[JPush] duplicate notificationArrived dropped, msgID:', msgID); + return; + } + // 记录到达时间,TTL 内重复到达即被去重 + this.arrivedMessageTimestamps.set(msgID, now); + } + const prefs = getNotificationPreferencesSync(); if (prefs.vibrationEnabled && AppState.currentState === 'active') { vibrateOnMessage('notification').catch(() => {}); } } + if (this.notificationCallback) { this.notificationCallback({ messageID: result.messageID || '', @@ -83,7 +118,7 @@ class JPushService { content: result.content || '', extras: result.extras || {}, notificationType: result.extras?.notification_type, - notificationEventType: result.notificationEventType, + notificationEventType: eventType as any, }); } }); @@ -431,6 +466,7 @@ class JPushService { this.notificationCallback = null; this.initPromise = null; this.connectResolved = false; + this.arrivedMessageTimestamps.clear(); } } diff --git a/src/services/post/postService.ts b/src/services/post/postService.ts index be83ce9..df0f2ee 100644 --- a/src/services/post/postService.ts +++ b/src/services/post/postService.ts @@ -27,6 +27,8 @@ interface CreatePostRequest { segments?: MessageSegment[]; images?: string[]; channel_id?: string; + // 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖 + client_request_id?: string; } // 更新帖子请求 @@ -104,7 +106,6 @@ class PostService { const response = await api.post('/posts', data); return response.data; } - // 更新帖子 async updatePost(postId: string, data: UpdatePostRequest): Promise { try { diff --git a/src/services/post/voteService.ts b/src/services/post/voteService.ts index 14d044a..d8b8b3e 100644 --- a/src/services/post/voteService.ts +++ b/src/services/post/voteService.ts @@ -58,6 +58,7 @@ class VoteService { segments, images: data.images, channel_id: data.channel_id, + client_request_id: data.client_request_id, }); return response.data; } diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts index 1c50f6d..5b60574 100644 --- a/src/stores/message/MessageManager.ts +++ b/src/stores/message/MessageManager.ts @@ -220,11 +220,18 @@ class MessageManager { async sendMessage( conversationId: string, segments: MessageSegment[], - options?: { replyToId?: string } + options?: { replyToId?: string; detailType?: 'private' | 'group' } ): Promise { return this.sendService.sendMessage(conversationId, segments, options); } + async retrySendMessage( + conversationId: string, + failedMessage: MessageResponse + ): Promise { + return this.sendService.retrySendMessage(conversationId, failedMessage); + } + async markAsRead(conversationId: string, seq: number): Promise { return this.readReceiptManager.markAsRead(conversationId, seq); } diff --git a/src/stores/message/hooks.ts b/src/stores/message/hooks.ts index ace59b8..c3090b9 100644 --- a/src/stores/message/hooks.ts +++ b/src/stores/message/hooks.ts @@ -228,7 +228,8 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { // ==================== useSendMessage - 发送消息 ==================== interface UseSendMessageReturn { - sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise; + sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise; + retrySendMessage: (failedMessage: MessageResponse) => Promise; isSending: boolean; } @@ -239,7 +240,7 @@ export function useSendMessage(conversationId: string | null): UseSendMessageRet const [isSending, setIsSending] = useState(false); const sendMessage = useCallback( - async (segments: MessageSegment[], options?: { replyToId?: string }): Promise => { + async (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }): Promise => { if (!conversationId) return null; setIsSending(true); @@ -253,8 +254,24 @@ export function useSendMessage(conversationId: string | null): UseSendMessageRet [conversationId] ); + const retrySendMessage = useCallback( + async (failedMessage: MessageResponse): Promise => { + if (!conversationId) return null; + + setIsSending(true); + try { + const message = await messageManager.retrySendMessage(conversationId, failedMessage); + return message; + } finally { + setIsSending(false); + } + }, + [conversationId] + ); + return { sendMessage, + retrySendMessage, isSending, }; } @@ -537,7 +554,8 @@ interface UseChatReturn { refreshMessages: () => Promise; // 发送消息 - sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise; + sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise; + retrySendMessage: (failedMessage: MessageResponse) => Promise; isSending: boolean; // 已读相关 @@ -549,7 +567,7 @@ interface UseChatReturn { export function useChat(conversationId: string | null): UseChatReturn { const { messages, isLoading: isLoadingMessages, hasMore: hasMoreMessages, loadMore, refresh } = useMessages(conversationId); - const { sendMessage, isSending } = useSendMessage(conversationId); + const { sendMessage, retrySendMessage, isSending } = useSendMessage(conversationId); const { markAsRead } = useMarkAsRead(conversationId); const { conversation } = useConversation(conversationId); @@ -560,6 +578,7 @@ export function useChat(conversationId: string | null): UseChatReturn { loadMoreMessages: loadMore, refreshMessages: refresh, sendMessage, + retrySendMessage, isSending, markAsRead, conversation, diff --git a/src/stores/message/services/MessageSendService.ts b/src/stores/message/services/MessageSendService.ts index cb0678b..539c9f4 100644 --- a/src/stores/message/services/MessageSendService.ts +++ b/src/stores/message/services/MessageSendService.ts @@ -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 { 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; + * 群聊失败消息重试走 HTTP(sendMessage 的 group 分支)也能成功。 + */ + async retrySendMessage( + conversationId: string, + failedMessage: MessageResponse + ): Promise { + 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); } } -} \ No newline at end of file +} diff --git a/src/stores/message/types.ts b/src/stores/message/types.ts index 842028a..5f36753 100644 --- a/src/stores/message/types.ts +++ b/src/stores/message/types.ts @@ -51,7 +51,11 @@ export interface IMessageSendService { sendMessage( conversationId: string, segments: any[], - options?: { replyToId?: string } + options?: { replyToId?: string; detailType?: 'private' | 'group' } + ): Promise; + retrySendMessage( + conversationId: string, + failedMessage: MessageResponse ): Promise; } diff --git a/src/types/dto/message.ts b/src/types/dto/message.ts index 2e3d443..aee3206 100644 --- a/src/types/dto/message.ts +++ b/src/types/dto/message.ts @@ -7,7 +7,7 @@ import type { GroupResponse } from './group'; export type ConversationType = 'private' | 'group'; export type MessageContentType = 'text' | 'image' | 'video' | 'audio' | 'file'; -export type MessageStatus = 'normal' | 'recalled' | 'deleted'; +export type MessageStatus = 'normal' | 'recalled' | 'deleted' | 'pending' | 'failed'; // ==================== Segment Types ==================== diff --git a/src/types/dto/pagination.ts b/src/types/dto/pagination.ts index 1b3236e..1a1ac0a 100644 --- a/src/types/dto/pagination.ts +++ b/src/types/dto/pagination.ts @@ -61,6 +61,8 @@ export interface CreateVotePostRequest { content: string; }>; channel_id?: string; + // 客户端幂等键:同一 key 在服务端 TTL 内重复提交只创建一次,避免重复发帖 + client_request_id?: string; } // ==================== Privacy & Account ==================== diff --git a/src/utils/idempotency.ts b/src/utils/idempotency.ts new file mode 100644 index 0000000..76e18eb --- /dev/null +++ b/src/utils/idempotency.ts @@ -0,0 +1,68 @@ +/** + * 客户端幂等键工具 + * + * 用于发帖、发评论等"创建型"请求,避免因重复点击 / 网络重试导致重复创建。 + * 客户端为每次"用户意图"生成一个唯一 ID(client_request_id), + * 后端基于 userID + client_request_id 做幂等去重: + * - 同一 key 在 TTL 内的重复请求直接返回首次创建的资源。 + * + * 实现说明: + * - React Native 的 `crypto` 全局由 @livekit/react-native 的 registerGlobals 注入 + * (见 src/polyfills.ts)。优先使用 crypto.randomUUID(),回退到基于 + * getRandomValues 的 v4 UUID,再回退到 timestamp+random 拼接。 + */ + +const HEX_CHARS = '0123456789abcdef'; + +/** + * 生成一个 RFC4122 v4 风格的 UUID 字符串。 + * 优先使用平台 crypto.randomUUID;不可用时手写 v4。 + */ +export function generateUUID(): string { + const g = globalThis as any; + + // 1) 现代 RN / Web:crypto.randomUUID + try { + if (g?.crypto?.randomUUID) { + const id = g.crypto.randomUUID(); + if (typeof id === 'string' && id.length > 0) { + return id; + } + } + } catch { + // fallthrough + } + + // 2) crypto.getRandomValues:手写 v4 + try { + if (g?.crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + g.crypto.getRandomValues(bytes); + // v4: version 0100, variant 10xx + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let out = ''; + for (let i = 0; i < 16; i++) { + out += HEX_CHARS[bytes[i] >> 4] + HEX_CHARS[bytes[i] & 0x0f]; + if (i === 3 || i === 5 || i === 7 || i === 9) { + out += '-'; + } + } + return out; + } + } catch { + // fallthrough + } + + // 3) 退化兜底:timestamp + Math.random(碰撞概率极低,仅用于无 crypto 的极端环境) + const rnd = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0'); + const now = Date.now().toString(16).padStart(12, '0'); + return `${now.slice(0, 8)}-${now.slice(8, 12)}-${rnd()}-${rnd()}-${rnd()}${rnd()}${rnd()}`; +} + +/** + * 生成发帖请求的幂等键。每次发帖意图生成一次,重试时复用同一个。 + */ +export function generateClientRequestId(): string { + return generateUUID(); +}